create-ailk 0.21.0 → 0.22.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 (175) hide show
  1. package/component-catalog.json +37 -3
  2. package/component-catalog.md +6 -5
  3. package/dist/add-module.js +9 -5
  4. package/dist/derive-architecture.d.ts +8 -1
  5. package/dist/derive-architecture.js +38 -3
  6. package/dist/design-package.js +7 -2
  7. package/dist/lib/env-catalog.js +5 -1
  8. package/dist/module-architecture.js +4 -2
  9. package/dist/programmatic.js +19 -5
  10. package/dist/site-chrome.d.ts +8 -0
  11. package/dist/site-chrome.js +17 -1
  12. package/dist/spoke-checkout.d.ts +39 -18
  13. package/dist/spoke-checkout.js +177 -236
  14. package/dist/surfaces.js +19 -14
  15. package/dist/sync-routes.js +3 -2
  16. package/dist/template-record.d.ts +4 -3
  17. package/dist/template-record.js +6 -3
  18. package/dist/topology.js +25 -1
  19. package/dist/update.js +5 -0
  20. package/package.json +3 -3
  21. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  22. package/templates/apps/api/CLAUDE.md +15 -6
  23. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  24. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  25. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  26. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  27. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  28. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  29. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  30. package/templates/apps/api/src/middleware/auth.ts +13 -6
  31. package/templates/apps/api/src/openapi/spec.ts +30 -3
  32. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  33. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  34. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  35. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  36. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  37. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  38. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  39. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  40. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  41. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  42. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  43. package/templates/apps/api/src/server.ts +28 -0
  44. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  45. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  46. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  47. package/templates/apps/api/src/services/consent.ts +95 -0
  48. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  49. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  50. package/templates/apps/api/src/services/person.ts +40 -8
  51. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  52. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  53. package/templates/apps/web/.env.example +14 -0
  54. package/templates/apps/web/CLAUDE.md +120 -9
  55. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  56. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  57. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  58. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  59. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  60. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  61. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  62. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  63. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  64. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  65. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  66. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  67. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  68. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  69. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  70. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  71. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  72. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  78. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  79. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  80. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  81. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  82. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  83. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  85. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  86. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  87. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  88. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  89. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  90. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  91. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  92. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  93. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  94. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  95. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  96. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  97. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  98. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  99. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  100. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  101. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +17 -1
  102. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  103. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +41 -2
  104. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +438 -31
  105. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +7 -0
  106. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +168 -57
  107. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  108. package/templates/apps/web/app/[locale]/contact/page.tsx +49 -17
  109. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  110. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  111. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  112. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  113. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  114. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  115. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  116. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  117. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  118. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  119. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  120. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  121. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  122. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  123. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  124. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  125. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  126. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  127. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  128. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  129. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  130. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  131. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  132. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  133. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  134. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  135. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  136. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  137. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  138. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  139. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  140. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  141. package/templates/apps/web/auth.ts +28 -2
  142. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  143. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  144. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  145. package/templates/apps/web/lib/__tests__/deck-page.test.ts +5 -0
  146. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +34 -17
  147. package/templates/apps/web/lib/account-invite.ts +37 -0
  148. package/templates/apps/web/lib/checkout-config.ts +27 -0
  149. package/templates/apps/web/lib/checkout.ts +93 -0
  150. package/templates/apps/web/lib/consent-policies.ts +22 -0
  151. package/templates/apps/web/lib/deck-page.ts +6 -0
  152. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  153. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  154. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  155. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  156. package/templates/apps/web/lib/site-chrome.ts +2 -0
  157. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  158. package/templates/apps/web/messages/en.json +24 -7
  159. package/templates/apps/web/package.json +1 -0
  160. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  161. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  162. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  163. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  164. package/templates/database/CHANGELOG.md +38 -0
  165. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  166. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  167. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  168. package/templates/database/__tests__/seed.test.ts +34 -1
  169. package/templates/database/inbox/schema.prisma +17 -2
  170. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  171. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  172. package/templates/database/package.json +1 -1
  173. package/templates/database/seed.ts +34 -3
  174. package/versions.json +34 -34
  175. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
@@ -0,0 +1,89 @@
1
+ "use client"; // Reads sessionStorage (a browser API) once on mount — a server page cannot.
2
+
3
+ /**
4
+ * AccountInviteLink — the /thanks page's "Track this in your account" link
5
+ * (#8771 D6). The magnet walk writes one `sessionStorage` item,
6
+ * `ailk:account-invite`, before it navigates here; this shows the link only
7
+ * for the thank-you page that walk was headed to, and only if the stored href
8
+ * is a same-site sign-in path. It reads the item once and removes it, so the
9
+ * email in the href does not stay in the tab. A refresh shows no link (the
10
+ * invite email still carries it).
11
+ *
12
+ * Base tree: it imports nothing from `flows/**`, so a build without the walk
13
+ * still compiles this page.
14
+ */
15
+ import { Button, Container, Link } from "@working-theory/ui";
16
+ import type { ReactElement } from "react";
17
+ import { useEffect, useState } from "react";
18
+
19
+ /** Must equal `ACCOUNT_INVITE_STORAGE_KEY` in the walk's `FlowStepperClient`. */
20
+ const STORAGE_KEY = "ailk:account-invite";
21
+ /** A reserved name that never resolves: only used to test an href stays on-site. */
22
+ const PLACEHOLDER_ORIGIN = "https://placeholder.invalid";
23
+ const HUB_SIGN_IN_PATH = "/api/hub-session/sign-in";
24
+
25
+ interface Props {
26
+ /** The route's `[slug]` — the hand-off must have been written for this page. */
27
+ thanksSlug: string;
28
+ /** `contact.trackInAccount`, read on the server. */
29
+ label: string;
30
+ }
31
+
32
+ /** The stored href when it is a same-site sign-in path, else `null`. */
33
+ function safeSignInHref(raw: unknown, thanks: unknown, thanksSlug: string): string | null {
34
+ if (typeof raw !== "string" || thanks !== thanksSlug) return null;
35
+ let url: URL;
36
+ try {
37
+ url = new URL(raw, PLACEHOLDER_ORIGIN);
38
+ } catch {
39
+ return null;
40
+ }
41
+ if (url.origin !== PLACEHOLDER_ORIGIN) return null;
42
+ if (url.pathname !== HUB_SIGN_IN_PATH && !url.pathname.endsWith("/sign-in")) {
43
+ return null;
44
+ }
45
+ return raw;
46
+ }
47
+
48
+ /** Read the hand-off once, remove it, and answer the link to show (or `null`). */
49
+ function takeHandOff(thanksSlug: string): string | null {
50
+ try {
51
+ const stored = window.sessionStorage.getItem(STORAGE_KEY);
52
+ if (stored === null) return null;
53
+ window.sessionStorage.removeItem(STORAGE_KEY);
54
+ const parsed: unknown = JSON.parse(stored);
55
+ if (typeof parsed !== "object" || parsed === null) return null;
56
+ const { thanks, href } = parsed as { thanks?: unknown; href?: unknown };
57
+ return safeSignInHref(href, thanks, thanksSlug);
58
+ } catch {
59
+ return null;
60
+ }
61
+ }
62
+
63
+ export function AccountInviteLink({ thanksSlug, label }: Props): ReactElement | null {
64
+ const [href, setHref] = useState<string | null>(null);
65
+
66
+ useEffect(() => {
67
+ // Only ever sets the link: React's development double-mount runs this
68
+ // twice, and the second run finds the item already removed.
69
+ const found = takeHandOff(thanksSlug);
70
+ if (found !== null) setHref(found);
71
+ }, [thanksSlug]);
72
+
73
+ if (href === null) return null;
74
+ return (
75
+ // Session Replay records the DOM, so the link (it carries the email) is
76
+ // blocked from it; `Link` does not forward an undeclared data-* attribute.
77
+ // The page shell sets no gutters, so the link sits in its own Container,
78
+ // lined up with the thank-you content above it.
79
+ <Container className="pb-section">
80
+ <div data-sentry-block>
81
+ <Button asChild variant="outline" className="min-h-11">
82
+ <Link href={href} variant="bare" data-testid="thanks-account-invite">
83
+ {label}
84
+ </Link>
85
+ </Button>
86
+ </div>
87
+ </Container>
88
+ );
89
+ }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * #8771 AC-5 — /thanks shows only its own hand-off: the walk's sessionStorage
3
+ * item, for this page's slug, when its href is a same-site sign-in path.
4
+ */
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ import { render, screen } from "@testing-library/react";
9
+ import React, { StrictMode } from "react";
10
+
11
+ import { AccountInviteLink } from "../AccountInviteLink";
12
+
13
+ const KEY = "ailk:account-invite";
14
+ const LABEL = "Track this in your account";
15
+ const SITE_HREF = "/sign-in?next=%2Faccount&email=jane%40example.com";
16
+ const HUB_HREF = "/api/hub-session/sign-in?next=%2Faccount&email=x%40y.example";
17
+ const WALK = join(__dirname, "..", "..", "..", "flows", "[slug]", "FlowStepperClient.tsx");
18
+ // A Free scaffold ships no magnet walk, and the scaffold matrix runs this suite
19
+ // inside one (#8799), so the key check skips where the walk does not exist.
20
+ const itWithWalk = existsSync(WALK) ? it : it.skip;
21
+
22
+ function store(value: unknown): void {
23
+ window.sessionStorage.setItem(KEY, typeof value === "string" ? value : JSON.stringify(value));
24
+ }
25
+
26
+ function show(slug = "flow-thanks") {
27
+ return render(<AccountInviteLink thanksSlug={slug} label={LABEL} />);
28
+ }
29
+
30
+ beforeEach(() => window.sessionStorage.clear());
31
+
32
+ describe("AccountInviteLink", () => {
33
+ it("renders the stored link for its own slug, blocked from Replay, and removes the item", () => {
34
+ store({ thanks: "flow-thanks", href: SITE_HREF });
35
+
36
+ show();
37
+
38
+ const link = screen.getByTestId("thanks-account-invite");
39
+ expect(link).toHaveAttribute("href", SITE_HREF);
40
+ expect(link).toHaveTextContent(LABEL);
41
+ expect(link.closest("[data-sentry-block]")).not.toBeNull();
42
+ expect(window.sessionStorage.getItem(KEY)).toBeNull();
43
+ });
44
+
45
+ it("renders a hub spoke's sign-in path under the matching slug", () => {
46
+ store({ thanks: "flow-thanks", href: HUB_HREF });
47
+ show();
48
+ expect(screen.getByTestId("thanks-account-invite")).toHaveAttribute("href", HUB_HREF);
49
+ });
50
+
51
+ it("renders a locale-prefixed site sign-in path", () => {
52
+ store({ thanks: "flow-thanks", href: "/fr/sign-in?email=a%40b.example" });
53
+ show();
54
+ expect(screen.getByTestId("thanks-account-invite")).toBeInTheDocument();
55
+ });
56
+
57
+ it.each([
58
+ ["another slug", { thanks: "other", href: SITE_HREF }, "flow-thanks"],
59
+ ["an off-site href", { thanks: "flow-thanks", href: "https://evil.example/sign-in" }, "flow-thanks"],
60
+ ["a protocol-relative href", { thanks: "flow-thanks", href: "//evil.example/sign-in" }, "flow-thanks"],
61
+ ["a non-sign-in path", { thanks: "flow-thanks", href: "/account" }, "flow-thanks"],
62
+ ["a hub href under another thanks", { thanks: "flow-thanks-qualified", href: HUB_HREF }, "flow-thanks"],
63
+ ["a value that is not JSON", "not json", "flow-thanks"],
64
+ ["a JSON null", "null", "flow-thanks"],
65
+ ])("renders nothing for %s", (_name, value, slug) => {
66
+ store(value);
67
+ show(slug);
68
+ expect(screen.queryByTestId("thanks-account-invite")).toBeNull();
69
+ });
70
+
71
+ it("renders nothing when sessionStorage throws", () => {
72
+ const spy = jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
73
+ throw new Error("denied");
74
+ });
75
+ show();
76
+ expect(screen.queryByTestId("thanks-account-invite")).toBeNull();
77
+ spy.mockRestore();
78
+ });
79
+
80
+ it("shows the link once: a fresh mount after it (a refresh) renders nothing", () => {
81
+ store({ thanks: "flow-thanks", href: SITE_HREF });
82
+ const first = show();
83
+ expect(screen.getByTestId("thanks-account-invite")).toBeInTheDocument();
84
+ first.unmount();
85
+
86
+ show();
87
+ expect(screen.queryByTestId("thanks-account-invite")).toBeNull();
88
+ });
89
+
90
+ it("keeps the link under React.StrictMode's double effect", () => {
91
+ store({ thanks: "flow-thanks", href: SITE_HREF });
92
+ render(
93
+ <StrictMode>
94
+ <AccountInviteLink thanksSlug="flow-thanks" label={LABEL} />
95
+ </StrictMode>,
96
+ );
97
+ expect(screen.getByTestId("thanks-account-invite")).toBeInTheDocument();
98
+ });
99
+
100
+ itWithWalk("reads the same storage key the walk writes (the two constants are tied only by this test)", () => {
101
+ const walk = readFileSync(WALK, "utf8");
102
+ expect(walk).toContain(`ACCOUNT_INVITE_STORAGE_KEY = "${KEY}"`);
103
+ });
104
+ });
@@ -37,6 +37,23 @@ jest.mock("@working-theory/ui", () => ({
37
37
  }),
38
38
  }));
39
39
 
40
+ // #8771 D6 — the page reads the link label on the server and renders the client
41
+ // component that owns the hand-off; this file asserts only that wiring.
42
+ jest.mock("next-intl/server", () => ({
43
+ getTranslations: jest.fn().mockResolvedValue((key: string) => `label:${key}`),
44
+ }));
45
+ // eslint-disable-next-line no-restricted-syntax -- the page's wiring is the unit under test; the component has its own suite
46
+ jest.mock("../AccountInviteLink", () => ({
47
+ AccountInviteLink: ({ thanksSlug, label }: { thanksSlug: string; label: string }) =>
48
+ React.createElement("div", {
49
+ "data-testid": "account-invite-link",
50
+ "data-slug": thanksSlug,
51
+ "data-label": label,
52
+ }),
53
+ }));
54
+
55
+ import { readFileSync } from "node:fs";
56
+ import { join } from "node:path";
40
57
  import { notFound } from "next/navigation";
41
58
  import ThankYouSlugPage from "../page";
42
59
 
@@ -93,4 +110,25 @@ describe("ThankYouSlugPage — detail route", () => {
93
110
  ).rejects.toThrow("NEXT_NOT_FOUND");
94
111
  expect(mockNotFound).toHaveBeenCalled();
95
112
  });
113
+
114
+ it("renders AccountInviteLink with the route's slug and the contact label (#8771)", async () => {
115
+ const getMdxSource = jest.fn().mockResolvedValue(makeThankYouSource("flow-thanks"));
116
+
117
+ const element = await ThankYouSlugPage({
118
+ params: Promise.resolve({ locale: "en", slug: "flow-thanks" }),
119
+ getMdxSource,
120
+ });
121
+ const { container } = render(element);
122
+
123
+ const link = container.querySelector('[data-testid="account-invite-link"]');
124
+ // Inside the page's own <main>, not after it (#8771 design review).
125
+ expect(link?.closest("main")).not.toBeNull();
126
+ expect(link).toHaveAttribute("data-slug", "flow-thanks");
127
+ expect(link).toHaveAttribute("data-label", "label:trackInAccount");
128
+ });
129
+
130
+ it("stays a server page: the module has no client directive (#8771)", () => {
131
+ const source = readFileSync(join(__dirname, "..", "page.tsx"), "utf8");
132
+ expect(source).not.toMatch(/["']use client["']/);
133
+ });
96
134
  });
@@ -7,8 +7,11 @@
7
7
  import type { getMdxSource as defaultGetMdxSource } from "@working-theory/page-renderer";
8
8
  import { renderPage, renderPageMetadata } from "@working-theory/page-renderer";
9
9
  import type { Metadata } from "next";
10
+ import { getTranslations } from "next-intl/server";
10
11
  import type { ReactElement } from "react";
11
12
 
13
+ import { AccountInviteLink } from "./AccountInviteLink";
14
+
12
15
  interface Props {
13
16
  params: Promise<{ locale: string; slug: string }>;
14
17
  getMdxSource?: typeof defaultGetMdxSource;
@@ -26,11 +29,17 @@ export default async function Page({
26
29
  getMdxSource,
27
30
  }: Props): Promise<ReactElement> {
28
31
  const { locale, slug } = await params;
32
+ // #8771 D6 — the magnet walk's "Track this in your account" hand-off. The
33
+ // page stays a server page; the client component decides if it has a link.
34
+ // It renders through `trailing`, inside the page's own <main>, so it keeps
35
+ // the page's landmark and layout rather than sitting after `</main>`.
36
+ const t = await getTranslations({ locale, namespace: "contact" });
29
37
  return renderPage({
30
38
  type: "ThankYouPage",
31
39
  slug,
32
40
  locale,
33
41
  populatedDescriptionTestId: "thank-you-description",
42
+ trailing: <AccountInviteLink thanksSlug={slug} label={t("trackInAccount")} />,
34
43
  ...(getMdxSource !== undefined && { getMdxSource }),
35
44
  });
36
45
  }
@@ -62,7 +62,7 @@ describe("whitepapers/[slug] — snapshot regression", () => {
62
62
  });
63
63
  const { container } = render(element);
64
64
  expect(container.innerHTML).toMatchInlineSnapshot(`
65
- "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products.","mainEntity":{"@context":"https://schema.org","@type":"Article","headline":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Whitepapers","item":"http://localhost:3000/en/whitepapers"},{"@type":"ListItem","position":3,"name":"AILK AEO Strategy 2026","item":"http://localhost:3000/en/whitepapers/ailk-aeo-strategy-2026"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">AILK AEO Strategy 2026</h1><p data-testid="whitepaper-description" class="mt-block-tight text-lead">In-depth research on answer engine optimization for AILK products.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) hover:bg-(--color-surface-secondary) active:bg-gray-200 rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div class="ailk-prose max-w-none text-(--color-text-muted) text-body-sm leading-relaxed [&amp;>*:first-child]:mt-0 [&amp;_h2]:mt-block-loose [&amp;_h2]:scroll-mt-24 [&amp;_h2]:text-heading-3 [&amp;_h2]:font-display [&amp;_h2]:text-(--color-fg) [&amp;_h3]:mt-block-loose [&amp;_h3]:scroll-mt-24 [&amp;_h3]:text-heading-4 [&amp;_h3]:font-display [&amp;_h3]:text-(--color-fg) [&amp;_h4]:mt-block-tight [&amp;_h4]:scroll-mt-24 [&amp;_h4]:text-heading-4 [&amp;_h4]:font-display [&amp;_h4]:text-(--color-fg) [&amp;_h5]:mt-block-tight [&amp;_h5]:scroll-mt-24 [&amp;_h5]:text-heading-4 [&amp;_h5]:font-display [&amp;_h5]:text-(--color-fg) [&amp;_h6]:mt-block-tight [&amp;_h6]:scroll-mt-24 [&amp;_h6]:text-heading-4 [&amp;_h6]:font-display [&amp;_h6]:text-(--color-fg) [&amp;_p]:mt-paragraph [&amp;_strong]:font-semibold [&amp;_strong]:text-(--color-fg) [&amp;_em]:italic [&amp;_ul]:mt-paragraph [&amp;_ul]:list-disc [&amp;_ul]:ps-6 [&amp;_ol]:mt-paragraph [&amp;_ol]:list-decimal [&amp;_ol]:ps-6 [&amp;_li]:mt-1 [&amp;_li]:ps-1 [&amp;_li]:marker:text-(--color-text-muted) [&amp;_li>ul]:mt-1 [&amp;_li>ol]:mt-1 [&amp;_blockquote]:mt-paragraph [&amp;_blockquote]:border-s-4 [&amp;_blockquote]:border-(--color-border-strong) [&amp;_blockquote]:ps-4 [&amp;_blockquote]:italic [&amp;_blockquote]:text-(--color-text-muted) [&amp;_hr]:my-block-loose [&amp;_hr]:border-(--color-border) [&amp;_a]:text-(--color-accent) [&amp;_a]:underline [&amp;_a]:underline-offset-4 [&amp;_:not(pre)>code]:rounded [&amp;_:not(pre)>code]:bg-(--color-surface-secondary) [&amp;_:not(pre)>code]:px-1.5 [&amp;_:not(pre)>code]:py-0.5 [&amp;_:not(pre)>code]:text-code [&amp;_:not(pre)>code]:font-mono [&amp;_:not(pre)>code]:text-(--color-fg) [&amp;_pre]:mt-paragraph [&amp;_pre]:overflow-auto [&amp;_pre]:rounded-md [&amp;_pre]:border-hairline [&amp;_pre]:border-(--color-border) [&amp;_pre]:bg-(--color-bg) [&amp;_pre]:p-4 [&amp;_pre]:font-mono [&amp;_pre]:text-body-sm [&amp;_img]:mt-paragraph [&amp;_img]:h-auto [&amp;_img]:max-w-full [&amp;_img]:rounded-lg [&amp;_table]:mt-paragraph [&amp;_table]:w-full [&amp;_table]:border-collapse [&amp;_table]:text-body-sm [&amp;_th]:border-b [&amp;_th]:border-(--color-border) [&amp;_th]:bg-(--color-surface-secondary) [&amp;_th]:px-3 [&amp;_th]:py-2 [&amp;_th]:text-start [&amp;_th]:font-semibold [&amp;_th]:text-(--color-fg) [&amp;_td]:border-b [&amp;_td]:border-(--color-border) [&amp;_td]:px-3 [&amp;_td]:py-2 [&amp;_kbd]:rounded [&amp;_kbd]:border-hairline [&amp;_kbd]:border-(--color-border) [&amp;_kbd]:bg-(--color-surface-secondary) [&amp;_kbd]:px-1.5 [&amp;_kbd]:py-0.5 [&amp;_kbd]:text-caption [&amp;_kbd]:font-mono" data-testid="mdx-content"><div data-testid="mdx-rendered">## Executive Summary
65
+ "<script type="application/ld+json" data-testid="json-ld">{"@context":"https://schema.org","@graph":[{"@context":"https://schema.org","@type":"WebPage","inLanguage":"en","name":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products.","mainEntity":{"@context":"https://schema.org","@type":"Article","headline":"AILK AEO Strategy 2026","description":"In-depth research on answer engine optimization for AILK products."}},{"@context":"https://schema.org","@type":"BreadcrumbList","inLanguage":"en","itemListElement":[{"@type":"ListItem","position":1,"name":"Home","item":"http://localhost:3000/en/"},{"@type":"ListItem","position":2,"name":"Whitepapers","item":"http://localhost:3000/en/whitepapers"},{"@type":"ListItem","position":3,"name":"AILK AEO Strategy 2026","item":"http://localhost:3000/en/whitepapers/ailk-aeo-strategy-2026"}]}]}</script><main id="main"><div class="mx-auto w-full max-w-container-xl px-4 md:px-6 lg:px-8 py-section"><h1 class="text-heading-1 lg:text-display-sm font-display text-fg hero-type-display">AILK AEO Strategy 2026</h1><p data-testid="whitepaper-description" class="mt-block-tight text-lead">In-depth research on answer engine optimization for AILK products.</p><div data-testid="markdown-view-toggle" class="ailk-markdown-view-toggle mt-block-loose"><div class="flex justify-end mb-3"><div role="radiogroup" aria-label="Page view" data-testid="markdown-view-toggle" class="inline-flex items-center gap-0.5 rounded-lg p-1 bg-(--color-surface-secondary)"><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) bg-(--color-bg) text-(--color-fg) shadow-sm" type="button" role="radio" aria-checked="true" aria-label="Rendered" tabindex="0" data-testid="markdown-view-toggle-rendered">Rendered</button><button class="inline-flex items-center justify-center gap-2 font-medium motion-safe:transition motion-safe:duration-(--button-hover-duration) focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 disabled:cursor-not-allowed disabled:opacity-50 [&amp;>svg]:shrink-0 text-(--color-fg) not-disabled:hover:bg-(--button-hover-quiet-bg) not-disabled:hover:text-(--button-hover-quiet-fg) not-disabled:hover:opacity-(--button-hover-opacity) not-disabled:hover:transform-(--button-hover-transform) not-disabled:hover:shadow-(--button-hover-shadow) not-disabled:active:bg-gray-200 not-disabled:active:text-(--color-fg) rounded-control px-control-x h-(--control-height-thin) text-caption leading-none rounded-md px-3 text-body-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-(--color-accent) focus-visible:ring-offset-1 min-w-(--control-height-thin) min-h-(--control-height-big) text-(--color-text-muted) hover:text-(--color-fg)" type="button" role="radio" aria-checked="false" aria-label="Markdown" tabindex="-1" data-testid="markdown-view-toggle-markdown">Markdown</button></div></div><div role="status" aria-live="polite" aria-atomic="true" class="sr-only" data-testid="markdown-view-toggle-status">Showing Rendered view</div><div data-testid="markdown-view-rendered"><article data-testid="mdx-body"><div class="ailk-prose max-w-none text-(--color-text-muted) text-body-sm leading-relaxed [&amp;>*:first-child]:mt-0 [&amp;_h2]:mt-block-loose [&amp;_h2]:scroll-mt-24 [&amp;_h2]:text-heading-3 [&amp;_h2]:font-display [&amp;_h2]:text-(--color-fg) [&amp;_h3]:mt-block-loose [&amp;_h3]:scroll-mt-24 [&amp;_h3]:text-heading-4 [&amp;_h3]:font-display [&amp;_h3]:text-(--color-fg) [&amp;_h4]:mt-block-tight [&amp;_h4]:scroll-mt-24 [&amp;_h4]:text-heading-4 [&amp;_h4]:font-display [&amp;_h4]:text-(--color-fg) [&amp;_h5]:mt-block-tight [&amp;_h5]:scroll-mt-24 [&amp;_h5]:text-heading-4 [&amp;_h5]:font-display [&amp;_h5]:text-(--color-fg) [&amp;_h6]:mt-block-tight [&amp;_h6]:scroll-mt-24 [&amp;_h6]:text-heading-4 [&amp;_h6]:font-display [&amp;_h6]:text-(--color-fg) [&amp;_p]:mt-paragraph [&amp;_strong]:font-semibold [&amp;_strong]:text-(--color-fg) [&amp;_em]:italic [&amp;_ul]:mt-paragraph [&amp;_ul]:list-disc [&amp;_ul]:ps-6 [&amp;_ol]:mt-paragraph [&amp;_ol]:list-decimal [&amp;_ol]:ps-6 [&amp;_li]:mt-1 [&amp;_li]:ps-1 [&amp;_li]:marker:text-(--color-text-muted) [&amp;_li>ul]:mt-1 [&amp;_li>ol]:mt-1 [&amp;_blockquote]:mt-paragraph [&amp;_blockquote]:border-s-4 [&amp;_blockquote]:border-(--color-border-strong) [&amp;_blockquote]:ps-4 [&amp;_blockquote]:italic [&amp;_blockquote]:text-(--color-text-muted) [&amp;_hr]:my-block-loose [&amp;_hr]:border-(--color-border) [&amp;_a]:text-(--color-accent) [&amp;_a]:underline [&amp;_a]:underline-offset-4 [&amp;_:not(pre)>code]:rounded [&amp;_:not(pre)>code]:bg-(--color-surface-secondary) [&amp;_:not(pre)>code]:px-1.5 [&amp;_:not(pre)>code]:py-0.5 [&amp;_:not(pre)>code]:text-code [&amp;_:not(pre)>code]:font-mono [&amp;_:not(pre)>code]:text-(--color-fg) [&amp;_pre]:mt-paragraph [&amp;_pre]:overflow-auto [&amp;_pre]:rounded-md [&amp;_pre]:border-hairline [&amp;_pre]:border-(--color-border) [&amp;_pre]:bg-(--color-bg) [&amp;_pre]:p-4 [&amp;_pre]:font-mono [&amp;_pre]:text-body-sm [&amp;_img]:mt-paragraph [&amp;_img]:h-auto [&amp;_img]:max-w-full [&amp;_img]:rounded-lg [&amp;_table]:mt-paragraph [&amp;_table]:w-full [&amp;_table]:border-collapse [&amp;_table]:text-body-sm [&amp;_th]:border-b [&amp;_th]:border-(--color-border) [&amp;_th]:bg-(--color-surface-secondary) [&amp;_th]:px-3 [&amp;_th]:py-2 [&amp;_th]:text-start [&amp;_th]:font-semibold [&amp;_th]:text-(--color-fg) [&amp;_td]:border-b [&amp;_td]:border-(--color-border) [&amp;_td]:px-3 [&amp;_td]:py-2 [&amp;_kbd]:rounded [&amp;_kbd]:border-hairline [&amp;_kbd]:border-(--color-border) [&amp;_kbd]:bg-(--color-surface-secondary) [&amp;_kbd]:px-1.5 [&amp;_kbd]:py-0.5 [&amp;_kbd]:text-caption [&amp;_kbd]:font-mono" data-testid="mdx-content"><div data-testid="mdx-rendered">## Executive Summary
66
66
 
67
67
  Body content.</div></div></article></div></div></div></main>"
68
68
  `);
@@ -10,6 +10,10 @@
10
10
  * are the two external boundaries; both are mocked. The route's own decisions
11
11
  * are what is under test: which query it accepts, who it sends to sign in,
12
12
  * where a minted code goes, and what it refuses to redirect to.
13
+ *
14
+ * #8757 D9 (AC-6): an optional `login_hint` reaches the sign-in page as
15
+ * `email` on the no-session branch only; it never enters the continuation ref
16
+ * or the code redirect, and a repeated one is a 400 like any repeated key.
13
17
  */
14
18
 
15
19
  // --- Mocks set up BEFORE imports ---
@@ -345,3 +349,113 @@ describe("a signed-in person", () => {
345
349
  },
346
350
  );
347
351
  });
352
+
353
+ describe("the login_hint (#8757 D9)", () => {
354
+ const HINT = "a@b.example";
355
+
356
+ /** The `next` value of the 303, and the ref inside it. */
357
+ function continuationOf(res: Response): { next: string; ref: string } {
358
+ const location = res.headers.get("location") ?? "";
359
+ const next = new URL(location, HUB).searchParams.get("next") ?? "";
360
+ return { next, ref: next.slice(`${CONTINUE}/`.length) };
361
+ }
362
+
363
+ it("no session + a valid login_hint: 303 to /sign-in?next=<continue path>&email=a%40b.example; the ref opens to the six-key canonical query alone", async () => {
364
+ mockGetSession.mockResolvedValue(null);
365
+ const canonical = query();
366
+
367
+ const res = await GET(
368
+ request(`${canonical}&login_hint=${encodeURIComponent(HINT)}`),
369
+ );
370
+
371
+ expect(res.status).toBe(303);
372
+ const { next, ref } = continuationOf(res);
373
+ expect(res.headers.get("location")).toBe(
374
+ `/sign-in?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}&email=a%40b.example`,
375
+ );
376
+ expect(next).toBe(`${CONTINUE}/${ref}`);
377
+ const opened = openContinuation(ref, mockSecret);
378
+ expect(opened).toBe(canonical);
379
+ expect(opened).not.toContain("login_hint");
380
+ expect(opened).not.toContain("email");
381
+ expect(mockCreateCode).not.toHaveBeenCalled();
382
+ });
383
+
384
+ it.each([
385
+ ["an invalid login_hint", "nope"],
386
+ ["a login_hint carrying a separator", "a@b.example&next=/x"],
387
+ ["an over-long login_hint", `${"a".repeat(250)}@b.example`],
388
+ ])(
389
+ "no session + %s: dropped — the 303 carries no email",
390
+ async (_name, hint) => {
391
+ mockGetSession.mockResolvedValue(null);
392
+ const canonical = query();
393
+
394
+ const res = await GET(
395
+ request(`${canonical}&login_hint=${encodeURIComponent(hint)}`),
396
+ );
397
+
398
+ expect(res.status).toBe(303);
399
+ const { ref } = continuationOf(res);
400
+ expect(res.headers.get("location")).toBe(
401
+ `/sign-in?next=${encodeURIComponent(`${CONTINUE}/${ref}`)}`,
402
+ );
403
+ expect(openContinuation(ref, mockSecret)).toBe(canonical);
404
+ },
405
+ );
406
+
407
+ it.each([
408
+ ["no session", null],
409
+ ["a session", { user: { id: "u1" } }],
410
+ ])(
411
+ "a repeated login_hint is a 400 invalid_request (%s)",
412
+ async (_name, session) => {
413
+ mockGetSession.mockResolvedValue(session);
414
+
415
+ const res = await GET(
416
+ request(`${query()}&login_hint=a%40b.example&login_hint=c%40d.example`),
417
+ );
418
+
419
+ expect(res.status).toBe(400);
420
+ expect(res.headers.get("location")).toBeNull();
421
+ expect(await res.json()).toEqual({ error: "invalid_request" });
422
+ expect(mockCreateCode).not.toHaveBeenCalled();
423
+ expect(mockGetSession).not.toHaveBeenCalled();
424
+ },
425
+ );
426
+
427
+ it("with a session: the code redirect is unchanged and carries no hint", async () => {
428
+ mockGetSession.mockResolvedValue({ user: { id: "u1" } });
429
+ mockCreateCode.mockResolvedValue({
430
+ ok: true,
431
+ body: { code: CODE, redirectUri: SPOKE_CALLBACK },
432
+ });
433
+
434
+ const withHint = await GET(
435
+ request(`${query()}&login_hint=${encodeURIComponent(HINT)}`, {
436
+ cookie: "better-auth.session_token=tok",
437
+ }),
438
+ );
439
+ const without = await GET(
440
+ request(query(), { cookie: "better-auth.session_token=tok" }),
441
+ );
442
+
443
+ expect(withHint.status).toBe(303);
444
+ const location = withHint.headers.get("location") ?? "";
445
+ expect(location).toBe(without.headers.get("location"));
446
+ expect(location).toBe(`${SPOKE_CALLBACK}?code=${CODE}&state=${STATE}`);
447
+ expect(location).not.toContain("login_hint");
448
+ expect(location).not.toContain("email");
449
+ expect(location).not.toContain("b.example");
450
+ // The hint is never handed to the API either.
451
+ expect(mockCreateCode).toHaveBeenNthCalledWith(
452
+ 1,
453
+ {
454
+ clientId: "spoke_abc",
455
+ redirectUri: SPOKE_CALLBACK,
456
+ codeChallenge: CHALLENGE,
457
+ },
458
+ { headers: { cookie: "better-auth.session_token=tok", origin: HUB } },
459
+ );
460
+ });
461
+ });
@@ -11,6 +11,7 @@
11
11
  * GET /api/auth/hub/authorize
12
12
  * ?response_type=code&client_id=…&redirect_uri=…&state=…
13
13
  * &code_challenge=…&code_challenge_method=S256
14
+ * [&login_hint=<email>] (optional, #8757 D9 — see below)
14
15
  *
15
16
  * - a malformed query is a 400 and NEVER a redirect;
16
17
  * - no Better Auth session: the six validated keys are sealed into a short
@@ -32,6 +33,14 @@
32
33
  * - any other refusal is a 400 with `{ "error": "invalid_request" }` and no
33
34
  * visitor copy: only a misconfigured client reaches it.
34
35
  *
36
+ * `login_hint` (#8757 D9) is optional and read beside the six keys with
37
+ * `accountEmailHintSchema` (`hubAuthorizeQuerySchema` is unchanged): repeated
38
+ * → 400 like any repeated key; refused → dropped. It acts only on the
39
+ * no-session branch, as the sign-in page's `email`, so the form opens with the
40
+ * address typed. It never enters the ref, a code or a cookie — the continue
41
+ * route rebuilds the query from the ref, so the hint is gone after sign-in —
42
+ * and with a session it is ignored.
43
+ *
35
44
  * `BETTER_AUTH_URL` pins the `Origin` sent with the cookie-authenticated write
36
45
  * (Node's `fetch` sets none). Unset, the step answers 503: it needs a pinned
37
46
  * origin, exactly as the emailed link does.
@@ -42,7 +51,10 @@
42
51
  * state, a challenge or a cookie.
43
52
  */
44
53
  import { hubSignIn } from "@working-theory/api-client";
45
- import { hubAuthorizeQuerySchema } from "@working-theory/validation";
54
+ import {
55
+ accountEmailHintSchema,
56
+ hubAuthorizeQuerySchema,
57
+ } from "@working-theory/validation";
46
58
 
47
59
  import { auth, resolveSecret } from "../../../../../auth";
48
60
  import {
@@ -109,9 +121,13 @@ export async function GET(request: Request): Promise<Response> {
109
121
  if (values.length > 1) return json(400, "invalid_request");
110
122
  raw[key] = values[0];
111
123
  }
124
+ // #8757 D9 — the optional hint: the same once-only rule; refused → dropped.
125
+ const hints = url.searchParams.getAll("login_hint");
126
+ if (hints.length > 1) return json(400, "invalid_request");
112
127
  const parsed = hubAuthorizeQuerySchema.safeParse(raw);
113
128
  if (!parsed.success) return json(400, "invalid_request");
114
129
  const query = parsed.data;
130
+ const hint = accountEmailHintSchema.safeParse(hints[0]);
115
131
 
116
132
  const session = await auth.api.getSession({ headers: request.headers });
117
133
  if (!session) {
@@ -121,8 +137,9 @@ export async function GET(request: Request): Promise<Response> {
121
137
  for (const key of QUERY_KEYS) canonical.set(key, query[key]);
122
138
  const ref = mintContinuation(canonical.toString(), resolveSecret());
123
139
  if (ref.length > CONTINUATION_REF_MAX) return json(400, "invalid_request");
124
- const next = `${CONTINUE_PATH}/${ref}`;
125
- return see(`/sign-in?next=${encodeURIComponent(next)}`);
140
+ const signIn = new URLSearchParams({ next: `${CONTINUE_PATH}/${ref}` });
141
+ if (hint.success) signIn.set("email", hint.data);
142
+ return see(`/sign-in?${signIn.toString()}`);
126
143
  }
127
144
 
128
145
  const origin = pinnedOrigin();
@@ -0,0 +1,48 @@
1
+ // The buy route — the standard spoke checkout (#8748), kept current by
2
+ // `create-ailk update`: do not edit it. Every guarantee lives in the
3
+ // spoke-checkout package and arrives by a package bump. This site's offerings
4
+ // are data in lib/checkout-config.ts, the one file to edit. It answers 404 on
5
+ // any build that is not a spoke.
6
+ //
7
+ // A buyer who is signed in when checkout starts returns to /account; everyone
8
+ // else returns to the thank-you page with Stripe's session id (#8757 D12). The
9
+ // session is read through the site's generated seam: Better Auth on a site,
10
+ // the sealed hub session on a hub-sign-in spoke, none without sign-in.
11
+ import { createSpokeCheckout } from "@working-theory/spoke-checkout";
12
+
13
+ import {
14
+ checkoutNotServed,
15
+ checkoutServed,
16
+ spokeCheckoutConfig,
17
+ } from "../../../../lib/checkout";
18
+ import { readSession } from "../../../../lib/topology.generated";
19
+
20
+ export const runtime = "nodejs";
21
+ export const dynamic = "force-dynamic";
22
+
23
+ type BuyRoute = ReturnType<typeof createSpokeCheckout>["buy"];
24
+ type BuyContext = Parameters<BuyRoute["GET"]>[1];
25
+
26
+ let buy: BuyRoute | undefined;
27
+
28
+ /** `/account` for a request with a session, else `null` (the thank-you page). */
29
+ async function successPath(request: Request): Promise<string | null> {
30
+ return (await readSession(request.headers)) ? "/account" : null;
31
+ }
32
+
33
+ /** Built on the first served request and kept, so the package's rate limiters persist. */
34
+ function buyRoute(): BuyRoute {
35
+ buy ??= createSpokeCheckout(spokeCheckoutConfig, { successPath }).buy;
36
+ return buy;
37
+ }
38
+
39
+ // platform-decisions-waiver: api-versioning — a browser buy link that redirects to Stripe Checkout, not a versioned API contract (spec 8748 §7)
40
+ export async function GET(request: Request, ctx: BuyContext): Promise<Response> {
41
+ if (!checkoutServed()) return checkoutNotServed();
42
+ return buyRoute().GET(request, ctx);
43
+ }
44
+
45
+ export async function POST(request: Request, ctx: BuyContext): Promise<Response> {
46
+ if (!checkoutServed()) return checkoutNotServed();
47
+ return buyRoute().POST(request, ctx);
48
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * The standard buy route (#8748 D1, D11; spec
7
+ * docs/specs/8748-a-standard-spoke-checkout-route-spec.md AC-2, AC-4).
8
+ *
9
+ * The checkout package is mocked: every guarantee is its own, proven by its
10
+ * own suites. These cases prove the route is wiring only — built once from
11
+ * `spokeCheckoutConfig`, serving the package's `buy` handlers on a spoke and
12
+ * a 404 anywhere else — and that it declares no offering and no Stripe
13
+ * client of its own.
14
+ */
15
+ import { readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ const mockBuyGet = jest.fn();
19
+ const mockBuyPost = jest.fn();
20
+ const mockCreateSpokeCheckout = jest.fn((_config: unknown) => ({
21
+ buy: { GET: mockBuyGet, POST: mockBuyPost },
22
+ }));
23
+ // `virtual`: CI runs this suite before the package's dist is built, so it
24
+ // may not resolve at all — the factory stands in either way.
25
+ jest.mock(
26
+ "@working-theory/spoke-checkout",
27
+ () => ({
28
+ __esModule: true,
29
+ createSpokeCheckout: (config: unknown) => mockCreateSpokeCheckout(config),
30
+ }),
31
+ { virtual: true },
32
+ );
33
+
34
+ const mockTopology: { value: "hub" | "spoke" } = { value: "spoke" };
35
+ // eslint-disable-next-line no-restricted-syntax -- #507: the topology is a generated constant (#7426), not a runtime input; the seam IS the boundary between a hub and a spoke build, and no env or HTTP surface selects it.
36
+ jest.mock("../../../../lib/topology.generated", () => ({
37
+ __esModule: true,
38
+ get TOPOLOGY() {
39
+ return mockTopology.value;
40
+ },
41
+ }));
42
+
43
+ import { checkoutConfig } from "../../../../lib/checkout-config";
44
+ import { spokeCheckoutConfig } from "../../../../lib/checkout";
45
+ import { GET, POST } from "../[offering]/route";
46
+
47
+ const ROUTE_SOURCE = readFileSync(
48
+ join(__dirname, "..", "[offering]", "route.ts"),
49
+ "utf8",
50
+ );
51
+
52
+ function ctxFor(offering: string) {
53
+ return { params: Promise.resolve({ offering }) };
54
+ }
55
+
56
+ function request(method: "GET" | "POST"): Request {
57
+ return new Request("https://acme.example.com/api/checkout/example", {
58
+ method,
59
+ });
60
+ }
61
+
62
+ describe("the standard buy route (#8748 AC-2)", () => {
63
+ beforeEach(() => {
64
+ mockBuyGet.mockReset();
65
+ mockBuyPost.mockReset();
66
+ });
67
+
68
+ it("answers 404 with no-store on a hub, and never builds the package's handlers (D11)", async () => {
69
+ mockTopology.value = "hub";
70
+ for (const res of [
71
+ await GET(request("GET"), ctxFor("example")),
72
+ await POST(request("POST"), ctxFor("example")),
73
+ ]) {
74
+ expect(res.status).toBe(404);
75
+ expect(res.headers.get("cache-control")).toBe("no-store");
76
+ }
77
+ expect(mockCreateSpokeCheckout).not.toHaveBeenCalled();
78
+ expect(mockBuyGet).not.toHaveBeenCalled();
79
+ expect(mockBuyPost).not.toHaveBeenCalled();
80
+ });
81
+
82
+ it("serves the package's buy handlers on a spoke, built once from spokeCheckoutConfig", async () => {
83
+ mockTopology.value = "spoke";
84
+ const fromGet = new Response(null, { status: 303 });
85
+ const fromPost = new Response(null, { status: 303 });
86
+ mockBuyGet.mockResolvedValue(fromGet);
87
+ mockBuyPost.mockResolvedValue(fromPost);
88
+
89
+ const getRequest = request("GET");
90
+ const getCtx = ctxFor("example");
91
+ expect(await GET(getRequest, getCtx)).toBe(fromGet);
92
+ expect(mockBuyGet).toHaveBeenCalledWith(getRequest, getCtx);
93
+
94
+ const postRequest = request("POST");
95
+ const postCtx = ctxFor("example");
96
+ expect(await POST(postRequest, postCtx)).toBe(fromPost);
97
+ expect(mockBuyPost).toHaveBeenCalledWith(postRequest, postCtx);
98
+
99
+ expect(mockCreateSpokeCheckout).toHaveBeenCalledTimes(1);
100
+ const [config] = mockCreateSpokeCheckout.mock.calls[0] as [
101
+ typeof spokeCheckoutConfig,
102
+ ];
103
+ expect(config).toBe(spokeCheckoutConfig);
104
+ expect(config.offerings).toBe(checkoutConfig.offerings);
105
+ });
106
+
107
+ it("names no offering of the site's own", () => {
108
+ for (const slug of Object.keys(checkoutConfig.offerings)) {
109
+ const quoted = new RegExp(`["'\`]${slug}["'\`]`);
110
+ const property = new RegExp(`\\b${slug}\\s*:`);
111
+ expect(ROUTE_SOURCE).not.toMatch(quoted);
112
+ expect(ROUTE_SOURCE).not.toMatch(property);
113
+ }
114
+ });
115
+
116
+ it("reads no Stripe setting and imports no Stripe client (AC-4)", () => {
117
+ expect(ROUTE_SOURCE).not.toContain("STRIPE_");
118
+ expect(ROUTE_SOURCE).not.toContain("stripe-client");
119
+ });
120
+ });