diff --git a/apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts b/apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts index d9662df8c..bb0263729 100644 --- a/apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts +++ b/apps/e2e/demo-e2e-bundle-size/e2e/browser-bundle-size.e2e.spec.ts @@ -24,8 +24,8 @@ import { } from './helpers/browser-bundle'; const BUDGETS: Array<{ entry: string; label: string; maxBytes: number }> = [ - { entry: 'create-tool.ts', label: 'create() + one tool', maxBytes: 1_333_269 }, - { entry: 'create-webmcp-tool.ts', label: 'create() + WebMcpPlugin + one tool', maxBytes: 1_340_136 }, + { entry: 'create-tool.ts', label: 'create() + one tool', maxBytes: 1_333_877 }, + { entry: 'create-webmcp-tool.ts', label: 'create() + WebMcpPlugin + one tool', maxBytes: 1_340_744 }, ]; const VITE_INITIAL_CHUNK_MAX_BYTES = 960_000; @@ -49,6 +49,7 @@ const FEATURE_MODULES: Record = { duties: sdkModule('duty/'), elicitation: sdkModule('elicitation/(store|flows)/|elicitation/send-elicitation-result\\.tool'), health: sdkModule('health/'), + 'http routes': sdkModule('http-route/'), 'http transport': sdkModule( 'scope/(flows/http\\.|http-scope\\.helper)|transport/(flows/|adapters/|transport\\.registry|web-fetch-handler)|server/(custom-routes\\.helper|server\\.instance)', ), diff --git a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts index 84e547b97..8e30b6bdc 100644 --- a/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts +++ b/apps/e2e/demo-e2e-dts-consumer/e2e/dts-consumer.e2e.spec.ts @@ -46,6 +46,7 @@ const fixtureFiles = [ 'materialized-resources-must-compile.ts', 'duties-must-compile.ts', 'offload-must-compile.ts', + 'http-routes-must-compile.ts', 'shared-server-must-compile.ts', 'durable-jobs-must-compile.ts', 'build-manifest-must-compile.ts', @@ -217,6 +218,10 @@ describe('@frontmcp/sdk declarations in a strict consumer project', () => { expect(describeEach(inFixtureFile('duties-must-compile.ts'))).toEqual([]); }); + it('types @HttpRoute, HttpRouteContext, SSE routes, HttpRouteHook and http.auth.people', () => { + expect(describeEach(inFixtureFile('http-routes-must-compile.ts'))).toEqual([]); + }); + it('types this.offload() from the offloaded function, @Resource({ worker }) and the offload option', () => { expect(describeEach(inFixtureFile('offload-must-compile.ts'))).toEqual([]); }); diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/http-routes-must-compile.ts b/apps/e2e/demo-e2e-dts-consumer/fixture/http-routes-must-compile.ts new file mode 100644 index 000000000..923147153 --- /dev/null +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/http-routes-must-compile.ts @@ -0,0 +1,85 @@ +import 'reflect-metadata'; + +import { + App, + FrontMcp, + httpRespond, + HttpRoute, + HttpRouteContext, + HttpRouteHook, + LoopbackTokenAuth, + resourceStore, + Tool, + ToolContext, + type HttpRouteMetadata, + type HttpRouteResult, + type PeopleSession, +} from '@frontmcp/sdk'; + +const boardStore = resourceStore(); +const board = boardStore.key('board', { initial: [] }); + +@HttpRoute({ method: 'GET', path: '/board/:id' }) +export class BoardPage extends HttpRouteContext { + async handle(): Promise { + const person: PeopleSession | undefined = this.person; + return { id: this.params['id'], cards: (await board.get()).value, until: person?.expiresAt }; + } +} + +@HttpRoute({ method: 'GET', path: () => '/board/page', auth: 'public' }) +export class BoardHtml extends HttpRouteContext { + handle() { + return httpRespond.html('

board

'); + } +} + +@HttpRoute({ method: 'GET', path: '/board/events', sse: { store: boardStore, topics: [board.name], coalesceMs: 25 } }) +export class BoardEvents extends HttpRouteContext { + async handle() { + return (await board.get()).value; + } +} + +@Tool({ name: 'sign-in-link', inputSchema: {} }) +export class SignInLinkTool extends ToolContext { + async execute() { + const { code, expiresAt } = this.get(LoopbackTokenAuth).createCode(); + return { path: `/_frontmcp/people/login?code=${code}`, expiresAt }; + } +} + +export class AuditRoutes { + @HttpRouteHook.Will('handle') + async audit(): Promise { + return undefined; + } +} + +@App({ name: 'board', tools: [SignInLinkTool], routes: [BoardPage, BoardHtml, BoardEvents] }) +class BoardApp {} + +@FrontMcp({ + info: { name: 'web-host', version: '1.0.0' }, + apps: [BoardApp], + http: { auth: { people: 'loopback-token' } }, +}) +export class WebHostServer {} + +@FrontMcp({ + info: { name: 'web-host-tuned', version: '1.0.0' }, + apps: [BoardApp], + http: { auth: { people: { mode: 'loopback-token', codeTtlMs: 30_000, cookiePath: '/board' } } }, +}) +export class TunedWebHostServer {} + +// @ts-expect-error a route needs a handle() method +@HttpRoute({ method: 'GET', path: '/nothing' }) +export class RouteWithoutHandle {} + +export class InvalidRoutes { + // @ts-expect-error method is GET, POST, PUT, PATCH or DELETE + static readonly method: HttpRouteMetadata = { method: 'TRACE', path: '/x' }; + // @ts-expect-error auth is 'people' or 'public' + static readonly auth: HttpRouteMetadata = { method: 'GET', path: '/x', auth: 'mcp' }; +} diff --git a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json index 756c1d2ac..1f9a27f5e 100644 --- a/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json +++ b/apps/e2e/demo-e2e-dts-consumer/fixture/tsconfig.json @@ -36,6 +36,7 @@ "shared-server-must-compile.ts", "durable-jobs-must-compile.ts", "materialized-resources-must-compile.ts", - "duties-must-compile.ts" + "duties-must-compile.ts", + "http-routes-must-compile.ts" ] } diff --git a/apps/e2e/demo-e2e-web-host/e2e/web-host.e2e.spec.ts b/apps/e2e/demo-e2e-web-host/e2e/web-host.e2e.spec.ts new file mode 100644 index 000000000..4688695bf --- /dev/null +++ b/apps/e2e/demo-e2e-web-host/e2e/web-host.e2e.spec.ts @@ -0,0 +1,182 @@ +/** + * The web host: `@HttpRoute` pages served beside the MCP endpoint through the `http:route` flow, behind people auth + * (`http.auth.people: 'loopback-token'`): the token printed at startup buys a one-time code, the code posted from the + * server's own page buys an HttpOnly cookie. Pages share the app's providers with its tools, a cross-site mutation is + * refused, and an SSE page hears a resource change a tool made. + */ +import { expect, test, type ServerFixture } from '@frontmcp/testing'; + +const TOKEN_LINE = /people sign-in token: ([A-Za-z0-9_-]{43})/; + +function startupToken(server: ServerFixture): string { + const line = server.getLogs().find((entry) => TOKEN_LINE.test(entry)); + if (!line) throw new Error('the server printed no sign-in token'); + return (TOKEN_LINE.exec(line) as RegExpExecArray)[1]; +} + +async function signInCode(server: ServerFixture, token: string): Promise { + const response = await fetch(`${server.info.baseUrl}/_frontmcp/people/code`, { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + expect(response.status).toBe(200); + return ((await response.json()) as { code: string }).code; +} + +function postCode(server: ServerFixture, code: string, origin = server.info.baseUrl): Promise { + return fetch(`${server.info.baseUrl}/_frontmcp/people/login`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: origin }, + body: new URLSearchParams({ code }).toString(), + redirect: 'manual', + }); +} + +async function signIn(server: ServerFixture): Promise { + const response = await postCode(server, await signInCode(server, startupToken(server))); + expect(response.status).toBe(200); + const cookie = response.headers.get('set-cookie') ?? ''; + return cookie.split(';')[0]; +} + +/** Read SSE events from `response` until `accept` takes one, with a generous deadline for a loaded machine. */ +async function nextEvent( + reader: ReadableStreamDefaultReader, + accept: (event: T) => boolean, + timeoutMs = 30_000, +): Promise { + const decoder = new TextDecoder(); + const deadline = Date.now() + timeoutMs; + let buffered = ''; + while (Date.now() < deadline) { + const timeout = new Promise<'timeout'>((resolve) => setTimeout(() => resolve('timeout'), deadline - Date.now())); + const chunk = await Promise.race([reader.read(), timeout]); + if (chunk === 'timeout' || chunk.done) break; + buffered += decoder.decode(chunk.value, { stream: true }); + let boundary = buffered.indexOf('\n\n'); + while (boundary !== -1) { + const block = buffered.slice(0, boundary); + buffered = buffered.slice(boundary + 2); + const data = block + .split('\n') + .filter((line) => line.startsWith('data: ')) + .map((line) => line.slice(6)) + .join('\n'); + if (data) { + const event = JSON.parse(data) as T; + if (accept(event)) return event; + } + boundary = buffered.indexOf('\n\n'); + } + } + throw new Error(`no matching event within ${timeoutMs} ms`); +} + +test.describe('Web host E2E', () => { + test.use({ server: 'apps/e2e/demo-e2e-web-host/src/main.ts', project: 'demo-e2e-web-host', publicMode: true }); + + test('prints the sign-in token once, and never again', async ({ server }) => { + const token = startupToken(server); + + expect(server.getLogs().join('\n').split(token).length - 1).toBe(1); + }); + + test('a person signs in with a one-time code posted from the server page', async ({ server }) => { + const { baseUrl } = server.info; + const code = await signInCode(server, startupToken(server)); + + const page = await fetch(`${baseUrl}/_frontmcp/people/login?code=${code}`); + expect(page.status).toBe(200); + expect(page.headers.get('set-cookie')).toBeNull(); + expect(await page.text()).toContain('method="post"'); + + const login = await postCode(server, code); + expect(login.status).toBe(200); + const setCookie = login.headers.get('set-cookie') ?? ''; + expect(setCookie).toMatch(/^frontmcp_people=[A-Za-z0-9_-]{43}; Path=\/; Max-Age=\d+; HttpOnly; SameSite=Strict$/); + expect(setCookie).not.toContain(startupToken(server)); + + const reused = await postCode(server, code); + expect(reused.status).toBe(401); + + const board = await fetch(`${baseUrl}/board`, { headers: { Cookie: setCookie.split(';')[0] } }); + expect(board.status).toBe(200); + }); + + test('a page refuses a browser that has not signed in, and a public page serves it', async ({ server }) => { + const { baseUrl } = server.info; + + expect((await fetch(`${baseUrl}/board`)).status).toBe(401); + expect((await fetch(`${baseUrl}/board`, { headers: { Cookie: 'frontmcp_people=forged' } })).status).toBe(401); + expect(await (await fetch(`${baseUrl}/status`)).json()).toEqual({ ok: true }); + }); + + test('refuses a code posted from another site', async ({ server }) => { + const code = await signInCode(server, startupToken(server)); + + const response = await postCode(server, code, 'https://evil.example'); + + expect(response.status).toBe(403); + expect(response.headers.get('set-cookie')).toBeNull(); + }); + + test('refuses a cross-site mutation, and serves the same one from the server page', async ({ server }) => { + const { baseUrl } = server.info; + const cookie = await signIn(server); + const add = (origin: string) => + fetch(`${baseUrl}/board/cards`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Cookie: cookie, Origin: origin }, + body: JSON.stringify({ title: 'from the page' }), + }); + + const crossSite = await add('https://evil.example'); + expect(crossSite.status).toBe(403); + + const sameOrigin = await add(baseUrl); + expect(sameOrigin.status).toBe(200); + }); + + test('an SSE page hears the resource change a tool makes', async ({ server, mcp }) => { + const cookie = await signIn(server); + const controller = new AbortController(); + const response = await fetch(`${server.info.baseUrl}/board/events`, { + headers: { Cookie: cookie, Accept: 'text/event-stream' }, + signal: controller.signal, + }); + expect(response.status).toBe(200); + expect(response.headers.get('content-type')).toContain('text/event-stream'); + const reader = (response.body as ReadableStream).getReader(); + + try { + const first = await nextEvent<{ cards: string[]; revision: number }>(reader, () => true); + const added = await mcp.tools.call('add-card', { title: 'from an agent' }); + expect(added).toBeSuccessful(); + + const update = await nextEvent<{ cards: string[]; revision: number }>( + reader, + (event) => event.revision > first.revision, + ); + expect(update.cards).toContain('from an agent'); + } finally { + controller.abort(); + } + }); + + test('a page and a tool share the app providers', async ({ server, mcp }) => { + const cookie = await signIn(server); + const visitsFromTool = async () => ((await mcp.tools.call('record-visit', {})).json() as { visits: number }).visits; + const visitsFromPage = async () => + ( + (await (await fetch(`${server.info.baseUrl}/board`, { headers: { Cookie: cookie } })).json()) as { + visits: number; + } + ).visits; + + const first = await visitsFromTool(); + const second = await visitsFromPage(); + const third = await visitsFromTool(); + + expect([second, third]).toEqual([first + 1, first + 2]); + }); +}); diff --git a/apps/e2e/demo-e2e-web-host/jest.e2e.config.ts b/apps/e2e/demo-e2e-web-host/jest.e2e.config.ts new file mode 100644 index 000000000..4b18c47c5 --- /dev/null +++ b/apps/e2e/demo-e2e-web-host/jest.e2e.config.ts @@ -0,0 +1,43 @@ +import { createRequire } from 'module'; + +import type { Config } from '@jest/types'; + +const require = createRequire(import.meta.url); +const e2eCoveragePreset = require('../../../jest.e2e.coverage.preset.js'); + +const config: Config.InitialOptions = { + displayName: 'demo-e2e-web-host', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + testMatch: ['/e2e/**/*.e2e.spec.ts'], + testTimeout: 60000, + maxWorkers: 1, + setupFilesAfterEnv: ['/../../../libs/testing/src/setup.ts'], + transformIgnorePatterns: ['node_modules/(?!(jose)/)'], + transform: { + '^.+\\.[tj]s$': [ + '@swc/jest', + { + jsc: { + parser: { + syntax: 'typescript', + decorators: true, + }, + transform: { + decoratorMetadata: true, + }, + target: 'es2022', + }, + }, + ], + }, + moduleNameMapper: { + '^@frontmcp/testing$': '/../../../libs/testing/src/index.ts', + '^@frontmcp/sdk$': '/../../../libs/sdk/src/index.ts', + '^@frontmcp/adapters$': '/../../../libs/adapters/src/index.ts', + }, + coverageDirectory: '../../../coverage/e2e/demo-e2e-web-host', + ...e2eCoveragePreset, +}; + +export default config; diff --git a/apps/e2e/demo-e2e-web-host/project.json b/apps/e2e/demo-e2e-web-host/project.json new file mode 100644 index 000000000..ca682665c --- /dev/null +++ b/apps/e2e/demo-e2e-web-host/project.json @@ -0,0 +1,45 @@ +{ + "name": "demo-e2e-web-host", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "apps/e2e/demo-e2e-web-host/src", + "projectType": "application", + "tags": ["scope:demo", "type:e2e", "feature:web-host"], + "targets": { + "build": { + "executor": "@nx/webpack:webpack", + "outputs": ["{options.outputPath}"], + "defaultConfiguration": "development", + "options": { + "target": "node", + "compiler": "tsc", + "outputPath": "dist/apps/e2e/demo-e2e-web-host", + "main": "apps/e2e/demo-e2e-web-host/src/main.ts", + "tsConfig": "apps/e2e/demo-e2e-web-host/tsconfig.app.json", + "webpackConfig": "apps/e2e/demo-e2e-web-host/webpack.config.js", + "generatePackageJson": true + }, + "configurations": { + "development": {}, + "production": { + "optimization": true + } + } + }, + "serve": { + "executor": "nx:run-commands", + "dependsOn": ["build"], + "options": { + "command": "node dist/apps/e2e/demo-e2e-web-host/main.js", + "cwd": "{workspaceRoot}" + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/apps/e2e/demo-e2e-web-host"], + "options": { + "jestConfig": "apps/e2e/demo-e2e-web-host/jest.e2e.config.ts", + "passWithNoTests": true + } + } + } +} diff --git a/apps/e2e/demo-e2e-web-host/src/apps/board/board.routes.ts b/apps/e2e/demo-e2e-web-host/src/apps/board/board.routes.ts new file mode 100644 index 000000000..c29322ea6 --- /dev/null +++ b/apps/e2e/demo-e2e-web-host/src/apps/board/board.routes.ts @@ -0,0 +1,36 @@ +import { HttpRoute, HttpRouteContext } from '@frontmcp/sdk'; + +import { board, boardStore } from './board.store'; +import { VisitCounter } from './visit-counter.provider'; + +@HttpRoute({ method: 'GET', path: '/board' }) +export class BoardPage extends HttpRouteContext { + async handle() { + const { value } = await board.get(); + return { cards: value, visits: this.get(VisitCounter).record() }; + } +} + +@HttpRoute({ method: 'POST', path: '/board/cards' }) +export class AddCardRoute extends HttpRouteContext { + async handle() { + const title = String((this.request.body as { title?: unknown } | undefined)?.title ?? ''); + const snapshot = await board.update((cards) => [...cards, title]); + return { revision: snapshot.revision }; + } +} + +@HttpRoute({ method: 'GET', path: '/board/events', sse: { store: boardStore, topics: ['board'] } }) +export class BoardEvents extends HttpRouteContext { + async handle() { + const { value, revision } = await board.get(); + return { cards: value, revision }; + } +} + +@HttpRoute({ method: 'GET', path: '/status', auth: 'public' }) +export class StatusRoute extends HttpRouteContext { + handle() { + return { ok: true }; + } +} diff --git a/apps/e2e/demo-e2e-web-host/src/apps/board/board.store.ts b/apps/e2e/demo-e2e-web-host/src/apps/board/board.store.ts new file mode 100644 index 000000000..3dca03e34 --- /dev/null +++ b/apps/e2e/demo-e2e-web-host/src/apps/board/board.store.ts @@ -0,0 +1,5 @@ +import { resourceStore } from '@frontmcp/sdk'; + +export const boardStore = resourceStore(); + +export const board = boardStore.key('board', { initial: [] }); diff --git a/apps/e2e/demo-e2e-web-host/src/apps/board/board.tools.ts b/apps/e2e/demo-e2e-web-host/src/apps/board/board.tools.ts new file mode 100644 index 000000000..c5f2b6744 --- /dev/null +++ b/apps/e2e/demo-e2e-web-host/src/apps/board/board.tools.ts @@ -0,0 +1,19 @@ +import { Tool, ToolContext, z } from '@frontmcp/sdk'; + +import { board } from './board.store'; +import { VisitCounter } from './visit-counter.provider'; + +@Tool({ name: 'add-card', description: 'Add a card to the board', inputSchema: { title: z.string() } }) +export class AddCardTool extends ToolContext { + async execute({ title }: { title: string }) { + const snapshot = await board.update((cards) => [...cards, title]); + return { revision: snapshot.revision }; + } +} + +@Tool({ name: 'record-visit', description: 'Record a visit and return the count', inputSchema: {} }) +export class RecordVisitTool extends ToolContext { + async execute() { + return { visits: this.get(VisitCounter).record() }; + } +} diff --git a/apps/e2e/demo-e2e-web-host/src/apps/board/index.ts b/apps/e2e/demo-e2e-web-host/src/apps/board/index.ts new file mode 100644 index 000000000..d6bff0194 --- /dev/null +++ b/apps/e2e/demo-e2e-web-host/src/apps/board/index.ts @@ -0,0 +1,14 @@ +import { App } from '@frontmcp/sdk'; + +import { AddCardRoute, BoardEvents, BoardPage, StatusRoute } from './board.routes'; +import { AddCardTool, RecordVisitTool } from './board.tools'; +import { VisitCounter } from './visit-counter.provider'; + +@App({ + name: 'board', + description: 'A board that agents change through tools and people see through pages', + providers: [VisitCounter], + tools: [AddCardTool, RecordVisitTool], + routes: [BoardPage, AddCardRoute, BoardEvents, StatusRoute], +}) +export class BoardApp {} diff --git a/apps/e2e/demo-e2e-web-host/src/apps/board/visit-counter.provider.ts b/apps/e2e/demo-e2e-web-host/src/apps/board/visit-counter.provider.ts new file mode 100644 index 000000000..b0b3f887b --- /dev/null +++ b/apps/e2e/demo-e2e-web-host/src/apps/board/visit-counter.provider.ts @@ -0,0 +1,11 @@ +import { Provider, ProviderScope } from '@frontmcp/sdk'; + +/** Counts the visits of tools and pages alike: both get the same instance from the app's providers. */ +@Provider({ name: 'VisitCounter', scope: ProviderScope.GLOBAL }) +export class VisitCounter { + private visits = 0; + + record(): number { + return ++this.visits; + } +} diff --git a/apps/e2e/demo-e2e-web-host/src/main.ts b/apps/e2e/demo-e2e-web-host/src/main.ts new file mode 100644 index 000000000..8cdd868b0 --- /dev/null +++ b/apps/e2e/demo-e2e-web-host/src/main.ts @@ -0,0 +1,14 @@ +import { FrontMcp, LogLevel } from '@frontmcp/sdk'; + +import { BoardApp } from './apps/board'; + +const parsedPort = Number.parseInt(process.env['PORT'] ?? '', 10); +const port = Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65_535 ? parsedPort : 3170; + +@FrontMcp({ + info: { name: 'Demo E2E Web Host', version: '0.1.0' }, + apps: [BoardApp], + logging: { level: LogLevel.Warn, enableConsole: true }, + http: { port, auth: { people: 'loopback-token' } }, +}) +export default class Server {} diff --git a/apps/e2e/demo-e2e-web-host/tsconfig.app.json b/apps/e2e/demo-e2e-web-host/tsconfig.app.json new file mode 100644 index 000000000..840e39680 --- /dev/null +++ b/apps/e2e/demo-e2e-web-host/tsconfig.app.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/app", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "jsx": "react-jsx", + "types": ["node"], + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "declaration": true + }, + "exclude": ["jest.config.ts", "jest.e2e.config.ts", "src/**/*.spec.ts", "src/**/*.spec.tsx", "e2e/**/*.ts"], + "include": ["src/**/*.ts", "src/**/*.tsx"] +} diff --git a/apps/e2e/demo-e2e-web-host/tsconfig.e2e.json b/apps/e2e/demo-e2e-web-host/tsconfig.e2e.json new file mode 100644 index 000000000..89ff94783 --- /dev/null +++ b/apps/e2e/demo-e2e-web-host/tsconfig.e2e.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "./out-tsc/e2e", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "types": ["node", "jest"], + "emitDecoratorMetadata": true, + "experimentalDecorators": true, + "declaration": true + }, + "include": ["e2e/**/*.ts"] +} diff --git a/apps/e2e/demo-e2e-web-host/tsconfig.json b/apps/e2e/demo-e2e-web-host/tsconfig.json new file mode 100644 index 000000000..a72bf4f01 --- /dev/null +++ b/apps/e2e/demo-e2e-web-host/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.app.json" + }, + { + "path": "./tsconfig.e2e.json" + } + ], + "compilerOptions": { + "esModuleInterop": true + } +} diff --git a/apps/e2e/demo-e2e-web-host/webpack.config.js b/apps/e2e/demo-e2e-web-host/webpack.config.js new file mode 100644 index 000000000..ed22f2a73 --- /dev/null +++ b/apps/e2e/demo-e2e-web-host/webpack.config.js @@ -0,0 +1,28 @@ +const { NxAppWebpackPlugin } = require('@nx/webpack/app-plugin'); +const { join } = require('path'); + +module.exports = { + output: { + path: join(__dirname, '../../../dist/apps/e2e/demo-e2e-web-host'), + ...(process.env.NODE_ENV !== 'production' && { + devtoolModuleFilenameTemplate: '[absolute-resource-path]', + }), + }, + mode: 'development', + devtool: 'eval-cheap-module-source-map', + plugins: [ + new NxAppWebpackPlugin({ + target: 'node', + compiler: 'tsc', + main: './src/main.ts', + sourceMap: true, + tsConfig: './tsconfig.app.json', + assets: [], + externalDependencies: 'all', + optimization: false, + outputHashing: 'none', + generatePackageJson: false, + buildLibsFromSource: true, + }), + ], +}; diff --git a/libs/auth/src/index.ts b/libs/auth/src/index.ts index dedec5049..f197bf94c 100644 --- a/libs/auth/src/index.ts +++ b/libs/auth/src/index.ts @@ -677,6 +677,21 @@ export type { FrontMcpFetchInit, } from './fetch'; +// ============================================ +// People Auth Module (sign-in for a server's own pages) +// ============================================ +export { LoopbackTokenAuth, PEOPLE_SESSION_COOKIE, peopleAuthOptionsSchema, peopleSessionCookie } from './people'; +export type { + LoopbackTokenPeopleAuthOptions, + PeopleAuthOption, + PeopleAuthOptions, + PeopleCodeIssue, + PeopleSession, + PeopleSessionCookie, + PeopleSessionIssue, + PeopleSignInRefusal, +} from './people'; + // ============================================ // Shared Local Server Identity Module // ============================================ diff --git a/libs/auth/src/people/__tests__/loopback-token.auth.spec.ts b/libs/auth/src/people/__tests__/loopback-token.auth.spec.ts new file mode 100644 index 000000000..0a9fa24c8 --- /dev/null +++ b/libs/auth/src/people/__tests__/loopback-token.auth.spec.ts @@ -0,0 +1,247 @@ +import * as utils from '@frontmcp/utils'; + +import { + LoopbackTokenAuth, + PEOPLE_SESSION_COOKIE, + peopleAuthOptionsSchema, + peopleSessionCookie, + type PeopleAuthOptions, +} from '../index'; + +jest.mock('@frontmcp/utils', () => { + const actual = jest.requireActual('@frontmcp/utils'); + return { ...actual, timingSafeEqual: jest.fn(actual.timingSafeEqual) }; +}); + +const DEFAULTS: PeopleAuthOptions = { + mode: 'loopback-token', + codeTtlMs: 60_000, + sessionTtlMs: 43_200_000, + maxFailedAttempts: 5, + lockoutMs: 60_000, + cookiePath: '/', +}; + +function signedIn(auth: LoopbackTokenAuth, client = 'client-a'): string { + const { code } = auth.createCode(); + const result = auth.exchangeCode(code, client); + if (!result.ok) throw new Error('the code was refused'); + return result.cookieValue; +} + +describe('peopleAuthOptionsSchema', () => { + it("expands 'loopback-token' to the defaults", () => { + expect(peopleAuthOptionsSchema().parse('loopback-token')).toEqual(DEFAULTS); + }); + + it('keeps the settings an object gives and fills in the rest', () => { + expect(peopleAuthOptionsSchema().parse({ mode: 'loopback-token', codeTtlMs: 30_000, cookiePath: '/app' })).toEqual({ + ...DEFAULTS, + codeTtlMs: 30_000, + cookiePath: '/app', + }); + }); + + it.each([ + ['an unknown mode', 'oidc'], + ['an unknown setting', { mode: 'loopback-token', ttl: 5 }], + ['a code that lives longer than 10 minutes', { mode: 'loopback-token', codeTtlMs: 600_001 }], + ['a session TTL of zero', { mode: 'loopback-token', sessionTtlMs: 0 }], + ['a cookie path that is not absolute', { mode: 'loopback-token', cookiePath: 'app' }], + ])('refuses %s', (_case, value) => { + expect(peopleAuthOptionsSchema().safeParse(value).success).toBe(false); + }); +}); + +describe('LoopbackTokenAuth', () => { + let auth: LoopbackTokenAuth; + + beforeEach(() => { + jest.useFakeTimers({ now: 1_000_000 }); + auth = new LoopbackTokenAuth(DEFAULTS); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + describe('the token', () => { + it('is handed out once, for the startup print', () => { + const token = auth.takeToken(); + + expect(token).toMatch(/^[A-Za-z0-9_-]{43}$/); + expect(auth.takeToken()).toBeUndefined(); + }); + + it('is different for every server', () => { + expect(new LoopbackTokenAuth(DEFAULTS).takeToken()).not.toBe(auth.takeToken()); + }); + + it('buys a one-time code', () => { + const token = auth.takeToken(); + + expect(auth.exchangeToken(token, 'client-a')).toEqual({ + ok: true, + code: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + expiresAt: 1_060_000, + }); + }); + + it.each([ + ['a wrong token', 'not-the-token'], + ['no token', undefined], + ])('refuses %s', (_case, presented) => { + expect(auth.exchangeToken(presented, 'client-a')).toEqual({ ok: false, reason: 'invalid' }); + }); + }); + + describe('a sign-in code', () => { + it('signs a person in once', () => { + const { code } = auth.createCode(); + + expect(auth.exchangeCode(code, 'client-a')).toEqual({ + ok: true, + cookieValue: expect.stringMatching(/^[A-Za-z0-9_-]{43}$/), + expiresAt: 1_000_000 + 43_200_000, + }); + expect(auth.exchangeCode(code, 'client-a')).toEqual({ ok: false, reason: 'invalid' }); + }); + + it('expires after codeTtlMs', () => { + const { code } = auth.createCode(); + + jest.advanceTimersByTime(60_001); + + expect(auth.exchangeCode(code, 'client-a')).toEqual({ ok: false, reason: 'invalid' }); + }); + + it('is compared in constant time', () => { + const { code } = auth.createCode(); + jest.mocked(utils.timingSafeEqual).mockClear(); + + auth.exchangeCode(`${code}x`, 'client-a'); + + expect(utils.timingSafeEqual).toHaveBeenCalled(); + for (const [left, right] of jest.mocked(utils.timingSafeEqual).mock.calls) { + expect(left.length).toBe(right.length); + } + }); + }); + + describe('bad codes', () => { + it('lock a client out after maxFailedAttempts, even from a good code', () => { + for (let attempt = 1; attempt < 5; attempt++) { + expect(auth.exchangeCode(`wrong-${attempt}`, 'client-a')).toEqual({ ok: false, reason: 'invalid' }); + } + expect(auth.exchangeCode('wrong-5', 'client-a')).toEqual({ ok: false, reason: 'locked' }); + + const { code } = auth.createCode(); + expect(auth.exchangeCode(code, 'client-a')).toEqual({ ok: false, reason: 'locked' }); + }); + + it('lock out the bad tokens of a client too', () => { + for (let attempt = 0; attempt < 5; attempt++) auth.exchangeToken('wrong', 'client-a'); + + expect(auth.exchangeToken(auth.takeToken(), 'client-a')).toEqual({ ok: false, reason: 'locked' }); + }); + + it('lock out only the client that sent them', () => { + for (let attempt = 0; attempt < 5; attempt++) auth.exchangeCode('wrong', 'client-a'); + + const { code } = auth.createCode(); + expect(auth.exchangeCode(code, 'client-b').ok).toBe(true); + }); + + it('end their lockout after lockoutMs', () => { + for (let attempt = 0; attempt < 5; attempt++) auth.exchangeCode('wrong', 'client-a'); + + jest.advanceTimersByTime(60_001); + + const { code } = auth.createCode(); + expect(auth.exchangeCode(code, 'client-a').ok).toBe(true); + }); + + it('keep a locked client locked while idle clients are forgotten', () => { + for (let client = 0; client < 1100; client++) auth.exchangeCode('wrong', `client-${client}`); + jest.advanceTimersByTime(60_001); + for (let attempt = 0; attempt < 5; attempt++) auth.exchangeCode('wrong', 'locked-client'); + + auth.exchangeCode('wrong', 'new-client'); + + const { code } = auth.createCode(); + expect(auth.exchangeCode(code, 'locked-client')).toEqual({ ok: false, reason: 'locked' }); + }); + + it('are forgotten once lockoutMs passes without another', () => { + for (let attempt = 0; attempt < 4; attempt++) auth.exchangeCode('wrong', 'client-a'); + + jest.advanceTimersByTime(60_001); + + expect(auth.exchangeCode('wrong', 'client-a')).toEqual({ ok: false, reason: 'invalid' }); + }); + }); + + describe('a session', () => { + it('is recognised from its cookie value', () => { + const cookieValue = signedIn(auth); + + expect(auth.verifySession(cookieValue)).toEqual({ expiresAt: 1_000_000 + 43_200_000 }); + }); + + it('never has the token or the code as its cookie value', () => { + const token = auth.takeToken(); + const issued = auth.exchangeToken(token, 'client-a'); + if (!issued.ok) throw new Error('the token was refused'); + const session = auth.exchangeCode(issued.code, 'client-a'); + + expect(session.ok && [session.cookieValue === token, session.cookieValue === issued.code]).toEqual([ + false, + false, + ]); + }); + + it('ends after sessionTtlMs', () => { + const cookieValue = signedIn(auth); + + jest.advanceTimersByTime(43_200_001); + + expect(auth.verifySession(cookieValue)).toBeUndefined(); + }); + + it.each([ + ['an unknown value', 'not-a-session'], + ['an empty value', ''], + ['no value', undefined], + ])('is not found from %s', (_case, presented) => { + signedIn(auth); + + expect(auth.verifySession(presented)).toBeUndefined(); + }); + + it('ends when the person signs out', () => { + const cookieValue = signedIn(auth); + + auth.endSession(cookieValue); + + expect(auth.verifySession(cookieValue)).toBeUndefined(); + }); + }); +}); + +describe('peopleSessionCookie', () => { + it('is HttpOnly, SameSite=Strict and scoped to its path', () => { + expect(peopleSessionCookie('value', { path: '/app', secure: false, maxAgeMs: 90_500 })).toEqual({ + name: PEOPLE_SESSION_COOKIE, + value: 'value', + path: '/app', + httpOnly: true, + sameSite: 'strict', + secure: false, + maxAge: 90, + }); + }); + + it('is Secure over https', () => { + expect(peopleSessionCookie('value', { path: '/', secure: true, maxAgeMs: 1000 }).secure).toBe(true); + }); +}); diff --git a/libs/auth/src/people/index.ts b/libs/auth/src/people/index.ts new file mode 100644 index 000000000..0c810f0ad --- /dev/null +++ b/libs/auth/src/people/index.ts @@ -0,0 +1,16 @@ +export { + peopleAuthOptionsSchema, + type LoopbackTokenPeopleAuthOptions, + type PeopleAuthOption, + type PeopleAuthOptions, +} from './people-auth.options'; +export { + LoopbackTokenAuth, + PEOPLE_SESSION_COOKIE, + peopleSessionCookie, + type PeopleCodeIssue, + type PeopleSession, + type PeopleSessionCookie, + type PeopleSessionIssue, + type PeopleSignInRefusal, +} from './loopback-token.auth'; diff --git a/libs/auth/src/people/loopback-token.auth.ts b/libs/auth/src/people/loopback-token.auth.ts new file mode 100644 index 000000000..a85fbe230 --- /dev/null +++ b/libs/auth/src/people/loopback-token.auth.ts @@ -0,0 +1,165 @@ +import { base64urlEncode, randomBytes, sha256, timingSafeEqual } from '@frontmcp/utils'; + +import type { PeopleAuthOptions } from './people-auth.options'; + +/** The cookie a signed-in browser presents. */ +export const PEOPLE_SESSION_COOKIE = 'frontmcp_people'; + +/** A signed-in browser. */ +export interface PeopleSession { + readonly expiresAt: number; +} + +export interface PeopleSignInRefusal { + ok: false; + /** `invalid`: a wrong, used or expired token or code; `locked`: too many of them from this client. */ + reason: 'invalid' | 'locked'; +} + +export type PeopleCodeIssue = { ok: true; code: string; expiresAt: number } | PeopleSignInRefusal; + +export type PeopleSessionIssue = { ok: true; cookieValue: string; expiresAt: number } | PeopleSignInRefusal; + +interface StoredSecret { + digest: Uint8Array; + expiresAt: number; +} + +interface ClientFailures { + count: number; + lastAt: number; + lockedUntil: number; +} + +const INVALID: PeopleSignInRefusal = { ok: false, reason: 'invalid' }; +const LOCKED: PeopleSignInRefusal = { ok: false, reason: 'locked' }; +const MAX_TRACKED_CLIENTS = 1024; + +function newSecret(): string { + return base64urlEncode(randomBytes(32)); +} + +/** Compares digests, so neither the time taken nor the equal-length requirement depends on what was presented. */ +function matches(presented: string, digest: Uint8Array): boolean { + return timingSafeEqual(sha256(presented), digest); +} + +function unexpired(secrets: StoredSecret[]): StoredSecret[] { + const now = Date.now(); + return secrets.filter((secret) => secret.expiresAt > now); +} + +/** + * People auth for a server's own pages: a token printed once at startup buys one-time sign-in codes, and a code, + * posted from the server's own page, buys a session cookie. Only digests of the token, codes and sessions are kept. + */ +export class LoopbackTokenAuth { + private token?: string; + private readonly tokenDigest: Uint8Array; + private codes: StoredSecret[] = []; + private sessions: StoredSecret[] = []; + private readonly failures = new Map(); + + constructor(readonly options: PeopleAuthOptions) { + const token = newSecret(); + this.token = token; + this.tokenDigest = sha256(token); + } + + /** The token, the first time it is asked for: the server prints it at startup, and nothing can read it after. */ + takeToken(): string | undefined { + const token = this.token; + this.token = undefined; + return token; + } + + /** A one-time sign-in code, for a caller this process already trusts, such as a tool. */ + createCode(): { code: string; expiresAt: number } { + const code = newSecret(); + const expiresAt = Date.now() + this.options.codeTtlMs; + this.codes = [...unexpired(this.codes), { digest: sha256(code), expiresAt }]; + return { code, expiresAt }; + } + + /** A one-time sign-in code for the holder of the token. */ + exchangeToken(presented: string | undefined, client: string): PeopleCodeIssue { + if (this.isLocked(client)) return LOCKED; + if (presented === undefined || !matches(presented, this.tokenDigest)) return this.refuse(client); + this.failures.delete(client); + return { ok: true, ...this.createCode() }; + } + + /** A session for the holder of a code; the code is spent. */ + exchangeCode(presented: string | undefined, client: string): PeopleSessionIssue { + if (this.isLocked(client)) return LOCKED; + this.codes = unexpired(this.codes); + const index = presented === undefined ? -1 : this.codes.findIndex((code) => matches(presented, code.digest)); + if (index === -1) return this.refuse(client); + this.codes.splice(index, 1); + this.failures.delete(client); + const cookieValue = newSecret(); + const expiresAt = Date.now() + this.options.sessionTtlMs; + this.sessions = [...unexpired(this.sessions), { digest: sha256(cookieValue), expiresAt }]; + return { ok: true, cookieValue, expiresAt }; + } + + verifySession(presented: string | undefined): PeopleSession | undefined { + if (!presented) return undefined; + this.sessions = unexpired(this.sessions); + const session = this.sessions.find((stored) => matches(presented, stored.digest)); + return session ? { expiresAt: session.expiresAt } : undefined; + } + + endSession(presented: string | undefined): void { + if (!presented) return; + this.sessions = this.sessions.filter((stored) => !matches(presented, stored.digest)); + } + + private isLocked(client: string): boolean { + return (this.failures.get(client)?.lockedUntil ?? 0) > Date.now(); + } + + private refuse(client: string): PeopleSignInRefusal { + const now = Date.now(); + const { maxFailedAttempts, lockoutMs } = this.options; + const previous = this.failures.get(client); + const count = previous && now - previous.lastAt <= lockoutMs ? previous.count + 1 : 1; + const locked = count >= maxFailedAttempts; + if (!previous && this.failures.size >= MAX_TRACKED_CLIENTS) this.forgetIdleClients(now); + this.failures.set(client, { count: locked ? 0 : count, lastAt: now, lockedUntil: locked ? now + lockoutMs : 0 }); + return locked ? LOCKED : INVALID; + } + + private forgetIdleClients(now: number): void { + for (const [client, failures] of this.failures) { + if (failures.lockedUntil <= now && now - failures.lastAt > this.options.lockoutMs) this.failures.delete(client); + } + } +} + +export interface PeopleSessionCookie { + name: string; + value: string; + path: string; + httpOnly: true; + sameSite: 'strict'; + secure: boolean; + /** Seconds. */ + maxAge: number; +} + +/** The session cookie: HttpOnly, SameSite=Strict, scoped to `path`, and Secure when the page is served over https. */ +export function peopleSessionCookie( + value: string, + options: { path: string; secure: boolean; maxAgeMs: number }, +): PeopleSessionCookie { + return { + name: PEOPLE_SESSION_COOKIE, + value, + path: options.path, + httpOnly: true, + sameSite: 'strict', + secure: options.secure, + maxAge: Math.floor(options.maxAgeMs / 1000), + }; +} diff --git a/libs/auth/src/people/people-auth.options.ts b/libs/auth/src/people/people-auth.options.ts new file mode 100644 index 000000000..c09bdd905 --- /dev/null +++ b/libs/auth/src/people/people-auth.options.ts @@ -0,0 +1,73 @@ +import { internalZ as z } from '@frontmcp/lazy-zod'; + +const MINUTE_MS = 60_000; + +/** How people sign in to a server's `@HttpRoute` pages: a token printed at startup, a one-time code, then a cookie. */ +export interface LoopbackTokenPeopleAuthOptions { + mode: 'loopback-token'; + /** How long a one-time sign-in code stays valid, at most 10 minutes. @default 60000 */ + codeTtlMs?: number; + /** How long a browser stays signed in. @default 43200000 (12 hours) */ + sessionTtlMs?: number; + /** Bad tokens or codes a client may send before it is locked out. @default 5 */ + maxFailedAttempts?: number; + /** How long a locked-out client waits, and how long a bad attempt is remembered. @default 60000 */ + lockoutMs?: number; + /** The `Path` of the session cookie. @default '/' */ + cookiePath?: string; +} + +/** `http.auth.people`: `'loopback-token'`, or the same mode with its settings. */ +export type PeopleAuthOption = 'loopback-token' | LoopbackTokenPeopleAuthOptions; + +export type PeopleAuthOptions = Required; + +function milliseconds(field: string) { + return z + .number({ message: `${field} must be a number of milliseconds` }) + .int(`${field} must be a whole number of milliseconds`) + .positive(`${field} must be greater than 0`); +} + +function createPeopleAuthOptionsSchema() { + const loopbackToken = z + .object({ + mode: z.literal('loopback-token'), + codeTtlMs: milliseconds('codeTtlMs') + .max(10 * MINUTE_MS, 'codeTtlMs must be at most 600000 (10 minutes): a sign-in code is for one hand-off') + .optional(), + sessionTtlMs: milliseconds('sessionTtlMs').optional(), + maxFailedAttempts: z + .number({ message: 'maxFailedAttempts must be a number' }) + .int('maxFailedAttempts must be a whole number') + .positive('maxFailedAttempts must be greater than 0') + .optional(), + lockoutMs: milliseconds('lockoutMs').optional(), + cookiePath: z.string().startsWith('/', 'cookiePath must start with "/"').optional(), + }) + .strict(); + + return z + .union([z.literal('loopback-token'), loopbackToken], { + message: "http.auth.people must be 'loopback-token' or { mode: 'loopback-token', ... }", + }) + .transform((value): PeopleAuthOptions => { + const settings: Partial = typeof value === 'string' ? {} : value; + return { + mode: 'loopback-token', + codeTtlMs: settings.codeTtlMs ?? MINUTE_MS, + sessionTtlMs: settings.sessionTtlMs ?? 12 * 60 * MINUTE_MS, + maxFailedAttempts: settings.maxFailedAttempts ?? 5, + lockoutMs: settings.lockoutMs ?? MINUTE_MS, + cookiePath: settings.cookiePath ?? '/', + }; + }); +} + +let schema: ReturnType | undefined; + +/** The schema of `http.auth.people`, built on first use so a server without people auth never builds it. */ +export function peopleAuthOptionsSchema(): ReturnType { + schema ??= createPeopleAuthOptionsSchema(); + return schema; +} diff --git a/libs/sdk/src/common/decorators/http-route.decorator.ts b/libs/sdk/src/common/decorators/http-route.decorator.ts new file mode 100644 index 000000000..6fdd1f60b --- /dev/null +++ b/libs/sdk/src/common/decorators/http-route.decorator.ts @@ -0,0 +1,61 @@ +import 'reflect-metadata'; + +import { z } from '@frontmcp/lazy-zod'; + +import { InvalidDecoratorMetadataError } from '../../errors/decorator.errors'; +import { type HttpRouteType } from '../interfaces/http-route.interface'; +import { + defineHttpRouteMetadata, + HTTP_ROUTE_PATH_MESSAGE, + isHttpRoutePath, + type HttpRouteMetadata, + type HttpRouteTopicSource, +} from '../metadata/http-route.metadata'; + +const httpRouteMetadataSchema = z + .object({ + method: z.enum(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], { + message: "method must be 'GET', 'POST', 'PUT', 'PATCH' or 'DELETE'", + }), + path: z.custom string)>((value) => typeof value === 'function' || isHttpRoutePath(value), { + message: HTTP_ROUTE_PATH_MESSAGE, + }), + auth: z.enum(['people', 'public'], { message: "auth must be 'people' or 'public'" }).optional(), + sse: z + .object({ + store: z.custom( + (value) => typeof (value as Partial | null)?.subscribe === 'function', + { message: 'sse.store must have subscribe(topic, listener), such as a resourceStore()' }, + ), + topics: z + .array(z.string().min(1, 'a topic must not be empty')) + .min(1, 'sse.topics must name at least one topic'), + coalesceMs: z.number().int().nonnegative('sse.coalesceMs must not be negative').default(50), + heartbeatMs: z.number().int().positive('sse.heartbeatMs must be greater than 0').default(15_000), + }) + .strict() + .optional(), + }) + .strict() + .refine((metadata) => !metadata.sse || metadata.method === 'GET', { + message: 'an sse route must use method GET', + path: ['sse'], + }); + +/** + * Marks a class as an HTTP route of its app, served beside the MCP endpoint through the hookable `http:route` flow: + * the IP filter, a same-origin check on mutations, people auth, then `handle()`. List it in `@App({ routes })`. + */ +export function HttpRoute(metadata: HttpRouteMetadata): (target: HttpRouteType) => void { + return (target) => { + const result = httpRouteMetadataSchema.safeParse(metadata); + if (!result.success) { + const [issue] = result.error.issues; + throw new InvalidDecoratorMetadataError('HttpRoute', issue.path.join('.') || 'metadata', issue.message); + } + if (typeof target.prototype?.handle !== 'function') { + throw new InvalidDecoratorMetadataError('HttpRoute', 'handle', `${target.name} has no handle() method`); + } + defineHttpRouteMetadata(target, result.data); + }; +} diff --git a/libs/sdk/src/common/decorators/index.ts b/libs/sdk/src/common/decorators/index.ts index b359d15dc..a3ab39f83 100644 --- a/libs/sdk/src/common/decorators/index.ts +++ b/libs/sdk/src/common/decorators/index.ts @@ -16,3 +16,4 @@ export * from './job.decorator'; export * from './workflow.decorator'; export * from './channel.decorator'; export * from './duty.decorator'; +export * from './http-route.decorator'; diff --git a/libs/sdk/src/common/entries/scope.entry.ts b/libs/sdk/src/common/entries/scope.entry.ts index b6b500df2..dc2f3a6d0 100644 --- a/libs/sdk/src/common/entries/scope.entry.ts +++ b/libs/sdk/src/common/entries/scope.entry.ts @@ -13,6 +13,7 @@ import type { ChannelEventBus } from '../../channel/sources/app-event.source'; import type { ElicitationStore } from '../../elicitation/store/elicitation.store'; import type { HaManager } from '../../ha'; import type HookRegistry from '../../hooks/hook.registry'; +import type { HttpRouteTable } from '../../http-route/http-route.table'; import type JobRegistry from '../../job/job.registry'; import type { NotificationService } from '../../notification'; import type PromptRegistry from '../../prompt/prompt.registry'; @@ -105,6 +106,9 @@ export abstract class ScopeEntry extends BaseEntry | readonly unknown[] | string | undefined | void; + +export interface HttpRouteInterface { + handle(): HttpRouteResult | Promise; +} + +export interface HttpRouteCtorArgs extends ExecutionContextBaseArgs { + request: ServerRequest; + person?: PeopleSession; +} + +/** An `@HttpRoute` class: listed in `@App({ routes })`. */ +export type HttpRouteType = abstract new (args: HttpRouteCtorArgs) => HttpRouteInterface; + +/** + * The base of an `@HttpRoute` class. `this.get()` resolves the providers of the route's app, the ones its tools get. + * + * @example + * ```typescript + * @HttpRoute({ method: 'GET', path: '/board/:id' }) + * export class BoardPage extends HttpRouteContext { + * async handle() { + * return this.get(BoardService).find(this.params['id']); + * } + * } + * ``` + */ +export abstract class HttpRouteContext extends ExecutionContextBase implements HttpRouteInterface { + /** The request: method, path, headers, query and parsed body. */ + readonly request: ServerRequest; + /** The signed-in person, on a route with people auth. */ + readonly person?: PeopleSession; + + constructor(args: HttpRouteCtorArgs) { + super(args); + this.request = args.request; + this.person = args.person; + } + + /** The parameters of the route's path (`id` in `/board/:id`). */ + get params(): Record { + return this.request.params ?? {}; + } + + abstract handle(): HttpRouteResult | Promise; +} diff --git a/libs/sdk/src/common/interfaces/index.ts b/libs/sdk/src/common/interfaces/index.ts index 708b098a0..2b978c1b9 100644 --- a/libs/sdk/src/common/interfaces/index.ts +++ b/libs/sdk/src/common/interfaces/index.ts @@ -38,5 +38,6 @@ export * from './job.interface'; export * from './workflow.interface'; export * from './channel.interface'; export * from './duty.interface'; +export * from './http-route.interface'; export * from './internal'; diff --git a/libs/sdk/src/common/metadata/app.metadata.ts b/libs/sdk/src/common/metadata/app.metadata.ts index c10098361..afd98ca2d 100644 --- a/libs/sdk/src/common/metadata/app.metadata.ts +++ b/libs/sdk/src/common/metadata/app.metadata.ts @@ -8,6 +8,7 @@ import type { AgentType, AuthProviderType, ChannelType, + HttpRouteType, JobType, PluginType, PromptType, @@ -145,6 +146,12 @@ export interface LocalAppMetadata { */ channels?: ChannelType[]; + /** + * `@HttpRoute` classes this app serves beside the MCP endpoint: pages, APIs and SSE streams for people, with the + * app's providers (`this.get()`), the ones its tools get. + */ + routes?: HttpRouteType[]; + /** * Configures the app's default authentication provider. * If not provided, the app will use the gateway's default auth provider. @@ -218,6 +225,11 @@ export const frontMcpLocalAppMetadataSchema = z.looseObject({ jobs: z.array(z.lazy(() => annotatedFrontMcpJobsSchema)).optional(), workflows: z.array(z.lazy(() => annotatedFrontMcpWorkflowsSchema)).optional(), channels: z.array(z.lazy(() => annotatedFrontMcpChannelsSchema)).optional(), + routes: z + .array( + z.custom((value) => typeof value === 'function', { message: 'routes takes @HttpRoute classes' }), + ) + .optional(), auth: authOptionsSchema.optional(), output: outputPolicySchema.optional(), ui: z.object({ servingMode: widgetServingModeSchema.optional() }).optional(), diff --git a/libs/sdk/src/common/metadata/http-route.metadata.ts b/libs/sdk/src/common/metadata/http-route.metadata.ts new file mode 100644 index 000000000..ae2eb9c24 --- /dev/null +++ b/libs/sdk/src/common/metadata/http-route.metadata.ts @@ -0,0 +1,63 @@ +export type HttpRouteMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; + +/** Who may call a route: a signed-in person (`http.auth.people`), or anyone. */ +export type HttpRouteAuth = 'people' | 'public'; + +/** Where an SSE route hears that its view changed: a `resourceStore()`, whose keys publish their name as a topic. */ +export interface HttpRouteTopicSource { + subscribe(topic: string, listener: () => void): () => void; +} + +export interface HttpRouteSseOptions { + store: HttpRouteTopicSource; + /** A publish on any of these sends a new view (`handle()`'s return value). */ + topics: readonly string[]; + /** Publishes within this window send one view, in milliseconds. @default 50 */ + coalesceMs?: number; + /** A keep-alive comment is sent after this long without a view, in milliseconds. @default 15000 */ + heartbeatMs?: number; +} + +export interface HttpRouteMetadata { + method: HttpRouteMethod; + /** + * The path: literal segments and `:name` parameters (`/board/:id`), or a function that returns it when the server + * starts, for a path that comes from options. The MCP endpoint's paths are refused, except a `GET` on its own path: a page load there + * reaches the route, and the MCP event stream stays the endpoint's. + */ + path: string | (() => string); + /** @default 'people' when `http.auth.people` is set, else 'public' */ + auth?: HttpRouteAuth; + /** Serve `text/event-stream`: `handle()` makes the view, sent at once and after each burst of publishes. */ + sse?: HttpRouteSseOptions; +} + +export interface ResolvedHttpRouteMetadata { + method: HttpRouteMethod; + path: string | (() => string); + auth?: HttpRouteAuth; + sse?: Required; +} + +/** Literal segments and `:name` parameters: what the route table matches. */ +const ROUTE_PATH = /^\/[^?#\s*(){}]*$/; +export const HTTP_ROUTE_PATH_MESSAGE = + 'path must start with "/" and hold no query, fragment or whitespace; it takes literal segments and :name ' + + 'parameters, not the wildcards or groups (*, (), {}) of Express'; + +export function isHttpRoutePath(path: unknown): path is string { + return typeof path === 'string' && ROUTE_PATH.test(path); +} + +const HTTP_ROUTE_METADATA = Symbol.for('frontmcp:http-route'); + +/** @internal Records what `@HttpRoute` resolved on the class. */ +export function defineHttpRouteMetadata(target: object, metadata: ResolvedHttpRouteMetadata): void { + Reflect.defineMetadata(HTTP_ROUTE_METADATA, metadata, target); +} + +/** The `@HttpRoute` metadata of a class, when it has some. */ +export function httpRouteMetadataOf(target: unknown): ResolvedHttpRouteMetadata | undefined { + if (typeof target !== 'function') return undefined; + return Reflect.getMetadata(HTTP_ROUTE_METADATA, target); +} diff --git a/libs/sdk/src/common/metadata/index.ts b/libs/sdk/src/common/metadata/index.ts index 762fa99e0..3600429e9 100644 --- a/libs/sdk/src/common/metadata/index.ts +++ b/libs/sdk/src/common/metadata/index.ts @@ -27,3 +27,4 @@ export * from './channel.metadata'; export * from './app-filter.metadata'; export * from './remote-primitive.metadata'; export * from './duty.metadata'; +export * from './http-route.metadata'; diff --git a/libs/sdk/src/common/tokens/app.tokens.ts b/libs/sdk/src/common/tokens/app.tokens.ts index 74bb753b2..5df982927 100644 --- a/libs/sdk/src/common/tokens/app.tokens.ts +++ b/libs/sdk/src/common/tokens/app.tokens.ts @@ -20,6 +20,7 @@ export const FrontMcpLocalAppTokens: RawMetadataShape = { jobs: tokenFactory.meta('jobs'), workflows: tokenFactory.meta('workflows'), channels: tokenFactory.meta('channels'), + routes: tokenFactory.meta('routes'), output: tokenFactory.meta('output'), // tool UI defaults (`ui.servingMode`) for the app's tools (#720) ui: tokenFactory.meta('ui'), diff --git a/libs/sdk/src/common/types/options/http/index.ts b/libs/sdk/src/common/types/options/http/index.ts index 4a0ddd624..ca84d8cec 100644 --- a/libs/sdk/src/common/types/options/http/index.ts +++ b/libs/sdk/src/common/types/options/http/index.ts @@ -8,6 +8,7 @@ export type { SecurityOptions, SecurityHeadersOptions, CspHeaderOptions, + HttpAuthOptions, } from './interfaces'; export { defaultHttpPort, httpOptionsSchema } from './schema'; export type { HttpOptions, HttpOptionsInput } from './schema'; diff --git a/libs/sdk/src/common/types/options/http/interfaces.ts b/libs/sdk/src/common/types/options/http/interfaces.ts index 9177f79ac..0f9d4b89f 100644 --- a/libs/sdk/src/common/types/options/http/interfaces.ts +++ b/libs/sdk/src/common/types/options/http/interfaces.ts @@ -1,6 +1,8 @@ // common/types/options/http/interfaces.ts // Explicit TypeScript interfaces for HTTP configuration +import { type PeopleAuthOption } from '@frontmcp/auth'; + import { type FrontMcpServer, type HttpMethod, type ServerRequestHandler } from '../../../interfaces'; /** @@ -182,6 +184,9 @@ export interface HttpOptionsInterface { */ routes?: HttpRouteConfig[]; + /** How people sign in to the `@HttpRoute` pages; see {@link HttpAuthOptions}. */ + auth?: HttpAuthOptions; + /** * Response security headers (`X-Content-Type-Options`, `X-Frame-Options`, * `Strict-Transport-Security`, `Content-Security-Policy`, custom headers). @@ -211,6 +216,18 @@ export interface HttpOptionsInterface { forwardHeaders?: string[]; } +/** Authentication of the `@HttpRoute` pages, apart from the MCP endpoint's `auth`. */ +export interface HttpAuthOptions { + /** + * People auth. `'loopback-token'`: a token printed once at startup buys one-time sign-in codes + * (`POST /_frontmcp/people/code` with `Authorization: Bearer `, or `this.get(LoopbackTokenAuth).createCode()` + * in a tool); the person opens `/_frontmcp/people/login?code=…`, whose page posts the code back and gets an + * HttpOnly, SameSite=Strict session cookie. With it set, routes need a signed-in person unless they say + * `auth: 'public'`. + */ + people?: PeopleAuthOption; +} + /** CSP configuration for {@link SecurityHeadersOptions.csp}. */ export interface CspHeaderOptions { /** Emit the CSP header. When omitted, `FRONTMCP_CSP_ENABLED` decides. */ diff --git a/libs/sdk/src/common/types/options/http/schema.ts b/libs/sdk/src/common/types/options/http/schema.ts index ee1d76ee9..4febbff11 100644 --- a/libs/sdk/src/common/types/options/http/schema.ts +++ b/libs/sdk/src/common/types/options/http/schema.ts @@ -2,6 +2,7 @@ // Zod schema for HTTP configuration. Uses lazy-zod so the schema tree is // not constructed at module load when nothing parses it. +import type { PeopleAuthOption } from '@frontmcp/auth'; import { internalZ as z } from '@frontmcp/lazy-zod'; import { getEnv } from '@frontmcp/utils'; @@ -184,6 +185,8 @@ export const httpOptionsSchema = z.object({ * `session:verify` flow. Reserved-path collisions are rejected at startup. */ routes: z.array(httpRouteSchema).optional(), + /** People auth for the `@HttpRoute` pages; see `HttpAuthOptions`. Checked when the server builds its routes. */ + auth: z.object({ people: z.custom().optional() }).strict().optional(), /** Response security headers; see `SecurityHeadersOptions`. */ securityHeaders: securityHeadersSchema.optional(), /** More request headers to keep in `customHeaders`, besides `x-frontmcp-*`; see `HttpOptionsInterface`. */ diff --git a/libs/sdk/src/errors/http-route.errors.ts b/libs/sdk/src/errors/http-route.errors.ts new file mode 100644 index 000000000..7a8f43a10 --- /dev/null +++ b/libs/sdk/src/errors/http-route.errors.ts @@ -0,0 +1,55 @@ +import { InternalMcpError, MCP_ERROR_CODES, PublicMcpError } from './mcp.error'; + +/** Thrown at boot for a route the server cannot serve: a bad path, a duplicate, or people auth that is not set up. */ +export class InvalidHttpRouteError extends InternalMcpError { + constructor(method: string, path: string, reason: string) { + super(`HTTP route ${method} ${path}: ${reason}`, 'INVALID_HTTP_ROUTE'); + } +} + +/** Thrown at boot for an `http.auth.people` the server cannot use. */ +export class InvalidPeopleAuthOptionsError extends InternalMcpError { + constructor(problems: readonly string[]) { + super(`http.auth.people: ${problems.join('; ')}`, 'INVALID_PEOPLE_AUTH_OPTIONS'); + } +} + +/** A route with people auth was called without a signed-in session. */ +export class PersonNotSignedInError extends PublicMcpError { + readonly mcpErrorCode = MCP_ERROR_CODES.UNAUTHORIZED; + + constructor() { + super( + 'Please sign in: open the one-time sign-in link the server gives you (POST /_frontmcp/people/code with its token).', + 'PERSON_NOT_SIGNED_IN', + 401, + ); + } +} + +/** A POST, PUT, PATCH or DELETE came from a page of another origin. */ +export class CrossOriginRequestError extends PublicMcpError { + readonly mcpErrorCode = MCP_ERROR_CODES.FORBIDDEN; + + constructor() { + super('Cross-origin requests may not change anything here.', 'CROSS_ORIGIN_REQUEST', 403); + } +} + +/** A wrong, used or expired sign-in token or code. */ +export class SignInRefusedError extends PublicMcpError { + readonly mcpErrorCode = MCP_ERROR_CODES.UNAUTHORIZED; + + constructor() { + super('The sign-in token or code is not valid; codes work once and expire.', 'SIGN_IN_REFUSED', 401); + } +} + +/** Too many wrong sign-in tokens or codes from one client. */ +export class SignInLockedError extends PublicMcpError { + readonly mcpErrorCode = MCP_ERROR_CODES.FORBIDDEN; + + constructor() { + super('Too many failed sign-in attempts; wait a minute and try again.', 'SIGN_IN_LOCKED', 429); + } +} diff --git a/libs/sdk/src/errors/index.ts b/libs/sdk/src/errors/index.ts index b6615cecf..c9f263c9e 100644 --- a/libs/sdk/src/errors/index.ts +++ b/libs/sdk/src/errors/index.ts @@ -219,6 +219,16 @@ export { InvalidDecoratorMetadataError, HookTargetNotDefinedError } from './deco // Export duty errors export { DutyNameConflictError, UnsafeDutyStoreError } from './duty.errors'; +// Export HTTP route errors +export { + CrossOriginRequestError, + InvalidHttpRouteError, + InvalidPeopleAuthOptionsError, + PersonNotSignedInError, + SignInLockedError, + SignInRefusedError, +} from './http-route.errors'; + // Export shared local server errors export { InvalidSharedServerOptionsError, diff --git a/libs/sdk/src/hooks/__tests__/fixtures/typed-hook-ctx.fixture.ts b/libs/sdk/src/hooks/__tests__/fixtures/typed-hook-ctx.fixture.ts index cdaa7985e..bbac7603c 100644 --- a/libs/sdk/src/hooks/__tests__/fixtures/typed-hook-ctx.fixture.ts +++ b/libs/sdk/src/hooks/__tests__/fixtures/typed-hook-ctx.fixture.ts @@ -6,6 +6,7 @@ import { ChannelSendHook, CompletionHook, HttpHook, + HttpRouteHook, JobHook, ListPromptsHook, ListResourcesHook, @@ -46,6 +47,12 @@ export class EveryExportRejectsAParameterItsCtxDoesNotSatisfy { return ctx; } + // @ts-expect-error the ctx has no notAField + @HttpRouteHook.Will('handle') + httpRoute(ctx: NotInAnyCtx) { + return ctx; + } + // @ts-expect-error the ctx has no notAField @OAuthAuthorizeHook.Will('parseInput') oauthAuthorize(ctx: NotInAnyCtx) { diff --git a/libs/sdk/src/http-route/__tests__/http-route.flow.spec.ts b/libs/sdk/src/http-route/__tests__/http-route.flow.spec.ts new file mode 100644 index 000000000..df4ffaa43 --- /dev/null +++ b/libs/sdk/src/http-route/__tests__/http-route.flow.spec.ts @@ -0,0 +1,727 @@ +/** + * `@HttpRoute` classes run through the `http:route` flow on the Express host and on the web-fetch handler alike: + * IP filter, same-origin check on mutations, people auth, then the route's `handle()` with the app's providers. + */ +import 'reflect-metadata'; + +import * as http from 'node:http'; +import type * as net from 'node:net'; + +import { LoopbackTokenAuth } from '@frontmcp/auth'; + +import { createTestFetchServer, rpc20260728 } from '../../__test-utils__/helpers/mcp-20260728.helpers'; +import { + App, + FlowHooksOf, + frontMcpMetadataSchema, + httpRespond, + HttpRoute, + LogLevel, + Plugin, + Provider, + ProviderScope, + Tool, + ToolContext, + type FrontMcpConfigInput, +} from '../../common'; +import { HttpRouteContext } from '../../common/interfaces/http-route.interface'; +import { PublicMcpError } from '../../errors'; +import { FrontMcpInstance } from '../../front-mcp/front-mcp'; +import { resourceStore } from '../../resource/materialized'; +import { type Scope } from '../../scope/scope.instance'; +import { createServerFetchHandler } from '../../transport/web-fetch-handler'; + +@Provider({ name: 'Counter', scope: ProviderScope.GLOBAL }) +class Counter { + private count = 0; + + next(): number { + return ++this.count; + } +} + +@Tool({ name: 'count', inputSchema: {} }) +class CountTool extends ToolContext { + async execute() { + return { count: this.get(Counter).next() }; + } +} + +@Tool({ name: 'sign-in-code', inputSchema: {} }) +class SignInCodeTool extends ToolContext { + async execute() { + return { code: this.get(LoopbackTokenAuth).createCode().code }; + } +} + +const notesStore = resourceStore({ coalesceMs: 0 }); +const notes = notesStore.key('notes', { initial: [] }); +const unsubscribed = jest.fn(); +const topics = { + subscribe(topic: string, listener: () => void) { + const stop = notesStore.subscribe(topic, listener); + return () => { + unsubscribed(topic); + stop(); + }; + }, +}; + +@HttpRoute({ method: 'GET', path: '/count' }) +class CountPage extends HttpRouteContext { + handle() { + return { count: this.get(Counter).next(), signedIn: this.person !== undefined }; + } +} + +@HttpRoute({ method: 'GET', path: '/hello/:name', auth: 'public' }) +class HelloRoute extends HttpRouteContext { + handle() { + return `hello ${this.params['name']}`; + } +} + +@HttpRoute({ method: 'GET', path: '/page', auth: 'public' }) +class PageRoute extends HttpRouteContext { + handle() { + return httpRespond.html('

page

'); + } +} + +@HttpRoute({ method: 'POST', path: '/notes', auth: 'public' }) +class AddNoteRoute extends HttpRouteContext { + async handle() { + await notes.update((current) => [...current, String((this.request.body as { note?: string })?.note)]); + } +} + +@HttpRoute({ method: 'GET', path: '/notes', auth: 'public' }) +class ListNotesRoute extends HttpRouteContext { + async handle() { + return (await notes.get()).value; + } +} + +@HttpRoute({ + method: 'GET', + path: '/notes/events', + auth: 'public', + sse: { store: topics, topics: ['notes'], coalesceMs: 5 }, +}) +class NoteEvents extends HttpRouteContext { + async handle() { + return (await notes.get()).value; + } +} + +@HttpRoute({ method: 'GET', path: '/notes/mine', sse: { store: topics, topics: ['notes'], coalesceMs: 5 } }) +class MyNoteEvents extends HttpRouteContext { + async handle() { + return (await notes.get()).value; + } +} + +@HttpRoute({ method: 'GET', path: '/', auth: 'public' }) +class HomeRoute extends HttpRouteContext { + handle() { + return 'home'; + } +} + +@HttpRoute({ method: 'GET', path: '/fails', auth: 'public' }) +class FailingRoute extends HttpRouteContext { + handle(): string { + throw new Error('the route failed'); + } +} + +@App({ + id: 'web', + name: 'web', + providers: [Counter], + tools: [CountTool, SignInCodeTool], + routes: [ + CountPage, + HelloRoute, + PageRoute, + AddNoteRoute, + ListNotesRoute, + NoteEvents, + MyNoteEvents, + HomeRoute, + FailingRoute, + ], +}) +class WebApp {} + +const CONFIG: FrontMcpConfigInput = { + info: { name: 'web-host', version: '1.0.0' }, + apps: [WebApp], + logging: { level: LogLevel.Off }, + // Permissive CORS turns the loopback Origin check off, so the route's own same-origin check is what refuses. + http: { auth: { people: 'loopback-token' }, cors: { origin: true } }, +}; + +interface Harness { + name: string; + origin: string; + instance: FrontMcpInstance; + send(path: string, init?: RequestInit): Promise; +} + +const printed: string[] = []; +const TOKEN_LINE = /people sign-in token: ([A-Za-z0-9_-]{43})/; + +function tokenPrintedAfter(index: number): string { + const line = printed.slice(index).find((entry) => TOKEN_LINE.test(entry)); + if (!line) throw new Error('no sign-in token was printed'); + return (TOKEN_LINE.exec(line) as RegExpExecArray)[1]; +} + +async function startExpress(config: FrontMcpConfigInput): Promise { + const opened: http.Server[] = []; + const realCreateServer = http.createServer.bind(http); + const spy = jest.spyOn(http, 'createServer').mockImplementation(((...args: never[]) => { + const created = (realCreateServer as (...a: never[]) => http.Server)(...args); + opened.push(created); + return created; + }) as never); + const log = jest.spyOn(console, 'log').mockImplementation(() => undefined); + try { + const instance = new FrontMcpInstance( + frontMcpMetadataSchema.parse({ ...config, http: { ...config.http, port: 0 } }), + ); + await instance.ready; + await instance.start(); + const { port } = opened[0].address() as net.AddressInfo; + const origin = `http://127.0.0.1:${port}`; + return { name: 'express', origin, instance, send: (path, init) => fetch(`${origin}${path}`, init) }; + } finally { + spy.mockRestore(); + log.mockRestore(); + } +} + +async function startWebFetch(config: FrontMcpConfigInput): Promise { + const { handler, instance } = await createTestFetchServer(config); + const origin = 'http://localhost'; + return { name: 'web-fetch', origin, instance, send: (path, init) => handler(new Request(`${origin}${path}`, init)) }; +} + +async function signIn(harness: Harness, token: string): Promise { + const issued = await harness.send('/_frontmcp/people/code', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + const { code } = (await issued.json()) as { code: string }; + const login = await harness.send('/_frontmcp/people/login', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: harness.origin }, + body: `code=${code}`, + }); + return (login.headers.get('set-cookie') ?? '').split(';')[0]; +} + +async function readEvent(reader: ReadableStreamDefaultReader): Promise { + const decoder = new TextDecoder(); + let text = ''; + while (!text.includes('\n\n')) { + const { value, done } = await reader.read(); + if (done) return text; + text += decoder.decode(value, { stream: true }); + } + return text; +} + +beforeAll(() => { + const write = process.stderr.write.bind(process.stderr); + jest.spyOn(process.stderr, 'write').mockImplementation(((chunk: string | Uint8Array, ...rest: never[]) => { + const text = typeof chunk === 'string' ? chunk : new TextDecoder().decode(chunk); + printed.push(text); + return TOKEN_LINE.test(text) ? true : write(chunk, ...rest); + }) as never); +}); + +afterAll(() => jest.restoreAllMocks()); + +describe.each([ + ['express', startExpress], + ['web-fetch', startWebFetch], +])('@HttpRoute on the %s adapter', (_adapter, start) => { + let harness: Harness; + let token: string; + + beforeAll(async () => { + const before = printed.length; + harness = await start(CONFIG); + token = tokenPrintedAfter(before); + }); + + // A fetch client keeps a spare connection after an aborted stream; the host closes it at the end of its drain. + afterAll(async () => { + await harness.instance.shutdown(); + }, 15_000); + + describe('people auth', () => { + it('refuses a page to a browser that has not signed in', async () => { + const response = await harness.send('/count'); + + expect(response.status).toBe(401); + expect(await response.json()).toEqual({ error: expect.stringContaining('sign in') }); + }); + + it('serves the page to a signed-in browser, with the person on the context', async () => { + const cookie = await signIn(harness, token); + + const response = await harness.send('/count', { headers: { Cookie: cookie } }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ count: expect.any(Number), signedIn: true }); + }); + + it('sets an HttpOnly, SameSite=Strict, Path-scoped cookie whose value is not the token', async () => { + const issued = await harness.send('/_frontmcp/people/code', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + const { code } = (await issued.json()) as { code: string }; + + const login = await harness.send('/_frontmcp/people/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Origin: harness.origin }, + body: JSON.stringify({ code, next: '/count' }), + }); + + expect(login.status).toBe(200); + const cookie = login.headers.get('set-cookie') ?? ''; + expect(cookie).toMatch(/^frontmcp_people=[A-Za-z0-9_-]{43}; Path=\/; Max-Age=43200; HttpOnly; SameSite=Strict$/); + expect(cookie).not.toContain(token); + expect(await login.text()).toContain('url=/count'); + }); + + it('refuses a code posted from another site', async () => { + const issued = await harness.send('/_frontmcp/people/code', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + const { code } = (await issued.json()) as { code: string }; + + const login = await harness.send('/_frontmcp/people/login', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: 'https://evil.example' }, + body: `code=${code}`, + }); + + expect(login.status).toBe(403); + expect(login.headers.get('set-cookie')).toBeNull(); + }); + + it('signs in one of two requests that spend the same code at once', async () => { + const issued = await harness.send('/_frontmcp/people/code', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + const { code } = (await issued.json()) as { code: string }; + const spend = () => + harness.send('/_frontmcp/people/login', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded', Origin: harness.origin }, + body: `code=${code}`, + }); + + const statuses = (await Promise.all([spend(), spend()])).map((response) => response.status).sort(); + + expect(statuses).toEqual([200, 401]); + }); + + it('refuses a wrong token', async () => { + const response = await harness.send('/_frontmcp/people/code', { + method: 'POST', + headers: { Authorization: 'Bearer not-the-token' }, + }); + + expect(response.status).toBe(401); + }); + + it('serves a sign-in page that posts the code, without signing in on GET', async () => { + const response = await harness.send('/_frontmcp/people/login?code=abc_DEF-123&next=/count'); + + expect(response.status).toBe(200); + expect(response.headers.get('set-cookie')).toBeNull(); + const page = await response.text(); + expect(page).toContain('
'); + expect(page).toContain('value="abc_DEF-123"'); + expect(page).toContain('value="/count"'); + }); + + it('does not echo a sign-in link that is not a code', async () => { + const response = await harness.send('/_frontmcp/people/login?code=%22%3E%3Cscript%3E&next=//evil.example'); + + expect(response.status).toBe(400); + expect(await response.text()).not.toContain('