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,86 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * The buy route's success path (#8757 D12, AC-10): a buyer signed in when
7
+ * checkout starts returns to `/account`, everyone else to the thank-you page.
8
+ *
9
+ * The route hands the checkout package a `successPath` hook built on the
10
+ * site's generated session seam (`readSession` in lib/topology.generated —
11
+ * Better Auth on a site, the sealed hub session on a hub-sign-in spoke, none
12
+ * on a spoke without sign-in). The package is mocked: what it does with the
13
+ * answer is its own suite's (`buy-route.test.ts`). These cases prove the hook
14
+ * answers `"/account"` for a request with a session and `null` without one.
15
+ */
16
+ const mockCreateSpokeCheckout = jest.fn((_config: unknown, _options?: unknown) => ({
17
+ buy: { GET: jest.fn(async () => new Response(null, { status: 303 })), POST: jest.fn() },
18
+ }));
19
+ // `virtual`: CI runs this suite before the package's dist is built.
20
+ jest.mock(
21
+ "@working-theory/spoke-checkout",
22
+ () => ({
23
+ __esModule: true,
24
+ createSpokeCheckout: (config: unknown, options?: unknown) =>
25
+ mockCreateSpokeCheckout(config, options),
26
+ }),
27
+ { virtual: true },
28
+ );
29
+
30
+ const mockReadSession = jest.fn();
31
+ // 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.
32
+ jest.mock("../../../../lib/topology.generated", () => ({
33
+ __esModule: true,
34
+ TOPOLOGY: "spoke",
35
+ readSession: (headers: Headers) => mockReadSession(headers),
36
+ }));
37
+
38
+ import { GET } from "../[offering]/route";
39
+
40
+ type SuccessPath = (request: Request) => Promise<string | null>;
41
+
42
+ /** The `successPath` the route built its handlers with. */
43
+ async function successPathHook(): Promise<SuccessPath> {
44
+ await GET(new Request("https://acme.example.com/api/checkout/example"), {
45
+ params: Promise.resolve({ offering: "example" }),
46
+ });
47
+ expect(mockCreateSpokeCheckout).toHaveBeenCalledTimes(1);
48
+ const [, options] = mockCreateSpokeCheckout.mock.calls[0] as [
49
+ unknown,
50
+ { successPath?: SuccessPath } | undefined,
51
+ ];
52
+ expect(typeof options?.successPath).toBe("function");
53
+ return options!.successPath!;
54
+ }
55
+
56
+ describe("the buy route's success path (#8757 AC-10)", () => {
57
+ let successPath: SuccessPath;
58
+
59
+ beforeAll(async () => {
60
+ successPath = await successPathHook();
61
+ });
62
+
63
+ beforeEach(() => {
64
+ mockReadSession.mockReset();
65
+ });
66
+
67
+ it("a request with a session answers /account", async () => {
68
+ mockReadSession.mockResolvedValue({
69
+ user: { email: "cleo@x.io", emailVerified: true },
70
+ });
71
+ const request = new Request("https://acme.example.com/api/checkout/example", {
72
+ headers: { cookie: "session=abc" },
73
+ });
74
+ await expect(successPath(request)).resolves.toBe("/account");
75
+ expect(mockReadSession).toHaveBeenCalledTimes(1);
76
+ const [headers] = mockReadSession.mock.calls[0] as [Headers];
77
+ expect(headers.get("cookie")).toBe("session=abc");
78
+ });
79
+
80
+ it("a request without one answers null", async () => {
81
+ mockReadSession.mockResolvedValue(null);
82
+ await expect(
83
+ successPath(new Request("https://acme.example.com/api/checkout/example")),
84
+ ).resolves.toBeNull();
85
+ });
86
+ });
@@ -13,6 +13,9 @@
13
13
  * a callback that BURNS a code it cannot complete (AC-5), and a sign-out that
14
14
  * refuses a cross-site request and revokes nothing (AC-5). Each route is a 404
15
15
  * unless the web seam's `SIGN_IN_PROVIDER` is `"hub"` (AC-9).
16
+ *
17
+ * #8757 D9 (AC-6): a valid single `?email=` on the sign-in route becomes the
18
+ * authorize URL's `login_hint`; the sealed flow cookie never carries it.
16
19
  */
17
20
 
18
21
  import { createHash } from "node:crypto";
@@ -36,7 +39,7 @@ jest.mock("../../../../lib/topology.generated", () => ({
36
39
  },
37
40
  }));
38
41
 
39
- import { seal } from "../../../../lib/hub-session/seal";
42
+ import { seal, unseal } from "../../../../lib/hub-session/seal";
40
43
  import { GET as callback } from "../callback/route";
41
44
  import { GET as signIn } from "../sign-in/route";
42
45
  import { POST as signOut } from "../sign-out/route";
@@ -161,6 +164,46 @@ describe("GET /api/hub-session/sign-in", () => {
161
164
  });
162
165
  });
163
166
 
167
+ describe("GET /api/hub-session/sign-in — the hinted email (#8757 D9)", () => {
168
+ function startWith(query: string): { authorize: URL; payload: Record<string, unknown> } {
169
+ const res = signIn(new Request(`${SITE}/api/hub-session/sign-in?${query}`));
170
+ expect(res.status).toBe(303);
171
+ const flow = cookieValue(res, "ailk-hub-flow");
172
+ if (flow === undefined) throw new Error("no flow cookie");
173
+ const payload = unseal(flow, SESSION_SECRET, "flow", "ailk-hub-flow");
174
+ if (payload === null || typeof payload !== "object") throw new Error("flow did not open");
175
+ return {
176
+ authorize: new URL(res.headers.get("location") ?? ""),
177
+ payload: payload as Record<string, unknown>,
178
+ };
179
+ }
180
+
181
+ it("?next=/account&email=a%40b.example → the authorize URL carries login_hint; the flow cookie does not", () => {
182
+ const { authorize, payload } = startWith("next=/account&email=a%40b.example");
183
+ expect(authorize.searchParams.get("login_hint")).toBe("a@b.example");
184
+ expect(authorize.search).toContain("login_hint=a%40b.example");
185
+ // The six keys are untouched beside it.
186
+ expect(authorize.searchParams.get("code_challenge_method")).toBe("S256");
187
+ expect(authorize.searchParams.getAll("login_hint")).toHaveLength(1);
188
+ expect(payload.next).toBe("/account");
189
+ expect(payload).not.toHaveProperty("email");
190
+ expect(payload).not.toHaveProperty("login_hint");
191
+ expect(JSON.stringify(payload)).not.toContain("a@b.example");
192
+ });
193
+
194
+ it.each([
195
+ ["an invalid value", "next=/account&email=bad"],
196
+ ["a repeated value", "next=/account&email=a%40b.example&email=c%40d.example"],
197
+ ["a value carrying a separator", "next=/account&email=a%40b.example%26x%3D1"],
198
+ ["no value", "next=/account"],
199
+ ])("%s → no login_hint", (_label, query) => {
200
+ const { authorize, payload } = startWith(query);
201
+ expect(authorize.searchParams.has("login_hint")).toBe(false);
202
+ expect(payload).not.toHaveProperty("email");
203
+ expect(payload).not.toHaveProperty("login_hint");
204
+ });
205
+ });
206
+
164
207
  describe("GET /api/hub-session/callback", () => {
165
208
  function exchangeOk(): void {
166
209
  mockExchange.mockResolvedValue({
@@ -10,10 +10,17 @@
10
10
  * - not a hub-mode spoke (`SIGN_IN_PROVIDER` is not `"hub"`): 404;
11
11
  * - hub mode but not configured: 303 to `/account`, whose gate says so.
12
12
  *
13
+ * Hinted email (#8757 D9): a single `?email=` that `accountEmailHintSchema`
14
+ * accepts rides to the hub as `login_hint`, so its sign-in form opens with
15
+ * the address typed. A refused or repeated value is dropped. The hint goes on
16
+ * the authorize URL only — never into the sealed flow cookie.
17
+ *
13
18
  * Nothing here logs the state, the verifier or the challenge.
14
19
  */
15
20
  import { createHash, randomBytes } from "node:crypto";
16
21
 
22
+ import { accountEmailHintSchema } from "@working-theory/validation";
23
+
17
24
  import { hubSessionConfig } from "../../../../lib/hub-session/config";
18
25
  import { seal } from "../../../../lib/hub-session/seal";
19
26
  import {
@@ -40,13 +47,22 @@ function see(location: string, setCookie?: string): Response {
40
47
  return new Response(null, { status: 303, headers });
41
48
  }
42
49
 
50
+ /** #8757 D9 — the one `email` the hint schema accepts; repeated or refused → none. */
51
+ function emailHint(params: URLSearchParams): string | undefined {
52
+ const values = params.getAll("email");
53
+ if (values.length !== 1) return undefined;
54
+ const parsed = accountEmailHintSchema.safeParse(values[0]);
55
+ return parsed.success ? parsed.data : undefined;
56
+ }
57
+
43
58
  // platform-decisions-waiver: api-versioning — sign-in redirect endpoint, not a versioned API contract (spec 7894 §7)
44
59
  export function GET(request: Request): Response {
45
60
  if (SIGN_IN_PROVIDER !== "hub") return new Response(null, { status: 404 });
46
61
  const config = hubSessionConfig();
47
62
  if (config === null) return see(HUB_SESSION_FALLBACK_PATH);
48
63
 
49
- const next = hubNextPath(new URL(request.url).searchParams.get("next"));
64
+ const params = new URL(request.url).searchParams;
65
+ const next = hubNextPath(params.get("next"));
50
66
  const state = randomBytes(32).toString("base64url");
51
67
  const verifier = randomBytes(32).toString("base64url");
52
68
  const challenge = createHash("sha256").update(verifier).digest("base64url");
@@ -60,14 +76,17 @@ export function GET(request: Request): Response {
60
76
  );
61
77
 
62
78
  const authorize = new URL(AUTHORIZE_PATH, config.hubOrigin);
63
- authorize.search = new URLSearchParams({
79
+ const query = new URLSearchParams({
64
80
  response_type: "code",
65
81
  client_id: config.clientId,
66
82
  redirect_uri: config.redirectUri,
67
83
  state,
68
84
  code_challenge: challenge,
69
85
  code_challenge_method: "S256",
70
- }).toString();
86
+ });
87
+ const loginHint = emailHint(params);
88
+ if (loginHint !== undefined) query.set("login_hint", loginHint);
89
+ authorize.search = query.toString();
71
90
 
72
91
  return see(
73
92
  authorize.toString(),
@@ -0,0 +1,92 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * The standard Stripe webhook 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: its verification, filters, retries and
10
+ * duplicate guard are proven by its own suites. These cases prove the route
11
+ * is wiring only — built once from `spokeCheckoutConfig`, serving the
12
+ * package's `webhook` handler on a spoke and a 404 anywhere else.
13
+ */
14
+ import { readFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+
17
+ const mockWebhookPost = jest.fn();
18
+ const mockCreateSpokeCheckout = jest.fn((_config: unknown) => ({
19
+ webhook: { POST: mockWebhookPost },
20
+ }));
21
+ // `virtual`: CI runs this suite before the package's dist is built, so it
22
+ // may not resolve at all — the factory stands in either way.
23
+ jest.mock(
24
+ "@working-theory/spoke-checkout",
25
+ () => ({
26
+ __esModule: true,
27
+ createSpokeCheckout: (config: unknown) => mockCreateSpokeCheckout(config),
28
+ }),
29
+ { virtual: true },
30
+ );
31
+
32
+ const mockTopology: { value: "hub" | "spoke" } = { value: "spoke" };
33
+ // 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.
34
+ jest.mock("../../../../../lib/topology.generated", () => ({
35
+ __esModule: true,
36
+ get TOPOLOGY() {
37
+ return mockTopology.value;
38
+ },
39
+ }));
40
+
41
+ import { checkoutConfig } from "../../../../../lib/checkout-config";
42
+ import { spokeCheckoutConfig } from "../../../../../lib/checkout";
43
+ import { POST } from "../route";
44
+
45
+ const ROUTE_SOURCE = readFileSync(join(__dirname, "..", "route.ts"), "utf8");
46
+
47
+ function delivery(): Request {
48
+ return new Request("https://acme.example.com/api/webhooks/stripe", {
49
+ method: "POST",
50
+ body: "{}",
51
+ });
52
+ }
53
+
54
+ describe("the standard Stripe webhook route (#8748 AC-2)", () => {
55
+ beforeEach(() => {
56
+ mockWebhookPost.mockReset();
57
+ });
58
+
59
+ it("answers 404 with no-store on a hub, and never builds the package's handlers (D11)", async () => {
60
+ mockTopology.value = "hub";
61
+ const res = await POST(delivery());
62
+ expect(res.status).toBe(404);
63
+ expect(res.headers.get("cache-control")).toBe("no-store");
64
+ expect(mockCreateSpokeCheckout).not.toHaveBeenCalled();
65
+ expect(mockWebhookPost).not.toHaveBeenCalled();
66
+ });
67
+
68
+ it("serves the package's webhook handler on a spoke, built once from spokeCheckoutConfig", async () => {
69
+ mockTopology.value = "spoke";
70
+ const answered = new Response(null, { status: 200 });
71
+ mockWebhookPost.mockResolvedValue(answered);
72
+
73
+ const first = delivery();
74
+ expect(await POST(first)).toBe(answered);
75
+ expect(mockWebhookPost).toHaveBeenCalledWith(first);
76
+ const second = delivery();
77
+ expect(await POST(second)).toBe(answered);
78
+ expect(mockWebhookPost).toHaveBeenLastCalledWith(second);
79
+
80
+ expect(mockCreateSpokeCheckout).toHaveBeenCalledTimes(1);
81
+ const [config] = mockCreateSpokeCheckout.mock.calls[0] as [
82
+ typeof spokeCheckoutConfig,
83
+ ];
84
+ expect(config).toBe(spokeCheckoutConfig);
85
+ expect(config.offerings).toBe(checkoutConfig.offerings);
86
+ });
87
+
88
+ it("reads no Stripe setting and imports no Stripe client (AC-4)", () => {
89
+ expect(ROUTE_SOURCE).not.toContain("STRIPE_");
90
+ expect(ROUTE_SOURCE).not.toContain("stripe-client");
91
+ });
92
+ });
@@ -0,0 +1,31 @@
1
+ // The Stripe webhook route — the standard spoke checkout (#8748), kept current
2
+ // by `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 checkout
4
+ // data is lib/checkout-config.ts, the one file to edit. It answers 404 on any
5
+ // build that is not a spoke.
6
+ import { createSpokeCheckout } from "@working-theory/spoke-checkout";
7
+
8
+ import {
9
+ checkoutNotServed,
10
+ checkoutServed,
11
+ spokeCheckoutConfig,
12
+ } from "../../../../lib/checkout";
13
+
14
+ export const runtime = "nodejs";
15
+ export const dynamic = "force-dynamic";
16
+
17
+ type WebhookRoute = ReturnType<typeof createSpokeCheckout>["webhook"];
18
+
19
+ let webhook: WebhookRoute | undefined;
20
+
21
+ /** Built on the first served request and kept for every later one. */
22
+ function webhookRoute(): WebhookRoute {
23
+ webhook ??= createSpokeCheckout(spokeCheckoutConfig).webhook;
24
+ return webhook;
25
+ }
26
+
27
+ // platform-decisions-waiver: api-versioning — the endpoint URL registered in Stripe, not a versioned API contract (spec 8748 §7)
28
+ export async function POST(request: Request): Promise<Response> {
29
+ if (!checkoutServed()) return checkoutNotServed();
30
+ return webhookRoute().POST(request);
31
+ }
@@ -43,6 +43,11 @@ import {
43
43
  isSignUpOpen,
44
44
  resetSendConfigured,
45
45
  } from "./lib/auth-env";
46
+ import {
47
+ signUpAgreementHooks,
48
+ signUpAgreementsPlugin,
49
+ withAuthTransactionCapture,
50
+ } from "./lib/sign-up-agreements";
46
51
 
47
52
  /**
48
53
  * Reset-token lifetime (#5962 § D11). One hour — the library's own default,
@@ -438,9 +443,25 @@ export function createAuth(deps: CreateAuthDeps = {}) {
438
443
  };
439
444
 
440
445
  const instance = betterAuth({
441
- database: prismaAdapter(prisma, {
446
+ /**
447
+ * `transaction: true` (#8765 D4): Better Auth opens one `$transaction` for
448
+ * exactly its three `runWithTransaction` sites — `/sign-up/email`,
449
+ * `createOAuthUser` and verification-token consumption — so a user, its
450
+ * account and its sign-up grants commit together or not at all. Every
451
+ * other write stays autocommit. The capture wrapper exposes that
452
+ * transaction's client to the sign-up hooks below; it changes nothing
453
+ * about the transaction itself (lib/sign-up-agreements.ts).
454
+ */
455
+ database: prismaAdapter(withAuthTransactionCapture(prisma), {
442
456
  provider: "postgresql",
457
+ transaction: true,
443
458
  }),
459
+ /**
460
+ * The sign-up grants (#8765 D4): the user hook decides whether the new
461
+ * user carries agreements, the account hook writes them on the captured
462
+ * transaction client. No other database hook is installed.
463
+ */
464
+ databaseHooks: signUpAgreementHooks(),
444
465
  secret: resolveSecret(),
445
466
  baseURL: process.env.BETTER_AUTH_URL,
446
467
  socialProviders: buildSocialProviders(),
@@ -653,13 +674,18 @@ export function createAuth(deps: CreateAuthDeps = {}) {
653
674
  * Sign-in only — the callback mails existing users and nobody else, and
654
675
  * `disableSignUp` refuses to create one even for a live token
655
676
  * (lib/auth-email.ts).
677
+ *
678
+ * `signUpAgreementsPlugin` always (#8765 D3): while sign-up is open,
679
+ * `/sign-up/email` without every sign-up agreement is refused before the
680
+ * handler looks the address up.
656
681
  */
657
682
  plugins: magicLinkActive
658
683
  ? [
659
684
  nextCookies(),
685
+ signUpAgreementsPlugin(),
660
686
  magicLink(makeMagicLinkOptions({ send: authEmailTransport })),
661
687
  ]
662
- : [nextCookies()],
688
+ : [nextCookies(), signUpAgreementsPlugin()],
663
689
  });
664
690
  return instance;
665
691
  }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * account-invite.test.ts — #8757 D8 / AC-5: the /contact thank-you's account
3
+ * link, by the generated sign-in provider. A site signs in at its own
4
+ * locale-prefixed `/sign-in` and lands on its own `/account`; a hub-sign-in
5
+ * spoke starts at `/api/hub-session/sign-in`; a site with no sign-in gets no
6
+ * link, and neither does an email the hint schema refuses.
7
+ */
8
+
9
+ let mockProvider: "site" | "hub" | "none" = "site";
10
+ // eslint-disable-next-line no-restricted-syntax -- #507: SIGN_IN_PROVIDER is a generated per-build constant (#8757 D8), not a runtime input; no env or HTTP surface selects it.
11
+ jest.mock("../topology.generated", () => ({
12
+ __esModule: true,
13
+ get SIGN_IN_PROVIDER() {
14
+ return mockProvider;
15
+ },
16
+ }));
17
+
18
+ import { accountInviteHref } from "../account-invite";
19
+
20
+ afterEach(() => {
21
+ mockProvider = "site";
22
+ });
23
+
24
+ describe("accountInviteHref (#8757 D8)", () => {
25
+ it("site, the default locale: the bare /sign-in with next=/account and the email", () => {
26
+ expect(accountInviteHref("en", "a@b.example")).toBe(
27
+ "/sign-in?next=%2Faccount&email=a%40b.example",
28
+ );
29
+ });
30
+
31
+ it("site, another locale: both the sign-in path and next carry the prefix", () => {
32
+ expect(accountInviteHref("fr", "a@b.example")).toBe(
33
+ "/fr/sign-in?next=%2Ffr%2Faccount&email=a%40b.example",
34
+ );
35
+ });
36
+
37
+ it("site: a plus sign in the address is encoded, never read as a space", () => {
38
+ expect(accountInviteHref("en", "a+b@x.example")).toBe(
39
+ "/sign-in?next=%2Faccount&email=a%2Bb%40x.example",
40
+ );
41
+ });
42
+
43
+ it("hub: the spoke's hub-session start, unprefixed", () => {
44
+ mockProvider = "hub";
45
+ expect(accountInviteHref("fr", "a@b.example")).toBe(
46
+ "/api/hub-session/sign-in?next=%2Faccount&email=a%40b.example",
47
+ );
48
+ });
49
+
50
+ it("none: no link", () => {
51
+ mockProvider = "none";
52
+ expect(accountInviteHref("en", "a@b.example")).toBeNull();
53
+ });
54
+
55
+ it.each(["x\r\n@y.example", "a&next=/evil@b.example", "not-an-email", ""])(
56
+ "an email the hint schema refuses (%j) gives no link, on either provider",
57
+ (email) => {
58
+ expect(accountInviteHref("en", email)).toBeNull();
59
+ mockProvider = "hub";
60
+ expect(accountInviteHref("en", email)).toBeNull();
61
+ },
62
+ );
63
+ });