// Framework-neutral request handler for the mock CMS, used by the Vite plugin // (dev:mock) and directly by tests (as an injectable `fetch`). import { Graph, HttpError } from './graph' import { blueprints, render, renderPage, schemas, seedGraph, types } from './fixtures' export interface MockOptions { /** Require this bearer token (to test the 401 path). Default: open. */ token?: string /** Require sign-in through the mock device flow (GET/POST /api/auth/device*). Default: open. */ auth?: boolean /** Auto-approve a device grant this long after it starts (dev:mock). Tests call approve(). */ approveAfterMs?: number } export function createMockCms(opts: MockOptions = {}) { let graph = seedGraph() const published: string[] = [] const requests: string[] = [] let previewApi = true // --- mock go-auth (RFC 8628 device grant) --- const access = new Set() let refreshTok: string | null = null let approved = false let n = 0 const issue = () => { const a = `mock-access-${++n}` access.add(a) refreshTok = `mock-refresh-${n}` return { access_token: a, token_type: 'Bearer', expires_in: 900, refresh_token: refreshTok } } const needsAuth = () => !!(opts.token || opts.auth) const authorised = (req: Request) => { const h = req.headers.get('authorization') return !!h && (h === `Bearer ${opts.token}` || access.has(h.replace(/^Bearer /, ''))) } const json = (status: number, body: unknown) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json', 'x-cms-mock': '1' } }) async function handle(req: Request): Promise { const url = new URL(req.url, 'http://mock') const path = url.pathname const m = req.method requests.push(`${m} ${path}${url.search}`) try { // The mock is its own issuer (same origin), so the browser can complete the flow with no network. if (path === '/device') { return new Response( `Mock approval

Approve sign-in?

Code MOCK-1234

`, { headers: { 'content-type': 'text/html; charset=utf-8' } }, ) } if (path === '/__mock/approve' && m === 'POST') { approved = true return new Response(null, { status: 204 }) } if (path === '/api/auth/config') return json(200, needsAuth() ? { mode: 'oidc', issuer: url.origin, clientId: 'mock' } : { mode: 'none' }) if (path === '/api/auth/device' && m === 'POST') { approved = false if (opts.approveAfterMs !== undefined) setTimeout(() => (approved = true), opts.approveAfterMs) return json(200, { device_code: 'mock-device', user_code: 'MOCK-1234', verification_uri: `${url.origin}/device`, verification_uri_complete: `${url.origin}/device?user_code=MOCK-1234`, expires_in: 600, interval: 1 }) } if (path === '/api/auth/device/token' && m === 'POST') return approved ? json(200, issue()) : json(400, { error: 'authorization_pending' }) if (path === '/api/auth/device/refresh' && m === 'POST') { const b = (await req.json()) as { refresh_token?: string } return b.refresh_token && b.refresh_token === refreshTok ? json(200, issue()) : json(400, { error: 'invalid_grant' }) } if (needsAuth() && path.startsWith('/api/') && !authorised(req)) return json(401, { error: 'unauthorized: auth: no credentials' }) if (path === '/api/auth/me') return json(200, { sub: 'mock-user', scopes: ['admin'] }) if (path === '/api/types') return json(200, { types, locales: [] }) if (path === '/api/blueprints') return json(200, blueprints) let r: RegExpExecArray | null if ((r = /^\/api\/schemas\/([^/]+)$/.exec(path))) { const s = schemas[decodeURIComponent(r[1])] return s ? json(200, s) : json(404, { error: 'unknown type' }) } if ((r = /^\/api\/nodes\/([^/]+)\/edges$/.exec(path))) { graph.node(r[1]) return json(200, graph.edgesOf(r[1])) } if ((r = /^\/api\/nodes\/([^/]+)$/.exec(path)) && m === 'GET') { const n = graph.node(r[1]) return json(200, { id: n.id, dataType: n.dataType, data: n.data, tags: n.tags, version: n.version }) } if ((r = /^\/api\/tree\/([^/]+)$/.exec(path)) && m === 'GET') { // TreeNode { node, edge, children[] } - one request for the whole page's data const walk = (id: string, edge?: unknown): unknown => { const n = graph.node(id) return { node: { id: n.id, dataType: n.dataType, data: n.data, tags: n.tags, version: n.version }, edge, children: graph.children(id).map((e) => walk(e.to, e)) } } return json(200, walk(r[1])) } if (path === '/api/edit' && m === 'POST') { const body = (await req.json()) as { ops: any[] } const res = graph.edit(body.ops) const patches = res.touched.map((id) => { if (graph.parents(id).length === 0) return { id, error: 'root cannot render standalone' } // like a real page node return { id, html: render(graph, id) } }) return json(200, { ...res, patches }) } if (path === '/api/publish-roots' && m === 'POST') { const body = (await req.json()) as { roots: string[] } published.push(...body.roots) return json(200, { published: body.roots }) } if (path === '/api/preview' && !previewApi) return json(404, { error: 'no such route' }) if (path === '/api/preview' || (m === 'GET' && req.headers.get('accept')?.includes('text/html'))) { return new Response(renderPage(graph), { headers: { 'content-type': 'text/html; charset=utf-8', 'cache-control': 'no-store', 'x-cms-page': '1' } }) } return json(404, { error: `mock: no route for ${m} ${path}` }) } catch (e) { if (e instanceof HttpError) return json(e.status, { error: e.message }) return json(500, { error: (e as Error).message }) } } return { handle, /** Back to the seed data (also exposed at POST /__mock/reset in dev). */ reset: () => void (graph = seedGraph()), /** Approve the pending device grant (what the user does on the issuer's /device page). */ approve: () => void (approved = true), /** Simulate access-token expiry: every issued access token starts returning 401. */ expireAccess: () => access.clear(), /** Simulate revocation: the refresh token stops working too. */ revoke: () => { access.clear() refreshTok = null }, get graph() { return graph }, published, requests, /** Simulate a CMS without GET /api/preview (the editor must fall back to the page URL). */ disablePreviewApi: () => void (previewApi = false), fetch: ((input: RequestInfo | URL, init?: RequestInit) => handle(new Request(new URL(String(input), 'http://mock'), { ...init, signal: undefined }))) as typeof fetch, } }