create-ailk 0.29.1 → 0.31.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +71 -3
  2. package/component-catalog.json +524 -8
  3. package/component-catalog.md +8 -5
  4. package/dist/add-route.js +1 -0
  5. package/dist/cli.js +114 -1
  6. package/dist/cutover.d.ts +109 -0
  7. package/dist/cutover.js +494 -0
  8. package/dist/design-presets.js +20 -4
  9. package/dist/export-content.d.ts +55 -0
  10. package/dist/export-content.js +303 -0
  11. package/dist/inventory.d.ts +216 -0
  12. package/dist/inventory.js +833 -0
  13. package/dist/language-import.d.ts +114 -0
  14. package/dist/language-import.js +435 -0
  15. package/dist/lib/env-catalog.js +2 -2
  16. package/dist/lib/import-mdx.d.ts +38 -0
  17. package/dist/lib/import-mdx.js +411 -64
  18. package/dist/lib/render-comparison.d.ts +200 -0
  19. package/dist/lib/render-comparison.js +374 -0
  20. package/dist/lib/scaffold-render-sweep.d.ts +1 -1
  21. package/dist/lib/scaffold-render-sweep.js +1 -1
  22. package/dist/migrate-content.d.ts +6 -2
  23. package/dist/migrate-content.js +60 -14
  24. package/dist/module-architecture.js +6 -1
  25. package/dist/provision-env.d.ts +28 -0
  26. package/dist/provision-env.js +210 -0
  27. package/dist/seed-workspace.d.ts +3 -1
  28. package/dist/seed-workspace.js +45 -16
  29. package/dist/surfaces.js +3 -0
  30. package/dist/topology.d.ts +12 -0
  31. package/dist/topology.js +28 -10
  32. package/package.json +3 -3
  33. package/templates/.claude/cloudparity-repo-hooks.json +5 -0
  34. package/templates/.claude/hooks/content-source-guard.sh +75 -0
  35. package/templates/.claude/settings.json +9 -0
  36. package/templates/.env.example +7 -0
  37. package/templates/.github/workflows/ci.yml +2 -0
  38. package/templates/CLAUDE.md +5 -0
  39. package/templates/apps/api/CLAUDE.md +6 -2
  40. package/templates/apps/mcp/.env.example +20 -0
  41. package/templates/apps/mcp/CLAUDE.md +46 -8
  42. package/templates/apps/web/.env.example +4 -2
  43. package/templates/apps/web/CLAUDE.md +39 -5
  44. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
  45. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
  46. package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
  47. package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
  48. package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
  49. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
  50. package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
  51. package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
  52. package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
  53. package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
  54. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
  55. package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
  56. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
  57. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
  58. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
  59. package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
  60. package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
  61. package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
  62. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
  63. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
  64. package/templates/apps/web/app/[locale]/layout.tsx +122 -27
  65. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
  66. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
  67. package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
  68. package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
  69. package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
  70. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
  71. package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
  72. package/templates/apps/web/app/llms.txt/route.ts +26 -9
  73. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
  74. package/templates/apps/web/app/openapi.json/route.ts +4 -0
  75. package/templates/apps/web/app/robots.txt/route.ts +18 -1
  76. package/templates/apps/web/app/sitemap.xml/route.ts +13 -0
  77. package/templates/apps/web/auth.ts +184 -7
  78. package/templates/apps/web/jest.config.cjs +46 -1
  79. package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
  80. package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
  81. package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
  82. package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
  83. package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
  84. package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
  85. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
  86. package/templates/apps/web/lib/auth-routes.ts +5 -3
  87. package/templates/apps/web/lib/bot-check.ts +113 -13
  88. package/templates/apps/web/lib/in-page-magnet.ts +4 -1
  89. package/templates/apps/web/lib/mcp-oauth.ts +702 -0
  90. package/templates/apps/web/lib/page-chrome.ts +65 -5
  91. package/templates/apps/web/lib/relay.ts +22 -3
  92. package/templates/apps/web/lib/site-identity.ts +75 -24
  93. package/templates/apps/web/messages/en.json +47 -14
  94. package/templates/apps/web/next.config.mjs +15 -1
  95. package/templates/apps/web/package.json +1 -1
  96. package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
  97. package/templates/apps/web/scripts/content-snapshot.ts +33 -0
  98. package/templates/database/seed.ts +6 -0
  99. package/templates/package.json +2 -1
  100. package/templates/tools/check-content-source.ts +108 -0
  101. package/versions.json +48 -48
@@ -0,0 +1,702 @@
1
+ /**
2
+ * The AILK half of the site's MCP authorization server (#10357 D5).
3
+ *
4
+ * better-auth's `mcp` plugin (configured in `auth.ts`) registers clients and
5
+ * issues opaque access and refresh tokens. This plugin sits beside it and adds
6
+ * what the plugin lacks, as hooks and endpoints — the plugin is configured,
7
+ * never patched:
8
+ *
9
+ * - every authorization passes AILK's consent step (`/consent`), where the
10
+ * person picks one workspace and approves scopes their role there holds; the
11
+ * chosen workspace is bound into the token's `scopes` as one
12
+ * `ailk:workspace:<id>` entry (D2, D3);
13
+ * - `/mcp/token` refuses any code that did not pass that step, rotates refresh
14
+ * tokens and ends a line whose used refresh token is replayed (D5);
15
+ * - RFC 7009 revocation, the person's own per-client revoke, and a
16
+ * stored-token self-check (`/mcp/token-info`, decision (a): lookup, never
17
+ * JWKS — D1);
18
+ * - registration refuses unsafe redirect URIs and metadata and normalises the
19
+ * self-claimed client name.
20
+ *
21
+ * Every refusal is code-shaped: no English string reaches a client. No answer
22
+ * or log line carries a token, code, verifier or client secret.
23
+ */
24
+ import { createHash, timingSafeEqual } from "node:crypto";
25
+
26
+ import {
27
+ lowerRole,
28
+ MCP_SCOPES,
29
+ type McpScope,
30
+ type MembershipRole,
31
+ grantsForWorkspace,
32
+ parseGrantedScopes,
33
+ scopesForRole,
34
+ WORKSPACE_SCOPE_PREFIX,
35
+ } from "@working-theory/validation";
36
+ import type { BetterAuthPlugin } from "better-auth";
37
+ import {
38
+ APIError,
39
+ createAuthEndpoint,
40
+ createAuthMiddleware,
41
+ sessionMiddleware,
42
+ } from "better-auth/api";
43
+ import { expireCookie } from "better-auth/cookies";
44
+ import { generateRandomString } from "better-auth/crypto";
45
+ import * as z from "zod";
46
+
47
+ /** One workspace a person may bind a token to, with their role in its organization. */
48
+ export interface McpMembership {
49
+ workspaceId: string;
50
+ workspaceName: string;
51
+ organizationName: string;
52
+ role: string;
53
+ }
54
+
55
+ export interface McpOAuthDeps {
56
+ /**
57
+ * The person's workspaces, `listTenancy`'s rule: every workspace of every
58
+ * organization they hold a membership in, with that membership's role.
59
+ */
60
+ readMemberships(userId: string): Promise<McpMembership[]>;
61
+ }
62
+
63
+ /** The scopes `/mcp/authorize` accepts a request for. */
64
+ const REQUESTABLE_SCOPES: ReadonlySet<string> = new Set([
65
+ "openid",
66
+ "profile",
67
+ "email",
68
+ "offline_access",
69
+ ...MCP_SCOPES,
70
+ ]);
71
+
72
+ const CONSENT_COOKIE = "oidc_consent_prompt";
73
+ const LOGIN_COOKIE = "oidc_login_prompt";
74
+ const ROTATED_PREFIX = "mcp-rotated:";
75
+ const NO_STORE_PATHS: ReadonlySet<string> = new Set([
76
+ "/mcp/token",
77
+ "/mcp/consent-details",
78
+ "/mcp/consent",
79
+ "/mcp/revoke",
80
+ "/mcp/revoke-client",
81
+ "/mcp/token-info",
82
+ ]);
83
+
84
+ /** Registration caps (SEC-10357-02, -03). */
85
+ const MAX_REDIRECT_URIS = 5;
86
+ const MAX_URI_LENGTH = 512;
87
+ const MAX_METADATA_BYTES = 2048;
88
+ const MAX_CLIENT_NAME = 60;
89
+ /** An application unused this long after registering is swept (SEC-10357-03). */
90
+ const UNUSED_CLIENT_TTL_MS = 24 * 60 * 60 * 1000;
91
+ /** Rows one sweep may look at, so a register never pays for a backlog at once. */
92
+ const SWEEP_BATCH = 100;
93
+ /**
94
+ * Where the last sweep stopped: the `createdAt` it reached, so applications
95
+ * that stay (used ones) never pin every later sweep to the same batch.
96
+ */
97
+ const SWEEP_CURSOR = "mcp-sweep-cursor";
98
+ /**
99
+ * An authorization code's shape — the plugin's and the consent step's
100
+ * `generateRandomString(32, …)`. The plugin consumes whatever verification row
101
+ * a `code` names, on every grant type, so anything else is refused first: a
102
+ * tombstone or the sweep cursor has a name a caller can compute.
103
+ */
104
+ const CODE_SHAPE = /^[A-Za-z0-9]{32}$/;
105
+ /** `/mcp/token` body fields, each a string when present. */
106
+ const TOKEN_FIELDS = [
107
+ "grant_type",
108
+ "code",
109
+ "code_verifier",
110
+ "redirect_uri",
111
+ "refresh_token",
112
+ "client_id",
113
+ "client_secret",
114
+ ] as const;
115
+
116
+ const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]"]);
117
+ const PRIVATE_SCHEME = /^[a-z][a-z0-9+.-]*$/;
118
+ const REFUSED_SCHEMES = new Set([
119
+ "http",
120
+ "https",
121
+ "file",
122
+ "javascript",
123
+ "data",
124
+ "vbscript",
125
+ "blob",
126
+ "about",
127
+ "ws",
128
+ "wss",
129
+ "ftp",
130
+ "intent",
131
+ ]);
132
+
133
+ /** Control, zero-width and bidi-override characters (SEC-10357-11). */
134
+ const UNSAFE_NAME_CHARS =
135
+ /[\p{Cc}\u200B-\u200F\u2028-\u202E\u2060-\u206F\uFEFF\u061C]/gu;
136
+
137
+ function refuse(
138
+ status: "BAD_REQUEST" | "UNAUTHORIZED" | "NOT_FOUND",
139
+ error: string,
140
+ headers?: Record<string, string>,
141
+ ): APIError {
142
+ return new APIError(status, { error, message: error }, { "Cache-Control": "no-store", ...headers });
143
+ }
144
+
145
+ /** A redirect URI a self-registered client may name (D5). */
146
+ export function isAllowedRedirectUri(value: unknown): boolean {
147
+ if (typeof value !== "string" || value.length > MAX_URI_LENGTH || value.includes(",")) return false;
148
+ let url: URL;
149
+ try {
150
+ url = new URL(value);
151
+ } catch {
152
+ return false;
153
+ }
154
+ if (url.protocol === "https:") return true;
155
+ if (url.protocol === "http:") return LOOPBACK_HOSTS.has(url.hostname);
156
+ const scheme = url.protocol.slice(0, -1);
157
+ return PRIVATE_SCHEME.test(scheme) && !REFUSED_SCHEMES.has(scheme);
158
+ }
159
+
160
+ /** The client's self-claimed name, made safe to show (SEC-10357-11). */
161
+ export function normaliseClientName(value: unknown): string {
162
+ const raw = typeof value === "string" ? value : "";
163
+ const name = raw.normalize("NFKC").replace(UNSAFE_NAME_CHARS, "").trim().slice(0, MAX_CLIENT_NAME).trim();
164
+ return name === "" ? "Unnamed app" : name;
165
+ }
166
+
167
+ /** Where a code will be sent, as the consent page names it. */
168
+ function redirectOriginOf(redirectURI: string): string {
169
+ const url = new URL(redirectURI);
170
+ return url.protocol === "http:" || url.protocol === "https:" ? url.origin : url.protocol;
171
+ }
172
+
173
+ function sha256(value: string): string {
174
+ return createHash("sha256").update(value).digest("hex");
175
+ }
176
+
177
+ function secretsMatch(stored: string, presented: string): boolean {
178
+ // Equal-length digests, so the comparison time never depends on the secret.
179
+ return timingSafeEqual(Buffer.from(sha256(stored)), Buffer.from(sha256(presented)));
180
+ }
181
+
182
+ /** A form or JSON body as a plain object. */
183
+ function bodyOf(body: unknown): Record<string, unknown> {
184
+ if (body instanceof FormData) return Object.fromEntries(body.entries());
185
+ return body !== null && typeof body === "object" ? (body as Record<string, unknown>) : {};
186
+ }
187
+
188
+ function str(value: unknown): string | undefined {
189
+ return typeof value === "string" && value !== "" ? value : undefined;
190
+ }
191
+
192
+ /** What `/mcp/authorize` stored in the verification row's value. */
193
+ interface AuthorizationValue {
194
+ clientId: string;
195
+ redirectURI: string;
196
+ scope: string[];
197
+ userId: string;
198
+ requireConsent: boolean;
199
+ state: string | null;
200
+ ailkConsent?: boolean;
201
+ /** The session that approved the code; the code dies with it. */
202
+ sessionId?: string;
203
+ }
204
+
205
+ interface OAuthApplicationRow {
206
+ clientId: string;
207
+ clientSecret?: string | null;
208
+ type?: string | null;
209
+ name?: string | null;
210
+ createdAt: Date;
211
+ }
212
+
213
+ interface OAuthAccessTokenRow {
214
+ accessToken: string;
215
+ refreshToken: string;
216
+ accessTokenExpiresAt: Date;
217
+ refreshTokenExpiresAt: Date;
218
+ clientId: string;
219
+ userId: string;
220
+ scopes: string;
221
+ }
222
+
223
+ function parseValue(raw: string): AuthorizationValue | null {
224
+ try {
225
+ const value = JSON.parse(raw) as AuthorizationValue;
226
+ return typeof value === "object" && value !== null ? value : null;
227
+ } catch {
228
+ return null;
229
+ }
230
+ }
231
+
232
+ export function mcpOAuthPlugin(deps: McpOAuthDeps) {
233
+ type AuthCtx = Parameters<Parameters<typeof createAuthMiddleware>[0]>[0]["context"];
234
+ /** What the helpers below need of an endpoint's or a hook's context. */
235
+ interface Ctx {
236
+ context: AuthCtx;
237
+ getSignedCookie(key: string, secret: string): Promise<string | null | false>;
238
+ }
239
+
240
+ /**
241
+ * The five consent checks (D5): the row exists, is unexpired and awaits
242
+ * consent, was issued to the signed-in person, and is the code in this
243
+ * browser's signed consent cookie.
244
+ */
245
+ async function pendingConsent(ctx: Ctx, consentCode: unknown, userId: string) {
246
+ if (typeof consentCode !== "string" || consentCode === "") throw refuse("NOT_FOUND", "invalid_request");
247
+ const row = await ctx.context.internalAdapter.findVerificationValue(consentCode);
248
+ const value = row ? parseValue(row.value) : null;
249
+ const cookie = await ctx.getSignedCookie(CONSENT_COOKIE, ctx.context.secret);
250
+ if (
251
+ !row ||
252
+ !value ||
253
+ new Date(row.expiresAt) < new Date() ||
254
+ value.requireConsent !== true ||
255
+ value.userId !== userId ||
256
+ cookie !== consentCode
257
+ ) {
258
+ throw refuse("NOT_FOUND", "invalid_request");
259
+ }
260
+ return value;
261
+ }
262
+
263
+ /** Each workspace with the scopes the request may approve there. */
264
+ async function offeredWorkspaces(userId: string, requested: string[]) {
265
+ const asked = requested.filter((s): s is McpScope => (MCP_SCOPES as readonly string[]).includes(s));
266
+ const pool = asked.length > 0 ? asked : [...MCP_SCOPES];
267
+ const memberships = await deps.readMemberships(userId);
268
+ return memberships.map((m) => {
269
+ const allowed = new Set(scopesForRole(m.role));
270
+ return {
271
+ id: m.workspaceId,
272
+ name: m.workspaceName,
273
+ organizationName: m.organizationName,
274
+ role: m.role,
275
+ offered: pool.filter((s) => allowed.has(s)),
276
+ };
277
+ });
278
+ }
279
+
280
+ /** Bounded cleanup on every registration (SEC-10357-03). */
281
+ async function sweep(ctx: Ctx): Promise<void> {
282
+ const adapter = ctx.context.adapter;
283
+ await adapter.deleteMany({
284
+ model: "oauthAccessToken",
285
+ where: [{ field: "refreshTokenExpiresAt", value: new Date(), operator: "lt" }],
286
+ });
287
+ const internal = ctx.context.internalAdapter;
288
+ const cursorRow = await internal.findVerificationValue(SWEEP_CURSOR);
289
+ const after = cursorRow ? new Date(cursorRow.value) : null;
290
+ const stale = await adapter.findMany<OAuthApplicationRow>({
291
+ model: "oauthApplication",
292
+ where: [
293
+ { field: "createdAt", value: new Date(Date.now() - UNUSED_CLIENT_TTL_MS), operator: "lt" },
294
+ ...(after && !Number.isNaN(after.getTime()) ? [{ field: "createdAt", value: after, operator: "gt" as const }] : []),
295
+ ],
296
+ sortBy: { field: "createdAt", direction: "asc" },
297
+ limit: SWEEP_BATCH,
298
+ });
299
+ // A full batch moves the cursor to its last row; a short one has reached
300
+ // the end, so the next sweep starts over.
301
+ if (cursorRow) await internal.deleteVerificationByIdentifier(SWEEP_CURSOR);
302
+ const last = stale.length === SWEEP_BATCH ? stale[stale.length - 1] : undefined;
303
+ if (last) {
304
+ await internal.createVerificationValue({
305
+ identifier: SWEEP_CURSOR,
306
+ value: new Date(last.createdAt).toISOString(),
307
+ expiresAt: new Date(Date.now() + UNUSED_CLIENT_TTL_MS),
308
+ });
309
+ }
310
+ for (const app of stale) {
311
+ const used =
312
+ (await adapter.findOne({ model: "oauthAccessToken", where: [{ field: "clientId", value: app.clientId }] })) ??
313
+ (await adapter.findOne({ model: "oauthConsent", where: [{ field: "clientId", value: app.clientId }] }));
314
+ if (!used) await adapter.delete({ model: "oauthApplication", where: [{ field: "clientId", value: app.clientId }] });
315
+ }
316
+ }
317
+
318
+ async function deleteTokensOf(ctx: Ctx, where: Array<{ field: string; value: string }>): Promise<number> {
319
+ return ctx.context.adapter.deleteMany({ model: "oauthAccessToken", where });
320
+ }
321
+
322
+ return {
323
+ id: "ailk-mcp-oauth",
324
+ hooks: {
325
+ before: [
326
+ {
327
+ matcher: (ctx) => ctx.path === "/mcp/authorize",
328
+ // Site-local refusals before the plugin can redirect an error to a
329
+ // self-registered `redirect_uri` (SEC-10357-07, -08); then consent,
330
+ // always (D2).
331
+ handler: createAuthMiddleware(async (ctx) => {
332
+ const query = (ctx.query ?? {}) as Record<string, unknown>;
333
+ if (query.response_type !== "code") throw refuse("BAD_REQUEST", "unsupported_response_type");
334
+ if (!str(query.code_challenge) || query.code_challenge_method !== "S256") {
335
+ throw refuse("BAD_REQUEST", "invalid_request");
336
+ }
337
+ const scopes = typeof query.scope === "string" ? query.scope.split(" ").filter(Boolean) : [];
338
+ if (scopes.some((s) => !REQUESTABLE_SCOPES.has(s))) throw refuse("BAD_REQUEST", "invalid_scope");
339
+ return { context: { query: { ...query, prompt: "consent" } } };
340
+ }),
341
+ },
342
+ {
343
+ matcher: (ctx) => ctx.path === "/mcp/register",
344
+ handler: createAuthMiddleware(async (ctx) => {
345
+ const body = bodyOf(ctx.body);
346
+ const uris = body.redirect_uris;
347
+ if (!Array.isArray(uris) || uris.length === 0 || !uris.every(isAllowedRedirectUri)) {
348
+ throw refuse("BAD_REQUEST", "invalid_redirect_uri");
349
+ }
350
+ const grants = body.grant_types;
351
+ const responses = body.response_types;
352
+ const tooLong = ["logo_uri", "client_uri", "tos_uri", "policy_uri"].some(
353
+ (key) => typeof body[key] === "string" && (body[key] as string).length > MAX_URI_LENGTH,
354
+ );
355
+ const metadataTooBig =
356
+ body.metadata !== undefined && JSON.stringify(body.metadata).length > MAX_METADATA_BYTES;
357
+ if (
358
+ uris.length > MAX_REDIRECT_URIS ||
359
+ (grants !== undefined &&
360
+ (!Array.isArray(grants) || grants.some((g) => g !== "authorization_code" && g !== "refresh_token"))) ||
361
+ (responses !== undefined && (!Array.isArray(responses) || responses.some((r) => r !== "code"))) ||
362
+ tooLong ||
363
+ metadataTooBig
364
+ ) {
365
+ throw refuse("BAD_REQUEST", "invalid_client_metadata");
366
+ }
367
+ // The register endpoint answers with a raw Response, which skips
368
+ // every after-hook, so the sweep runs here.
369
+ await sweep(ctx);
370
+ return { context: { body: { ...body, client_name: normaliseClientName(body.client_name) } } };
371
+ }),
372
+ },
373
+ {
374
+ matcher: (ctx) => ctx.path === "/mcp/token",
375
+ handler: createAuthMiddleware(async (ctx) => {
376
+ const body = bodyOf(ctx.body);
377
+ // The plugin `toString()`s these, so an array would pass every
378
+ // `typeof` check below unexamined (SEC-10357-01, -04).
379
+ if (TOKEN_FIELDS.some((key) => body[key] !== undefined && typeof body[key] !== "string")) {
380
+ throw refuse("BAD_REQUEST", "invalid_request");
381
+ }
382
+ if (typeof body.code === "string" && !CODE_SHAPE.test(body.code)) {
383
+ throw refuse("BAD_REQUEST", "invalid_grant");
384
+ }
385
+ if (body.grant_type === "authorization_code" && typeof body.code === "string") {
386
+ // Only a code AILK's consent minted is exchangeable: the
387
+ // `consent_code` in the consent page's URL is otherwise one too
388
+ // (SEC-10357-01). Any other code is burnt.
389
+ const row = await ctx.context.internalAdapter.findVerificationValue(body.code);
390
+ const value = row ? parseValue(row.value) : null;
391
+ // And only while the session that approved it lives: a password
392
+ // reset revokes every session, so no approved code outlives a
393
+ // recovery (SEC-10357, D9).
394
+ const approver =
395
+ value?.ailkConsent === true && typeof value.sessionId === "string"
396
+ ? await ctx.context.adapter.findOne<{ expiresAt: Date }>({
397
+ model: "session",
398
+ where: [{ field: "id", value: value.sessionId }],
399
+ })
400
+ : null;
401
+ if (!approver || new Date(approver.expiresAt) <= new Date()) {
402
+ if (row) await ctx.context.internalAdapter.deleteVerificationByIdentifier(body.code);
403
+ throw refuse("BAD_REQUEST", "invalid_grant");
404
+ }
405
+ }
406
+ if (body.grant_type === "refresh_token" && typeof body.refresh_token === "string") {
407
+ const live = await ctx.context.adapter.findOne({
408
+ model: "oauthAccessToken",
409
+ where: [{ field: "refreshToken", value: body.refresh_token }],
410
+ });
411
+ if (!live) {
412
+ // A rotated refresh token presented again: end the whole line
413
+ // (RFC 9700 §4.14.2, SEC-10357-04).
414
+ const tombstone = await ctx.context.internalAdapter.findVerificationValue(
415
+ ROTATED_PREFIX + sha256(body.refresh_token),
416
+ );
417
+ const owner = tombstone ? (JSON.parse(tombstone.value) as { userId: string; clientId: string }) : null;
418
+ if (owner) {
419
+ await deleteTokensOf(ctx, [
420
+ { field: "userId", value: owner.userId },
421
+ { field: "clientId", value: owner.clientId },
422
+ ]);
423
+ throw refuse("BAD_REQUEST", "invalid_grant");
424
+ }
425
+ }
426
+ }
427
+ }),
428
+ },
429
+ ],
430
+ after: [
431
+ {
432
+ // Every answer of the token endpoint (the plugin sets these on the
433
+ // code grant only — SEC-10357-17) and of AILK's own endpoints.
434
+ matcher: (ctx) => NO_STORE_PATHS.has(ctx.path ?? ""),
435
+ handler: createAuthMiddleware(async (ctx) => {
436
+ ctx.setHeader("Cache-Control", "no-store");
437
+ ctx.setHeader("Pragma", "no-cache");
438
+ }),
439
+ },
440
+ {
441
+ matcher: (ctx) => ctx.path === "/mcp/token",
442
+ handler: createAuthMiddleware(async (ctx) => {
443
+ const body = bodyOf(ctx.body);
444
+ const returned = ctx.context.returned as Record<string, unknown> | undefined;
445
+ if (
446
+ body.grant_type !== "refresh_token" ||
447
+ typeof body.refresh_token !== "string" ||
448
+ !returned ||
449
+ typeof returned.access_token !== "string"
450
+ ) {
451
+ return;
452
+ }
453
+ // Rotation (D5): the used refresh token, and the access token
454
+ // minted with it, stop working now; a later replay of it ends the
455
+ // line.
456
+ const used = await ctx.context.adapter.findOne<OAuthAccessTokenRow>({
457
+ model: "oauthAccessToken",
458
+ where: [{ field: "refreshToken", value: body.refresh_token }],
459
+ });
460
+ if (!used) return;
461
+ await deleteTokensOf(ctx, [{ field: "refreshToken", value: body.refresh_token }]);
462
+ await ctx.context.internalAdapter.createVerificationValue({
463
+ identifier: ROTATED_PREFIX + sha256(body.refresh_token),
464
+ value: JSON.stringify({ userId: used.userId, clientId: used.clientId }),
465
+ expiresAt: new Date(used.refreshTokenExpiresAt),
466
+ });
467
+ }),
468
+ },
469
+ {
470
+ matcher: (ctx) => ctx.path === "/change-password",
471
+ // A changed password ends every MCP token the person holds (D9).
472
+ handler: createAuthMiddleware(async (ctx) => {
473
+ const returned = ctx.context.returned as { user?: { id?: unknown } } | undefined;
474
+ const userId = returned?.user?.id;
475
+ if (typeof userId !== "string") return;
476
+ try {
477
+ await deleteTokensOf(ctx, [{ field: "userId", value: userId }]);
478
+ } catch {
479
+ console.warn(
480
+ `[auth-email] ${JSON.stringify({ userId, kind: "password-change", reason: "mcp_token_revocation_failed" })}`,
481
+ );
482
+ }
483
+ }),
484
+ },
485
+ ],
486
+ },
487
+ endpoints: {
488
+ /**
489
+ * The `loginPage`: the site's own sign-in, resuming at `/mcp/authorize`
490
+ * — which forces consent again — rather than the plugin's cookie resume,
491
+ * which would skip AILK's before-hook.
492
+ */
493
+ mcpSignIn: createAuthEndpoint(
494
+ "/mcp/sign-in",
495
+ { method: "GET", query: z.record(z.string(), z.any()) },
496
+ async (ctx) => {
497
+ expireCookie(ctx, { name: LOGIN_COOKIE, attributes: { path: "/" } });
498
+ const params = new URLSearchParams();
499
+ for (const [key, value] of Object.entries(ctx.query ?? {})) {
500
+ if (typeof value === "string") params.set(key, value);
501
+ }
502
+ const next = `/api/auth/mcp/authorize?${params.toString()}`;
503
+ throw ctx.redirect(`/sign-in?next=${encodeURIComponent(next)}`);
504
+ },
505
+ ),
506
+
507
+ mcpConsentDetails: createAuthEndpoint(
508
+ "/mcp/consent-details",
509
+ { method: "GET", query: z.object({ consent_code: z.string().optional() }), use: [sessionMiddleware] },
510
+ async (ctx) => {
511
+ const userId = ctx.context.session.user.id;
512
+ const value = await pendingConsent(ctx, ctx.query.consent_code, userId);
513
+ const client = await ctx.context.adapter.findOne<OAuthApplicationRow>({
514
+ model: "oauthApplication",
515
+ where: [{ field: "clientId", value: value.clientId }],
516
+ });
517
+ if (!client) throw refuse("NOT_FOUND", "invalid_request");
518
+ return ctx.json({
519
+ client: { name: normaliseClientName(client.name), redirectOrigin: redirectOriginOf(value.redirectURI) },
520
+ workspaces: await offeredWorkspaces(userId, value.scope),
521
+ });
522
+ },
523
+ ),
524
+
525
+ mcpConsent: createAuthEndpoint(
526
+ "/mcp/consent",
527
+ {
528
+ method: "POST",
529
+ body: z.object({
530
+ consent_code: z.string(),
531
+ accept: z.boolean(),
532
+ workspace_id: z.string().optional(),
533
+ scopes: z.array(z.string()).optional(),
534
+ }),
535
+ use: [sessionMiddleware],
536
+ },
537
+ async (ctx) => {
538
+ const userId = ctx.context.session.user.id;
539
+ const consentCode = ctx.body.consent_code;
540
+ const value = await pendingConsent(ctx, consentCode, userId);
541
+ const redirect = new URL(value.redirectURI);
542
+
543
+ if (!ctx.body.accept) {
544
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(consentCode);
545
+ expireCookie(ctx, { name: CONSENT_COOKIE, attributes: { path: "/" } });
546
+ redirect.searchParams.set("error", "access_denied");
547
+ if (value.state) redirect.searchParams.set("state", value.state);
548
+ return ctx.json({ redirectURI: redirect.toString() });
549
+ }
550
+
551
+ const workspace = (await offeredWorkspaces(userId, value.scope)).find(
552
+ (w) => w.id === ctx.body.workspace_id,
553
+ );
554
+ const chosen = ctx.body.scopes ?? [];
555
+ if (!workspace || chosen.some((s) => !(workspace.offered as string[]).includes(s))) {
556
+ throw refuse("BAD_REQUEST", "invalid_scope");
557
+ }
558
+
559
+ // The plugin's token endpoint treats anything but "public" as
560
+ // confidential, so the type is re-derived here from what the
561
+ // register stored, whatever it wrote: a public client has an empty
562
+ // secret.
563
+ const client = await ctx.context.adapter.findOne<OAuthApplicationRow>({
564
+ model: "oauthApplication",
565
+ where: [{ field: "clientId", value: value.clientId }],
566
+ });
567
+ if (!client) throw refuse("NOT_FOUND", "invalid_request");
568
+ const type = client.clientSecret ? "web" : "public";
569
+ if (client.type !== type) {
570
+ await ctx.context.adapter.update({
571
+ model: "oauthApplication",
572
+ where: [{ field: "clientId", value: value.clientId }],
573
+ update: { type },
574
+ });
575
+ }
576
+
577
+ // `openid`, `profile` and `email` are never bound: no id_token.
578
+ const scope = ["offline_access", ...new Set(chosen), WORKSPACE_SCOPE_PREFIX + workspace.id];
579
+ const code = generateRandomString(32, "a-z", "A-Z", "0-9");
580
+ await ctx.context.internalAdapter.updateVerificationByIdentifier(consentCode, {
581
+ value: JSON.stringify({
582
+ ...value,
583
+ scope,
584
+ requireConsent: false,
585
+ ailkConsent: true,
586
+ sessionId: ctx.context.session.session.id,
587
+ }),
588
+ identifier: code,
589
+ expiresAt: new Date(Date.now() + 600 * 1000),
590
+ });
591
+ await ctx.context.adapter.create({
592
+ model: "oauthConsent",
593
+ data: {
594
+ clientId: value.clientId,
595
+ userId,
596
+ scopes: scope.join(" "),
597
+ consentGiven: true,
598
+ createdAt: new Date(),
599
+ updatedAt: new Date(),
600
+ },
601
+ });
602
+ expireCookie(ctx, { name: CONSENT_COOKIE, attributes: { path: "/" } });
603
+ redirect.searchParams.set("code", code);
604
+ if (value.state) redirect.searchParams.set("state", value.state);
605
+ const issuer = ctx.context.options.baseURL;
606
+ if (typeof issuer === "string") redirect.searchParams.set("iss", issuer);
607
+ return ctx.json({ redirectURI: redirect.toString() });
608
+ },
609
+ ),
610
+
611
+ /** RFC 7009 revocation, advertised as `revocation_endpoint`. */
612
+ mcpRevoke: createAuthEndpoint(
613
+ "/mcp/revoke",
614
+ {
615
+ method: "POST",
616
+ body: z.record(z.string(), z.any()),
617
+ metadata: { allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"] },
618
+ },
619
+ async (ctx) => {
620
+ const body = bodyOf(ctx.body);
621
+ let clientId = str(body.client_id);
622
+ let clientSecret = str(body.client_secret);
623
+ const authorization = ctx.request?.headers.get("authorization") ?? "";
624
+ if (authorization.startsWith("Basic ")) {
625
+ const decoded = Buffer.from(authorization.slice(6), "base64").toString("utf8");
626
+ const colon = decoded.indexOf(":");
627
+ if (colon > 0) {
628
+ try {
629
+ clientId = decodeURIComponent(decoded.slice(0, colon));
630
+ clientSecret = decodeURIComponent(decoded.slice(colon + 1));
631
+ } catch {
632
+ throw refuse("UNAUTHORIZED", "invalid_client");
633
+ }
634
+ }
635
+ }
636
+ const token = str(body.token);
637
+ if (!clientId) throw refuse("UNAUTHORIZED", "invalid_client");
638
+ const client = await ctx.context.adapter.findOne<OAuthApplicationRow>({
639
+ model: "oauthApplication",
640
+ where: [{ field: "clientId", value: clientId }],
641
+ });
642
+ if (!client) throw refuse("UNAUTHORIZED", "invalid_client");
643
+ if (client.clientSecret && !(clientSecret && secretsMatch(client.clientSecret, clientSecret))) {
644
+ throw refuse("UNAUTHORIZED", "invalid_client");
645
+ }
646
+ if (!token) throw refuse("BAD_REQUEST", "invalid_request");
647
+ await deleteTokensOf(ctx, [
648
+ { field: "clientId", value: clientId },
649
+ { field: "accessToken", value: token },
650
+ ]);
651
+ await deleteTokensOf(ctx, [
652
+ { field: "clientId", value: clientId },
653
+ { field: "refreshToken", value: token },
654
+ ]);
655
+ return ctx.json({});
656
+ },
657
+ ),
658
+
659
+ /** The person's own kill switch for one client (SEC-10357-10). */
660
+ mcpRevokeClient: createAuthEndpoint(
661
+ "/mcp/revoke-client",
662
+ { method: "POST", body: z.object({ client_id: z.string() }), use: [sessionMiddleware] },
663
+ async (ctx) => {
664
+ const revoked = await deleteTokensOf(ctx, [
665
+ { field: "clientId", value: ctx.body.client_id },
666
+ { field: "userId", value: ctx.context.session.user.id },
667
+ ]);
668
+ return ctx.json({ revoked });
669
+ },
670
+ ),
671
+
672
+ /** The stored-token lookup of decision (a) (D1, D16). Never echoes a token. */
673
+ mcpTokenInfo: createAuthEndpoint("/mcp/token-info", { method: "GET", requireHeaders: true }, async (ctx) => {
674
+ const invalid = () =>
675
+ refuse("UNAUTHORIZED", "invalid_token", { "WWW-Authenticate": 'Bearer error="invalid_token"' });
676
+ const authorization = ctx.headers?.get("authorization") ?? "";
677
+ const accessToken = authorization.startsWith("Bearer ") ? authorization.slice(7) : "";
678
+ if (!accessToken) throw invalid();
679
+ const row = await ctx.context.adapter.findOne<OAuthAccessTokenRow>({
680
+ model: "oauthAccessToken",
681
+ where: [{ field: "accessToken", value: accessToken }],
682
+ });
683
+ if (!row || new Date(row.accessTokenExpiresAt) < new Date() || !row.userId) throw invalid();
684
+ const { workspaceId } = parseGrantedScopes(row.scopes);
685
+ if (!workspaceId) throw invalid();
686
+ const membership = (await deps.readMemberships(row.userId)).find((m) => m.workspaceId === workspaceId);
687
+ if (!membership) throw invalid();
688
+ return ctx.json({
689
+ active: true,
690
+ userId: row.userId,
691
+ clientId: row.clientId,
692
+ workspaceId,
693
+ // The member clamp an agent acts at — never the person's own role
694
+ // (SEC-10357-09).
695
+ actingRole: lowerRole(membership.role as MembershipRole, "member"),
696
+ grants: grantsForWorkspace(row.scopes, workspaceId, membership.role) ?? [],
697
+ expiresAt: new Date(row.accessTokenExpiresAt).toISOString(),
698
+ });
699
+ }),
700
+ },
701
+ } satisfies BetterAuthPlugin;
702
+ }