create-ailk 0.20.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 (222) hide show
  1. package/README.md +21 -1
  2. package/component-catalog.json +220 -8
  3. package/component-catalog.md +21 -9
  4. package/dist/add-module.js +9 -5
  5. package/dist/apply-design.d.ts +9 -0
  6. package/dist/apply-design.js +78 -31
  7. package/dist/derive-architecture.d.ts +8 -1
  8. package/dist/derive-architecture.js +38 -3
  9. package/dist/design-home.d.ts +54 -11
  10. package/dist/design-home.js +160 -22
  11. package/dist/design-package.js +7 -2
  12. package/dist/lib/design-checks.d.ts +6 -1
  13. package/dist/lib/design-checks.js +23 -2
  14. package/dist/lib/env-catalog.js +5 -1
  15. package/dist/module-architecture.js +4 -2
  16. package/dist/programmatic.js +63 -25
  17. package/dist/site-chrome.d.ts +8 -0
  18. package/dist/site-chrome.js +17 -1
  19. package/dist/site-roster.d.ts +4 -2
  20. package/dist/site-roster.js +7 -5
  21. package/dist/spoke-checkout.d.ts +39 -18
  22. package/dist/spoke-checkout.js +177 -236
  23. package/dist/starter-grid.js +16 -0
  24. package/dist/surfaces.js +30 -14
  25. package/dist/sync-routes.d.ts +28 -0
  26. package/dist/sync-routes.js +124 -1
  27. package/dist/template-record.d.ts +4 -3
  28. package/dist/template-record.js +6 -3
  29. package/dist/topology.js +25 -1
  30. package/dist/update.d.ts +6 -1
  31. package/dist/update.js +125 -18
  32. package/package.json +3 -3
  33. package/site-templates/_base/ailk-template.json +2 -2
  34. package/site-templates/_base/content/en/WorkingAgreementPage/working-agreement.mdx +78 -0
  35. package/site-templates/designs/minimalism/DESIGN.md +1 -1
  36. package/site-templates/designs/minimalism/contact/local-business-service.mdx +23 -0
  37. package/site-templates/designs/minimalism/contact/organization-product.mdx +23 -0
  38. package/site-templates/designs/minimalism/contact/organization-service.mdx +23 -0
  39. package/site-templates/designs/minimalism/contact/person-service.mdx +23 -0
  40. package/site-templates/marketing-site/TEMPLATE-MANIFEST.json +18 -1
  41. package/site-templates/marketing-site/TEMPLATE-MANIFEST.md +11 -0
  42. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.json +18 -1
  43. package/site-templates/marketing-site-free/TEMPLATE-MANIFEST.md +34 -23
  44. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.json +18 -1
  45. package/site-templates/shapes/local-business-service/TEMPLATE-MANIFEST.md +33 -23
  46. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.json +18 -1
  47. package/site-templates/shapes/organization-service/TEMPLATE-MANIFEST.md +33 -23
  48. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.json +18 -1
  49. package/site-templates/shapes/person-service/TEMPLATE-MANIFEST.md +31 -21
  50. package/templates/README.md +26 -25
  51. package/templates/apps/api/CLAUDE.md +20 -7
  52. package/templates/apps/api/jest.config.cjs +1 -0
  53. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +19 -0
  54. package/templates/apps/api/src/lib/__tests__/flow-completion-store.test.ts +149 -0
  55. package/templates/apps/api/src/lib/consent-store.ts +28 -0
  56. package/templates/apps/api/src/lib/flow-completion-store.ts +136 -0
  57. package/templates/apps/api/src/lib/hub-sign-in-store.ts +38 -2
  58. package/templates/apps/api/src/lib/proposal-paid-store.ts +21 -4
  59. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +14 -3
  60. package/templates/apps/api/src/middleware/auth.ts +13 -6
  61. package/templates/apps/api/src/openapi/spec.ts +30 -3
  62. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +49 -0
  63. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +41 -0
  64. package/templates/apps/api/src/routes/consent/__tests__/account.test.ts +386 -0
  65. package/templates/apps/api/src/routes/consent/account.ts +266 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +15 -7
  67. package/templates/apps/api/src/routes/content/__tests__/get-page.test.ts +36 -0
  68. package/templates/apps/api/src/routes/content/get-page.ts +4 -0
  69. package/templates/apps/api/src/routes/leads/__tests__/magnet-completion.test.ts +515 -0
  70. package/templates/apps/api/src/routes/leads/index.ts +161 -7
  71. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +26 -0
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-checkout-consent.test.ts +649 -0
  73. package/templates/apps/api/src/routes/webhooks/stripe.ts +154 -19
  74. package/templates/apps/api/src/server.ts +28 -0
  75. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +1038 -118
  76. package/templates/apps/api/src/services/__tests__/magnet-completion.test.ts +389 -0
  77. package/templates/apps/api/src/services/checkout-buyer.ts +100 -4
  78. package/templates/apps/api/src/services/consent.ts +95 -0
  79. package/templates/apps/api/src/services/lead-routing.ts +337 -8
  80. package/templates/apps/api/src/services/magnet-completion.ts +242 -0
  81. package/templates/apps/api/src/services/person.ts +40 -8
  82. package/templates/apps/api/src/services/plan-offerings.ts +59 -0
  83. package/templates/apps/api/src/services/project-listings.ts +6 -1
  84. package/templates/apps/api/src/services/public-site-resolver.ts +8 -1
  85. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +69 -61
  86. package/templates/apps/mcp/jest.config.cjs +1 -0
  87. package/templates/apps/web/.env.example +14 -0
  88. package/templates/apps/web/CLAUDE.md +139 -3
  89. package/templates/apps/web/__tests__/__mocks__/next-intl-server.ts +6 -2
  90. package/templates/apps/web/app/[locale]/(auth)/__tests__/auth-pages.test.tsx +5 -4
  91. package/templates/apps/web/app/[locale]/(auth)/forgot-password/forgot-password-card.tsx +5 -1
  92. package/templates/apps/web/app/[locale]/(auth)/forgot-password/page.tsx +2 -0
  93. package/templates/apps/web/app/[locale]/(auth)/layout.tsx +3 -14
  94. package/templates/apps/web/app/[locale]/(auth)/reset-password/page.tsx +2 -0
  95. package/templates/apps/web/app/[locale]/(auth)/reset-password/reset-password-card.tsx +5 -1
  96. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/page.test.tsx +160 -8
  97. package/templates/apps/web/app/[locale]/(auth)/sign-in/__tests__/sign-in-card.test.tsx +12 -9
  98. package/templates/apps/web/app/[locale]/(auth)/sign-in/page.tsx +105 -2
  99. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-card.tsx +36 -3
  100. package/templates/apps/web/app/[locale]/(auth)/sign-in/sign-in-text.ts +47 -1
  101. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +60 -22
  102. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +16 -8
  103. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +85 -31
  104. package/templates/apps/web/app/[locale]/(auth)/use-auth-card-flow.ts +18 -4
  105. package/templates/apps/web/app/[locale]/(auth)/verify-email/page.tsx +3 -1
  106. package/templates/apps/web/app/[locale]/(authed)/__tests__/gate.test.tsx +7 -4
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +227 -207
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +17 -1
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +143 -1
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +4 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +385 -89
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +5 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +44 -5
  114. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +68 -34
  115. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +0 -8
  116. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +39 -151
  117. package/templates/apps/web/app/[locale]/(authed)/account/agreements-gate.ts +61 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +143 -140
  119. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +40 -6
  120. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +68 -54
  121. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +5 -1
  122. package/templates/apps/web/app/[locale]/about/[slug]/page.tsx +46 -0
  123. package/templates/apps/web/app/[locale]/about/__tests__/about-routes.test.tsx +350 -0
  124. package/templates/apps/web/app/[locale]/about/page.tsx +83 -1
  125. package/templates/apps/web/app/[locale]/careers/[slug]/apply.actions.ts +293 -0
  126. package/templates/apps/web/app/[locale]/careers/[slug]/job-application-form.ts +115 -0
  127. package/templates/apps/web/app/[locale]/careers/[slug]/page.tsx +146 -1
  128. package/templates/apps/web/app/[locale]/careers/__tests__/apply.actions.test.ts +310 -0
  129. package/templates/apps/web/app/[locale]/careers/__tests__/job-application-form.test.ts +143 -0
  130. package/templates/apps/web/app/[locale]/careers/__tests__/job-apply-page.test.tsx +350 -0
  131. package/templates/apps/web/app/[locale]/careers/__tests__/page-renderer-migration.test.tsx +29 -3
  132. package/templates/apps/web/app/[locale]/case-studies/__tests__/page-renderer-migration.test.tsx +2 -2
  133. package/templates/apps/web/app/[locale]/checkout/__tests__/pages.test.tsx +139 -0
  134. package/templates/apps/web/app/[locale]/checkout/__tests__/thank-you-account.test.tsx +136 -0
  135. package/templates/apps/web/app/[locale]/checkout/thank-you/page.tsx +114 -0
  136. package/templates/apps/web/app/[locale]/checkout/unavailable/page.tsx +51 -0
  137. package/templates/apps/web/app/[locale]/clients/__tests__/page-renderer-migration.test.tsx +1 -1
  138. package/templates/apps/web/app/[locale]/contact/ConversationalIntakeWrapper.tsx +24 -1
  139. package/templates/apps/web/app/[locale]/contact/__tests__/ConversationalIntakeWrapper.test.tsx +79 -4
  140. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-magnet.test.ts +138 -0
  141. package/templates/apps/web/app/[locale]/contact/__tests__/contact-form-text.test.ts +17 -0
  142. package/templates/apps/web/app/[locale]/contact/__tests__/contact-magnet-page.test.tsx +386 -0
  143. package/templates/apps/web/app/[locale]/contact/__tests__/contact.actions.test.ts +504 -24
  144. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +12 -3
  145. package/templates/apps/web/app/[locale]/contact/contact-form-magnet.ts +77 -0
  146. package/templates/apps/web/app/[locale]/contact/contact-form-text.ts +2 -0
  147. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +184 -41
  148. package/templates/apps/web/app/[locale]/contact/intake-script.ts +26 -0
  149. package/templates/apps/web/app/[locale]/contact/page.tsx +120 -16
  150. package/templates/apps/web/app/[locale]/courses/__tests__/page-renderer-migration.test.tsx +2 -2
  151. package/templates/apps/web/app/[locale]/downloads/__tests__/page-renderer-migration.test.tsx +1 -1
  152. package/templates/apps/web/app/[locale]/events/__tests__/page-renderer-migration.test.tsx +2 -2
  153. package/templates/apps/web/app/[locale]/flows/[slug]/FlowStepperClient.tsx +106 -1
  154. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/FlowStepperClient.invite.test.tsx +257 -0
  155. package/templates/apps/web/app/[locale]/flows/[slug]/__tests__/account-invite.actions.test.ts +69 -0
  156. package/templates/apps/web/app/[locale]/flows/[slug]/account-invite.actions.ts +35 -0
  157. package/templates/apps/web/app/[locale]/for/__tests__/page-renderer-migration.test.tsx +1 -1
  158. package/templates/apps/web/app/[locale]/how-to/__tests__/page-renderer-migration.test.tsx +1 -1
  159. package/templates/apps/web/app/[locale]/integrations/__tests__/page-renderer-migration.test.tsx +2 -2
  160. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  161. package/templates/apps/web/app/[locale]/page.tsx +13 -1
  162. package/templates/apps/web/app/[locale]/partners/__tests__/page-renderer-migration.test.tsx +2 -2
  163. package/templates/apps/web/app/[locale]/products/__tests__/page-renderer-migration.test.tsx +2 -2
  164. package/templates/apps/web/app/[locale]/resources/__tests__/page-renderer-migration.test.tsx +2 -2
  165. package/templates/apps/web/app/[locale]/services/__tests__/page-renderer-migration.test.tsx +2 -2
  166. package/templates/apps/web/app/[locale]/software/__tests__/page-renderer-migration.test.tsx +1 -1
  167. package/templates/apps/web/app/[locale]/tech-articles/__tests__/page-renderer-migration.test.tsx +1 -1
  168. package/templates/apps/web/app/[locale]/testimonials/__tests__/page-renderer-migration.test.tsx +1 -1
  169. package/templates/apps/web/app/[locale]/thanks/[slug]/AccountInviteLink.tsx +89 -0
  170. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/AccountInviteLink.test.tsx +104 -0
  171. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +38 -0
  172. package/templates/apps/web/app/[locale]/thanks/[slug]/page.tsx +9 -0
  173. package/templates/apps/web/app/[locale]/whitepapers/__tests__/page-renderer-migration.test.tsx +1 -1
  174. package/templates/apps/web/app/[locale]/working-agreement/__tests__/page.test.tsx +63 -0
  175. package/templates/apps/web/app/[locale]/working-agreement/page.tsx +41 -0
  176. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +114 -0
  177. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +20 -3
  178. package/templates/apps/web/app/api/checkout/[offering]/route.ts +48 -0
  179. package/templates/apps/web/app/api/checkout/__tests__/route.test.ts +120 -0
  180. package/templates/apps/web/app/api/checkout/__tests__/success-path.test.ts +86 -0
  181. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +44 -1
  182. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +22 -3
  183. package/templates/apps/web/app/api/webhooks/stripe/__tests__/route.test.ts +92 -0
  184. package/templates/apps/web/app/api/webhooks/stripe/route.ts +31 -0
  185. package/templates/apps/web/auth.ts +28 -2
  186. package/templates/apps/web/jest.config.cjs +1 -0
  187. package/templates/apps/web/lib/__tests__/account-invite.test.ts +63 -0
  188. package/templates/apps/web/lib/__tests__/checkout.test.ts +243 -0
  189. package/templates/apps/web/lib/__tests__/consent-policies.test.ts +23 -0
  190. package/templates/apps/web/lib/__tests__/deck-page.test.ts +249 -0
  191. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +38 -19
  192. package/templates/apps/web/lib/account-invite.ts +37 -0
  193. package/templates/apps/web/lib/checkout-config.ts +27 -0
  194. package/templates/apps/web/lib/checkout.ts +93 -0
  195. package/templates/apps/web/lib/consent-policies.ts +22 -0
  196. package/templates/apps/web/lib/deck-page.ts +192 -0
  197. package/templates/apps/web/lib/hub-session/__tests__/sign-in-gate.test.tsx +111 -0
  198. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +88 -32
  199. package/templates/apps/web/lib/prisma-for-auth.ts +8 -0
  200. package/templates/apps/web/lib/sign-up-agreements.ts +447 -0
  201. package/templates/apps/web/lib/site-chrome.ts +2 -0
  202. package/templates/apps/web/lib/telemetry-scrub.ts +5 -1
  203. package/templates/apps/web/messages/en.json +40 -12
  204. package/templates/apps/web/package.json +1 -0
  205. package/templates/apps/web/src/lib/__tests__/lead-write.test.ts +187 -0
  206. package/templates/apps/web/src/lib/__tests__/rate-limit.test.ts +67 -0
  207. package/templates/apps/web/src/lib/lead-write.ts +90 -0
  208. package/templates/apps/web/src/lib/rate-limit.ts +79 -0
  209. package/templates/database/CHANGELOG.md +47 -0
  210. package/templates/database/__tests__/consent-source-checkout-migration.test.ts +79 -0
  211. package/templates/database/__tests__/flow-completion-migration.test.ts +115 -0
  212. package/templates/database/__tests__/flow-completion.db.test.ts +136 -0
  213. package/templates/database/__tests__/seed.test.ts +72 -2
  214. package/templates/database/inbox/schema.prisma +17 -2
  215. package/templates/database/jest.config.cjs +1 -0
  216. package/templates/database/migrations/20261018120000_flow_completion/migration.sql +52 -0
  217. package/templates/database/migrations/20261019120000_consent_source_checkout/migration.sql +27 -0
  218. package/templates/database/package.json +1 -1
  219. package/templates/database/seed.ts +60 -0
  220. package/templates/tools/jest/pin-timezone.cjs +34 -0
  221. package/versions.json +34 -34
  222. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +0 -125
@@ -1,6 +1,6 @@
1
1
  /**
2
2
  * HubSignInGate — the signed-out account gate of a spoke that signs in
3
- * through its hub (#7894 D10).
3
+ * through its hub (#7894 D10; #8790 AC-1/AC-2).
4
4
  *
5
5
  * A Server Component, rendered by the `(authed)` layout through the generated
6
6
  * gate seam (`signed-out-gate.generated.tsx`, its `signIn: "hub"` render). It
@@ -9,16 +9,26 @@
9
9
  * link is a plain anchor (the `Link` primitive), so nothing prefetches the
10
10
  * route and starts a flow the visitor did not ask for.
11
11
  *
12
+ * #8790 — the gate reads as one of the site's inner pages, not a bare
13
+ * full-height stop: the site's inner-page header (the back arrow at the start
14
+ * edge, the mark centred — the shape `Nav bar="bare"` draws, composed here
15
+ * from the same `NavBackLink` and `siteLogo()`), a heading at the label size
16
+ * (`heading-2`), and an optional quiet prospect line a site turns on by
17
+ * adding `account.gate.prospectPrompt` + `prospectLink` + `prospectHref`
18
+ * to its messages — absent, the shipped default, renders nothing.
19
+ * The document title is the account page's job (`generateMetadata` there
20
+ * reads the session and names this state "Sign in · <site>").
21
+ *
12
22
  * Not configured (`hubSessionConfig()` is `null`: a client id, secret or
13
23
  * session secret missing), it renders the account area's existing "sign-in
14
24
  * isn't available yet" state, whose one action leads back to the site.
15
25
  */
16
-
17
26
  import { localeHref } from "@working-theory/ui";
27
+ import { NavBackLink } from "@working-theory/ui/chrome";
18
28
  import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
19
29
  import { getTranslations } from "next-intl/server";
20
30
  import type { ReactElement } from "react";
21
-
31
+ import { siteLogo } from "../site-logo";
22
32
  import { hubSessionConfig } from "./config";
23
33
  import { HUB_SESSION_FALLBACK_PATH } from "./session";
24
34
 
@@ -40,40 +50,86 @@ export async function HubSignInGate({
40
50
  const next =
41
51
  requestHeaders.get("x-pathname") ??
42
52
  localeHref(locale, HUB_SESSION_FALLBACK_PATH);
53
+ const home = localeHref(locale, "/");
54
+ // The three prospect keys are optional extensions of the typed catalog (the
55
+ // shipped en.json carries none — every leaf must be a non-empty string), so
56
+ // presence is the switch; hence the cast on `has`.
57
+ type Key = Parameters<typeof t.has>[0];
58
+ const has = (key: string): boolean => t.has(key as Key);
59
+ const prospect =
60
+ has("prospectPrompt") && has("prospectLink") && has("prospectHref")
61
+ ? {
62
+ prompt: t("prospectPrompt" as Key),
63
+ linkLabel: t("prospectLink" as Key),
64
+ href: t("prospectHref" as Key),
65
+ }
66
+ : null;
43
67
 
44
68
  return (
45
69
  <div
46
- className="flex min-h-screen flex-col items-center justify-center gap-4 py-section text-center"
70
+ className="flex min-h-screen flex-col"
47
71
  data-testid="account-sign-in-gate"
48
72
  >
49
- <Heading level={1} visual={4}>
50
- {configured ? t("hubHeadline") : t("emptyHeadline")}
51
- </Heading>
52
- <Text variant="body-sm" tone="muted" className="max-w-xs">
53
- {configured ? t("hubDescription") : t("emptyDescription")}
54
- </Text>
55
- {configured ? (
56
- // The primary action: 44px on a phone, 36px from md up.
57
- <Button asChild className="max-md:min-h-11">
58
- <Link
59
- href={`${HUB_SIGN_IN_ROUTE}?next=${encodeURIComponent(next)}`}
60
- variant="bare"
61
- data-testid="account-hub-sign-in"
62
- >
63
- {t("hubSignIn")}
64
- </Link>
65
- </Button>
66
- ) : (
67
- <Button asChild variant="secondary" className="max-md:min-h-11">
68
- <Link
69
- href={localeHref(locale, "/")}
70
- variant="bare"
71
- data-testid="account-sign-in-gate-home"
72
- >
73
- {t("homeLink")}
74
- </Link>
75
- </Button>
76
- )}
73
+ {/* The site's inner-page header: back arrow at the start, the mark centred (#8790 AC-1). */}
74
+ <header
75
+ className="grid h-16 grid-cols-[1fr_auto_1fr] items-center px-5 md:px-8"
76
+ data-testid="account-sign-in-gate-header"
77
+ >
78
+ <div className="justify-self-start">
79
+ <NavBackLink href={home} label={t("homeLink")} />
80
+ </div>
81
+ <Link
82
+ href={home}
83
+ variant="unstyled"
84
+ className="inline-flex h-7 items-center [&>img]:h-full [&>img]:w-auto [&>img]:object-contain"
85
+ data-testid="account-sign-in-gate-mark"
86
+ >
87
+ {siteLogo()}
88
+ </Link>
89
+ <div />
90
+ </header>
91
+ <div className="flex flex-1 flex-col items-center justify-center gap-4 py-section text-center">
92
+ <Heading level={1} visual={2}>
93
+ {configured ? t("hubHeadline") : t("emptyHeadline")}
94
+ </Heading>
95
+ <Text variant="body-sm" tone="muted" className="max-w-xs">
96
+ {configured ? t("hubDescription") : t("emptyDescription")}
97
+ </Text>
98
+ {configured ? (
99
+ // The primary action: 44px on a phone, 36px from md up.
100
+ <Button asChild className="max-md:min-h-11">
101
+ <Link
102
+ href={`${HUB_SIGN_IN_ROUTE}?next=${encodeURIComponent(next)}`}
103
+ variant="bare"
104
+ data-testid="account-hub-sign-in"
105
+ >
106
+ {t("hubSignIn")}
107
+ </Link>
108
+ </Button>
109
+ ) : (
110
+ <Button asChild variant="secondary" className="max-md:min-h-11">
111
+ <Link
112
+ href={home}
113
+ variant="bare"
114
+ data-testid="account-sign-in-gate-home"
115
+ >
116
+ {t("homeLink")}
117
+ </Link>
118
+ </Button>
119
+ )}
120
+ {configured && prospect ? (
121
+ // A wrapping div carries the hook: `Text` forwards no data-* attributes.
122
+ <div data-testid="account-sign-in-gate-prospect">
123
+ <Text as="p" variant="body-sm" tone="muted">
124
+ {prospect.prompt}{" "}
125
+ <Link href={prospect.href} variant="inline">
126
+ {prospect.linkLabel}
127
+ </Link>
128
+ .
129
+ </Text>
130
+ </div>
131
+ ) : null}
132
+ </div>
77
133
  </div>
78
134
  );
79
135
  }
@@ -9,6 +9,14 @@
9
9
  * `lib/license-key-store.ts`, which hands `prismaForAuth.siteSetting` to the
10
10
  * licence client's SiteSetting store for the ONE deployment-wide
11
11
  * `license.renewed_key` row (#6677 DD-6 — a narrow, named exception).
12
+ *
13
+ * A third use rides on `auth.ts`'s own (#8765 D4, SEC-8765-K):
14
+ * `lib/sign-up-agreements.ts`'s account hook writes the new user's `sign_up`
15
+ * consent grants through `consentGrant.createMany` on the transaction client
16
+ * Better Auth's sign-up transaction runs on — only rows for the user that
17
+ * transaction is creating, never a read and never any other row. It imports
18
+ * nothing from here; it reaches the client through the adapter.
19
+ *
12
20
  * Every other business-logic caller goes through apps/api, never the
13
21
  * database directly (architecture.yaml `apps/web` may_not_import_from
14
22
  * `database`; the single-file singleton here is the documented exception,
@@ -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