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
@@ -25,8 +25,12 @@
25
25
  * the docs path; otherwise those rows are skipped (the MDX docs route never
26
26
  * serves them) and the docs directory is listed (`getDocsPages`, default
27
27
  * `listDocsPages`).
28
+ *
29
+ * Blog (#10644 D1): under the database adapter the adapter's `BlogList` /
30
+ * `BlogPosting` rows are the blog, and `getBlogPages` is not called.
28
31
  */
29
32
 
33
+ import { siteDiscoverable } from "@working-theory/api-client";
30
34
  import {
31
35
  SCHEMA_ORG_TYPES,
32
36
  resolvedAdapterKind,
@@ -44,6 +48,10 @@ import { PUBLIC_PAGES } from "../../lib/modules.generated";
44
48
  import { SITE_NAME } from "../../lib/site-brand";
45
49
  import { pagePathFor, resolveBaseUrl } from "../../src/lib/routes";
46
50
 
51
+ // #10510 D4 — read per request: the owner's discovery switch is a runtime
52
+ // setting a prerendered file could never follow.
53
+ export const dynamic = "force-dynamic";
54
+
47
55
  export interface LlmsHandlerDeps {
48
56
  /**
49
57
  * Content adapter factory. Defaults to the real implementation.
@@ -54,10 +62,14 @@ export interface LlmsHandlerDeps {
54
62
  * Blog pages getter. Defaults to none — Free has no blog (D-46, #5365 D9).
55
63
  * The docs + blog module's own copy of this route reads `blogSource`.
56
64
  * Override in tests to inject stub pages.
65
+ * Not called under the database adapter, whose `BlogList` / `BlogPosting`
66
+ * rows are the blog (#10644 D1).
57
67
  */
58
68
  getBlogPages?: () => Array<{ url: string; data: { title: string; description?: string } }>;
59
69
  /** The docs pages (#10041). Defaults to `listDocsPages` over this app's docs directory. */
60
70
  getDocsPages?: () => DocsIndexPage[];
71
+ /** The owner's discovery switch (#10510 D4). Defaults to `siteDiscoverable`. */
72
+ discoverable?: () => Promise<boolean>;
61
73
  }
62
74
 
63
75
  /**
@@ -68,10 +80,13 @@ export function createLlmsHandler(deps: LlmsHandlerDeps = {}) {
68
80
  const resolveAdapter = deps.getContentAdapter ?? defaultGetContentAdapter;
69
81
  const resolveBlogPages = deps.getBlogPages ?? (() => []);
70
82
  const resolveDocsPages = deps.getDocsPages ?? listDocsPages;
83
+ const isDiscoverable = deps.discoverable ?? siteDiscoverable;
71
84
 
72
85
  return async function llmsGET(): Promise<Response> {
73
86
  // #10040 decision 7 — no public page, no content index: 404 before the adapter.
74
87
  if (!PUBLIC_PAGES) return new Response(null, { status: 404 });
88
+ // #10510 D4 — hidden by the owner: 404, never cached, before the adapter.
89
+ if (!(await isDiscoverable())) return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
75
90
  const adapter = resolveAdapter();
76
91
  const baseUrl = resolveBaseUrl();
77
92
  const lines: string[] = [`# ${SITE_NAME} — content index`, ""];
@@ -87,10 +102,10 @@ export function createLlmsHandler(deps: LlmsHandlerDeps = {}) {
87
102
  types.map((type) => adapter.listPages(type, defaultLocale)),
88
103
  );
89
104
 
90
- const databaseDocs = resolvedAdapterKind() === "database";
105
+ const databaseCorpus = resolvedAdapterKind() === "database";
91
106
  for (const pages of pageLists) {
92
107
  for (const page of pages) {
93
- if (isDocsType(page.type) && !databaseDocs) continue;
108
+ if (isDocsType(page.type) && !databaseCorpus) continue;
94
109
  const path = isDocsType(page.type)
95
110
  ? adapterDocsUrl(page.type, page.slug)
96
111
  : pagePathFor(page);
@@ -108,18 +123,20 @@ export function createLlmsHandler(deps: LlmsHandlerDeps = {}) {
108
123
  // blogSource.getPages() returns pages ordered by Fumadocs; the index page
109
124
  // (/blog) sorts first naturally because its slug array is empty.
110
125
  lines.push("");
111
- for (const page of resolveBlogPages()) {
112
- lines.push(`- Title: ${page.data.title}`);
113
- lines.push(` URL: ${baseUrl}${page.url}`);
114
- if (page.data.description) {
115
- lines.push(` Description: ${page.data.description}`);
126
+ if (!databaseCorpus) {
127
+ for (const page of resolveBlogPages()) {
128
+ lines.push(`- Title: ${page.data.title}`);
129
+ lines.push(` URL: ${baseUrl}${page.url}`);
130
+ if (page.data.description) {
131
+ lines.push(` Description: ${page.data.description}`);
132
+ }
133
+ lines.push("");
116
134
  }
117
- lines.push("");
118
135
  }
119
136
 
120
137
  // The docs directory (#10041 D8) — not under the database adapter, whose
121
138
  // docs rows are listed above.
122
- if (!databaseDocs) {
139
+ if (!databaseCorpus) {
123
140
  for (const doc of resolveDocsPages()) {
124
141
  lines.push(`- Title: ${doc.title}`);
125
142
  lines.push(` URL: ${baseUrl}${doc.url}`);
@@ -15,6 +15,21 @@
15
15
 
16
16
  import { GET } from "../route";
17
17
 
18
+ // #10510 D4 — the owner's discovery switch, mocked so no case reads a hub. On
19
+ // (`true`) by default: the state every case above the #10510 describe assumes.
20
+ const mockSiteDiscoverable = jest.fn<Promise<boolean>, []>();
21
+ jest.mock("@working-theory/api-client", () => ({
22
+ ...jest.requireActual<typeof import("@working-theory/api-client")>(
23
+ "@working-theory/api-client",
24
+ ),
25
+ siteDiscoverable: () => mockSiteDiscoverable(),
26
+ }));
27
+
28
+ beforeEach(() => {
29
+ mockSiteDiscoverable.mockReset();
30
+ mockSiteDiscoverable.mockResolvedValue(true);
31
+ });
32
+
18
33
  // The REAL committed seam, for the #8060 hub-only guards: the mock below
19
34
  // shadows `TOPOLOGY` per case, but whether a hub-only case runs depends on the
20
35
  // render this scaffold carries, not on the topology a case picks.
@@ -295,3 +310,28 @@ describe("GET /openapi.json on a spoke (#7426 D12)", () => {
295
310
  expect(JSON.stringify(body)).toContain("hub.example.com");
296
311
  });
297
312
  });
313
+
314
+ // #10510 D4, AC-1–AC-2 — the owner's discovery switch: read once per request,
315
+ // before the API is fetched.
316
+ describe("GET /openapi.json and the owner's discovery switch (#10510)", () => {
317
+ it("on: today's cache-control, the switch read once per request", async () => {
318
+ apiAnswers(200, JSON.stringify(SPEC));
319
+ const response = await GET();
320
+ expect(response.status).toBe(200);
321
+ expect(response.headers.get("cache-control")).toBe("public, max-age=3600");
322
+ expect(mockSiteDiscoverable).toHaveBeenCalledTimes(1);
323
+ apiAnswers(200, JSON.stringify(SPEC));
324
+ await GET();
325
+ expect(mockSiteDiscoverable).toHaveBeenCalledTimes(2);
326
+ });
327
+
328
+ it("off: 404 with no body, never cached, and the API never fetched", async () => {
329
+ mockSiteDiscoverable.mockResolvedValue(false);
330
+ apiAnswers(200, JSON.stringify(SPEC));
331
+ const response = await GET();
332
+ expect(response.status).toBe(404);
333
+ expect(await response.text()).toBe("");
334
+ expect(response.headers.get("cache-control")).toBe("no-store");
335
+ expect(fetchSpy).not.toHaveBeenCalled();
336
+ });
337
+ });
@@ -23,6 +23,8 @@
23
23
  * host, and never calls a path this site would answer 404.
24
24
  */
25
25
 
26
+ import { siteDiscoverable } from "@working-theory/api-client";
27
+
26
28
  import { browserApiOrigin } from "../../lib/api-origin.generated";
27
29
  import { RELAY_ROUTES } from "../../lib/relay";
28
30
  import { TOPOLOGY } from "../../lib/topology.generated";
@@ -157,6 +159,8 @@ function badGateway(message: string): Response {
157
159
  }
158
160
 
159
161
  export async function GET(): Promise<Response> {
162
+ // #10510 D4 — hidden by the owner: 404, never cached, the API never read.
163
+ if (!(await siteDiscoverable())) return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
160
164
  let upstream: Response;
161
165
  try {
162
166
  upstream = await fetch(`${apiBase()}${UPSTREAM_PATH}`, {
@@ -13,12 +13,17 @@
13
13
  * parse (#8511), so 1, yes and on count too, and an unknown value throws.
14
14
  */
15
15
 
16
+ import { siteDiscoverable } from "@working-theory/api-client";
16
17
  import { parseStealth } from "@working-theory/web-i18n/stealth";
17
18
 
18
19
  import { PUBLIC_PAGES } from "../../lib/modules.generated";
19
20
  import { resolveBaseUrl } from "../../src/lib/routes";
20
21
 
21
- export function GET(): Response {
22
+ // #10510 D4 — read per request: the owner's discovery switch is a runtime
23
+ // setting a prerendered file could never follow.
24
+ export const dynamic = "force-dynamic";
25
+
26
+ export async function GET(): Promise<Response> {
22
27
  const baseUrl = resolveBaseUrl();
23
28
  const disallowEnv = process.env["AILK_ROBOTS_DISALLOW"] ?? "";
24
29
  // #10040 decision 7 — a site with neither marketing nor docs-blog (a hub)
@@ -26,6 +31,18 @@ export function GET(): Response {
26
31
  // STEALTH, read from the module list, never from a setting.
27
32
  const stealth = parseStealth(process.env["STEALTH"]) || !PUBLIC_PAGES;
28
33
 
34
+ // #10510 D4 — the owner's switch, read only when neither already hides the
35
+ // site: off is STEALTH's body, never cached, so a crawler asks again.
36
+ if (!stealth && !(await siteDiscoverable())) {
37
+ return new Response("User-agent: *\nDisallow: /", {
38
+ status: 200,
39
+ headers: {
40
+ "content-type": "text/plain; charset=utf-8",
41
+ "cache-control": "no-store",
42
+ },
43
+ });
44
+ }
45
+
29
46
  const disallowPaths = stealth
30
47
  ? ["/"]
31
48
  : disallowEnv
@@ -37,6 +37,7 @@
37
37
  * directory is listed instead (`getDocsPages`, default `listDocsPages`).
38
38
  */
39
39
 
40
+ import { siteDiscoverable } from "@working-theory/api-client";
40
41
  import {
41
42
  SCHEMA_ORG_TYPES,
42
43
  resolvedAdapterKind,
@@ -53,6 +54,10 @@ import {
53
54
  import { PUBLIC_PAGES } from "../../lib/modules.generated";
54
55
  import { pagePathFor, resolveBaseUrl } from "../../src/lib/routes";
55
56
 
57
+ // #10510 D4 — read per request: the owner's discovery switch is a runtime
58
+ // setting a prerendered file could never follow.
59
+ export const dynamic = "force-dynamic";
60
+
56
61
  export interface SitemapHandlerDeps {
57
62
  /**
58
63
  * Content adapter factory. Defaults to the real implementation.
@@ -61,6 +66,8 @@ export interface SitemapHandlerDeps {
61
66
  getContentAdapter?: () => ContentAdapter;
62
67
  /** The docs pages (#10041). Defaults to `listDocsPages` over this app's docs directory. */
63
68
  getDocsPages?: () => DocsIndexPage[];
69
+ /** The owner's discovery switch (#10510 D4). Defaults to `siteDiscoverable`. */
70
+ discoverable?: () => Promise<boolean>;
64
71
  }
65
72
 
66
73
  function escapeXml(value: string): string {
@@ -96,9 +103,12 @@ function buildLocaleUrl(
96
103
  export function createSitemapHandler(deps: SitemapHandlerDeps = {}) {
97
104
  const resolveAdapter = deps.getContentAdapter ?? defaultGetContentAdapter;
98
105
  const resolveDocsPages = deps.getDocsPages ?? listDocsPages;
106
+ const isDiscoverable = deps.discoverable ?? siteDiscoverable;
99
107
  return async function sitemapGET(): Promise<Response> {
100
108
  // #10040 decision 7 — no public page, no sitemap: 404 before the adapter.
101
109
  if (!PUBLIC_PAGES) return new Response(null, { status: 404 });
110
+ // #10510 D4 — hidden by the owner: 404, never cached, before the adapter.
111
+ if (!(await isDiscoverable())) return new Response(null, { status: 404, headers: { "cache-control": "no-store" } });
102
112
  const adapter = resolveAdapter();
103
113
  const baseUrl = resolveBaseUrl();
104
114
  const today = new Date().toISOString().slice(0, 10);
@@ -142,6 +152,9 @@ export function createSitemapHandler(deps: SitemapHandlerDeps = {}) {
142
152
  }
143
153
  continue;
144
154
  }
155
+ // #10617 — a flow's completion target renders `robots: noindex`
156
+ // (`renderPageMetadata`), so it is never listed, in any locale.
157
+ if (page.type === "ThankYouPage") continue;
145
158
  const path = pagePathFor(page);
146
159
  const lastmod = page.publishedAt ?? today;
147
160
 
@@ -26,11 +26,14 @@
26
26
  */
27
27
 
28
28
  import type { PrismaClient } from "@working-theory/database";
29
- import { betterAuth } from "better-auth";
29
+ import { MCP_SCOPES } from "@working-theory/validation";
30
+ import { type BetterAuthPlugin, betterAuth } from "better-auth";
30
31
  import { prismaAdapter } from "better-auth/adapters/prisma";
32
+ import type { AuthMiddleware } from "better-auth/api";
31
33
  import { nextCookies } from "better-auth/next-js";
32
- import { magicLink } from "better-auth/plugins";
34
+ import { magicLink, mcp } from "better-auth/plugins";
33
35
 
36
+ import { sealMcpCallbacks } from "./app/api/auth/mcp/continuation";
34
37
  import {
35
38
  authEmailTransport,
36
39
  makeMagicLinkOptions,
@@ -44,6 +47,7 @@ import {
44
47
  resetSendConfigured,
45
48
  } from "./lib/auth-env";
46
49
  import { resolveSecret } from "./lib/auth-secret";
50
+ import { type McpMembership, type McpOAuthDeps, mcpOAuthPlugin } from "./lib/mcp-oauth";
47
51
  import {
48
52
  signUpAgreementHooks,
49
53
  signUpAgreementsPlugin,
@@ -67,6 +71,105 @@ const RESET_TOKEN_TTL_SECONDS = 3600;
67
71
  */
68
72
  const VERIFY_TOKEN_TTL_SECONDS = 86_400;
69
73
 
74
+ // mcp-oauth:begin
75
+ /**
76
+ * The site's MCP authorization server (#10357 D4): better-auth's `mcp` plugin
77
+ * — dynamic client registration, PKCE, opaque tokens with refresh — and
78
+ * AILK's plugin beside it (`lib/mcp-oauth.ts`: the workspace + scope consent
79
+ * step, rotation, revocation, the token self-check). Installed only when
80
+ * `baseURL` is pinned: the plugin's metadata has no issuer without one, and
81
+ * an issuer derived from the request's host is the § D13 chain again.
82
+ *
83
+ * Every value is set explicitly, even where it equals the library default,
84
+ * so the numbers are reviewable.
85
+ */
86
+ /** An authorization code's lifetime: ten minutes to finish consent. */
87
+ const MCP_CODE_TTL_SECONDS = 600;
88
+ /** An access token's lifetime: one hour. */
89
+ const MCP_ACCESS_TOKEN_TTL_SECONDS = 3600;
90
+ /** A refresh token's lifetime: seven days, sliding by rotation. */
91
+ const MCP_REFRESH_TOKEN_TTL_SECONDS = 604_800;
92
+
93
+ /**
94
+ * The plugin's HTTP routes AILK turns off: its consent (binds no workspace,
95
+ * narrows no scope — D2), `/mcp/get-session` (hands any access-token holder
96
+ * the row, refresh token included), and its own discovery document, which
97
+ * the site serves once at its root (D7).
98
+ */
99
+ export const MCP_DISABLED_PATHS = [
100
+ "/oauth2/consent",
101
+ "/mcp/get-session",
102
+ "/.well-known/oauth-authorization-server",
103
+ ];
104
+
105
+ /**
106
+ * Rate rules for the anonymous MCP endpoints — applied only beside the other
107
+ * custom rules, where the client IP resolves (see the rate-limit block).
108
+ */
109
+ export const MCP_RATE_RULES = {
110
+ "/mcp/register": { window: 3600, max: 10 },
111
+ "/mcp/token": { window: 60, max: 30 },
112
+ "/mcp/revoke": { window: 60, max: 30 },
113
+ // apps/mcp validates every OAuth bearer here from one egress address, so
114
+ // its lookups get their own bucket, not the global 60/60 s (#10391 D7).
115
+ "/mcp/token-info": { window: 60, max: 600 },
116
+ };
117
+
118
+ /**
119
+ * A sign-in that resumes an MCP authorization carries
120
+ * `/api/auth/mcp/authorize?<query>` as its callback — the `next` the plugin's
121
+ * `loginPage` hands `/sign-in` — which the emailed link's verify refuses once
122
+ * decoded (#10479). Before any sign-in endpoint reads it, this seals it to
123
+ * `/api/auth/mcp/continue/<ref>` (`app/api/auth/mcp/continuation.ts`).
124
+ */
125
+ let sealMcpSignIn: AuthMiddleware | undefined;
126
+
127
+ export const mcpSignInContinuation = {
128
+ id: "ailk-mcp-sign-in-continuation",
129
+ hooks: {
130
+ before: [
131
+ {
132
+ matcher: (ctx) => ctx.path?.startsWith("/sign-in/") ?? false,
133
+ // `better-auth/api` is ESM-only and CommonJS Jest cannot load it (#507),
134
+ // so it is imported when a hook first runs, never when this module loads.
135
+ handler: async (ctx) => {
136
+ const { createAuthMiddleware } = await import("better-auth/api");
137
+ sealMcpSignIn ??= createAuthMiddleware(async (inner) => {
138
+ const body = inner.body as Record<string, unknown> | undefined;
139
+ const sealed = body ? sealMcpCallbacks(body, resolveSecret()) : null;
140
+ return sealed ? { context: { body: { ...body, ...sealed } } } : undefined;
141
+ });
142
+ return sealMcpSignIn(ctx);
143
+ },
144
+ },
145
+ ],
146
+ },
147
+ } satisfies BetterAuthPlugin;
148
+
149
+ export function mcpAuthorizationServer(deps: McpOAuthDeps) {
150
+ return [
151
+ mcp({
152
+ loginPage: "/api/auth/mcp/sign-in",
153
+ oidcConfig: {
154
+ loginPage: "/api/auth/mcp/sign-in",
155
+ consentPage: "/consent",
156
+ // OAuth 2.1: PKCE on every authorization, S256 only.
157
+ requirePKCE: true,
158
+ allowPlainCodeChallengeMethod: false,
159
+ codeExpiresIn: MCP_CODE_TTL_SECONDS,
160
+ accessTokenExpiresIn: MCP_ACCESS_TOKEN_TTL_SECONDS,
161
+ refreshTokenExpiresIn: MCP_REFRESH_TOKEN_TTL_SECONDS,
162
+ scopes: [...MCP_SCOPES],
163
+ defaultScope: ["offline_access", ...MCP_SCOPES].join(" "),
164
+ metadata: { scopes_supported: ["offline_access", ...MCP_SCOPES] },
165
+ },
166
+ }),
167
+ mcpOAuthPlugin(deps),
168
+ mcpSignInContinuation,
169
+ ];
170
+ }
171
+ // mcp-oauth:end
172
+
70
173
  let prismaForAuthLoad: Promise<PrismaClient> | undefined;
71
174
 
72
175
  /**
@@ -140,16 +243,20 @@ function deferredPrismaForAuth(): PrismaClient {
140
243
  }
141
244
 
142
245
  /**
143
- * The two delegate methods `onPasswordReset` reaches for, named so the call
246
+ * The delegate methods `onPasswordReset` reaches for, named so the call
144
247
  * site needs no cast. Method shorthand for the same reason `AuthEmailPrisma`
145
248
  * uses it: the generated `PrismaClient` satisfies this structurally, and
146
- * nothing wider than `verification.deleteMany` and
147
- * `oAuthAuthorization.updateMany` is reachable through it. The second ends
148
- * every credential a spoke holds for the person (#7894 D4, SEC-7894-5).
249
+ * nothing wider than `verification.deleteMany`,
250
+ * `oAuthAuthorization.updateMany`, `session.deleteMany` and
251
+ * `oauthAccessToken.deleteMany` is reachable through it. The second ends
252
+ * every credential a spoke holds for the person (#7894 D4, SEC-7894-5); the
253
+ * last two every session and MCP token (#10357 D9).
149
254
  */
150
255
  interface VerificationStore {
151
256
  verification: { deleteMany(args: unknown): Promise<unknown> };
152
257
  oAuthAuthorization: { updateMany(args: unknown): Promise<unknown> };
258
+ session: { deleteMany(args: unknown): Promise<unknown> };
259
+ oauthAccessToken: { deleteMany(args: unknown): Promise<unknown> };
153
260
  }
154
261
 
155
262
  type SocialProviders = {
@@ -471,6 +578,40 @@ export function createAuth(deps: CreateAuthDeps = {}) {
471
578
  })}`,
472
579
  );
473
580
  }
581
+ /**
582
+ * And every MCP client token (#10357 D9): an attacker who connected a
583
+ * client before the owner recovered the account would otherwise keep the
584
+ * workspace by sliding refresh. Guarded the same way.
585
+ *
586
+ * The sessions go first: Better Auth deletes them only after this
587
+ * callback returns, and until then a live session could approve and
588
+ * exchange a fresh code (SEC-10357). The library's own delete then finds
589
+ * none.
590
+ */
591
+ try {
592
+ await verificationStore.session.deleteMany({ where: { userId: user.id } });
593
+ } catch {
594
+ console.warn(
595
+ `[auth-email] ${JSON.stringify({
596
+ userId: user.id,
597
+ kind: "password-reset",
598
+ reason: "session_revocation_failed",
599
+ })}`,
600
+ );
601
+ }
602
+ try {
603
+ await verificationStore.oauthAccessToken.deleteMany({
604
+ where: { userId: user.id },
605
+ });
606
+ } catch {
607
+ console.warn(
608
+ `[auth-email] ${JSON.stringify({
609
+ userId: user.id,
610
+ kind: "password-reset",
611
+ reason: "mcp_token_revocation_failed",
612
+ })}`,
613
+ );
614
+ }
474
615
  /**
475
616
  * Guarded like its sibling above, though `lib/auth-email.ts` now also
476
617
  * guards its own signing step (#6068). BOTH, deliberately: this callback
@@ -495,6 +636,36 @@ export function createAuth(deps: CreateAuthDeps = {}) {
495
636
  }
496
637
  };
497
638
 
639
+ /**
640
+ * The person's workspaces for the MCP consent step and token self-check
641
+ * (#10357 D4): `listTenancy`'s rule — every workspace of every organization
642
+ * they belong to, with that membership's role.
643
+ */
644
+ const readMemberships = async (userId: string): Promise<McpMembership[]> => {
645
+ const memberships = await prisma.membership.findMany({
646
+ where: { userId },
647
+ select: {
648
+ role: true,
649
+ organization: {
650
+ select: {
651
+ name: true,
652
+ workspaces: { select: { id: true, name: true }, orderBy: { createdAt: "asc" } },
653
+ },
654
+ },
655
+ },
656
+ orderBy: { createdAt: "asc" },
657
+ });
658
+ return memberships.flatMap((m) =>
659
+ m.organization.workspaces.map((w) => ({
660
+ workspaceId: w.id,
661
+ workspaceName: w.name,
662
+ organizationName: m.organization.name,
663
+ role: m.role,
664
+ })),
665
+ );
666
+ };
667
+ const mcpServer = baseURL ? mcpAuthorizationServer({ readMemberships }) : [];
668
+
498
669
  const instance = betterAuth({
499
670
  /**
500
671
  * `transaction: true` (#8765 D4): Better Auth opens one `$transaction` for
@@ -538,6 +709,7 @@ export function createAuth(deps: CreateAuthDeps = {}) {
538
709
  * deployment that has not pinned its origin never offers reset at all.
539
710
  */
540
711
  ...(baseURL ? { trustedOrigins: [baseURL] } : {}),
712
+ ...(baseURL ? { disabledPaths: MCP_DISABLED_PATHS } : {}),
541
713
  emailAndPassword: {
542
714
  enabled: passwordMethodActive,
543
715
  // Turning the password method on also opens POST /sign-up/email —
@@ -653,6 +825,8 @@ export function createAuth(deps: CreateAuthDeps = {}) {
653
825
  ...(magicLinkActive
654
826
  ? { "/sign-in/magic-link": { window: 900, max: 5 } }
655
827
  : {}),
828
+ // #10357 D4: registration and the token endpoints.
829
+ ...(baseURL ? MCP_RATE_RULES : {}),
656
830
  },
657
831
  }
658
832
  : {}),
@@ -734,14 +908,17 @@ export function createAuth(deps: CreateAuthDeps = {}) {
734
908
  * `signUpAgreementsPlugin` always (#8765 D3): while sign-up is open,
735
909
  * `/sign-up/email` without every sign-up agreement is refused before the
736
910
  * handler looks the address up.
911
+ *
912
+ * The MCP authorization server whenever `baseURL` is pinned (#10357 D4).
737
913
  */
738
914
  plugins: magicLinkActive
739
915
  ? [
740
916
  nextCookies(),
741
917
  signUpAgreementsPlugin(),
742
918
  magicLink(makeMagicLinkOptions({ send: authEmailTransport })),
919
+ ...mcpServer,
743
920
  ]
744
- : [nextCookies(), signUpAgreementsPlugin()],
921
+ : [nextCookies(), signUpAgreementsPlugin(), ...mcpServer],
745
922
  });
746
923
  return instance;
747
924
  }
@@ -1,5 +1,50 @@
1
1
  // Mirrors apps/api's ts-jest setup (CONVENTIONS.md mentions Vitest aspirationally,
2
2
  // but no Vitest is installed anywhere yet). JSDOM env so RTL can mount components.
3
+ /**
4
+ * #10699 D4: lib/runtime-source.ts derives the toc through
5
+ * fumadocs-core/content/toc, whose remark closure ships ESM only. These are
6
+ * pnpm-store name prefixes (the `tools/jest/mdxeditor-esm.cjs` shape, inlined
7
+ * because a scaffold's copy of this file has no `tools/`): `mdast-util-`
8
+ * covers every `mdast-util-*` package, `remark` covers `remark-gfm` /
9
+ * `remark-parse`, `vfile` covers `vfile-message`. Re-derive from the
10
+ * installed tree when the lockfile moves; the runtime-source suites loading
11
+ * is the proof.
12
+ */
13
+ const REMARK_TOC_ESM = [
14
+ "bail",
15
+ "ccount",
16
+ "character-",
17
+ "decode-named-character-reference",
18
+ "devlop",
19
+ "escape-string-regexp",
20
+ "estree-util-",
21
+ "github-slugger",
22
+ "is-plain-obj",
23
+ "longest-streak",
24
+ "markdown-table",
25
+ "mdast-util-",
26
+ "micromark",
27
+ "remark",
28
+ "trough",
29
+ "unified",
30
+ "unist-util-",
31
+ "vfile",
32
+ "zwitch",
33
+ ];
34
+
35
+ /**
36
+ * Admit REMARK_TOC_ESM beside whatever a carve-out already admits, leaving
37
+ * the carve-out's own literal untouched — create-ailk's template build and the
38
+ * docs-blog `jest-config` patch both find it by exact string.
39
+ */
40
+ function admitRemarkToc(pattern) {
41
+ const head = "/node_modules/(?!(\\.pnpm/)?";
42
+ return pattern.replace(
43
+ head,
44
+ `${head}(?:${REMARK_TOC_ESM.join("|")})|(\\.pnpm/)?`,
45
+ );
46
+ }
47
+
3
48
  module.exports = {
4
49
  preset: "ts-jest",
5
50
  globalSetup: require.resolve("../../tools/jest/pin-timezone.cjs"),
@@ -17,7 +62,7 @@ module.exports = {
17
62
  // Mirrors the canonical jose carve-out used in apps/api, apps/mcp, and apps-paid/license-issuer.
18
63
  // Added: issue #1077
19
64
  transformIgnorePatterns: [
20
- "/node_modules/(?!(\\.pnpm/)?@working-theory(?:\\+[^/]*?@?[^/]*?|/[^/]+)(/|$))",
65
+ admitRemarkToc("/node_modules/(?!(\\.pnpm/)?@working-theory(?:\\+[^/]*?@?[^/]*?|/[^/]+)(/|$))"),
21
66
  ],
22
67
  moduleFileExtensions: ["ts", "tsx", "js", "jsx", "json"],
23
68
  // Workspace packages publish ESM in dist/, but ts-jest runs in CommonJS.
@@ -12,13 +12,14 @@ import { AUTH_SEGMENTS, isAccountPath, isAuthPath, isBookingPath } from "../auth
12
12
  const LOCALES = ["en", "fr", "de"];
13
13
 
14
14
  describe("AUTH_SEGMENTS", () => {
15
- it("names exactly the five auth routes", () => {
15
+ it("names exactly the six auth routes", () => {
16
16
  expect([...AUTH_SEGMENTS]).toEqual([
17
17
  "sign-in",
18
18
  "sign-up",
19
19
  "forgot-password",
20
20
  "reset-password",
21
21
  "verify-email",
22
+ "consent",
22
23
  ]);
23
24
  });
24
25
  });