diff --git a/src/auth/access-snapshot.integration.test.ts b/src/auth/access-snapshot.integration.test.ts index 7c0e87d..39aac1b 100644 --- a/src/auth/access-snapshot.integration.test.ts +++ b/src/auth/access-snapshot.integration.test.ts @@ -138,5 +138,29 @@ describe.skipIf(!baseURL || !databaseURL || !clientId || !audience)( ]), ); }); + + it("returns registered users to the backend without requiring a first Signatures visit", async () => { + const url = `${baseURL}/api/internal/esign-users`; + expect((await fetch(url)).status).toBe(401); + expect( + ( + await fetch(url, { + headers: { Authorization: `Bearer ${await token({ scope: "backend:tg:read" })}` }, + }) + ).status, + ).toBe(401); + const response = await fetch(url, { headers: { Authorization: `Bearer ${await token()}` } }); + expect(response.status).toBe(200); + expect(response.headers.get("cache-control")).toContain("no-store"); + expect(await response.json()).toEqual( + expect.arrayContaining([ + { + sub: "snapshot-integration-user", + name: "Snapshot User", + email: "snapshot@identity.invalid", + }, + ]), + ); + }); }, ); diff --git a/src/dev/personas.ts b/src/dev/personas.ts index 0cec4ff..7d0bab2 100644 --- a/src/dev/personas.ts +++ b/src/dev/personas.ts @@ -135,8 +135,8 @@ export function listPersonas(): DevPersonaSummary[] { description, ...(id === DEV_ADMIN_ID && !env.IDP_ADMIN_USER_IDS.includes(DEV_ADMIN_ID) ? { - warning: `Add ${DEV_ADMIN_ID} to IDP_ADMIN_USER_IDS in .env.local to make this Master Admin.`, - } + warning: `Add ${DEV_ADMIN_ID} to IDP_ADMIN_USER_IDS in .env.local to make this Master Admin.`, + } : {}), })); } @@ -144,7 +144,7 @@ export function listPersonas(): DevPersonaSummary[] { type Writer = Parameters[0]>[0]; /** Thrown instead of silently moving an identity between people. */ -export class DevFixtureConflict extends Error { } +export class DevFixtureConflict extends Error {} /** Finds the fixture role by its stable ID, so a key renamed in the role editor still matches; * then by key, so a role created by hand under that key is reused rather than collided with. */ diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index 0b5aad8..5b09a6c 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -32,6 +32,7 @@ import { Route as AccessRolesNewRouteImport } from './routes/access/roles/new' import { Route as ApiAuthSplatRouteImport } from './routes/api/auth/$' import { Route as ApiDevLoginRouteImport } from './routes/api/dev/login' import { Route as ApiInternalAccessSnapshotRouteImport } from './routes/api/internal/access-snapshot' +import { Route as ApiInternalEsignUsersRouteImport } from './routes/api/internal/esign-users' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -150,6 +151,11 @@ const ApiInternalAccessSnapshotRoute = path: '/api/internal/access-snapshot', getParentRoute: () => rootRouteImport, } as any) +const ApiInternalEsignUsersRoute = ApiInternalEsignUsersRouteImport.update({ + id: '/api/internal/esign-users', + path: '/api/internal/esign-users', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute @@ -173,6 +179,7 @@ export interface FileRoutesByFullPath { '/api/auth/$': typeof ApiAuthSplatRoute '/api/dev/login': typeof ApiDevLoginRoute '/api/internal/access-snapshot': typeof ApiInternalAccessSnapshotRoute + '/api/internal/esign-users': typeof ApiInternalEsignUsersRoute '/access/permissions/': typeof AccessPermissionsIndexRoute '/access/roles/': typeof AccessRolesIndexRoute } @@ -195,6 +202,7 @@ export interface FileRoutesByTo { '/api/auth/$': typeof ApiAuthSplatRoute '/api/dev/login': typeof ApiDevLoginRoute '/api/internal/access-snapshot': typeof ApiInternalAccessSnapshotRoute + '/api/internal/esign-users': typeof ApiInternalEsignUsersRoute '/access/permissions': typeof AccessPermissionsIndexRoute '/access/roles': typeof AccessRolesIndexRoute } @@ -221,6 +229,7 @@ export interface FileRoutesById { '/api/auth/$': typeof ApiAuthSplatRoute '/api/dev/login': typeof ApiDevLoginRoute '/api/internal/access-snapshot': typeof ApiInternalAccessSnapshotRoute + '/api/internal/esign-users': typeof ApiInternalEsignUsersRoute '/access/permissions/': typeof AccessPermissionsIndexRoute '/access/roles/': typeof AccessRolesIndexRoute } @@ -248,6 +257,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/dev/login' | '/api/internal/access-snapshot' + | '/api/internal/esign-users' | '/access/permissions/' | '/access/roles/' fileRoutesByTo: FileRoutesByTo @@ -270,6 +280,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/dev/login' | '/api/internal/access-snapshot' + | '/api/internal/esign-users' | '/access/permissions' | '/access/roles' id: @@ -295,6 +306,7 @@ export interface FileRouteTypes { | '/api/auth/$' | '/api/dev/login' | '/api/internal/access-snapshot' + | '/api/internal/esign-users' | '/access/permissions/' | '/access/roles/' fileRoutesById: FileRoutesById @@ -311,6 +323,7 @@ export interface RootRouteChildren { ApiAuthSplatRoute: typeof ApiAuthSplatRoute ApiDevLoginRoute: typeof ApiDevLoginRoute ApiInternalAccessSnapshotRoute: typeof ApiInternalAccessSnapshotRoute + ApiInternalEsignUsersRoute: typeof ApiInternalEsignUsersRoute } declare module '@tanstack/react-router' { @@ -476,6 +489,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiInternalAccessSnapshotRouteImport parentRoute: typeof rootRouteImport } + '/api/internal/esign-users': { + id: '/api/internal/esign-users' + path: '/api/internal/esign-users' + fullPath: '/api/internal/esign-users' + preLoaderRoute: typeof ApiInternalEsignUsersRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -544,6 +564,7 @@ const rootRouteChildren: RootRouteChildren = { ApiAuthSplatRoute: ApiAuthSplatRoute, ApiDevLoginRoute: ApiDevLoginRoute, ApiInternalAccessSnapshotRoute: ApiInternalAccessSnapshotRoute, + ApiInternalEsignUsersRoute: ApiInternalEsignUsersRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/src/routes/api/internal/esign-users.ts b/src/routes/api/internal/esign-users.ts new file mode 100644 index 0000000..2424eec --- /dev/null +++ b/src/routes/api/internal/esign-users.ts @@ -0,0 +1,30 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { asc } from "drizzle-orm"; +import { snapshotClientId } from "@/auth/access-snapshot-token"; +import { projectionForClient } from "@/auth/access-projections"; +import { db } from "@/db"; +import { user } from "@/db/schema"; +import { env } from "@/env"; + +const headers = { "Cache-Control": "private, no-store", Vary: "Authorization" }; + +/** Registered identities for the backend's e-signing picker; access is checked by the backend's live snapshot. */ +export const Route = createFileRoute("/api/internal/esign-users")({ + server: { + handlers: { + GET: async ({ request }) => { + if (!env.OAUTH_BACKEND_CLIENT_ID || !env.OAUTH_INTERNAL_RESOURCE_URI) + return Response.json({ error: "Unavailable" }, { status: 503, headers }); + const clientId = await snapshotClientId(request.headers.get("authorization")); + if (!clientId) return Response.json({ error: "Unauthorized" }, { status: 401, headers }); + if (projectionForClient(clientId) !== "backend") + return Response.json({ error: "Forbidden" }, { status: 403, headers }); + const people = await db + .select({ sub: user.id, name: user.name, email: user.email }) + .from(user) + .orderBy(asc(user.name), asc(user.id)); + return Response.json(people, { headers }); + }, + }, + }, +});