From 74c849074bc9e97f446df981aed10635ad8e3644 Mon Sep 17 00:00:00 2001 From: David Antoon Date: Mon, 2 Nov 2026 21:01:33 +0200 Subject: [PATCH 01/11] feat(sdk,auth,plugin-dashboard): serve @HttpRoute pages through a hookable http:route flow, with people auth and SSE from resource topics @HttpRoute classes listed in @App({ routes }) run through a new http:route flow on the Express host and the fetch handler alike: the IP filter, the global rate limit, a same-origin check on POST, PUT, PATCH and DELETE, people auth, then handle() with the app's providers, the ones its tools get. An SSE route sends handle()'s view at once and after each burst of publishes on its resource-store topics, with a heartbeat, and ends when the client leaves or the server shuts down. http.auth.people: 'loopback-token' (libs/auth) prints a token once at startup; the token buys single-use sign-in codes with a short TTL, and a code posted from the server's own page buys an HttpOnly, SameSite=Strict, Path-scoped session cookie. Tokens, codes and sessions are compared in constant time, and bad ones lock a client out. The dashboard page is now DashboardPageRoute, so it is served on every adapter and behind throttle.ipFilter, with its options unchanged. --- .../e2e/web-host.e2e.spec.ts | 182 +++++++ apps/e2e/demo-e2e-web-host/jest.e2e.config.ts | 43 ++ apps/e2e/demo-e2e-web-host/project.json | 45 ++ .../src/apps/board/board.routes.ts | 36 ++ .../src/apps/board/board.store.ts | 5 + .../src/apps/board/board.tools.ts | 19 + .../demo-e2e-web-host/src/apps/board/index.ts | 14 + .../src/apps/board/visit-counter.provider.ts | 11 + apps/e2e/demo-e2e-web-host/src/main.ts | 14 + apps/e2e/demo-e2e-web-host/tsconfig.app.json | 15 + apps/e2e/demo-e2e-web-host/tsconfig.e2e.json | 13 + apps/e2e/demo-e2e-web-host/tsconfig.json | 16 + apps/e2e/demo-e2e-web-host/webpack.config.js | 28 + libs/auth/src/index.ts | 15 + .../__tests__/loopback-token.auth.spec.ts | 247 +++++++++ libs/auth/src/people/index.ts | 16 + libs/auth/src/people/loopback-token.auth.ts | 165 ++++++ libs/auth/src/people/people-auth.options.ts | 63 +++ .../common/decorators/http-route.decorator.ts | 27 + libs/sdk/src/common/decorators/index.ts | 1 + libs/sdk/src/common/entries/scope.entry.ts | 4 + .../common/interfaces/http-route.interface.ts | 56 ++ libs/sdk/src/common/interfaces/index.ts | 1 + libs/sdk/src/common/metadata/app.metadata.ts | 15 + .../common/metadata/http-route.metadata.ts | 92 ++++ libs/sdk/src/common/metadata/index.ts | 1 + libs/sdk/src/common/tokens/app.tokens.ts | 1 + .../src/common/types/options/http/index.ts | 1 + .../common/types/options/http/interfaces.ts | 17 + .../src/common/types/options/http/schema.ts | 3 + libs/sdk/src/errors/http-route.errors.ts | 48 ++ libs/sdk/src/errors/index.ts | 9 + .../__tests__/http-route.flow.spec.ts | 490 ++++++++++++++++++ .../__tests__/http-route.validation.spec.ts | 122 +++++ .../__tests__/sse-route.stream.spec.ts | 161 ++++++ .../src/http-route/flows/http.route.flow.ts | 192 +++++++ libs/sdk/src/http-route/http-route.table.ts | 179 +++++++ .../src/http-route/people-sign-in.routes.ts | 109 ++++ libs/sdk/src/http-route/sse-route.stream.ts | 115 ++++ libs/sdk/src/index.ts | 6 + libs/sdk/src/scope/http-scope.helper.ts | 2 + libs/sdk/src/scope/scope.instance.ts | 19 +- .../security/__tests__/same-origin.spec.ts | 52 ++ libs/sdk/src/server/security/same-origin.ts | 58 +++ libs/sdk/src/server/server.validation.ts | 12 +- libs/sdk/src/transport/web-fetch-handler.ts | 27 +- .../src/__tests__/dashboard.app.spec.ts | 277 +--------- .../__tests__/dashboard.page-route.spec.ts | 102 ++++ .../src/app/dashboard-page.route.ts | 66 +++ .../plugin-dashboard/src/app/dashboard.app.ts | 111 +--- 50 files changed, 2935 insertions(+), 388 deletions(-) create mode 100644 apps/e2e/demo-e2e-web-host/e2e/web-host.e2e.spec.ts create mode 100644 apps/e2e/demo-e2e-web-host/jest.e2e.config.ts create mode 100644 apps/e2e/demo-e2e-web-host/project.json create mode 100644 apps/e2e/demo-e2e-web-host/src/apps/board/board.routes.ts create mode 100644 apps/e2e/demo-e2e-web-host/src/apps/board/board.store.ts create mode 100644 apps/e2e/demo-e2e-web-host/src/apps/board/board.tools.ts create mode 100644 apps/e2e/demo-e2e-web-host/src/apps/board/index.ts create mode 100644 apps/e2e/demo-e2e-web-host/src/apps/board/visit-counter.provider.ts create mode 100644 apps/e2e/demo-e2e-web-host/src/main.ts create mode 100644 apps/e2e/demo-e2e-web-host/tsconfig.app.json create mode 100644 apps/e2e/demo-e2e-web-host/tsconfig.e2e.json create mode 100644 apps/e2e/demo-e2e-web-host/tsconfig.json create mode 100644 apps/e2e/demo-e2e-web-host/webpack.config.js create mode 100644 libs/auth/src/people/__tests__/loopback-token.auth.spec.ts create mode 100644 libs/auth/src/people/index.ts create mode 100644 libs/auth/src/people/loopback-token.auth.ts create mode 100644 libs/auth/src/people/people-auth.options.ts create mode 100644 libs/sdk/src/common/decorators/http-route.decorator.ts create mode 100644 libs/sdk/src/common/interfaces/http-route.interface.ts create mode 100644 libs/sdk/src/common/metadata/http-route.metadata.ts create mode 100644 libs/sdk/src/errors/http-route.errors.ts create mode 100644 libs/sdk/src/http-route/__tests__/http-route.flow.spec.ts create mode 100644 libs/sdk/src/http-route/__tests__/http-route.validation.spec.ts create mode 100644 libs/sdk/src/http-route/__tests__/sse-route.stream.spec.ts create mode 100644 libs/sdk/src/http-route/flows/http.route.flow.ts create mode 100644 libs/sdk/src/http-route/http-route.table.ts create mode 100644 libs/sdk/src/http-route/people-sign-in.routes.ts create mode 100644 libs/sdk/src/http-route/sse-route.stream.ts create mode 100644 libs/sdk/src/server/security/__tests__/same-origin.spec.ts create mode 100644 libs/sdk/src/server/security/same-origin.ts create mode 100644 plugins/plugin-dashboard/src/__tests__/dashboard.page-route.spec.ts create mode 100644 plugins/plugin-dashboard/src/app/dashboard-page.route.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 ac1c58339..bacbd1e8f 100644 --- a/libs/auth/src/index.ts +++ b/libs/auth/src/index.ts @@ -676,3 +676,18 @@ export type { FrontMcpCredentials, 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'; 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..1f5a719ff --- /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..457bbb6a4 --- /dev/null +++ b/libs/auth/src/people/people-auth.options.ts @@ -0,0 +1,63 @@ +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`); +} + +const loopbackTokenSchema = 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(); + +export const peopleAuthOptionsSchema = z + .union([z.literal('loopback-token'), loopbackTokenSchema], { + 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 ?? '/', + }; + }); 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..f226d6ef9 --- /dev/null +++ b/libs/sdk/src/common/decorators/http-route.decorator.ts @@ -0,0 +1,27 @@ +import 'reflect-metadata'; + +import { InvalidDecoratorMetadataError } from '../../errors/decorator.errors'; +import { type HttpRouteType } from '../interfaces/http-route.interface'; +import { + defineHttpRouteMetadata, + httpRouteMetadataSchema, + type HttpRouteMetadata, +} from '../metadata/http-route.metadata'; + +/** + * 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..ebf246dc1 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..de6a09d96 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, @@ -33,6 +34,7 @@ import { } from '../schemas'; import { type AuthOptionsInput, type RawZodShape } from '../types'; import { appFilterConfigSchema, type AppFilterConfig } from './app-filter.metadata'; +import { httpRouteMetadataOf } from './http-route.metadata'; import { outputPolicySchema, type OutputPolicy } from './output-policy'; import { type EsmOptions, type RemoteOptions } from './remote-primitive.metadata'; import type { WidgetServingMode } from './tool-ui.metadata'; @@ -145,6 +147,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 +226,13 @@ 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) => httpRouteMetadataOf(value) !== undefined, { + 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..4aa981923 --- /dev/null +++ b/libs/sdk/src/common/metadata/http-route.metadata.ts @@ -0,0 +1,92 @@ +import { z } from '@frontmcp/lazy-zod'; + +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, Express style (`/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; +} + +const ROUTE_PATH = /^\/[^?#\s]*$/; +export const HTTP_ROUTE_PATH_MESSAGE = 'path must start with "/" and hold no query, fragment or whitespace'; + +export function isHttpRoutePath(path: unknown): path is string { + return typeof path === 'string' && ROUTE_PATH.test(path); +} + +export 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'], + }); + +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..f5a2c8625 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 { peopleAuthOptionsSchema } 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`. */ + auth: z.object({ people: peopleAuthOptionsSchema.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..02dba7fac --- /dev/null +++ b/libs/sdk/src/errors/http-route.errors.ts @@ -0,0 +1,48 @@ +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'); + } +} + +/** 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 8060ef72c..d65a45df7 100644 --- a/libs/sdk/src/errors/index.ts +++ b/libs/sdk/src/errors/index.ts @@ -219,6 +219,15 @@ export { InvalidDecoratorMetadataError, HookTargetNotDefinedError } from './deco // Export duty errors export { DutyNameConflictError, UnsafeDutyStoreError } from './duty.errors'; +// Export HTTP route errors +export { + CrossOriginRequestError, + InvalidHttpRouteError, + PersonNotSignedInError, + SignInLockedError, + SignInRefusedError, +} from './http-route.errors'; + // Export transport errors export { MethodNotImplementedError, 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..1e5a018bb --- /dev/null +++ b/libs/sdk/src/http-route/__tests__/http-route.flow.spec.ts @@ -0,0 +1,490 @@ +/** + * `@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 { createTestFetchServer, rpc20260728 } from '../../__test-utils__/helpers/mcp-20260728.helpers'; +import { + App, + frontMcpMetadataSchema, + httpRespond, + HttpRoute, + LogLevel, + Provider, + ProviderScope, + Tool, + ToolContext, + type FrontMcpConfigInput, +} from '../../common'; +import { HttpRouteContext } from '../../common/interfaces/http-route.interface'; +import { FrontMcpInstance } from '../../front-mcp/front-mcp'; +import { resourceStore } from '../../resource/materialized'; +import { type Scope } from '../../scope/scope.instance'; + +@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() }; + } +} + +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: '/', 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], + routes: [CountPage, HelloRoute, PageRoute, AddNoteRoute, ListNotesRoute, NoteEvents, 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('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('