From ac992ea1f376808a3575df77566e55bfece980d2 Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Wed, 22 Jul 2026 16:18:17 +0800 Subject: [PATCH 1/4] feat(demo): draft isolated demo authentication realm --- .env.example | 4 + src/features/demo/server/demo-api-auth.ts | 19 ++++ src/features/demo/server/demo-auth.ts | 125 ++++++++++++++++++++++ src/features/demo/server/demo-fixtures.ts | 35 ++++++ src/routes/api/demo/todos/+server.ts | 36 +++++++ src/routes/api/demo/token/+server.ts | 20 ++++ src/routes/demo/+page.server.ts | 37 +++++++ src/routes/demo/+page.svelte | 40 +++++++ tests/unit/demo-auth.test.ts | 57 ++++++++++ 9 files changed, 373 insertions(+) create mode 100644 src/features/demo/server/demo-api-auth.ts create mode 100644 src/features/demo/server/demo-auth.ts create mode 100644 src/features/demo/server/demo-fixtures.ts create mode 100644 src/routes/api/demo/todos/+server.ts create mode 100644 src/routes/api/demo/token/+server.ts create mode 100644 src/routes/demo/+page.server.ts create mode 100644 src/routes/demo/+page.svelte create mode 100644 tests/unit/demo-auth.test.ts diff --git a/.env.example b/.env.example index 95c21b3b5..cbb7c1a7d 100644 --- a/.env.example +++ b/.env.example @@ -15,6 +15,10 @@ DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:5432/life_ustc_dev" # CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgresql://postgres:postgres@127.0.0.1:5432/life_ustc_dev" WEBHOOK_SECRET="replace-with-random-secret" AUTH_SECRET="replace-with-random-secret" +# Draft-only isolated demo realm. Keep disabled unless explicitly testing the +# `/demo` vertical slice; production config intentionally does not enable it. +DEMO_MODE_ENABLED="false" +# DEMO_SIGNING_SECRET="replace-with-at-least-32-random-characters" # Required in production. Local dev falls back to http://localhost:3000. # APP_PUBLIC_ORIGIN="https://life-ustc.example.com" # Optional canonical WebAuthn RP origin when preview and public origins differ. diff --git a/src/features/demo/server/demo-api-auth.ts b/src/features/demo/server/demo-api-auth.ts new file mode 100644 index 000000000..c92d479db --- /dev/null +++ b/src/features/demo/server/demo-api-auth.ts @@ -0,0 +1,19 @@ +import { parseBearerAuthorizationHeader } from "@/lib/auth/authorization-header"; +import { type DemoApiScope, verifyDemoApiToken } from "./demo-auth"; + +export async function requireDemoApiScope( + request: Request, + scope: DemoApiScope, +) { + const bearer = parseBearerAuthorizationHeader(request.headers); + const principal = bearer?.token + ? await verifyDemoApiToken(bearer.token) + : null; + if (!principal) { + return new Response("Unauthorized", { status: 401 }); + } + if (!principal.scopes.has(scope)) { + return new Response("Forbidden", { status: 403 }); + } + return principal; +} diff --git a/src/features/demo/server/demo-auth.ts b/src/features/demo/server/demo-auth.ts new file mode 100644 index 000000000..73d84e4b8 --- /dev/null +++ b/src/features/demo/server/demo-auth.ts @@ -0,0 +1,125 @@ +import { createHmac, randomUUID } from "node:crypto"; +import { jwtVerify, SignJWT } from "jose"; +import { getOptionalTrimmedEnv } from "@/app-env"; + +export const DEMO_SESSION_COOKIE = "life_ustc_demo"; +export const DEMO_FIXTURE_VERSION = "2026-07-22"; +const DEMO_ISSUER = "urn:life-ustc:demo"; +const WEB_AUDIENCE = "urn:life-ustc:demo:web"; +const API_AUDIENCE = "urn:life-ustc:demo:api"; + +export const DEMO_API_SCOPES = ["demo:todo:read", "demo:todo:write"] as const; +export type DemoApiScope = (typeof DEMO_API_SCOPES)[number]; + +export type DemoPrincipal = { + kind: "demo"; + sessionId: string; + fixtureVersion: string; + scopes: Set; +}; + +export function isDemoModeEnabled(input?: NodeJS.ProcessEnv) { + return ( + getOptionalTrimmedEnv("DEMO_MODE_ENABLED", input)?.toLowerCase() === "true" + ); +} + +function getSigningKey(input?: NodeJS.ProcessEnv) { + const secret = getOptionalTrimmedEnv("DEMO_SIGNING_SECRET", input); + if (!secret || secret.length < 32) { + throw new Error("DEMO_SIGNING_SECRET must contain at least 32 characters"); + } + return new TextEncoder().encode(secret); +} + +async function mintDemoJwt(input: { + audience: string; + sessionId: string; + scopes: readonly DemoApiScope[]; + ttl: string; +}) { + return new SignJWT({ + demo: true, + fixtureVersion: DEMO_FIXTURE_VERSION, + scope: input.scopes.join(" "), + }) + .setProtectedHeader({ alg: "HS256", typ: "JWT" }) + .setIssuer(DEMO_ISSUER) + .setAudience(input.audience) + .setSubject(input.sessionId) + .setJti(randomUUID()) + .setIssuedAt() + .setExpirationTime(input.ttl) + .sign(getSigningKey()); +} + +export function mintDemoWebSession(sessionId: string = randomUUID()) { + return mintDemoJwt({ + audience: WEB_AUDIENCE, + sessionId, + scopes: [], + ttl: "15m", + }); +} + +export function mintDemoApiToken(sessionId: string) { + return mintDemoJwt({ + audience: API_AUDIENCE, + sessionId, + scopes: DEMO_API_SCOPES, + ttl: "5m", + }); +} + +async function verifyDemoJwt( + token: string, + audience: string, +): Promise { + if (!isDemoModeEnabled()) return null; + try { + const { payload } = await jwtVerify(token, getSigningKey(), { + algorithms: ["HS256"], + audience, + issuer: DEMO_ISSUER, + }); + if ( + payload.demo !== true || + typeof payload.sub !== "string" || + payload.fixtureVersion !== DEMO_FIXTURE_VERSION + ) { + return null; + } + const scopes = new Set( + typeof payload.scope === "string" + ? payload.scope + .split(" ") + .filter((scope): scope is DemoApiScope => + DEMO_API_SCOPES.includes(scope as DemoApiScope), + ) + : [], + ); + return { + kind: "demo", + sessionId: payload.sub, + fixtureVersion: DEMO_FIXTURE_VERSION, + scopes, + }; + } catch { + return null; + } +} + +export function verifyDemoWebSession(token: string) { + return verifyDemoJwt(token, WEB_AUDIENCE); +} + +export function verifyDemoApiToken(token: string) { + return verifyDemoJwt(token, API_AUDIENCE); +} + +export function getDemoSessionAuditId(sessionId: string) { + return createHmac("sha256", getSigningKey()) + .update(sessionId) + .digest("hex") + .slice(0, 24); +} diff --git a/src/features/demo/server/demo-fixtures.ts b/src/features/demo/server/demo-fixtures.ts new file mode 100644 index 000000000..a51308481 --- /dev/null +++ b/src/features/demo/server/demo-fixtures.ts @@ -0,0 +1,35 @@ +import type { DemoPrincipal } from "./demo-auth"; + +const TODOS = [ + { + id: "demo-todo-prepare", + title: "准备下一节课", + completed: false, + priority: "high", + }, + { + id: "demo-todo-review", + title: "复习课程笔记", + completed: true, + priority: "medium", + }, +] as const; + +export function getDemoTodos(_principal: DemoPrincipal) { + return TODOS; +} + +export function simulateDemoTodoCreate( + principal: DemoPrincipal, + title: string, +) { + return { + simulated: true as const, + todo: { + id: `demo-simulated-${principal.sessionId.slice(0, 8)}`, + title, + completed: false, + priority: "medium" as const, + }, + }; +} diff --git a/src/routes/api/demo/todos/+server.ts b/src/routes/api/demo/todos/+server.ts new file mode 100644 index 000000000..4abd9a32e --- /dev/null +++ b/src/routes/api/demo/todos/+server.ts @@ -0,0 +1,36 @@ +import { json } from "@sveltejs/kit"; +import { requireDemoApiScope } from "@/features/demo/server/demo-api-auth"; +import { getDemoSessionAuditId } from "@/features/demo/server/demo-auth"; +import { + getDemoTodos, + simulateDemoTodoCreate, +} from "@/features/demo/server/demo-fixtures"; +import { logAppEvent } from "@/lib/log/app-logger"; +import type { RequestHandler } from "./$types"; + +export const GET: RequestHandler = async ({ request }) => { + const principal = await requireDemoApiScope(request, "demo:todo:read"); + if (principal instanceof Response) return principal; + return json({ fixture: true, todos: getDemoTodos(principal) }); +}; + +export const POST: RequestHandler = async ({ request }) => { + const principal = await requireDemoApiScope(request, "demo:todo:write"); + if (principal instanceof Response) return principal; + const body = (await request.json().catch(() => null)) as { + title?: unknown; + } | null; + const title = typeof body?.title === "string" ? body.title.trim() : ""; + if (!title || title.length > 200) { + return json({ error: "invalid_title" }, { status: 400 }); + } + logAppEvent("info", "demo mutation simulated", { + event: "demo.mutation.simulated", + fixtureVersion: principal.fixtureVersion, + operation: "todo.create", + sessionHash: getDemoSessionAuditId(principal.sessionId), + }); + return json(simulateDemoTodoCreate(principal, title), { + headers: { "x-life-ustc-simulated": "true" }, + }); +}; diff --git a/src/routes/api/demo/token/+server.ts b/src/routes/api/demo/token/+server.ts new file mode 100644 index 000000000..2cb645013 --- /dev/null +++ b/src/routes/api/demo/token/+server.ts @@ -0,0 +1,20 @@ +import { json } from "@sveltejs/kit"; +import { + DEMO_SESSION_COOKIE, + isDemoModeEnabled, + mintDemoApiToken, + verifyDemoWebSession, +} from "@/features/demo/server/demo-auth"; +import type { RequestHandler } from "./$types"; + +export const POST: RequestHandler = async ({ cookies }) => { + if (!isDemoModeEnabled()) return new Response("Not found", { status: 404 }); + const session = cookies.get(DEMO_SESSION_COOKIE); + const principal = session ? await verifyDemoWebSession(session) : null; + if (!principal) return new Response("Unauthorized", { status: 401 }); + return json({ + accessToken: await mintDemoApiToken(principal.sessionId), + expiresIn: 5 * 60, + tokenType: "Bearer", + }); +}; diff --git a/src/routes/demo/+page.server.ts b/src/routes/demo/+page.server.ts new file mode 100644 index 000000000..22c179f03 --- /dev/null +++ b/src/routes/demo/+page.server.ts @@ -0,0 +1,37 @@ +import { error, redirect } from "@sveltejs/kit"; +import { + DEMO_SESSION_COOKIE, + isDemoModeEnabled, + mintDemoWebSession, + verifyDemoWebSession, +} from "@/features/demo/server/demo-auth"; +import { getDemoTodos } from "@/features/demo/server/demo-fixtures"; +import type { Actions, PageServerLoad } from "./$types"; + +function requireDemoEnabled() { + if (!isDemoModeEnabled()) error(404, "Not found"); +} + +export const load: PageServerLoad = async ({ cookies }) => { + requireDemoEnabled(); + const token = cookies.get(DEMO_SESSION_COOKIE); + const principal = token ? await verifyDemoWebSession(token) : null; + return { + authenticated: Boolean(principal), + todos: principal ? getDemoTodos(principal) : [], + }; +}; + +export const actions: Actions = { + default: async ({ cookies, url }) => { + requireDemoEnabled(); + cookies.set(DEMO_SESSION_COOKIE, await mintDemoWebSession(), { + httpOnly: true, + maxAge: 15 * 60, + path: "/", + sameSite: "lax", + secure: url.protocol === "https:", + }); + redirect(303, "/demo"); + }, +}; diff --git a/src/routes/demo/+page.svelte b/src/routes/demo/+page.svelte new file mode 100644 index 000000000..30efc732f --- /dev/null +++ b/src/routes/demo/+page.svelte @@ -0,0 +1,40 @@ + + + + Demo | Life@USTC + + + +
+
+

Production-safe draft

+

Demo workspace

+

+ This isolated session uses deterministic fixtures. Changes are simulated and never written to the application database. +

+
+ + {#if data.authenticated} +
+

Sample todos

+
    + {#each data.todos as todo} +
  • + {todo.title} + {todo.priority} +
  • + {/each} +
+
+ {:else} +
+ +
+ {/if} +
diff --git a/tests/unit/demo-auth.test.ts b/tests/unit/demo-auth.test.ts new file mode 100644 index 000000000..51834d1e4 --- /dev/null +++ b/tests/unit/demo-auth.test.ts @@ -0,0 +1,57 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { + getDemoSessionAuditId, + isDemoModeEnabled, + mintDemoApiToken, + mintDemoWebSession, + verifyDemoApiToken, + verifyDemoWebSession, +} from "@/features/demo/server/demo-auth"; + +const previousEnv = { ...process.env }; + +describe("demo authentication realm", () => { + beforeEach(() => { + process.env.DEMO_MODE_ENABLED = "true"; + process.env.DEMO_SIGNING_SECRET = + "demo-test-secret-that-is-at-least-32-bytes"; + }); + + afterEach(() => { + process.env = { ...previousEnv }; + }); + + it("is disabled unless explicitly enabled", () => { + expect(isDemoModeEnabled({})).toBe(false); + expect(isDemoModeEnabled({ DEMO_MODE_ENABLED: "true" })).toBe(true); + }); + + it("keeps web sessions and API tokens in separate audiences", async () => { + const web = await mintDemoWebSession("session-1"); + const api = await mintDemoApiToken("session-1"); + + await expect(verifyDemoWebSession(web)).resolves.toMatchObject({ + kind: "demo", + sessionId: "session-1", + }); + await expect(verifyDemoApiToken(api)).resolves.toMatchObject({ + kind: "demo", + sessionId: "session-1", + }); + await expect(verifyDemoApiToken(web)).resolves.toBeNull(); + await expect(verifyDemoWebSession(api)).resolves.toBeNull(); + }); + + it("rejects every token when the kill switch is off", async () => { + const token = await mintDemoApiToken("session-1"); + process.env.DEMO_MODE_ENABLED = "false"; + await expect(verifyDemoApiToken(token)).resolves.toBeNull(); + }); + + it("uses a stable opaque audit identifier instead of the session ID", () => { + const auditId = getDemoSessionAuditId("session-1"); + expect(auditId).toHaveLength(24); + expect(auditId).not.toContain("session-1"); + expect(getDemoSessionAuditId("session-1")).toBe(auditId); + }); +}); From 519c28b8b334fb77f1f41433ba8cec7662ecedc0 Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Wed, 22 Jul 2026 16:37:27 +0800 Subject: [PATCH 2/4] fix(demo): harden isolated API boundary --- src/features/demo/server/demo-api-auth.ts | 12 +++++++--- src/features/demo/server/demo-fixtures.ts | 5 ++-- src/routes/api/demo/todos/+server.ts | 2 ++ tests/unit/demo-auth.test.ts | 28 +++++++++++++++++++++++ 4 files changed, 42 insertions(+), 5 deletions(-) diff --git a/src/features/demo/server/demo-api-auth.ts b/src/features/demo/server/demo-api-auth.ts index c92d479db..8f8dd5cfd 100644 --- a/src/features/demo/server/demo-api-auth.ts +++ b/src/features/demo/server/demo-api-auth.ts @@ -1,19 +1,25 @@ +import { forbidden, notFound, unauthorized } from "@/lib/api/helpers"; import { parseBearerAuthorizationHeader } from "@/lib/auth/authorization-header"; -import { type DemoApiScope, verifyDemoApiToken } from "./demo-auth"; +import { + type DemoApiScope, + isDemoModeEnabled, + verifyDemoApiToken, +} from "./demo-auth"; export async function requireDemoApiScope( request: Request, scope: DemoApiScope, ) { + if (!isDemoModeEnabled()) return notFound(); const bearer = parseBearerAuthorizationHeader(request.headers); const principal = bearer?.token ? await verifyDemoApiToken(bearer.token) : null; if (!principal) { - return new Response("Unauthorized", { status: 401 }); + return unauthorized(); } if (!principal.scopes.has(scope)) { - return new Response("Forbidden", { status: 403 }); + return forbidden(); } return principal; } diff --git a/src/features/demo/server/demo-fixtures.ts b/src/features/demo/server/demo-fixtures.ts index a51308481..fa8fc2d0d 100644 --- a/src/features/demo/server/demo-fixtures.ts +++ b/src/features/demo/server/demo-fixtures.ts @@ -1,3 +1,4 @@ +import { randomUUID } from "node:crypto"; import type { DemoPrincipal } from "./demo-auth"; const TODOS = [ @@ -20,13 +21,13 @@ export function getDemoTodos(_principal: DemoPrincipal) { } export function simulateDemoTodoCreate( - principal: DemoPrincipal, + _principal: DemoPrincipal, title: string, ) { return { simulated: true as const, todo: { - id: `demo-simulated-${principal.sessionId.slice(0, 8)}`, + id: `demo-simulated-${randomUUID()}`, title, completed: false, priority: "medium" as const, diff --git a/src/routes/api/demo/todos/+server.ts b/src/routes/api/demo/todos/+server.ts index 4abd9a32e..81da00624 100644 --- a/src/routes/api/demo/todos/+server.ts +++ b/src/routes/api/demo/todos/+server.ts @@ -5,6 +5,7 @@ import { getDemoTodos, simulateDemoTodoCreate, } from "@/features/demo/server/demo-fixtures"; +import { apiRequestContext } from "@/lib/log/api-observability-context"; import { logAppEvent } from "@/lib/log/app-logger"; import type { RequestHandler } from "./$types"; @@ -28,6 +29,7 @@ export const POST: RequestHandler = async ({ request }) => { event: "demo.mutation.simulated", fixtureVersion: principal.fixtureVersion, operation: "todo.create", + requestId: apiRequestContext(request).requestId, sessionHash: getDemoSessionAuditId(principal.sessionId), }); return json(simulateDemoTodoCreate(principal, title), { diff --git a/tests/unit/demo-auth.test.ts b/tests/unit/demo-auth.test.ts index 51834d1e4..7e3b63ed3 100644 --- a/tests/unit/demo-auth.test.ts +++ b/tests/unit/demo-auth.test.ts @@ -1,4 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { requireDemoApiScope } from "@/features/demo/server/demo-api-auth"; import { getDemoSessionAuditId, isDemoModeEnabled, @@ -7,6 +8,7 @@ import { verifyDemoApiToken, verifyDemoWebSession, } from "@/features/demo/server/demo-auth"; +import { simulateDemoTodoCreate } from "@/features/demo/server/demo-fixtures"; const previousEnv = { ...process.env }; @@ -54,4 +56,30 @@ describe("demo authentication realm", () => { expect(auditId).not.toContain("session-1"); expect(getDemoSessionAuditId("session-1")).toBe(auditId); }); + + it("hides demo API routes when the kill switch is off", async () => { + process.env.DEMO_MODE_ENABLED = "false"; + const response = await requireDemoApiScope( + new Request("https://example.test/api/demo/todos"), + "demo:todo:read", + ); + expect(response).toBeInstanceOf(Response); + expect((response as Response).status).toBe(404); + await expect((response as Response).json()).resolves.toEqual({ + error: "Not found", + }); + }); + + it("does not derive simulated resource IDs from session IDs", () => { + const result = simulateDemoTodoCreate( + { + kind: "demo", + sessionId: "sensitive-session-prefix", + fixtureVersion: "2026-07-22", + scopes: new Set(), + }, + "Simulated", + ); + expect(result.todo.id).not.toContain("sensitive"); + }); }); From 49a0aa558a30bbd73bdcde49b85557919f0b6e83 Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Wed, 22 Jul 2026 16:56:52 +0800 Subject: [PATCH 3/4] fix(demo): harden draft API contract --- public/openapi.generated.json | 315 +++++++++++++++++++ src/lib/api/schemas/demo-request-schemas.ts | 5 + src/lib/api/schemas/demo-response-schemas.ts | 24 ++ src/lib/api/schemas/request-schemas.ts | 1 + src/lib/api/schemas/response-schemas.ts | 1 + src/routes/api/demo/todos/+server.ts | 32 +- src/routes/api/demo/token/+server.ts | 25 +- src/routes/demo/+page.server.ts | 2 +- tests/unit/demo-auth.test.ts | 14 +- 9 files changed, 403 insertions(+), 16 deletions(-) create mode 100644 src/lib/api/schemas/demo-request-schemas.ts create mode 100644 src/lib/api/schemas/demo-response-schemas.ts diff --git a/public/openapi.generated.json b/public/openapi.generated.json index ba0399cae..b8d808d03 100644 --- a/public/openapi.generated.json +++ b/public/openapi.generated.json @@ -4435,6 +4435,191 @@ } } }, + "/api/demo/todos": { + "get": { + "operationId": "get-api-demo-todos", + "summary": "List the immutable demo todo fixture", + "tags": [ + "Api" + ], + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/demoTodosResponseSchema" + } + } + } + }, + "401": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "403": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "404": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + } + } + }, + "post": { + "operationId": "post-api-demo-todos", + "summary": "Simulate creating a demo todo without persistence", + "tags": [ + "Api" + ], + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/demoTodoCreateRequestSchema" + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/demoTodoCreateResponseSchema" + } + } + } + }, + "400": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "401": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "403": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "404": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + } + } + } + }, + "/api/demo/token": { + "post": { + "operationId": "post-api-demo-token", + "summary": "Exchange a demo web session for a short-lived demo API token", + "tags": [ + "Api" + ], + "security": [ + { + "bearerAuth": [] + }, + { + "sessionCookie": [] + } + ], + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/demoTokenResponseSchema" + } + } + } + }, + "401": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + }, + "404": { + "description": "Error response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/openApiErrorSchema" + } + } + } + } + } + } + }, "/api/homeworks/{id}": { "patch": { "operationId": "updateHomework", @@ -24500,6 +24685,136 @@ ], "additionalProperties": false }, + "demoTodosResponseSchema": { + "type": "object", + "properties": { + "fixture": { + "type": "boolean", + "enum": [ + true + ] + }, + "todos": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "completed": { + "type": "boolean" + }, + "priority": { + "type": "string", + "enum": [ + "medium", + "high" + ] + } + }, + "required": [ + "id", + "title", + "completed", + "priority" + ], + "additionalProperties": false + } + } + }, + "required": [ + "fixture", + "todos" + ], + "additionalProperties": false + }, + "demoTodoCreateRequestSchema": { + "type": "object", + "properties": { + "title": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "required": [ + "title" + ], + "additionalProperties": false + }, + "demoTodoCreateResponseSchema": { + "type": "object", + "properties": { + "simulated": { + "type": "boolean", + "enum": [ + true + ] + }, + "todo": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "title": { + "type": "string" + }, + "completed": { + "type": "boolean" + }, + "priority": { + "type": "string", + "enum": [ + "medium", + "high" + ] + } + }, + "required": [ + "id", + "title", + "completed", + "priority" + ], + "additionalProperties": false + } + }, + "required": [ + "simulated", + "todo" + ], + "additionalProperties": false + }, + "demoTokenResponseSchema": { + "type": "object", + "properties": { + "accessToken": { + "type": "string" + }, + "expiresIn": { + "type": "integer", + "exclusiveMinimum": true, + "maximum": 9007199254740991 + }, + "tokenType": { + "type": "string", + "enum": [ + "Bearer" + ] + } + }, + "required": [ + "accessToken", + "expiresIn", + "tokenType" + ], + "additionalProperties": false + }, "homeworkUpdateRequestSchema": { "type": "object", "properties": { diff --git a/src/lib/api/schemas/demo-request-schemas.ts b/src/lib/api/schemas/demo-request-schemas.ts new file mode 100644 index 000000000..a4d62a926 --- /dev/null +++ b/src/lib/api/schemas/demo-request-schemas.ts @@ -0,0 +1,5 @@ +import * as z from "zod"; + +export const demoTodoCreateRequestSchema = z.object({ + title: z.string().trim().min(1).max(200), +}); diff --git a/src/lib/api/schemas/demo-response-schemas.ts b/src/lib/api/schemas/demo-response-schemas.ts new file mode 100644 index 000000000..1d8ce0b2f --- /dev/null +++ b/src/lib/api/schemas/demo-response-schemas.ts @@ -0,0 +1,24 @@ +import * as z from "zod"; + +const demoTodoSchema = z.object({ + id: z.string(), + title: z.string(), + completed: z.boolean(), + priority: z.enum(["medium", "high"]), +}); + +export const demoTokenResponseSchema = z.object({ + accessToken: z.string(), + expiresIn: z.number().int().positive(), + tokenType: z.literal("Bearer"), +}); + +export const demoTodosResponseSchema = z.object({ + fixture: z.literal(true), + todos: z.array(demoTodoSchema), +}); + +export const demoTodoCreateResponseSchema = z.object({ + simulated: z.literal(true), + todo: demoTodoSchema, +}); diff --git a/src/lib/api/schemas/request-schemas.ts b/src/lib/api/schemas/request-schemas.ts index 594a8165a..cb5fafe0d 100644 --- a/src/lib/api/schemas/request-schemas.ts +++ b/src/lib/api/schemas/request-schemas.ts @@ -1,3 +1,4 @@ +export * from "./demo-request-schemas"; export * from "./request-mutation-schemas"; export * from "./request-path-schemas"; export * from "./request-query-schemas"; diff --git a/src/lib/api/schemas/response-schemas.ts b/src/lib/api/schemas/response-schemas.ts index c6c4552e5..e7d12f485 100644 --- a/src/lib/api/schemas/response-schemas.ts +++ b/src/lib/api/schemas/response-schemas.ts @@ -2,6 +2,7 @@ export * from "./academic-response-schema-core"; export * from "./admin-response-schemas"; export * from "./bus-response-schemas"; export * from "./comments-response-schemas"; +export * from "./demo-response-schemas"; export * from "./descriptions-response-schemas"; export * from "./homeworks-response-schemas"; export * from "./misc-response-schema-core"; diff --git a/src/routes/api/demo/todos/+server.ts b/src/routes/api/demo/todos/+server.ts index 81da00624..eb29bac3a 100644 --- a/src/routes/api/demo/todos/+server.ts +++ b/src/routes/api/demo/todos/+server.ts @@ -1,20 +1,39 @@ -import { json } from "@sveltejs/kit"; import { requireDemoApiScope } from "@/features/demo/server/demo-api-auth"; import { getDemoSessionAuditId } from "@/features/demo/server/demo-auth"; import { getDemoTodos, simulateDemoTodoCreate, } from "@/features/demo/server/demo-fixtures"; +import { badRequest, jsonResponse } from "@/lib/api/responses"; import { apiRequestContext } from "@/lib/log/api-observability-context"; import { logAppEvent } from "@/lib/log/app-logger"; import type { RequestHandler } from "./$types"; +/** + * List the immutable demo todo fixture. + * @response demoTodosResponseSchema + * @response 401:openApiErrorSchema + * @response 403:openApiErrorSchema + * @response 404:openApiErrorSchema + */ export const GET: RequestHandler = async ({ request }) => { const principal = await requireDemoApiScope(request, "demo:todo:read"); if (principal instanceof Response) return principal; - return json({ fixture: true, todos: getDemoTodos(principal) }); + return jsonResponse( + { fixture: true, todos: getDemoTodos(principal) }, + { headers: { "Cache-Control": "no-store" } }, + ); }; +/** + * Simulate creating a demo todo without persistence. + * @body demoTodoCreateRequestSchema + * @response demoTodoCreateResponseSchema + * @response 400:openApiErrorSchema + * @response 401:openApiErrorSchema + * @response 403:openApiErrorSchema + * @response 404:openApiErrorSchema + */ export const POST: RequestHandler = async ({ request }) => { const principal = await requireDemoApiScope(request, "demo:todo:write"); if (principal instanceof Response) return principal; @@ -23,7 +42,7 @@ export const POST: RequestHandler = async ({ request }) => { } | null; const title = typeof body?.title === "string" ? body.title.trim() : ""; if (!title || title.length > 200) { - return json({ error: "invalid_title" }, { status: 400 }); + return badRequest("invalid_title"); } logAppEvent("info", "demo mutation simulated", { event: "demo.mutation.simulated", @@ -32,7 +51,10 @@ export const POST: RequestHandler = async ({ request }) => { requestId: apiRequestContext(request).requestId, sessionHash: getDemoSessionAuditId(principal.sessionId), }); - return json(simulateDemoTodoCreate(principal, title), { - headers: { "x-life-ustc-simulated": "true" }, + return jsonResponse(simulateDemoTodoCreate(principal, title), { + headers: { + "Cache-Control": "no-store", + "x-life-ustc-simulated": "true", + }, }); }; diff --git a/src/routes/api/demo/token/+server.ts b/src/routes/api/demo/token/+server.ts index 2cb645013..51731dc6b 100644 --- a/src/routes/api/demo/token/+server.ts +++ b/src/routes/api/demo/token/+server.ts @@ -1,20 +1,29 @@ -import { json } from "@sveltejs/kit"; import { DEMO_SESSION_COOKIE, isDemoModeEnabled, mintDemoApiToken, verifyDemoWebSession, } from "@/features/demo/server/demo-auth"; +import { jsonResponse, notFound, unauthorized } from "@/lib/api/responses"; import type { RequestHandler } from "./$types"; +/** + * Exchange a demo web session for a short-lived demo API token. + * @response demoTokenResponseSchema + * @response 401:openApiErrorSchema + * @response 404:openApiErrorSchema + */ export const POST: RequestHandler = async ({ cookies }) => { - if (!isDemoModeEnabled()) return new Response("Not found", { status: 404 }); + if (!isDemoModeEnabled()) return notFound(); const session = cookies.get(DEMO_SESSION_COOKIE); const principal = session ? await verifyDemoWebSession(session) : null; - if (!principal) return new Response("Unauthorized", { status: 401 }); - return json({ - accessToken: await mintDemoApiToken(principal.sessionId), - expiresIn: 5 * 60, - tokenType: "Bearer", - }); + if (!principal) return unauthorized(); + return jsonResponse( + { + accessToken: await mintDemoApiToken(principal.sessionId), + expiresIn: 5 * 60, + tokenType: "Bearer", + }, + { headers: { "Cache-Control": "no-store" } }, + ); }; diff --git a/src/routes/demo/+page.server.ts b/src/routes/demo/+page.server.ts index 22c179f03..d9f2fae52 100644 --- a/src/routes/demo/+page.server.ts +++ b/src/routes/demo/+page.server.ts @@ -32,6 +32,6 @@ export const actions: Actions = { sameSite: "lax", secure: url.protocol === "https:", }); - redirect(303, "/demo"); + throw redirect(303, "/demo"); }, }; diff --git a/tests/unit/demo-auth.test.ts b/tests/unit/demo-auth.test.ts index 7e3b63ed3..bbe859058 100644 --- a/tests/unit/demo-auth.test.ts +++ b/tests/unit/demo-auth.test.ts @@ -10,7 +10,8 @@ import { } from "@/features/demo/server/demo-auth"; import { simulateDemoTodoCreate } from "@/features/demo/server/demo-fixtures"; -const previousEnv = { ...process.env }; +const previousDemoModeEnabled = process.env.DEMO_MODE_ENABLED; +const previousDemoSigningSecret = process.env.DEMO_SIGNING_SECRET; describe("demo authentication realm", () => { beforeEach(() => { @@ -20,7 +21,16 @@ describe("demo authentication realm", () => { }); afterEach(() => { - process.env = { ...previousEnv }; + if (previousDemoModeEnabled === undefined) { + delete process.env.DEMO_MODE_ENABLED; + } else { + process.env.DEMO_MODE_ENABLED = previousDemoModeEnabled; + } + if (previousDemoSigningSecret === undefined) { + delete process.env.DEMO_SIGNING_SECRET; + } else { + process.env.DEMO_SIGNING_SECRET = previousDemoSigningSecret; + } }); it("is disabled unless explicitly enabled", () => { From a4117ede58155f7aa4ead959ab778b01511b99bb Mon Sep 17 00:00:00 2001 From: TianKai Ma Date: Wed, 22 Jul 2026 17:15:40 +0800 Subject: [PATCH 4/4] fix(demo): document isolated auth realm --- public/openapi.generated.json | 27 +++++++++++++++------------ scripts/openapi/generate.ts | 14 ++++++++++++++ scripts/openapi/route-collector.ts | 8 ++++++++ src/routes/api/demo/todos/+server.ts | 9 ++++++--- tests/unit/openapi-collectors.test.ts | 25 +++++++++++++++++++++++++ tests/unit/openapi-generator.test.ts | 8 ++++++++ 6 files changed, 76 insertions(+), 15 deletions(-) diff --git a/public/openapi.generated.json b/public/openapi.generated.json index b8d808d03..1fe9f6bf6 100644 --- a/public/openapi.generated.json +++ b/public/openapi.generated.json @@ -4444,10 +4444,7 @@ ], "security": [ { - "bearerAuth": [] - }, - { - "sessionCookie": [] + "demoBearerAuth": [] } ], "responses": { @@ -4501,10 +4498,7 @@ ], "security": [ { - "bearerAuth": [] - }, - { - "sessionCookie": [] + "demoBearerAuth": [] } ], "requestBody": { @@ -4580,10 +4574,7 @@ ], "security": [ { - "bearerAuth": [] - }, - { - "sessionCookie": [] + "demoSessionCookie": [] } ], "responses": { @@ -33536,6 +33527,18 @@ "name": "better-auth.session_token", "description": "Better Auth session cookie used by the web UI. Production cookies may use the __Secure- prefix." }, + "demoBearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "JWT", + "description": "Short-lived bearer token issued by /api/demo/token for the isolated, non-persistent demo realm." + }, + "demoSessionCookie": { + "type": "apiKey", + "in": "cookie", + "name": "life_ustc_demo", + "description": "Short-lived web session cookie for the isolated demo realm. It is not a Better Auth session." + }, "mcpBearerAuth": { "type": "http", "scheme": "bearer", diff --git a/scripts/openapi/generate.ts b/scripts/openapi/generate.ts index 8d59ce679..ec3e642ab 100644 --- a/scripts/openapi/generate.ts +++ b/scripts/openapi/generate.ts @@ -23,6 +23,20 @@ const SECURITY_SCHEMES = { description: "Better Auth session cookie used by the web UI. Production cookies may use the __Secure- prefix.", }, + demoBearerAuth: { + type: "http" as const, + scheme: "bearer", + bearerFormat: "JWT", + description: + "Short-lived bearer token issued by /api/demo/token for the isolated, non-persistent demo realm.", + }, + demoSessionCookie: { + type: "apiKey" as const, + in: "cookie" as const, + name: "life_ustc_demo", + description: + "Short-lived web session cookie for the isolated demo realm. It is not a Better Auth session.", + }, mcpBearerAuth: { type: "http" as const, scheme: "bearer", diff --git a/scripts/openapi/route-collector.ts b/scripts/openapi/route-collector.ts index ff9b77c5d..2f9ccc35c 100644 --- a/scripts/openapi/route-collector.ts +++ b/scripts/openapi/route-collector.ts @@ -561,6 +561,14 @@ function buildSecurity( return undefined; } + if (routePath === "/api/demo/token") { + return [{ demoSessionCookie: [] }]; + } + + if (routePath.startsWith("/api/demo/")) { + return [{ demoBearerAuth: [] }]; + } + if (tag === "Admin") { return [{ sessionCookie: [] }]; } diff --git a/src/routes/api/demo/todos/+server.ts b/src/routes/api/demo/todos/+server.ts index eb29bac3a..fae356df0 100644 --- a/src/routes/api/demo/todos/+server.ts +++ b/src/routes/api/demo/todos/+server.ts @@ -37,9 +37,12 @@ export const GET: RequestHandler = async ({ request }) => { export const POST: RequestHandler = async ({ request }) => { const principal = await requireDemoApiScope(request, "demo:todo:write"); if (principal instanceof Response) return principal; - const body = (await request.json().catch(() => null)) as { - title?: unknown; - } | null; + let body: { title?: unknown } | null; + try { + body = (await request.json()) as { title?: unknown } | null; + } catch { + return badRequest("invalid_request"); + } const title = typeof body?.title === "string" ? body.title.trim() : ""; if (!title || title.length > 200) { return badRequest("invalid_title"); diff --git a/tests/unit/openapi-collectors.test.ts b/tests/unit/openapi-collectors.test.ts index 5461f7d24..dd5e377bb 100644 --- a/tests/unit/openapi-collectors.test.ts +++ b/tests/unit/openapi-collectors.test.ts @@ -64,6 +64,31 @@ export const GET = () => new Response(); expect(responses["401"].description).toBe("Error response"); }); + it("describes the demo realm without advertising Better Auth", () => { + const project = new Project({ useInMemoryFileSystem: true }); + for (const routePath of ["token", "todos"]) { + project.createSourceFile( + `src/routes/api/demo/${routePath}/+server.ts`, + ` +/** + * Demo endpoint. + * @response 401:openApiErrorSchema + */ +export const POST = () => new Response(); +`, + { overwrite: true }, + ); + } + + const paths = collectPaths(project, new SchemaCollector()); + expect(paths["/api/demo/token"].post).toMatchObject({ + security: [{ demoSessionCookie: [] }], + }); + expect(paths["/api/demo/todos"].post).toMatchObject({ + security: [{ demoBearerAuth: [] }], + }); + }); + it("handles response shortcuts", () => { const project = new Project({ useInMemoryFileSystem: true }); project.createSourceFile( diff --git a/tests/unit/openapi-generator.test.ts b/tests/unit/openapi-generator.test.ts index ccdadf3ab..a9ad308fc 100644 --- a/tests/unit/openapi-generator.test.ts +++ b/tests/unit/openapi-generator.test.ts @@ -17,6 +17,14 @@ describe("openapi generator", () => { ); expect(doc.servers).toEqual([{ url: "/", description: "Current origin" }]); expect(doc.components?.securitySchemes).toBeDefined(); + expect(doc.components?.securitySchemes).toMatchObject({ + demoBearerAuth: { scheme: "bearer", type: "http" }, + demoSessionCookie: { + in: "cookie", + name: "life_ustc_demo", + type: "apiKey", + }, + }); expect(doc.components?.schemas).toBeDefined(); });