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,447 @@
1
+ /**
2
+ * Sign-up agreements, refused and recorded on the server (#8765 D3, D4, spec
3
+ * docs/specs/8765-sign-up-refuses-without-the-spec.md).
4
+ *
5
+ * THREE PIECES, ONE FILE. `auth.ts` installs all three:
6
+ *
7
+ * signUpAgreementsPlugin() a Better Auth plugin. While `isSignUpOpen()`,
8
+ * `POST /sign-up/email` without every sign-up
9
+ * agreement in its `agreements` body key is
10
+ * refused `400 AGREEMENTS_REQUIRED` — BEFORE
11
+ * the handler's user lookup, so the answer is
12
+ * the same for a new and a taken address
13
+ * (NFR2). The OAuth callback only warms the
14
+ * evidence cache, outside the transaction.
15
+ * withAuthTransactionCapture() wraps the client the Prisma adapter holds,
16
+ * so the transaction client Better Auth's
17
+ * sign-up transaction runs on is reachable
18
+ * from a database hook.
19
+ * signUpAgreementHooks() two database hooks: the user hook decides
20
+ * whether this request's new user carries
21
+ * agreements; the account hook writes one
22
+ * `sign_up` grant per agreement on the
23
+ * captured transaction client, so the User,
24
+ * its Account and the grants commit together
25
+ * or not at all (NFR1).
26
+ *
27
+ * THE EVIDENCE IS THE API'S. Each grant's statement, hash and version come
28
+ * from `GET /v1/consent/sign-up` — built by the same functions `apps/api`'s
29
+ * `services/consent.ts` stamps with — never from this app's copy of the
30
+ * registry. It is cached per process: fresh for 60 s, the last good entry
31
+ * served for up to 10 minutes while a refresh fails, at most one entry per
32
+ * enabled locale (SEC-8765-D). No usable entry → `503
33
+ * AGREEMENTS_UNAVAILABLE`: no user is created without evidence. The database
34
+ * hooks run INSIDE the transaction and never call the network; they read the
35
+ * cache only (SEC-8765-I).
36
+ *
37
+ * THE SECOND LEDGER WRITER. `services/consent.ts` writes every other row.
38
+ * This hook writes only `sign_up` rows for a user it is creating in the same
39
+ * transaction, so `consent.ts`'s advisory lock and write-only-on-change rule
40
+ * have nothing to guard here. It reaches the database through the auth
41
+ * client apps/web already holds as its one documented exception
42
+ * (`lib/prisma-for-auth.ts`), and imports no `@working-theory/database`
43
+ * value.
44
+ *
45
+ * NOT EVERY NEW USER HAS A GRANT (SEC-8765-C). A first social sign-in from
46
+ * `/sign-in`, or the `idToken` branch, carries no agreements; Better Auth
47
+ * still creates the user, and the account interstitial asks before the
48
+ * account loads.
49
+ *
50
+ * `better-auth/api` is imported lazily, inside the hooks: it is ESM-only, and
51
+ * the CommonJS Jest suites that build `createAuth()` mock only the
52
+ * `better-auth` modules `auth.ts` itself imports. Precedent for the file's
53
+ * shape: `lib/auth-email.ts`.
54
+ */
55
+
56
+ import { AsyncLocalStorage } from "node:async_hooks";
57
+
58
+ import { consent } from "@working-theory/api-client";
59
+ import {
60
+ consentSignUpResponseSchema,
61
+ SIGN_UP_AGREEMENTS_FIELD,
62
+ signUpAgreementsSchema,
63
+ type ConsentSignUpResponse,
64
+ } from "@working-theory/validation";
65
+ import type { BetterAuthOptions, BetterAuthPlugin } from "better-auth";
66
+
67
+ import { isSignUpOpen } from "./auth-env";
68
+
69
+ const SIGN_UP_PATH = "/sign-up/email";
70
+ const CALLBACK_PREFIX = "/callback/";
71
+ const DEFAULT_LOCALE = "en";
72
+ const FRESH_MS = 60_000;
73
+ const STALE_MS = 10 * 60_000;
74
+ /** The longest one evidence read may hold a sign-up or a sign-in. */
75
+ const READ_TIMEOUT_MS = 2_000;
76
+
77
+ // ── The evidence cache ──────────────────────────────────────────────────────
78
+
79
+ interface CacheEntry {
80
+ evidence: ConsentSignUpResponse;
81
+ fetchedAt: number;
82
+ }
83
+
84
+ const cache = new Map<string, CacheEntry>();
85
+ const refreshing = new Map<string, Promise<ConsentSignUpResponse | null>>();
86
+ let clock: () => number = Date.now;
87
+
88
+ /** Test seam: empties the per-process cache and sets the clock it reads. */
89
+ export function resetSignUpEvidenceCache(now: () => number = Date.now): void {
90
+ cache.clear();
91
+ refreshing.clear();
92
+ clock = now;
93
+ }
94
+
95
+ /**
96
+ * A body or state `locale` counts only when it is one of the site's enabled
97
+ * locales — the list `middleware.ts` routes. Anything else reads as `en`, so
98
+ * the cache holds at most one entry per enabled locale (SEC-8765-D).
99
+ */
100
+ function normaliseLocale(raw: unknown): string {
101
+ return typeof raw === "string" && enabledLocales().includes(raw)
102
+ ? raw
103
+ : DEFAULT_LOCALE;
104
+ }
105
+
106
+ function enabledLocales(): string[] {
107
+ const enabled = (process.env.NEXT_PUBLIC_ENABLED_LOCALES ?? DEFAULT_LOCALE)
108
+ .split(",")
109
+ .map((locale) => locale.trim())
110
+ .filter((locale) => locale.length > 0);
111
+ return enabled.length > 0 ? enabled : [DEFAULT_LOCALE];
112
+ }
113
+
114
+ /** One read of the API's evidence. Never throws: a failure is `null`. */
115
+ async function fetchEvidence(
116
+ locale: string,
117
+ ): Promise<ConsentSignUpResponse | null> {
118
+ // Bounded, so a slow API never holds a sign-up or an OAuth callback: a
119
+ // timeout is a failed read (sign-up fails closed; the interstitial asks an
120
+ // OAuth user later).
121
+ let timer: ReturnType<typeof setTimeout> | undefined;
122
+ const timedOut = new Promise<null>((resolve) => {
123
+ timer = setTimeout(() => resolve(null), READ_TIMEOUT_MS);
124
+ });
125
+ try {
126
+ const res = await Promise.race([
127
+ consent.signUpEvidence({ locale }),
128
+ timedOut,
129
+ ]);
130
+ if (res === null || !res.ok) return null;
131
+ const parsed = consentSignUpResponseSchema.safeParse(res.body);
132
+ if (!parsed.success) return null;
133
+ cache.set(locale, { evidence: parsed.data, fetchedAt: clock() });
134
+ return parsed.data;
135
+ } catch {
136
+ return null;
137
+ } finally {
138
+ clearTimeout(timer);
139
+ }
140
+ }
141
+
142
+ /** Concurrent misses for one locale share one read. */
143
+ function refresh(locale: string): Promise<ConsentSignUpResponse | null> {
144
+ const pending = refreshing.get(locale);
145
+ if (pending) return pending;
146
+ const read = fetchEvidence(locale).finally(() => refreshing.delete(locale));
147
+ refreshing.set(locale, read);
148
+ return read;
149
+ }
150
+
151
+ /** The cached entry while it may still be served (≤ 10 min old), no network. */
152
+ function cachedEvidence(locale: string): ConsentSignUpResponse | null {
153
+ const entry = cache.get(locale);
154
+ return entry && clock() - entry.fetchedAt <= STALE_MS ? entry.evidence : null;
155
+ }
156
+
157
+ /** Fresh evidence, refreshing when older than 60 s; stale on a failed refresh. */
158
+ async function readEvidence(
159
+ locale: string,
160
+ ): Promise<ConsentSignUpResponse | null> {
161
+ const entry = cache.get(locale);
162
+ if (entry && clock() - entry.fetchedAt < FRESH_MS) return entry.evidence;
163
+ return (await refresh(locale)) ?? cachedEvidence(locale);
164
+ }
165
+
166
+ /** Whether `raw` names every purpose the evidence asks for. */
167
+ function coversEvidence(
168
+ raw: unknown,
169
+ evidence: ConsentSignUpResponse,
170
+ ): boolean {
171
+ const parsed = signUpAgreementsSchema.safeParse(raw);
172
+ if (!parsed.success) return false;
173
+ return evidence.agreements.every((agreement) =>
174
+ parsed.data.includes(agreement.purposeId),
175
+ );
176
+ }
177
+
178
+ async function apiError(
179
+ status: "BAD_REQUEST" | "SERVICE_UNAVAILABLE",
180
+ code: "AGREEMENTS_REQUIRED" | "AGREEMENTS_UNAVAILABLE",
181
+ message: string,
182
+ ): Promise<Error> {
183
+ const { APIError } = await import("better-auth/api");
184
+ return new APIError(status, { code, message });
185
+ }
186
+
187
+ const agreementsRequired = (): Promise<Error> =>
188
+ apiError(
189
+ "BAD_REQUEST",
190
+ "AGREEMENTS_REQUIRED",
191
+ "Accept the agreements to create an account.",
192
+ );
193
+
194
+ const agreementsUnavailable = (): Promise<Error> =>
195
+ apiError(
196
+ "SERVICE_UNAVAILABLE",
197
+ "AGREEMENTS_UNAVAILABLE",
198
+ "The sign-up agreements could not be read. Try again in a moment.",
199
+ );
200
+
201
+ /** A request body, as the hooks see it: never trusted, read by key. */
202
+ function bodyField(body: unknown, key: string): unknown {
203
+ return typeof body === "object" && body !== null
204
+ ? Reflect.get(body, key)
205
+ : undefined;
206
+ }
207
+
208
+ // ── D3 — the plugin ─────────────────────────────────────────────────────────
209
+
210
+ /**
211
+ * Refuses `POST /sign-up/email` without the agreements (D3), and warms the
212
+ * evidence cache on the OAuth callback so the user hook below finds it
213
+ * without a network call inside the transaction (SEC-8765-I).
214
+ */
215
+ export function signUpAgreementsPlugin(): BetterAuthPlugin {
216
+ return {
217
+ id: "sign-up-agreements",
218
+ hooks: {
219
+ before: [
220
+ {
221
+ matcher: (context) => context.path === SIGN_UP_PATH,
222
+ handler: async (context) => {
223
+ // Closed sign-up refuses in the handler itself (`disableSignUp`).
224
+ if (!isSignUpOpen()) return;
225
+ const body: unknown = context.body;
226
+ const evidence = await readEvidence(
227
+ normaliseLocale(bodyField(body, "locale")),
228
+ );
229
+ if (!evidence) throw await agreementsUnavailable();
230
+ if (evidence.agreements.length === 0) return;
231
+ if (
232
+ !coversEvidence(
233
+ bodyField(body, SIGN_UP_AGREEMENTS_FIELD),
234
+ evidence,
235
+ )
236
+ ) {
237
+ throw await agreementsRequired();
238
+ }
239
+ },
240
+ },
241
+ {
242
+ matcher: (context) =>
243
+ context.path?.startsWith(CALLBACK_PREFIX) ?? false,
244
+ // Warms only, and only while sign-up is open. The state — and so
245
+ // the locale the card sent — is parsed inside the handler, so every
246
+ // enabled locale is warmed; each is read at most once a minute and
247
+ // each read is bounded. Never refuses.
248
+ handler: async () => {
249
+ if (!isSignUpOpen()) return;
250
+ await Promise.all(
251
+ enabledLocales().map((locale) => readEvidence(locale)),
252
+ );
253
+ },
254
+ },
255
+ ],
256
+ },
257
+ };
258
+ }
259
+
260
+ // ── D4 — the transaction capture ────────────────────────────────────────────
261
+
262
+ const capturedTx = new AsyncLocalStorage<unknown>();
263
+
264
+ /**
265
+ * Wraps the client the Prisma adapter holds. Its `$transaction(fn, options)`
266
+ * calls the client's own `$transaction` and runs `fn(tx)` inside an
267
+ * `AsyncLocalStorage` holding `tx`; every other member is the client's own.
268
+ * The capture never changes what a transaction does: options pass through
269
+ * unchanged, and the array form is delegated as is.
270
+ *
271
+ * A client with no `$transaction` member (a test double) gets `fn(client)` —
272
+ * what the adapter did for it before `transaction: true` — and no `tx` is
273
+ * captured, so a sign-up with agreements fails closed on it.
274
+ */
275
+ export function withAuthTransactionCapture<Client extends object>(
276
+ client: Client,
277
+ ): Client {
278
+ const $transaction = (
279
+ fn: unknown,
280
+ ...options: unknown[]
281
+ ): Promise<unknown> => {
282
+ const own: unknown = Reflect.get(client, "$transaction");
283
+ if (typeof fn !== "function") {
284
+ return Promise.resolve(
285
+ typeof own === "function"
286
+ ? Reflect.apply(own, client, [fn, ...options])
287
+ : undefined,
288
+ );
289
+ }
290
+ if (typeof own !== "function") {
291
+ return Promise.resolve().then(() =>
292
+ Reflect.apply(fn, undefined, [client]),
293
+ );
294
+ }
295
+ const run = (tx: unknown): unknown =>
296
+ capturedTx.run(tx, () => Reflect.apply(fn, undefined, [tx]));
297
+ return Promise.resolve(Reflect.apply(own, client, [run, ...options]));
298
+ };
299
+
300
+ return new Proxy(client, {
301
+ get: (target, member) =>
302
+ member === "$transaction" ? $transaction : Reflect.get(target, member),
303
+ });
304
+ }
305
+
306
+ // ── D4 — the database hooks ─────────────────────────────────────────────────
307
+
308
+ /** The one delegate method the account hook calls on the transaction client. */
309
+ interface ConsentGrantWriter {
310
+ consentGrant: {
311
+ createMany(args: { data: ConsentGrantRow[] }): Promise<unknown>;
312
+ };
313
+ }
314
+
315
+ interface ConsentGrantRow {
316
+ siteId: string;
317
+ workspaceId: string;
318
+ subjectType: "user";
319
+ subjectId: string;
320
+ personId: null;
321
+ purposeId: string;
322
+ granted: true;
323
+ source: "sign_up";
324
+ sourceRef: null;
325
+ policyVersion: number;
326
+ locale: string;
327
+ statement: string;
328
+ statementHash: string;
329
+ }
330
+
331
+ function isConsentGrantWriter(tx: unknown): tx is ConsentGrantWriter {
332
+ if (typeof tx !== "object" || tx === null) return false;
333
+ const delegate: unknown = Reflect.get(tx, "consentGrant");
334
+ return (
335
+ typeof delegate === "object" &&
336
+ delegate !== null &&
337
+ typeof Reflect.get(delegate, "createMany") === "function"
338
+ );
339
+ }
340
+
341
+ type DatabaseHooks = NonNullable<BetterAuthOptions["databaseHooks"]>;
342
+ type HookContext = Parameters<
343
+ NonNullable<
344
+ NonNullable<NonNullable<DatabaseHooks["user"]>["create"]>["before"]
345
+ >
346
+ >[1];
347
+
348
+ /**
349
+ * The evidence the user being created agreed to, or `null` when none is to be
350
+ * recorded. Reads the cache only — the transaction is already open.
351
+ */
352
+ async function agreedEvidence(
353
+ context: NonNullable<HookContext>,
354
+ ): Promise<ConsentSignUpResponse | null> {
355
+ const path = typeof context.path === "string" ? context.path : "";
356
+
357
+ if (path === SIGN_UP_PATH) {
358
+ if (!isSignUpOpen()) return null;
359
+ const evidence = cachedEvidence(
360
+ normaliseLocale(bodyField(context.body, "locale")),
361
+ );
362
+ // The entry aged out between the plugin and here: fail closed.
363
+ if (!evidence) throw await agreementsUnavailable();
364
+ if (evidence.agreements.length === 0) return null;
365
+ // The plugin checked the body against the entry it read; a refresh since
366
+ // may have changed it, so the evidence recorded is checked again here.
367
+ if (
368
+ !coversEvidence(
369
+ bodyField(context.body, SIGN_UP_AGREEMENTS_FIELD),
370
+ evidence,
371
+ )
372
+ ) {
373
+ throw await agreementsRequired();
374
+ }
375
+ return evidence;
376
+ }
377
+
378
+ if (path.startsWith(CALLBACK_PREFIX)) {
379
+ const { getOAuthState } = await import("better-auth/api");
380
+ const state: unknown = await getOAuthState().catch(() => null);
381
+ const evidence = cachedEvidence(
382
+ normaliseLocale(bodyField(state, "locale")),
383
+ );
384
+ if (!evidence || evidence.agreements.length === 0) return null;
385
+ return coversEvidence(bodyField(state, SIGN_UP_AGREEMENTS_FIELD), evidence)
386
+ ? evidence
387
+ : null;
388
+ }
389
+
390
+ return null;
391
+ }
392
+
393
+ /**
394
+ * The two database hooks (D4). They share one request's context object —
395
+ * Better Auth hands both the endpoint context it runs the handler in — and
396
+ * that object keys the `WeakMap` between them.
397
+ */
398
+ export function signUpAgreementHooks(): DatabaseHooks {
399
+ const pending = new WeakMap<object, ConsentSignUpResponse>();
400
+
401
+ return {
402
+ user: {
403
+ create: {
404
+ // Changes nothing on the user; only remembers what it agreed to.
405
+ before: async (_user, context) => {
406
+ if (!context) return;
407
+ const evidence = await agreedEvidence(context);
408
+ if (evidence) pending.set(context, evidence);
409
+ },
410
+ },
411
+ },
412
+ account: {
413
+ create: {
414
+ // Set only in a request creating a user, whose first account this is.
415
+ before: async (account, context) => {
416
+ if (!context) return;
417
+ const evidence = pending.get(context);
418
+ if (!evidence) return;
419
+ pending.delete(context);
420
+ const tx = capturedTx.getStore();
421
+ if (!isConsentGrantWriter(tx)) {
422
+ throw new Error(
423
+ "sign-up agreements: no transaction client captured; refusing to create the user without its grants",
424
+ );
425
+ }
426
+ await tx.consentGrant.createMany({
427
+ data: evidence.agreements.map((agreement) => ({
428
+ siteId: evidence.workspaceId,
429
+ workspaceId: evidence.workspaceId,
430
+ subjectType: "user",
431
+ subjectId: account.userId,
432
+ personId: null,
433
+ purposeId: agreement.purposeId,
434
+ granted: true,
435
+ source: "sign_up",
436
+ sourceRef: null,
437
+ policyVersion: agreement.policyVersion,
438
+ locale: agreement.locale,
439
+ statement: agreement.statement,
440
+ statementHash: agreement.statementHash,
441
+ })),
442
+ });
443
+ },
444
+ },
445
+ },
446
+ };
447
+ }
@@ -16,6 +16,7 @@
16
16
  // - innerPages: "bare" → every non-home page renders the bare header and
17
17
  // the one-bar footer.
18
18
  // - deckPresentation → the deck home presents slide by slide: fitted slides, slide anchors, the mark follows the ground
19
+ // - deckAnchors: "bare" → the presenting deck names its slides #<id> instead of #slide-<id>
19
20
  // - colorMode: "light" → the site is pinned to light and shows no theme
20
21
  // switch; dark appears only as a deck ground.
21
22
  // - chromeStyle: "minimal" → the deck's closing slide is the wordmark,
@@ -31,6 +32,7 @@ export interface SiteChrome {
31
32
  home: "default" | "deck";
32
33
  innerPages?: "bare";
33
34
  deckPresentation?: true;
35
+ deckAnchors?: "bare";
34
36
  colorMode?: "light";
35
37
  chromeStyle?: "minimal";
36
38
  closingFooterPaths?: readonly string[];
@@ -9,10 +9,14 @@
9
9
  // `/purchase/success?session_id=cs_…` is the SAME class of bearer capability
10
10
  // — the exact defect #5562 fixed for `?g=`, recurring on a second surface.
11
11
  //
12
+ // `email` and `login_hint` joined by #8757 (D9, SEC-8757-C): an account link
13
+ // carries the submitter's address as `/sign-in?email=…` and the hub's
14
+ // authorize step as `login_hint=…` — personal data GA and Sentry must not get.
15
+ //
12
16
  // Mirrored (duplicated, not shared) in packages/analytics/src/scrub.ts,
13
17
  // since apps/* must not import @working-theory/* packages — keep both lists
14
18
  // in sync (#5613).
15
- export const TOKEN_QUERY_PARAMS = ["g", "session_id"];
19
+ export const TOKEN_QUERY_PARAMS = ["g", "session_id", "email", "login_hint"];
16
20
 
17
21
  // Sentry navigation breadcrumbs (`data.to`/`data.from`) are relative
18
22
  // (`path?search`, no origin) rather than the absolute URLs GA and
@@ -94,7 +94,8 @@
94
94
  },
95
95
  "careers": {
96
96
  "heading": "Careers",
97
- "emptyState": "Career listings coming soon."
97
+ "emptyState": "Career listings coming soon.",
98
+ "applicationSuccess": "Thank you for applying! We have your application and will be in touch."
98
99
  },
99
100
  "case-studies": {
100
101
  "heading": "Case Studies",
@@ -125,6 +126,7 @@
125
126
  "sending": "Sending…",
126
127
  "sendMessage": "Send message",
127
128
  "successMessage": "Thank you for reaching out! We will be in touch shortly.",
129
+ "trackInAccount": "Track this in your account",
128
130
  "consentStatement": "I accept the [Terms](terms), [Privacy Policy](privacy), and [Working Agreement](working_agreement).",
129
131
  "consentRequired": "Please accept the terms to send your message.",
130
132
  "genericError": "Something went wrong, please try again.",
@@ -140,8 +142,12 @@
140
142
  "namePrompt": "What's your name?",
141
143
  "messageAck": "Good to meet you.",
142
144
  "messagePrompt": "What are you hoping to get done?",
143
- "emailAck": "Got it — last one.",
145
+ "emailAck": "Got it.",
144
146
  "emailPrompt": "What's the best email to reach you at?",
147
+ "consentAck": "Thanks — one last thing.",
148
+ "consentPrompt": "Do you accept our Terms, Privacy Policy and Working Agreement? They're linked at the foot of every page.",
149
+ "consentAccept": "Yes, I accept",
150
+ "consentDecline": "Not yet",
145
151
  "skipMessage": "No worries — here's our form instead.",
146
152
  "completionMessage": "Thanks! We'll be in touch shortly.",
147
153
  "submitError": "We couldn't send that automatically — please review and send below."
@@ -312,7 +318,8 @@
312
318
  },
313
319
  "checkoutThankYou": {
314
320
  "title": "Thank you",
315
- "body": "Your payment went through. We'll email you a receipt and the next steps."
321
+ "body": "Your payment went through. We'll email you a receipt and the next steps.",
322
+ "openAccount": "Open your account"
316
323
  },
317
324
  "checkoutUnavailable": {
318
325
  "title": "Checkout isn't available right now",
@@ -499,8 +506,7 @@
499
506
  "signIn": {
500
507
  "title": "Sign in",
501
508
  "description": "Sign in to your account.",
502
- "heading": "Sign in to your account",
503
- "subhead": "Welcome back. Choose how you'd like to continue.",
509
+ "heading": "Welcome back",
504
510
  "notConfiguredSubhead": "Sign-in is not configured for this site yet. Set a provider's environment variables to enable it.",
505
511
  "ssoPrefix": "Sign in with",
506
512
  "google": "Google",
@@ -520,7 +526,14 @@
520
526
  "emailLinkSentBody": "If an account uses that address, a sign-in link is on its way. It works once and expires in 5 minutes.",
521
527
  "emailLinkFailed": "We couldn't send a sign-in link. Try again.",
522
528
  "emailLinkRateLimited": "Too many requests. Wait a minute, then try again.",
523
- "emailLinkExpired": "That sign-in link has expired or was already used. Request a new one."
529
+ "emailLinkExpired": "That sign-in link has expired or was already used. Request a new one.",
530
+ "useEmailLink": "Email me a sign-in link instead",
531
+ "emailPlaceholder": "you@example.com",
532
+ "backTo": "Back to {site}",
533
+ "acknowledgement": "You acknowledge that you read and agree to our",
534
+ "termsLink": "Terms of Service",
535
+ "privacyLink": "Privacy Policy",
536
+ "workingAgreementLink": "Working Agreement"
524
537
  },
525
538
  "auth": {
526
539
  "signUpTitle": "Create an account",
@@ -546,6 +559,7 @@
546
559
  "signUpFooterPrompt": "Already have an account?",
547
560
  "signUpFooterLink": "Sign in",
548
561
  "signUpAgreementsLegend": "Agreements",
562
+ "signUpAgreementsRequired": "Accept the agreements to create an account.",
549
563
  "signUpNotConfiguredSubhead": "Sign-up is not configured for this site yet. Set a provider's environment variables to enable it.",
550
564
  "signUpFailed": "We couldn't create your account. Check your details and try again.",
551
565
  "providerFailed": "We couldn't start sign-up with that provider. Try again.",
@@ -852,7 +866,9 @@
852
866
  "agreements": {
853
867
  "titleNew": "Please accept our terms",
854
868
  "titleUpdated": "We've updated our terms",
869
+ "lead": "To keep using your account, accept the agreements below.",
855
870
  "accept": "Accept",
871
+ "signOut": "Sign out",
856
872
  "error": "We couldn't save that. Try again."
857
873
  },
858
874
  "gate": {
@@ -862,7 +878,8 @@
862
878
  "homeLink": "Back to the site",
863
879
  "hubHeadline": "Sign in to your account",
864
880
  "hubDescription": "You sign in on our main site, then come straight back here.",
865
- "hubSignIn": "Sign in"
881
+ "hubSignIn": "Sign in",
882
+ "title": "Sign in"
866
883
  },
867
884
  "profile": {
868
885
  "email": "Email",
@@ -30,6 +30,7 @@
30
30
  "@working-theory/schema-pack-registry": "workspace:*",
31
31
  "@working-theory/schema-validator": "workspace:*",
32
32
  "@working-theory/site-config": "workspace:*",
33
+ "@working-theory/spoke-checkout": "workspace:*",
33
34
  "@working-theory/templates": "workspace:*",
34
35
  "@working-theory/theme": "workspace:*",
35
36
  "@working-theory/ui": "workspace:*",