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.
- package/README.md +71 -3
- package/component-catalog.json +524 -8
- package/component-catalog.md +8 -5
- package/dist/add-route.js +1 -0
- package/dist/cli.js +114 -1
- package/dist/cutover.d.ts +109 -0
- package/dist/cutover.js +494 -0
- package/dist/design-presets.js +20 -4
- package/dist/export-content.d.ts +55 -0
- package/dist/export-content.js +303 -0
- package/dist/inventory.d.ts +216 -0
- package/dist/inventory.js +833 -0
- package/dist/language-import.d.ts +114 -0
- package/dist/language-import.js +435 -0
- package/dist/lib/env-catalog.js +2 -2
- package/dist/lib/import-mdx.d.ts +38 -0
- package/dist/lib/import-mdx.js +411 -64
- package/dist/lib/render-comparison.d.ts +200 -0
- package/dist/lib/render-comparison.js +374 -0
- package/dist/lib/scaffold-render-sweep.d.ts +1 -1
- package/dist/lib/scaffold-render-sweep.js +1 -1
- package/dist/migrate-content.d.ts +6 -2
- package/dist/migrate-content.js +60 -14
- package/dist/module-architecture.js +6 -1
- package/dist/provision-env.d.ts +28 -0
- package/dist/provision-env.js +210 -0
- package/dist/seed-workspace.d.ts +3 -1
- package/dist/seed-workspace.js +45 -16
- package/dist/surfaces.js +3 -0
- package/dist/topology.d.ts +12 -0
- package/dist/topology.js +28 -10
- package/package.json +3 -3
- package/templates/.claude/cloudparity-repo-hooks.json +5 -0
- package/templates/.claude/hooks/content-source-guard.sh +75 -0
- package/templates/.claude/settings.json +9 -0
- package/templates/.env.example +7 -0
- package/templates/.github/workflows/ci.yml +2 -0
- package/templates/CLAUDE.md +5 -0
- package/templates/apps/api/CLAUDE.md +6 -2
- package/templates/apps/mcp/.env.example +20 -0
- package/templates/apps/mcp/CLAUDE.md +46 -8
- package/templates/apps/web/.env.example +4 -2
- package/templates/apps/web/CLAUDE.md +39 -5
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +9 -1
- package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +58 -0
- package/templates/apps/web/app/.well-known/mcp.json/route.ts +16 -0
- package/templates/apps/web/app/.well-known/oauth-authorization-server/route.ts +53 -0
- package/templates/apps/web/app/[locale]/(auth)/__tests__/password-reset.test.tsx +8 -1
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/consent-actions.test.ts +56 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/__tests__/page.test.tsx +164 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent-form.tsx +132 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/consent.actions.ts +51 -0
- package/templates/apps/web/app/[locale]/(auth)/consent/page.tsx +144 -0
- package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +153 -4
- package/templates/apps/web/app/[locale]/[slug]/[detail]/page.tsx +45 -2
- package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +2 -2
- package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +49 -0
- package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +6 -4
- package/templates/apps/web/app/[locale]/events/[slug]/page.tsx +13 -1
- package/templates/apps/web/app/[locale]/events/__tests__/program-event.test.tsx +120 -0
- package/templates/apps/web/app/[locale]/events/page.tsx +11 -0
- package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +44 -5
- package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.thanks-by-tier.test.tsx +306 -0
- package/templates/apps/web/app/[locale]/layout.tsx +122 -27
- package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +21 -2
- package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +1 -0
- package/templates/apps/web/app/api/auth/hub/continuation.ts +8 -3
- package/templates/apps/web/app/api/auth/mcp/__tests__/continuation.test.ts +92 -0
- package/templates/apps/web/app/api/auth/mcp/continuation.ts +89 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/__tests__/route.test.ts +59 -0
- package/templates/apps/web/app/api/auth/mcp/continue/[ref]/route.ts +41 -0
- package/templates/apps/web/app/llms.txt/route.ts +26 -9
- package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +40 -0
- package/templates/apps/web/app/openapi.json/route.ts +4 -0
- package/templates/apps/web/app/robots.txt/route.ts +18 -1
- package/templates/apps/web/app/sitemap.xml/route.ts +13 -0
- package/templates/apps/web/auth.ts +184 -7
- package/templates/apps/web/jest.config.cjs +46 -1
- package/templates/apps/web/lib/__tests__/auth-routes.test.ts +2 -1
- package/templates/apps/web/lib/__tests__/bot-check.test.ts +247 -4
- package/templates/apps/web/lib/__tests__/in-page-magnet-self-request.test.ts +92 -0
- package/templates/apps/web/lib/__tests__/page-chrome.test.ts +90 -0
- package/templates/apps/web/lib/__tests__/relay.test.ts +33 -8
- package/templates/apps/web/lib/__tests__/site-identity-feature-list.test.ts +196 -0
- package/templates/apps/web/lib/__tests__/structured-data.test.tsx +11 -1
- package/templates/apps/web/lib/auth-routes.ts +5 -3
- package/templates/apps/web/lib/bot-check.ts +113 -13
- package/templates/apps/web/lib/in-page-magnet.ts +4 -1
- package/templates/apps/web/lib/mcp-oauth.ts +702 -0
- package/templates/apps/web/lib/page-chrome.ts +65 -5
- package/templates/apps/web/lib/relay.ts +22 -3
- package/templates/apps/web/lib/site-identity.ts +75 -24
- package/templates/apps/web/messages/en.json +47 -14
- package/templates/apps/web/next.config.mjs +15 -1
- package/templates/apps/web/package.json +1 -1
- package/templates/apps/web/scripts/assert-content-bundled.ts +36 -8
- package/templates/apps/web/scripts/content-snapshot.ts +33 -0
- package/templates/database/seed.ts +6 -0
- package/templates/package.json +2 -1
- package/templates/tools/check-content-source.ts +108 -0
- 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
|
|
304
|
-
* `
|
|
305
|
-
* one-field signup, and the API accepts signups. The
|
|
306
|
-
* never fills
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
...(
|
|
933
|
-
? {
|
|
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
|
-
{
|
|
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
|
|
1000
|
-
//
|
|
1001
|
-
//
|
|
1002
|
-
//
|
|
1003
|
-
{...(
|
|
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
|
|
1042
|
-
//
|
|
1043
|
-
{...(
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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",
|
|
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
|
+
}
|