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,10 +25,17 @@
25
25
  // every page in the tree (#4090 item 1).
26
26
  import "../globals.css";
27
27
 
28
+ import { readFileSync } from "node:fs";
29
+ import { join } from "node:path";
30
+
28
31
  import { Telemetry, VerificationMeta } from "@working-theory/analytics";
32
+ import {
33
+ SiteConsentSchema,
34
+ type ConsentAppearance,
35
+ } from "@working-theory/site-config/schema";
29
36
  import { MarketingShell } from "@working-theory/templates/shells";
30
37
  import { themeToShellCss } from "@working-theory/theme";
31
- import { Link, isRtl, localeHref } from "@working-theory/ui";
38
+ import { Link, NewsletterSignupProvider, isRtl, localeHref } from "@working-theory/ui";
32
39
  import { BRAND_PATHS, LocaleSwitcherNav } from "@working-theory/ui/blocks";
33
40
  import {
34
41
  Banner,
@@ -54,6 +61,7 @@ import { cookies, headers } from "next/headers";
54
61
  import { NextIntlClientProvider } from "next-intl";
55
62
  import { getMessages, getTranslations } from "next-intl/server";
56
63
  import type { ReactElement, ReactNode } from "react";
64
+ import { parse as parseYaml } from "yaml";
57
65
 
58
66
  import { GoogleAnalytics } from "../../components/google-analytics";
59
67
  import {
@@ -71,6 +79,8 @@ import {
71
79
  import {
72
80
  inPageView,
73
81
  newsletterSignup,
82
+ type InPageView,
83
+ type NewsletterFormView,
74
84
  readCaptureStatus,
75
85
  readServedMagnet,
76
86
  } from "../../lib/in-page-magnet";
@@ -84,6 +94,7 @@ import { footerColumns, navItems } from "../../lib/nav-config";
84
94
  import { navMessageKeys, usedNavLabelKeys } from "../../lib/nav-label-keys";
85
95
  import { getOgImageUrl } from "../../lib/og";
86
96
  import {
97
+ readClosingFooterSettings,
87
98
  resolveChromePage,
88
99
  resolveHomeChrome,
89
100
  resolvePageChrome,
@@ -98,6 +109,7 @@ import {
98
109
  COPYRIGHT_HOLDER,
99
110
  SITE_DESCRIPTION,
100
111
  SITE_NAME,
112
+ resolveContentRoot,
101
113
  } from "../../lib/site-brand";
102
114
  import { siteChrome, type SiteChrome } from "../../lib/site-chrome";
103
115
  import {
@@ -209,6 +221,38 @@ async function resolveThemeMode(): Promise<ThemeMode> {
209
221
  const DECK_FIRST_SLIDE_SELECTOR =
210
222
  '.ailk-snap-deck .ailk-section-renderer > :not(.ailk-section-separator):not([data-section="separator"])';
211
223
 
224
+ let warnedConsentAppearance = false;
225
+
226
+ /**
227
+ * `_site.mdx`'s `consent.appearance` (#10555), else `undefined`. The same
228
+ * local frontmatter read as `lib/site-header.ts`, validated against the
229
+ * canonical schema. An absent file, frontmatter or key is silent; an invalid
230
+ * value warns once per process, naming the field path and never the value.
231
+ */
232
+ function readConsentAppearance(): ConsentAppearance | undefined {
233
+ try {
234
+ const raw = readFileSync(join(resolveContentRoot(), "_site.mdx"), "utf8");
235
+ const match = /^---\r?\n([\s\S]*?)\r?\n---/.exec(raw);
236
+ if (!match) return undefined;
237
+ const fm: unknown = parseYaml(match[1] ?? "");
238
+ if (!fm || typeof fm !== "object") return undefined;
239
+ const { consent } = fm as { consent?: unknown };
240
+ if (consent === undefined) return undefined;
241
+ const parsed = SiteConsentSchema.safeParse(consent);
242
+ if (parsed.success) return parsed.data.appearance;
243
+ if (!warnedConsentAppearance) {
244
+ warnedConsentAppearance = true;
245
+ const issue = parsed.error.issues[0];
246
+ console.warn(
247
+ `[layout] ignoring _site.mdx ${["consent", ...(issue?.path ?? [])].join(".")}: ${issue?.message ?? "invalid"}`,
248
+ );
249
+ }
250
+ return undefined;
251
+ } catch {
252
+ return undefined;
253
+ }
254
+ }
255
+
212
256
  /**
213
257
  * Whether this visitor has already accepted or declined cookies (#5557).
214
258
  *
@@ -300,12 +344,15 @@ interface Props {
300
344
  }
301
345
 
302
346
  /**
303
- * #8847 D13 — the footer's newsletter signup: the newsletter magnet's compact
304
- * `NewsletterFormInline`, only when the magnet is served (live), maps as a
305
- * one-field signup, and the API accepts signups. The seeded built-in config
306
- * never fills the footer. Both reads are shared for 15 seconds per instance.
347
+ * #8847 D13 — the newsletter magnet's view for the footer's signup, and
348
+ * (#10629 D4) for every bound `newsletter` section: only when the magnet is
349
+ * served (live), maps as a one-field signup, and the API accepts signups. The
350
+ * seeded built-in config never fills either. Both reads are shared for 15
351
+ * seconds per instance.
307
352
  */
308
- async function footerNewsletter(locale: string): Promise<ReactElement | undefined> {
353
+ async function servedNewsletterView(
354
+ locale: string,
355
+ ): Promise<(InPageView & { form: NewsletterFormView }) | undefined> {
309
356
  const [magnet, status] = await Promise.all([
310
357
  readServedMagnet(NEWSLETTER_FLOW_SLUG),
311
358
  readCaptureStatus(),
@@ -313,11 +360,7 @@ async function footerNewsletter(locale: string): Promise<ReactElement | undefine
313
360
  if (magnet === null || status?.newsletter !== true) return undefined;
314
361
  const view = inPageView(magnet, locale, "newsletter");
315
362
  if (view === null || !("kind" in view.form)) return undefined;
316
- return (
317
- <div data-testid="footer-newsletter">
318
- {newsletterSignup({ ...view, form: view.form }, { locale, compact: true })}
319
- </div>
320
- );
363
+ return { ...view, form: view.form };
321
364
  }
322
365
 
323
366
  export default async function LocaleLayout({
@@ -336,6 +379,11 @@ export default async function LocaleLayout({
336
379
  // #7742 D3/D5 — the minimal chrome: the closing slide, the hairline consent
337
380
  // bar and the cue offset.
338
381
  const isMinimalChrome = chrome.chromeStyle === "minimal";
382
+ // #10555 — `_site.mdx`'s `consent.appearance` picks the cookie notice's
383
+ // look; absent, the chrome does (hairline under minimal, else compact).
384
+ const isHairlineConsent =
385
+ (readConsentAppearance() ?? (isMinimalChrome ? "hairline" : "compact")) ===
386
+ "hairline";
339
387
  const consentDecided = await hasConsentChoice();
340
388
  // Server-side session read (#4984) — the same `auth.api.getSession` idiom
341
389
  // the `(authed)` segment guard uses, through the generated topology seam
@@ -343,7 +391,26 @@ export default async function LocaleLayout({
343
391
  // Null when signed out or auth is unset.
344
392
  const session = await readSession(await headers());
345
393
 
346
- const footerSignup = await footerNewsletter(locale);
394
+ const newsletterView = await servedNewsletterView(locale);
395
+ // #8847 D13 — the footer's compact signup.
396
+ const footerSignup =
397
+ newsletterView === undefined ? undefined : (
398
+ <div data-testid="footer-newsletter">
399
+ {newsletterSignup(newsletterView, { locale, compact: true })}
400
+ </div>
401
+ );
402
+ // #10629 D4 — the full-size signup every bound `newsletter` section renders
403
+ // (as on /newsletter), with the on-submit consent gate. Both shell branches
404
+ // render the page under its provider, so a bound section on any page reads it.
405
+ const page = (
406
+ <NewsletterSignupProvider
407
+ {...(newsletterView !== undefined && {
408
+ signup: newsletterSignup(newsletterView, { locale, consentGate: "on-submit" }),
409
+ })}
410
+ >
411
+ {children}
412
+ </NewsletterSignupProvider>
413
+ );
347
414
 
348
415
  // Resolve nav labels server-side so Nav + Footer components stay
349
416
  // i18n-hook-free and are testable without a NextIntlClientProvider wrapper.
@@ -486,6 +553,22 @@ export default async function LocaleLayout({
486
553
  const deckFooterActions = session
487
554
  ? [{ labelKey: "account", path: "/account", variant: "outline-strong" as const }]
488
555
  : [...authActions];
556
+ // #10550 D4 — the closing footer's site settings: its locality line, its
557
+ // actions (the primary, then the secondary — they replace the auth links
558
+ // on the closing footer in every header state) and its legal bar. The
559
+ // site's labels ride `itemLabels` under keys no nav key can collide with
560
+ // (no nav key contains a dot).
561
+ const closingFooter = readClosingFooterSettings();
562
+ const closingFooterActions = closingFooter.actions?.map(
563
+ (action, i) => ({
564
+ labelKey: `closingFooter.action.${i}`,
565
+ path: action.href,
566
+ variant: i === 0 ? ("primary" as const) : ("ghost" as const),
567
+ }),
568
+ );
569
+ const closingFooterLabels = Object.fromEntries(
570
+ (closingFooter.actions ?? []).map((action, i) => [`closingFooter.action.${i}`, action.label]),
571
+ );
489
572
  // A bare inner page's auth (#7721 D3/D4) rides the one-bar footer's link
490
573
  // group: Sign in / Sign up signed out, Account signed in (where Sign out
491
574
  // lives). Labels from the existing `nav` namespace. Under a full header
@@ -788,7 +871,7 @@ export default async function LocaleLayout({
788
871
  * consent Banner below. The booking paths (#8843 D10) and a magnet
789
872
  * walk (#8840 D10) take it and keep the Banner.
790
873
  */
791
- children
874
+ page
792
875
  ) : (
793
876
  <MarketingShell
794
877
  contentOwnsMain
@@ -925,13 +1008,23 @@ export default async function LocaleLayout({
925
1008
  // #10049 D5 — the style is `pageChrome.footer`, and every
926
1009
  // footer with an actions row takes the auth links when the
927
1010
  // header is not full.
1011
+ // #10550 D4 — a site's own `footer.locality`, `footer.actions`
1012
+ // and `footer.legalBar` set the closing footer's locality line,
1013
+ // its actions (in place of the auth links, whatever the header)
1014
+ // and its legal bar.
928
1015
  {...(pageChrome.footer === "closing"
929
1016
  ? {
930
1017
  layout: "closing" as const,
931
- contactLines: readSiteContactLines(),
932
- ...(!headerIsFull && !(isHome && isMinimalChrome)
933
- ? { actions: deckFooterActions }
934
- : {}),
1018
+ contactLines: readSiteContactLines(closingFooter.locality),
1019
+ ...(closingFooterActions !== undefined
1020
+ ? {
1021
+ actions: closingFooterActions,
1022
+ itemLabels: { ...navLabels.items, ...closingFooterLabels },
1023
+ }
1024
+ : !headerIsFull && !(isHome && isMinimalChrome)
1025
+ ? { actions: deckFooterActions }
1026
+ : {}),
1027
+ ...(closingFooter.legalBar ? { legalBar: true } : {}),
935
1028
  }
936
1029
  : pageChrome.footer === "line"
937
1030
  ? { layout: "line" as const, accountLinks }
@@ -967,7 +1060,7 @@ export default async function LocaleLayout({
967
1060
  )
968
1061
  }
969
1062
  >
970
- {children}
1063
+ {page}
971
1064
  </MarketingShell>
972
1065
  )}
973
1066
  {/*
@@ -996,11 +1089,11 @@ export default async function LocaleLayout({
996
1089
  consentCookieName={CONSENT_COOKIE_NAME}
997
1090
  consentDecided={consentDecided}
998
1091
  title={tConsent("title")}
999
- // #9656 D7 — the standard chrome mounts the compact floating
1000
- // card on every width, its Privacy Policy link on the button
1001
- // row; the minimal chrome keeps its hairline band (#7742 D5),
1002
- // the statement with the link inline.
1003
- {...(isMinimalChrome
1092
+ // #9656 D7 — the compact floating card on every width, its
1093
+ // Privacy Policy link on the button row; the hairline band
1094
+ // (#7742 D5), the statement with the link inline. #10555 —
1095
+ // which one is `isHairlineConsent`'s, not the chrome's.
1096
+ {...(isHairlineConsent
1004
1097
  ? {
1005
1098
  message: (
1006
1099
  <>
@@ -1038,12 +1131,14 @@ export default async function LocaleLayout({
1038
1131
  // (`CONSENT_COOKIE_NAME`) stays the operative switch the
1039
1132
  // island itself still writes (C3).
1040
1133
  locale={locale}
1041
- // #7742 D5 — the minimal chrome's consent bar: the page ground,
1042
- // a hairline, no shadow.
1043
- {...(isMinimalChrome ? { consentAppearance: "hairline" as const } : {})}
1134
+ // #7742 D5 — the hairline consent bar: the page ground, a
1135
+ // hairline, no shadow.
1136
+ {...(isHairlineConsent ? { consentAppearance: "hairline" as const } : {})}
1044
1137
  // #7782 — on the minimal deck home the bar first shows once the
1045
1138
  // first slide has left view; every other page shows it on load.
1046
- {...(isDeckHome && isMinimalChrome
1139
+ // #10721 — the bar's alone: a minimal site that picks the card
1140
+ // (`consent.appearance: compact`) shows it on load there too.
1141
+ {...(isDeckHome && isMinimalChrome && isHairlineConsent
1047
1142
  ? { consentRevealAfter: DECK_FIRST_SLIDE_SELECTOR }
1048
1143
  : {})}
1049
1144
  />
@@ -52,10 +52,10 @@ jest.mock("../AccountInviteLink", () => ({
52
52
  }),
53
53
  }));
54
54
 
55
- import { readFileSync } from "node:fs";
55
+ import { existsSync, readFileSync } from "node:fs";
56
56
  import { join } from "node:path";
57
57
  import { notFound } from "next/navigation";
58
- import ThankYouSlugPage from "../page";
58
+ import ThankYouSlugPage, { generateMetadata } from "../page";
59
59
 
60
60
  const mockNotFound = notFound as jest.MockedFunction<typeof notFound>;
61
61
 
@@ -132,3 +132,22 @@ describe("ThankYouSlugPage — detail route", () => {
132
132
  expect(source).not.toMatch(/["']use client["']/);
133
133
  });
134
134
  });
135
+
136
+ // #10617 AC-1 — the route's own metadata, read from the real seed, carries
137
+ // the noindex directive the page-renderer gives every ThankYouPage.
138
+ // The scaffold matrix runs this suite inside a scaffold that ships no
139
+ // flow-thanks seed (#10763), so the test skips where the seed does not exist.
140
+ const SEED = join(
141
+ __dirname, "..", "..", "..", "..", "..", "..", "..",
142
+ "content", "en", "ThankYouPage", "flow-thanks.mdx",
143
+ );
144
+ const itWithSeed = existsSync(SEED) ? it : it.skip;
145
+
146
+ describe("ThankYouSlugPage — generateMetadata (#10617)", () => {
147
+ itWithSeed("carries robots noindex, follow", async () => {
148
+ const metadata = await generateMetadata({
149
+ params: Promise.resolve({ locale: "en", slug: "flow-thanks" }),
150
+ });
151
+ expect(metadata.robots).toEqual({ index: false, follow: true });
152
+ });
153
+ });
@@ -4,6 +4,7 @@
4
4
  // header says why there is no static-params export (#7379). Unknown slugs 404
5
5
  // via renderPage's own detail-route branch (no translationsNamespace passed
6
6
  // — renderPage calls notFound() itself when getMdxSource resolves null).
7
+ // `renderPageMetadata` makes every ThankYouPage `noindex, follow` (#10617).
7
8
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
8
9
  import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
9
10
  import type { Metadata } from "next";
@@ -30,7 +30,10 @@ export const CONTINUATION_REF_MAX = 4096;
30
30
  */
31
31
  export const CONTINUATION_TTL_SECONDS = 600;
32
32
 
33
- /** A fixed name: a value sealed for a cookie never opens here, nor the reverse. */
33
+ /**
34
+ * A fixed name: a value sealed for a cookie never opens here, nor the reverse.
35
+ * The MCP sign-in leg seals under its own name (`../mcp/continuation.ts`).
36
+ */
34
37
  const AAD = "hub-continue";
35
38
 
36
39
  const BASE64URL = /^[A-Za-z0-9_-]+$/;
@@ -40,12 +43,13 @@ export function mintContinuation(
40
43
  search: string,
41
44
  secret: string,
42
45
  now: number = Date.now(),
46
+ name: string = AAD,
43
47
  ): string {
44
48
  return seal(
45
49
  { v: 1, q: search, exp: now + CONTINUATION_TTL_SECONDS * 1000 },
46
50
  secret,
47
51
  "continue",
48
- AAD,
52
+ name,
49
53
  );
50
54
  }
51
55
 
@@ -62,9 +66,10 @@ export function openContinuation(
62
66
  ref: string,
63
67
  secret: string,
64
68
  now: number = Date.now(),
69
+ name: string = AAD,
65
70
  ): string | null {
66
71
  if (ref.length > CONTINUATION_REF_MAX || !BASE64URL.test(ref)) return null;
67
- const value = unseal(ref, secret, "continue", AAD);
72
+ const value = unseal(ref, secret, "continue", name);
68
73
  if (typeof value !== "object" || value === null) return null;
69
74
  const { v, q, exp } = value as Record<string, unknown>;
70
75
  if (v !== 1 || typeof q !== "string" || q === "") return null;
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * The MCP sign-in leg's continuation (#10479): the hub's sealed ref, under a
7
+ * name of its own. Pure functions over `seal.ts`; no mocks.
8
+ */
9
+ import { mintContinuation } from "../../hub/continuation";
10
+ import {
11
+ MCP_CONTINUE_PATH,
12
+ mintMcpContinuation,
13
+ openMcpContinuation,
14
+ sealMcpCallbacks,
15
+ } from "../continuation";
16
+
17
+ const SECRET = "k".repeat(43);
18
+ const T = 1_790_000_000_000;
19
+
20
+ const Q = new URLSearchParams({
21
+ response_type: "code",
22
+ client_id: "mcp-client",
23
+ redirect_uri: "http://127.0.0.1:53682/callback",
24
+ state: "s".repeat(43),
25
+ code_challenge: "c".repeat(43),
26
+ code_challenge_method: "S256",
27
+ }).toString();
28
+
29
+ it("pins the continue path", () => {
30
+ expect(MCP_CONTINUE_PATH).toBe("/api/auth/mcp/continue");
31
+ });
32
+
33
+ it("round-trips the authorize query, scheme-bearing redirect_uri included", () => {
34
+ expect(openMcpContinuation(mintMcpContinuation(Q, SECRET, T), SECRET, T + 1_000)).toBe(Q);
35
+ });
36
+
37
+ it("mints a ref no decode can change: base64url only, so no ':', '%', '?', '&' or '='", () => {
38
+ expect(mintMcpContinuation(Q, SECRET, T)).toMatch(/^[A-Za-z0-9_-]+$/);
39
+ });
40
+
41
+ it("expires at 600 s", () => {
42
+ expect(openMcpContinuation(mintMcpContinuation(Q, SECRET, T), SECRET, T + 600_000)).toBeNull();
43
+ });
44
+
45
+ it("never opens a hub ref, nor under another secret", () => {
46
+ expect(openMcpContinuation(mintContinuation(Q, SECRET, T), SECRET, T)).toBeNull();
47
+ expect(openMcpContinuation(mintMcpContinuation(Q, SECRET, T), "x".repeat(43), T)).toBeNull();
48
+ });
49
+
50
+ describe("sealMcpCallbacks — the sign-in body's MCP resume, sealed", () => {
51
+ const next = `/api/auth/mcp/authorize?${Q}`;
52
+ const refOf = (path: string | undefined) =>
53
+ /^\/api\/auth\/mcp\/continue\/([A-Za-z0-9_-]+)$/.exec(path ?? "")?.[1] ?? "";
54
+
55
+ it("seals callbackURL and the next inside the sign-in page's errorCallbackURL; both open to the query", () => {
56
+ const sealed = sealMcpCallbacks(
57
+ { callbackURL: next, errorCallbackURL: `/de/sign-in?next=${encodeURIComponent(next)}` },
58
+ SECRET,
59
+ );
60
+ expect(openMcpContinuation(refOf(sealed?.callbackURL), SECRET)).toBe(Q);
61
+ const error = new URL(sealed?.errorCallbackURL ?? "", "http://x.invalid");
62
+ expect(error.pathname).toBe("/de/sign-in");
63
+ expect(openMcpContinuation(refOf(error.searchParams.get("next") ?? ""), SECRET)).toBe(Q);
64
+ // No decode of either value can produce a ':'.
65
+ expect(decodeURIComponent(decodeURIComponent(sealed?.errorCallbackURL ?? ""))).not.toContain(":");
66
+ });
67
+
68
+ it.each([
69
+ ["no callbacks", {}],
70
+ ["an ordinary next", { callbackURL: "/account", errorCallbackURL: "/sign-in?next=%2Faccount" }],
71
+ ["the hub's own authorize path", { callbackURL: `/api/auth/hub/authorize?${Q}` }],
72
+ ["a foreign errorCallbackURL", { errorCallbackURL: `https://evil.example/?next=${encodeURIComponent(next)}` }],
73
+ ["a non-string callbackURL", { callbackURL: [next] }],
74
+ // Security review: URL's dot-segment collapse would emit //evil.example.
75
+ ["a dot-segment errorCallbackURL", { errorCallbackURL: `/.//evil.example?next=${encodeURIComponent(next)}` }],
76
+ ["an errorCallbackURL on another page", { errorCallbackURL: `/account?next=${encodeURIComponent(next)}` }],
77
+ ["an errorCallbackURL with a second key", { errorCallbackURL: `/sign-in?next=${encodeURIComponent(next)}&x=1` }],
78
+ ["a next holding CR/LF", { callbackURL: `${next}%0D%0A`.replace("%0D%0A", "\r\n") }],
79
+ ["an undecodable next", { errorCallbackURL: "/sign-in?next=%E0%A4%A" }],
80
+ ])("leaves %s alone (null)", (_name, body) => {
81
+ expect(sealMcpCallbacks(body, SECRET)).toBeNull();
82
+ });
83
+
84
+ it("seals the sign-up page's errorCallbackURL the same way", () => {
85
+ const sealed = sealMcpCallbacks({ errorCallbackURL: `/sign-up?next=${encodeURIComponent(next)}` }, SECRET);
86
+ expect(sealed?.errorCallbackURL).toMatch(/^\/sign-up\?next=%2Fapi%2Fauth%2Fmcp%2Fcontinue%2F[A-Za-z0-9_-]+$/);
87
+ });
88
+
89
+ it("leaves a query too long to seal under the continue route's cap as it was", () => {
90
+ expect(sealMcpCallbacks({ callbackURL: `${next}&state=${"s".repeat(4096)}` }, SECRET)).toBeNull();
91
+ });
92
+ });
@@ -0,0 +1,89 @@
1
+ /**
2
+ * The MCP sign-in leg's continuation (#10479).
3
+ *
4
+ * The plugin's `loginPage` (`lib/mcp-oauth.ts`, `/mcp/sign-in`) hands
5
+ * `/sign-in` a `next` of `/api/auth/mcp/authorize?<query>`, and the page sends
6
+ * it on as the sign-in's `callbackURL`. An MCP client's query carries a
7
+ * scheme-bearing `redirect_uri` (`http://127.0.0.1:PORT/callback`, any
8
+ * `https://` callback), and Better Auth's magic-link verify decodes
9
+ * `callbackURL` and checks it as a relative path whose query admits no `:` —
10
+ * so the emailed link answered 403 INVALID_CALLBACK_URL and the person never
11
+ * reached `/consent`.
12
+ *
13
+ * `auth.ts` therefore rewrites such a callback, as a sign-in request arrives,
14
+ * to the same-origin path `/api/auth/mcp/continue/<ref>` — the hub's sealed
15
+ * ref (`../hub/continuation.ts`), under a name of its own so a ref minted for
16
+ * one leg never opens at the other — and the continue route returns the
17
+ * person to `/mcp/authorize`, which forces consent again.
18
+ */
19
+ import { CONTINUATION_REF_MAX, mintContinuation, openContinuation } from "../hub/continuation";
20
+
21
+ /** The same-origin path a ref is appended to. */
22
+ export const MCP_CONTINUE_PATH = "/api/auth/mcp/continue";
23
+
24
+ /** The resume target the plugin's `loginPage` puts in `next`. */
25
+ const AUTHORIZE_PREFIX = "/api/auth/mcp/authorize?";
26
+
27
+ const NAME = "mcp-continue";
28
+
29
+ const PRINTABLE = /^[\x21-\x7e]+$/;
30
+
31
+ /**
32
+ * The exact `errorCallbackURL` the sign-in and sign-up pages send:
33
+ * `[/<locale>]/sign-in?next=<encoded>` (or `/sign-up`). Matched on the raw
34
+ * string and rebuilt from its own path bytes — never through `URL`, whose
35
+ * dot-segment collapse would turn an accepted `/.//host` into a
36
+ * protocol-relative `//host` after Better Auth's origin check has run.
37
+ */
38
+ const SIGN_IN_ERROR_CALLBACK = /^(\/(?:[a-z]{2}(?:-[A-Za-z]{2,4})?\/)?sign-(?:in|up))\?next=([^&#]*)$/;
39
+
40
+ /** `search` (the authorize query, no leading `?`) as a sealed ref. */
41
+ export function mintMcpContinuation(search: string, secret: string, now: number = Date.now()): string {
42
+ return mintContinuation(search, secret, now, NAME);
43
+ }
44
+
45
+ /** The authorize query a ref holds, or `null` (see `openContinuation`). */
46
+ export function openMcpContinuation(ref: string, secret: string, now: number = Date.now()): string | null {
47
+ return openContinuation(ref, secret, now, NAME);
48
+ }
49
+
50
+ /** `path` as a continue path when it resumes an MCP authorization, else unchanged. */
51
+ function sealed(path: string, secret: string): string {
52
+ // Printable ASCII only, as the origin check admits: nothing sealed may hold
53
+ // a control or non-Latin-1 character the continue route's Location refuses.
54
+ if (!path.startsWith(AUTHORIZE_PREFIX) || !PRINTABLE.test(path)) return path;
55
+ const ref = mintMcpContinuation(path.slice(AUTHORIZE_PREFIX.length), secret);
56
+ // Past the cap the continue route would not open it: leave the path as it was.
57
+ return ref.length > CONTINUATION_REF_MAX ? path : `${MCP_CONTINUE_PATH}/${ref}`;
58
+ }
59
+
60
+ /**
61
+ * The sign-in body's callbacks with every MCP resume sealed, or `null` when
62
+ * none is: `callbackURL` itself, and the `next` the sign-in page puts inside
63
+ * `errorCallbackURL` (`SIGN_IN_ERROR_CALLBACK`), whose decoded form holds the
64
+ * same query. Every other value — any other shape included — passes through
65
+ * unchanged, so this hook can only ever write a same-origin continue path.
66
+ */
67
+ export function sealMcpCallbacks(
68
+ body: { callbackURL?: unknown; errorCallbackURL?: unknown },
69
+ secret: string,
70
+ ): { callbackURL?: string; errorCallbackURL?: string } | null {
71
+ const out: { callbackURL?: string; errorCallbackURL?: string } = {};
72
+ if (typeof body.callbackURL === "string") {
73
+ const callbackURL = sealed(body.callbackURL, secret);
74
+ if (callbackURL !== body.callbackURL) out.callbackURL = callbackURL;
75
+ }
76
+ const match = typeof body.errorCallbackURL === "string" ? SIGN_IN_ERROR_CALLBACK.exec(body.errorCallbackURL) : null;
77
+ if (match) {
78
+ const [, page, encoded = ""] = match;
79
+ let next: string;
80
+ try {
81
+ next = decodeURIComponent(encoded);
82
+ } catch {
83
+ next = "";
84
+ }
85
+ const continued = sealed(next, secret);
86
+ if (continued !== next) out.errorCallbackURL = `${page}?next=${encodeURIComponent(continued)}`;
87
+ }
88
+ return Object.keys(out).length > 0 ? out : null;
89
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * The MCP sign-in leg's continue step (#10479): a ref returns the person to
7
+ * `/api/auth/mcp/authorize` with the original query, and nowhere else.
8
+ */
9
+ import { mintContinuation } from "../../../../hub/continuation";
10
+ import { mintMcpContinuation } from "../../../continuation";
11
+ import { GET } from "../route";
12
+
13
+ const SITE = "https://site.example.com";
14
+ const SECRET = "flow-secret-0123456789abcdef0123456789abcdef";
15
+ const ORIGINAL_ENV = process.env;
16
+
17
+ const QUERY = new URLSearchParams({
18
+ response_type: "code",
19
+ client_id: "mcp-client",
20
+ redirect_uri: "http://127.0.0.1:53682/callback",
21
+ state: "s".repeat(43),
22
+ code_challenge: "c".repeat(43),
23
+ code_challenge_method: "S256",
24
+ }).toString();
25
+
26
+ function continueTo(ref: string): Promise<Response> {
27
+ return GET(new Request(`${SITE}/api/auth/mcp/continue/${ref}`), {
28
+ params: Promise.resolve({ ref }),
29
+ });
30
+ }
31
+
32
+ beforeEach(() => {
33
+ process.env = { ...ORIGINAL_ENV, BETTER_AUTH_SECRET: SECRET };
34
+ });
35
+
36
+ afterAll(() => {
37
+ process.env = ORIGINAL_ENV;
38
+ });
39
+
40
+ it("answers a valid ref with 303 to /api/auth/mcp/authorize and the same query", async () => {
41
+ const res = await continueTo(mintMcpContinuation(QUERY, SECRET));
42
+
43
+ expect(res.status).toBe(303);
44
+ expect(res.headers.get("location")).toBe(`/api/auth/mcp/authorize?${QUERY}`);
45
+ expect(res.headers.get("cache-control")).toBe("no-store");
46
+ expect(res.headers.get("referrer-policy")).toBe("no-referrer");
47
+ });
48
+
49
+ it.each([
50
+ ["a forged ref", "not-a-ref"],
51
+ ["a hub ref", mintContinuation(QUERY, SECRET)],
52
+ ["an expired ref", mintMcpContinuation(QUERY, SECRET, Date.now() - 601_000)],
53
+ ["a ref sealed under another secret", mintMcpContinuation(QUERY, "x".repeat(43))],
54
+ ])("sends %s to /account, never to a URL it carries", async (_name, ref) => {
55
+ const res = await continueTo(ref);
56
+
57
+ expect(res.status).toBe(303);
58
+ expect(res.headers.get("location")).toBe("/account");
59
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * The MCP sign-in leg's continue step (#10479).
3
+ *
4
+ * GET /api/auth/mcp/continue/<ref>
5
+ *
6
+ * After sign-in — by the emailed link, the password or a social provider —
7
+ * the person lands here, and this route returns them to the authorize step:
8
+ *
9
+ * - a valid, unexpired ref: 303 to `/api/auth/mcp/authorize?<the query>`;
10
+ * - any other ref (forged, a hub ref, expired, unreadable): 303 to
11
+ * `/account`.
12
+ *
13
+ * Both targets are fixed relative paths: this route redirects nowhere else,
14
+ * and never to a URL carried in the ref. It reads no session — the authorize
15
+ * step does, and forces consent. It logs nothing.
16
+ */
17
+ import { resolveSecret } from "../../../../../../lib/auth-secret";
18
+ import { openMcpContinuation } from "../../continuation";
19
+
20
+ const AUTHORIZE_PATH = "/api/auth/mcp/authorize";
21
+
22
+ /** Where an unreadable ref goes: the site's own signed-in destination. */
23
+ const FALLBACK_PATH = "/account";
24
+
25
+ // platform-decisions-waiver: api-versioning — sign-in redirect step, not a versioned API contract (#10479)
26
+ export async function GET(
27
+ _request: Request,
28
+ { params }: { params: Promise<{ ref: string }> },
29
+ ): Promise<Response> {
30
+ const { ref } = await params;
31
+ const query = openMcpContinuation(ref, resolveSecret());
32
+ return new Response(null, {
33
+ status: 303,
34
+ headers: {
35
+ Location: query === null ? FALLBACK_PATH : `${AUTHORIZE_PATH}?${query}`,
36
+ "Cache-Control": "no-store",
37
+ // The authorize query rides this URL; no page may leak it through a Referer.
38
+ "Referrer-Policy": "no-referrer",
39
+ },
40
+ });
41
+ }