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
@@ -0,0 +1,386 @@
1
+ /**
2
+ * @file account.test.ts
3
+ * @description The sign-up agreements' evidence route and the account-level
4
+ * reading (#8765 D2, D5; AC-2, AC-3 and AC-4, route parts).
5
+ *
6
+ * External dependency boundaries mocked:
7
+ * - @working-theory/database (via moduleNameMapper -> prisma-client.js mock;
8
+ * prisma singleton via __mocks__/prisma.ts), driven by the in-memory
9
+ * ledger double
10
+ * - `resolveSiteWorkspaceId` — the seam to the deployment's site workspace
11
+ *
12
+ * The real `services/consent.ts` writer and `lib/consent-store.ts` reader run
13
+ * over the ledger double. The auth preHandler is replaced by a hook that sets
14
+ * the fields it sets (`userId`, `sessionTenant`, `hubSession`), so user `u1`'s
15
+ * cookie session, spoke `S1`'s hub credential for `u1` and a personal token
16
+ * are each one line.
17
+ */
18
+
19
+ // eslint-disable-next-line no-restricted-syntax -- lib/prisma.ts wraps the external DB boundary (PrismaClient requires a live connection); __mocks__/prisma.ts is the repo-standard test double for this boundary.
20
+ jest.mock("../../../lib/prisma.js");
21
+
22
+ const mockResolveSiteWorkspaceId = jest.fn();
23
+ // eslint-disable-next-line no-restricted-syntax -- jest mock factory must be hoisted
24
+ jest.mock("../../../lib/site.js", () => ({
25
+ resolveSiteWorkspaceId: () => mockResolveSiteWorkspaceId(),
26
+ }));
27
+
28
+ import { createHash } from "node:crypto";
29
+
30
+ import {
31
+ CONTACT_FORM_TERMS_STATEMENT,
32
+ SITE_CONSENT_CONFIG_DEFAULTS,
33
+ siteConsentConfigSchema,
34
+ } from "@working-theory/validation";
35
+ import Fastify from "fastify";
36
+ import type { FastifyInstance } from "fastify";
37
+
38
+ import {
39
+ armConsentLedger,
40
+ type ConsentLedgerDouble,
41
+ } from "../../../lib/__tests__/consent-ledger-double.js";
42
+ import { prisma } from "../../../lib/prisma.js";
43
+ import * as consentService from "../../../services/consent.js";
44
+ import { consentAccountRoutes, consentSignUpRoute } from "../index.js";
45
+
46
+ /** The deployment's own site workspace. */
47
+ const W = "wksp_01920000-0000-7000-8000-0000000000a1";
48
+ /** Spoke S1's own workspace — never where an account-level row is written. */
49
+ const S1_WS = "wksp_01920000-0000-7000-8000-0000000000b1";
50
+ const S1 = "spoke_01920000-0000-7000-8000-0000000000b1";
51
+ const U1 = "user_01920000-0000-7000-8000-0000000000a1";
52
+
53
+ const sha256 = (text: string) =>
54
+ createHash("sha256").update(text).digest("hex");
55
+
56
+ type Caller = "cookie" | "hub" | "token" | "none";
57
+
58
+ let double: ConsentLedgerDouble;
59
+ let app: FastifyInstance;
60
+
61
+ async function build(caller: Caller): Promise<FastifyInstance> {
62
+ const instance = Fastify();
63
+ instance.decorateRequest("userId", undefined);
64
+ instance.decorateRequest("sessionTenant", undefined);
65
+ instance.decorateRequest("hubSession", undefined);
66
+ instance.addHook("onRequest", async (request) => {
67
+ // eslint-disable-next-line no-restricted-syntax -- the test stands in for the auth preHandler, which assigns decorated fields the bare Fastify type does not declare
68
+ const c = request as unknown as Record<string, unknown>;
69
+ if (caller === "none") return;
70
+ c["userId"] = U1;
71
+ if (caller === "cookie") c["sessionTenant"] = { sessionRowId: "s" };
72
+ if (caller === "hub") c["hubSession"] = { spokeId: S1, workspaceId: S1_WS };
73
+ });
74
+ await instance.register(consentSignUpRoute);
75
+ await instance.register(consentAccountRoutes);
76
+ await instance.ready();
77
+ return instance;
78
+ }
79
+
80
+ /** The registry with `account_terms` moved to `version`, everything else the default. */
81
+ function registryAt(version: number) {
82
+ return siteConsentConfigSchema.parse({
83
+ ...SITE_CONSENT_CONFIG_DEFAULTS,
84
+ purposes: SITE_CONSENT_CONFIG_DEFAULTS.purposes.map((p) =>
85
+ p.id === "account_terms" ? { ...p, policyVersion: version } : p,
86
+ ),
87
+ });
88
+ }
89
+
90
+ const getAccount = (query = "") =>
91
+ app.inject({ method: "GET", url: `/v1/consent/account${query}` });
92
+ const postAccount = (payload: unknown) =>
93
+ app.inject({
94
+ method: "POST",
95
+ url: "/v1/consent/account",
96
+ payload: payload as object,
97
+ });
98
+
99
+ beforeEach(() => {
100
+ jest.restoreAllMocks();
101
+ jest.clearAllMocks();
102
+ double = armConsentLedger(prisma);
103
+ mockResolveSiteWorkspaceId.mockResolvedValue(W);
104
+ });
105
+
106
+ afterEach(async () => {
107
+ await app?.close();
108
+ });
109
+
110
+ describe("GET /v1/consent/sign-up (D2)", () => {
111
+ const get = (query = "") =>
112
+ app.inject({ method: "GET", url: `/v1/consent/sign-up${query}` });
113
+
114
+ beforeEach(async () => {
115
+ app = await build("none");
116
+ });
117
+
118
+ it("answers anonymously with W and one account_terms entry at version 2, its statement and its sha256", async () => {
119
+ const res = await get();
120
+ expect(res.statusCode).toBe(200);
121
+ expect(res.headers["cache-control"]).toBe("no-store");
122
+ expect(res.json()).toEqual({
123
+ ok: true,
124
+ data: {
125
+ workspaceId: W,
126
+ agreements: [
127
+ {
128
+ purposeId: "account_terms",
129
+ policyVersion: 2,
130
+ locale: "en",
131
+ statement: CONTACT_FORM_TERMS_STATEMENT,
132
+ statementHash: sha256(CONTACT_FORM_TERMS_STATEMENT),
133
+ },
134
+ ],
135
+ },
136
+ });
137
+ });
138
+
139
+ it("reads the default locale en, and records the locale the statement was read in", async () => {
140
+ const plain = (await get()).json().data.agreements[0];
141
+ const fr = (await get("?locale=fr")).json().data.agreements[0];
142
+ expect(plain.locale).toBe("en");
143
+ // The registry declares only English, so a French reader read English.
144
+ expect(fr).toEqual(plain);
145
+ });
146
+
147
+ it("is the evidence the writer stamps: the hash of the statement a grant records", async () => {
148
+ const evidence = (await get()).json().data.agreements[0];
149
+ await app.close();
150
+ app = await build("cookie");
151
+ await postAccount({ purposeIds: ["account_terms"] });
152
+ expect(double.ledger[0]?.statement).toBe(evidence.statement);
153
+ expect(
154
+ consentService.consentStatementHash(double.ledger[0]!.statement),
155
+ ).toBe(evidence.statementHash);
156
+ });
157
+
158
+ it("refuses a malformed query with 400 invalid_input", async () => {
159
+ for (const query of ["?locale=not_a_locale", "?workspaceId=x"]) {
160
+ const res = await get(query);
161
+ expect(res.statusCode).toBe(400);
162
+ expect(res.json().error.code).toBe("invalid_input");
163
+ }
164
+ });
165
+
166
+ it("answers 503 tenant_unresolved, uncached, when the site workspace does not resolve", async () => {
167
+ mockResolveSiteWorkspaceId.mockResolvedValue(null);
168
+ const res = await get();
169
+ expect(res.statusCode).toBe(503);
170
+ expect(res.json().error.code).toBe("tenant_unresolved");
171
+ expect(res.headers["cache-control"]).toBe("no-store");
172
+ });
173
+ });
174
+
175
+ describe("GET/POST /v1/consent/account (D5) — AC-2", () => {
176
+ it("asks a user with no user rows: complete false, one entry never accepted", async () => {
177
+ app = await build("cookie");
178
+ const res = await getAccount();
179
+ expect(res.statusCode).toBe(200);
180
+ expect(res.json().data).toEqual({
181
+ complete: false,
182
+ agreements: [
183
+ {
184
+ id: "account_terms",
185
+ statement: CONTACT_FORM_TERMS_STATEMENT,
186
+ policyVersion: 2,
187
+ acceptedVersion: null,
188
+ acceptedAt: null,
189
+ current: false,
190
+ },
191
+ ],
192
+ });
193
+ });
194
+
195
+ it("POST writes one dated user row in W and answers complete; a second POST adds nothing", async () => {
196
+ app = await build("cookie");
197
+ const res = await postAccount({ purposeIds: ["account_terms"] });
198
+ expect(res.statusCode).toBe(200);
199
+ expect(res.json().data.complete).toBe(true);
200
+ expect(double.ledger).toHaveLength(1);
201
+ expect(double.ledger[0]).toMatchObject({
202
+ workspaceId: W,
203
+ subjectType: "user",
204
+ subjectId: U1,
205
+ personId: null,
206
+ purposeId: "account_terms",
207
+ granted: true,
208
+ source: "account_settings",
209
+ sourceRef: null,
210
+ policyVersion: 2,
211
+ statement: CONTACT_FORM_TERMS_STATEMENT,
212
+ });
213
+ expect(double.ledger[0]?.createdAt).toBeInstanceOf(Date);
214
+ const [entry] = res.json().data.agreements;
215
+ expect(entry).toMatchObject({
216
+ acceptedVersion: 2,
217
+ acceptedAt: double.ledger[0]!.createdAt.toISOString(),
218
+ current: true,
219
+ });
220
+
221
+ const again = await postAccount({ purposeIds: ["account_terms"] });
222
+ expect(again.statusCode).toBe(200);
223
+ expect(again.json().data.complete).toBe(true);
224
+ expect(double.ledger).toHaveLength(1);
225
+ });
226
+
227
+ it("the same POST from S1's credential writes in W, with sourceRef spoke:S1", async () => {
228
+ app = await build("hub");
229
+ const res = await postAccount({ purposeIds: ["account_terms"] });
230
+ expect(res.statusCode).toBe(200);
231
+ expect(double.ledger).toHaveLength(1);
232
+ expect(double.ledger[0]).toMatchObject({
233
+ workspaceId: W,
234
+ subjectType: "user",
235
+ subjectId: U1,
236
+ sourceRef: `spoke:${S1}`,
237
+ });
238
+ });
239
+
240
+ it("refuses a purpose that is not a sign-up agreement with 404 purpose_unknown, writing nothing — even beside a valid one", async () => {
241
+ app = await build("cookie");
242
+ for (const purposeIds of [
243
+ ["service_terms"],
244
+ ["account_terms", "service_terms"],
245
+ ["nope"],
246
+ ]) {
247
+ const res = await postAccount({ purposeIds });
248
+ expect(res.statusCode).toBe(404);
249
+ expect(res.json().error.code).toBe("purpose_unknown");
250
+ }
251
+ expect(double.ledger).toHaveLength(0);
252
+ });
253
+
254
+ it("refuses a malformed body with 400 invalid_input", async () => {
255
+ app = await build("cookie");
256
+ for (const body of [
257
+ {},
258
+ { purposeIds: [] },
259
+ { purposeIds: ["account_terms"], userId: U1 },
260
+ { purposeIds: ["account_terms"], locale: "not_a_locale" },
261
+ ]) {
262
+ const res = await postAccount(body);
263
+ expect(res.statusCode).toBe(400);
264
+ expect(res.json().error.code).toBe("invalid_input");
265
+ }
266
+ expect(double.ledger).toHaveLength(0);
267
+ });
268
+
269
+ it("refuses a personal token and an anonymous caller with 401, before any read", async () => {
270
+ for (const caller of ["token", "none"] as const) {
271
+ app = await build(caller);
272
+ expect((await getAccount()).statusCode).toBe(401);
273
+ expect(
274
+ (await postAccount({ purposeIds: ["account_terms"] })).statusCode,
275
+ ).toBe(401);
276
+ await app.close();
277
+ }
278
+ app = await build("cookie");
279
+ expect(prisma.$queryRaw).not.toHaveBeenCalled();
280
+ expect(double.ledger).toHaveLength(0);
281
+ });
282
+
283
+ it("answers 503 tenant_unresolved when the site workspace does not resolve", async () => {
284
+ mockResolveSiteWorkspaceId.mockResolvedValue(null);
285
+ app = await build("cookie");
286
+ expect((await getAccount()).statusCode).toBe(503);
287
+ const res = await postAccount({ purposeIds: ["account_terms"] });
288
+ expect(res.statusCode).toBe(503);
289
+ expect(res.json().error.code).toBe("tenant_unresolved");
290
+ expect(double.ledger).toHaveLength(0);
291
+ });
292
+
293
+ it("reads a newest row that is not a grant as never accepted", async () => {
294
+ app = await build("cookie");
295
+ await postAccount({ purposeIds: ["account_terms"] });
296
+ double.ledger.push({
297
+ ...double.ledger[0]!,
298
+ id: "cnsnt_00000000-0000-7000-8000-0000000000f1",
299
+ granted: false,
300
+ createdAt: new Date(Date.UTC(2026, 9, 13)),
301
+ });
302
+ expect((await getAccount()).json().data.agreements[0]).toMatchObject({
303
+ acceptedVersion: null,
304
+ acceptedAt: null,
305
+ current: false,
306
+ });
307
+ });
308
+
309
+ it("logs { event, outcome } only", async () => {
310
+ app = await build("cookie");
311
+ const lines: unknown[] = [];
312
+ const capture = ((obj: unknown) => {
313
+ lines.push(obj);
314
+ }) as never;
315
+ jest.spyOn(app.log, "info").mockImplementation(capture);
316
+ jest.spyOn(app.log, "warn").mockImplementation(capture);
317
+ await postAccount({ purposeIds: ["account_terms"] });
318
+ await postAccount({ purposeIds: ["service_terms"] });
319
+ const ours = lines.filter(
320
+ (l) => typeof l === "object" && l !== null && "event" in l,
321
+ );
322
+ expect(ours).toEqual([
323
+ { event: "account-agreements", outcome: "saved" },
324
+ { event: "account-agreements", outcome: "purpose_unknown" },
325
+ ]);
326
+ });
327
+ });
328
+
329
+ describe("GET /v1/consent/account — AC-3 (route)", () => {
330
+ it("a grant at version 2 in W reads complete from S1's credential", async () => {
331
+ app = await build("cookie");
332
+ await postAccount({ purposeIds: ["account_terms"] });
333
+ await app.close();
334
+ app = await build("hub");
335
+ const res = await getAccount();
336
+ expect(res.statusCode).toBe(200);
337
+ expect(res.json().data.complete).toBe(true);
338
+ expect(res.json().data.agreements[0]).toMatchObject({
339
+ acceptedVersion: 2,
340
+ current: true,
341
+ });
342
+ });
343
+ });
344
+
345
+ describe("GET/POST /v1/consent/account — AC-4 (route)", () => {
346
+ it("a bump to version 3 asks again; accepting appends a row and keeps the old one unchanged", async () => {
347
+ app = await build("cookie");
348
+ await postAccount({ purposeIds: ["account_terms"] });
349
+ const before = structuredClone(double.ledger[0]);
350
+
351
+ jest
352
+ .spyOn(consentService, "consentConfigForSite")
353
+ .mockResolvedValue(registryAt(3));
354
+
355
+ const read = (await getAccount()).json().data;
356
+ expect(read.complete).toBe(false);
357
+ expect(read.agreements[0]).toMatchObject({
358
+ policyVersion: 3,
359
+ acceptedVersion: 2,
360
+ current: false,
361
+ });
362
+
363
+ const res = await postAccount({ purposeIds: ["account_terms"] });
364
+ expect(res.statusCode).toBe(200);
365
+ expect(res.json().data.complete).toBe(true);
366
+ expect(double.ledger).toHaveLength(2);
367
+ expect(double.ledger[0]).toEqual(before);
368
+ expect(double.ledger[1]).toMatchObject({
369
+ subjectType: "user",
370
+ subjectId: U1,
371
+ policyVersion: 3,
372
+ granted: true,
373
+ });
374
+ // The append-only double offers no way to change a stored row: the
375
+ // ledger delegate is append and read only, so an update would throw.
376
+ for (const method of [
377
+ "update",
378
+ "updateMany",
379
+ "upsert",
380
+ "delete",
381
+ "deleteMany",
382
+ ]) {
383
+ expect(method in prisma.consentGrant).toBe(false);
384
+ }
385
+ });
386
+ });
@@ -0,0 +1,266 @@
1
+ /**
2
+ * The sign-up agreements: their evidence, and the account-level reading
3
+ * (#8765, spec docs/specs/8765-sign-up-refuses-without-the-spec.md D2, D5).
4
+ *
5
+ * GET /v1/consent/sign-up?locale=
6
+ * Anonymous and keyless. `{ workspaceId, agreements: [{ purposeId,
7
+ * policyVersion, locale, statement, statementHash }] }` — one entry per
8
+ * `signUp` agreement of the site registry, its statement resolved by
9
+ * `resolveConsentStatement` for `locale` (default `en`) and hashed by
10
+ * `consentStatementHash`: the same evidence `recordPersonConsent`
11
+ * stamps, from the same functions. `Cache-Control: no-store`.
12
+ * GET /v1/consent/account?locale=
13
+ * The caller's sign-up agreements: `{ agreements: [{ id, statement,
14
+ * policyVersion, acceptedVersion, acceptedAt, current }], complete }`,
15
+ * folded from the caller's `user`-subject rows (`userPlatformRows`).
16
+ * `acceptedVersion`/`acceptedAt` come from the newest row when it is a
17
+ * grant, else `null`; `current` is a grant at the registry's version;
18
+ * `complete` is every entry current.
19
+ * POST /v1/consent/account
20
+ * `{ purposeIds, locale? }` — every id must be a `signUp` agreement
21
+ * (`404 purpose_unknown` with NOTHING written, all ids checked before
22
+ * any write); each is written through `recordPersonConsent` against
23
+ * subject `user` / the caller, no Person, source `account_settings`,
24
+ * with `sourceRef: spoke:<id>` for a hub credential (SEC-8765-A). A
25
+ * grant already at the current version adds nothing. Answers the GET
26
+ * body.
27
+ *
28
+ * The agreement belongs to the User account, a login shared by every site
29
+ * that signs in through this hub (C3), so the workspace is ALWAYS
30
+ * `resolveSiteWorkspaceId()` — the deployment's own site, where its `User`
31
+ * rows live — never a key, body or query value; `null` is
32
+ * `503 tenant_unresolved`. One row there answers the hub and every spoke.
33
+ *
34
+ * Callers. The evidence route: the web's own server, stamping the grant it
35
+ * writes inside Better Auth's sign-up transaction (`apps/web/lib/
36
+ * sign-up-agreements.ts`, D3, D4). Registered at the top level in its own
37
+ * rate-limited scope (`signupevidence`), outside every module gate. The
38
+ * account pair: `memberCaller` — the hub's own cookie session or a hub
39
+ * credential (the account layout's interstitial, D7); a personal `ApiToken`
40
+ * is `401 unauthorized`. There is no verified-email floor: the agreement is
41
+ * about the `User` row, and no Person is read or created. Registered in
42
+ * `authScope` unconditionally (auth and consent are never-gate surfaces),
43
+ * and both are in `HUB_SESSION_ROUTES`. Logs `{ event: "account-agreements",
44
+ * outcome }` only.
45
+ *
46
+ * MCP parity note: no MCP twin — the evidence route is part of the sign-up
47
+ * flow and the account pair is identity-bound; the anonymous MCP surface is
48
+ * identity-blind (tests/parity/README.md).
49
+ */
50
+ import {
51
+ accountAgreementsBodySchema,
52
+ accountAgreementsQuerySchema,
53
+ accountAgreementsResponseSchema,
54
+ consentSignUpQuerySchema,
55
+ consentSignUpResponseSchema,
56
+ resolveConsentStatement,
57
+ signUpAgreements,
58
+ type AccountAgreementsResponse,
59
+ type SiteConsentConfig,
60
+ } from "@working-theory/validation";
61
+ import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from "fastify";
62
+ import type { z } from "zod";
63
+
64
+ import { userPlatformRows } from "../../lib/consent-store.js";
65
+ import { resolveSiteWorkspaceId } from "../../lib/site.js";
66
+ import { memberCaller } from "../../middleware/auth.js";
67
+ import {
68
+ consentConfigForSite,
69
+ consentStatementHash,
70
+ recordPersonConsent,
71
+ } from "../../services/consent.js";
72
+
73
+ const EVENT = "account-agreements";
74
+
75
+ function refuse(
76
+ reply: FastifyReply,
77
+ status: number,
78
+ code: string,
79
+ message: string,
80
+ ) {
81
+ return reply.status(status).send({ ok: false, error: { code, message } });
82
+ }
83
+
84
+ function send<T extends z.ZodTypeAny>(
85
+ reply: FastifyReply,
86
+ schema: T,
87
+ data: unknown,
88
+ ) {
89
+ const parsed = schema.safeParse(data);
90
+ if (!parsed.success) {
91
+ return refuse(
92
+ reply,
93
+ 500,
94
+ "internal_error",
95
+ "Response failed contract validation",
96
+ );
97
+ }
98
+ return reply.send({ ok: true, data: parsed.data });
99
+ }
100
+
101
+ function log(request: FastifyRequest, outcome: string): void {
102
+ if (outcome === "saved" || outcome === "unchanged") {
103
+ request.log.info({ event: EVENT, outcome }, "[consent] account agreements");
104
+ } else {
105
+ request.log.warn({ event: EVENT, outcome }, "[consent] refused");
106
+ }
107
+ }
108
+
109
+ function refuseUnresolved(request: FastifyRequest, reply: FastifyReply) {
110
+ log(request, "tenant_unresolved");
111
+ return refuse(
112
+ reply,
113
+ 503,
114
+ "tenant_unresolved",
115
+ "This site cannot take this request.",
116
+ );
117
+ }
118
+
119
+ /** The caller's sign-up agreements in the site workspace (D5). */
120
+ async function accountReading(
121
+ workspaceId: string,
122
+ userId: string,
123
+ config: SiteConsentConfig,
124
+ locale: string,
125
+ ): Promise<AccountAgreementsResponse> {
126
+ const rows = await userPlatformRows(workspaceId, userId);
127
+ const newest = new Map(rows.map((row) => [row.purposeId, row]));
128
+ const agreements = signUpAgreements(config).map((purpose) => {
129
+ const row = newest.get(purpose.id);
130
+ const grant = row?.granted === true ? row : null;
131
+ return {
132
+ id: purpose.id,
133
+ statement: resolveConsentStatement(purpose, locale).statement,
134
+ policyVersion: purpose.policyVersion,
135
+ acceptedVersion: grant?.policyVersion ?? null,
136
+ acceptedAt: grant === null ? null : grant.at.toISOString(),
137
+ current: grant !== null && grant.policyVersion === purpose.policyVersion,
138
+ };
139
+ });
140
+ return { agreements, complete: agreements.every((a) => a.current) };
141
+ }
142
+
143
+ /** `GET /v1/consent/sign-up` — top level, its own rate-limited scope (D2). */
144
+ export const consentSignUpRoute: FastifyPluginAsync = async (fastify) => {
145
+ fastify.get("/v1/consent/sign-up", async (request, reply) => {
146
+ reply.header("Cache-Control", "no-store");
147
+ const query = consentSignUpQuerySchema.safeParse(request.query);
148
+ if (!query.success) {
149
+ return refuse(reply, 400, "invalid_input", "The request was not valid.");
150
+ }
151
+ const locale = query.data.locale ?? "en";
152
+
153
+ const workspaceId = await resolveSiteWorkspaceId();
154
+ if (workspaceId === null) return refuseUnresolved(request, reply);
155
+
156
+ const config = await consentConfigForSite(workspaceId);
157
+ const agreements = signUpAgreements(config).map((purpose) => {
158
+ const resolved = resolveConsentStatement(purpose, locale);
159
+ return {
160
+ purposeId: purpose.id,
161
+ policyVersion: purpose.policyVersion,
162
+ locale: resolved.locale,
163
+ statement: resolved.statement,
164
+ statementHash: consentStatementHash(resolved.statement),
165
+ };
166
+ });
167
+ return send(reply, consentSignUpResponseSchema, {
168
+ workspaceId,
169
+ agreements,
170
+ });
171
+ });
172
+ };
173
+
174
+ /** `GET`/`POST /v1/consent/account` — in `authScope`, unconditionally (D5). */
175
+ export const consentAccountRoutes: FastifyPluginAsync = async (fastify) => {
176
+ fastify.get("/v1/consent/account", async (request, reply) => {
177
+ const userId = memberCaller(request);
178
+ if (userId === null) {
179
+ log(request, "unauthorized");
180
+ return reply.status(401).send({ error: "unauthorized" });
181
+ }
182
+ const query = accountAgreementsQuerySchema.safeParse(request.query);
183
+ if (!query.success) {
184
+ log(request, "invalid_input");
185
+ return refuse(reply, 400, "invalid_input", "The request was not valid.");
186
+ }
187
+ const workspaceId = await resolveSiteWorkspaceId();
188
+ if (workspaceId === null) return refuseUnresolved(request, reply);
189
+
190
+ const config = await consentConfigForSite(workspaceId);
191
+ return send(
192
+ reply,
193
+ accountAgreementsResponseSchema,
194
+ await accountReading(
195
+ workspaceId,
196
+ userId,
197
+ config,
198
+ query.data.locale ?? "en",
199
+ ),
200
+ );
201
+ });
202
+
203
+ fastify.post("/v1/consent/account", async (request, reply) => {
204
+ const userId = memberCaller(request);
205
+ if (userId === null) {
206
+ log(request, "unauthorized");
207
+ return reply.status(401).send({ error: "unauthorized" });
208
+ }
209
+ const body = accountAgreementsBodySchema.safeParse(request.body);
210
+ if (!body.success) {
211
+ log(request, "invalid_input");
212
+ return refuse(reply, 400, "invalid_input", "The request was not valid.");
213
+ }
214
+ const locale = body.data.locale ?? "en";
215
+ const workspaceId = await resolveSiteWorkspaceId();
216
+ if (workspaceId === null) return refuseUnresolved(request, reply);
217
+
218
+ const config = await consentConfigForSite(workspaceId);
219
+ // Every id is checked before any write, so a refusal writes nothing.
220
+ const agreementIds = new Set(signUpAgreements(config).map((p) => p.id));
221
+ const purposeIds = [...new Set(body.data.purposeIds)];
222
+ if (!purposeIds.every((id) => agreementIds.has(id))) {
223
+ log(request, "purpose_unknown");
224
+ return refuse(
225
+ reply,
226
+ 404,
227
+ "purpose_unknown",
228
+ "That agreement can't be accepted here.",
229
+ );
230
+ }
231
+
232
+ let changed = false;
233
+ for (const purposeId of purposeIds) {
234
+ const result = await recordPersonConsent({
235
+ workspaceId,
236
+ personId: null,
237
+ purposeId,
238
+ granted: true,
239
+ source: "account_settings",
240
+ ...(request.hubSession !== undefined && {
241
+ sourceRef: `spoke:${request.hubSession.spokeId}`,
242
+ }),
243
+ locale,
244
+ config,
245
+ subject: { subjectType: "user", subjectId: userId },
246
+ });
247
+ if (!result.ok) {
248
+ log(request, result.code);
249
+ return refuse(
250
+ reply,
251
+ result.code === "purpose_unknown" ? 404 : 400,
252
+ result.code,
253
+ "That agreement can't be accepted here.",
254
+ );
255
+ }
256
+ changed ||= result.changed;
257
+ }
258
+
259
+ log(request, changed ? "saved" : "unchanged");
260
+ return send(
261
+ reply,
262
+ accountAgreementsResponseSchema,
263
+ await accountReading(workspaceId, userId, config, locale),
264
+ );
265
+ });
266
+ };
@@ -1,18 +1,26 @@
1
1
  /**
2
2
  * The consent routes (#8438, spec
3
- * docs/specs/8438-consent-registry-and-ledger-step-spec.md D5, D8, D9).
3
+ * docs/specs/8438-consent-registry-and-ledger-step-spec.md D5, D8, D9; #8765,
4
+ * spec docs/specs/8765-sign-up-refuses-without-the-spec.md D2, D5).
4
5
  *
5
6
  * cookie.ts POST /v1/consent/cookie — the banner's log
6
7
  * unsubscribe.ts POST /v1/consent/unsubscribe — a signed link's landing
8
+ * account.ts GET /v1/consent/sign-up — the sign-up agreements'
9
+ * evidence (anonymous)
10
+ * GET/POST /v1/consent/account — the caller's account-level
11
+ * sign-up agreements (cookie session or hub credential)
7
12
  * platform.ts GET/POST /v1/consent/platform — the Admin owner's
8
13
  * consents to Working Theory (multi-tenant module only)
9
14
  *
10
- * The first two are the never-gated privacy floor: registered at the top
11
- * level, each in its OWN rate-limited scope (`consent`, `unsubscribe`) so
12
- * filling one never blocks the other, and both on the forwarded-visitor
13
- * route list (`middleware/rate-limit.ts`). This barrel re-exports exactly
14
- * those two; the platform routes belong to the multi-tenant slice and are
15
- * loaded only through `server.ts`'s slice table.
15
+ * The first two and the evidence route are the never-gated floor: registered
16
+ * at the top level, each in its OWN rate-limited scope (`consent`,
17
+ * `unsubscribe`, `signupevidence`) so filling one never blocks another; the
18
+ * first two are also on the forwarded-visitor route list
19
+ * (`middleware/rate-limit.ts`). The account pair registers in `authScope`,
20
+ * unconditionally. This barrel re-exports those four plugins; the platform
21
+ * routes belong to the multi-tenant slice and are loaded only through
22
+ * `server.ts`'s slice table.
16
23
  */
24
+ export { consentAccountRoutes, consentSignUpRoute } from "./account.js";
17
25
  export { consentCookieRoute } from "./cookie.js";
18
26
  export { consentUnsubscribeRoute } from "./unsubscribe.js";