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
@@ -26,6 +26,11 @@
26
26
  * (#8441): a sub-route of a settings path stays inside the shell.
27
27
  * 8. One main landmark (#8441): every settings route and the Inbox, each
28
28
  * rendered through this layout with its real page, draw exactly one.
29
+ * 9. The agreements interstitial (#8765 D7, AC-2, AC-3, AC-4): the layout
30
+ * reads `consent.accountAgreements`; `complete: false` renders the
31
+ * interstitial in place of the frame and the page; a read that fails
32
+ * (anything but a 2xx or a 404) fails closed to the registry's own
33
+ * agreements; an older hub's 404 renders the account as before.
29
34
  */
30
35
  import { fireEvent, render, screen, waitFor, within } from "@testing-library/react";
31
36
  import type { ReactElement, ReactNode } from "react";
@@ -85,9 +90,19 @@ jest.mock("@working-theory/api-client", () => ({
85
90
  requestForm: (...args: unknown[]) => mockMemberNotFound(...args),
86
91
  },
87
92
  hubSignIn: { me: jest.fn() },
93
+ // #8765 D7 — the interstitial's read; `complete: true` unless a case says otherwise.
94
+ consent: { accountAgreements: (...args: unknown[]) => mockAccountAgreements(...args) },
88
95
  visitorForwardHeaders: () => null,
89
96
  }));
90
97
 
98
+ const mockAccountAgreements = jest.fn();
99
+ const mockAcceptAction = jest.fn();
100
+ // eslint-disable-next-line no-restricted-syntax -- test-seam mock: the Server Action module is a "use server" boundary; the interstitial only passes it through.
101
+ jest.mock("../agreements-actions", () => ({
102
+ __esModule: true,
103
+ acceptAccountAgreementsAction: (...args: unknown[]) => mockAcceptAction(...args),
104
+ }));
105
+
91
106
  const mockSignOut = jest.fn();
92
107
  jest.mock("better-auth/react", () => ({
93
108
  __esModule: true,
@@ -107,6 +122,8 @@ jest.mock("next/navigation", () => ({
107
122
  }));
108
123
 
109
124
  import { usePhoneTab } from "@working-theory/templates/account";
125
+ import { consentStatementText } from "@working-theory/ui/blocks";
126
+ import { CONTACT_FORM_TERMS_STATEMENT } from "@working-theory/validation";
110
127
 
111
128
  import AccountLayout from "../layout";
112
129
  import InboxPage from "../page";
@@ -127,6 +144,27 @@ const SESSION = {
127
144
  },
128
145
  };
129
146
 
147
+ /** One `account_terms` entry of `GET /v1/consent/account` (#8765 D5). */
148
+ function agreementsAnswer(entry: { acceptedVersion: number | null; current: boolean }) {
149
+ return {
150
+ ok: true,
151
+ body: {
152
+ agreements: [
153
+ {
154
+ id: "account_terms",
155
+ statement: CONTACT_FORM_TERMS_STATEMENT,
156
+ policyVersion: 2,
157
+ acceptedVersion: entry.acceptedVersion,
158
+ acceptedAt: entry.acceptedVersion === null ? null : "2026-09-01T00:00:00.000Z",
159
+ current: entry.current,
160
+ },
161
+ ],
162
+ complete: entry.current,
163
+ },
164
+ };
165
+ }
166
+ const COMPLETE = agreementsAnswer({ acceptedVersion: 2, current: true });
167
+
130
168
  const NO_SUBSCRIPTION = {
131
169
  ok: true,
132
170
  body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: false },
@@ -169,6 +207,8 @@ beforeEach(() => {
169
207
  mockSubscriptionMe.mockReset().mockResolvedValue(NO_SUBSCRIPTION);
170
208
  mockMemberNotFound.mockReset().mockResolvedValue({ ok: false, status: 404, body: null });
171
209
  mockSignOut.mockReset().mockResolvedValue({ data: {}, error: null });
210
+ mockAccountAgreements.mockReset().mockResolvedValue(COMPLETE);
211
+ mockAcceptAction.mockReset();
172
212
  mockPush.mockReset();
173
213
  mockRefresh.mockReset();
174
214
  });
@@ -519,7 +559,7 @@ describe("the settings routes take the screen (#8430)", () => {
519
559
  });
520
560
 
521
561
  describe("one main landmark (#8441)", () => {
522
- type Page = (props: { params: Promise<{ locale: string }> }) => Promise<ReactElement>;
562
+ type Page = (props: { params: Promise<{ locale: string }> }) => Promise<ReactElement | null>;
523
563
  // Keyed by the list's own type, so a tab added to `SETTINGS_ROUTES` without
524
564
  // its page here fails to compile. Billing loads with `import()`:
525
565
  // architecture.yaml bans a static import of a relative billing/ path
@@ -575,3 +615,105 @@ describe("the shell marker (#8337 D4, AC-6)", () => {
575
615
  expect(document.querySelector("[data-ailk-shell]")).not.toBeInTheDocument();
576
616
  });
577
617
  });
618
+
619
+ describe("the agreements interstitial (#8765 D7)", () => {
620
+ const PAGE_REFUSAL = (status: number) => ({
621
+ ok: false,
622
+ status,
623
+ body: { ok: false, error: { code: "x", message: "x" } },
624
+ });
625
+
626
+ function expectInterstitialAlone(title: string): void {
627
+ const page = screen.getByTestId("account-agreements-interstitial");
628
+ expect(within(page).getByRole("heading", { level: 1 })).toHaveTextContent(title);
629
+ expect(screen.queryByTestId("account-shell")).not.toBeInTheDocument();
630
+ expect(screen.queryByTestId("dashboard-shell")).not.toBeInTheDocument();
631
+ expect(screen.queryByTestId("account-child")).not.toBeInTheDocument();
632
+ expect(screen.queryByTestId("app-shell-aside")).not.toBeInTheDocument();
633
+ }
634
+
635
+ it("reads the account-level agreements with the session's own init and locale", async () => {
636
+ await renderLayout("fr");
637
+ expect(mockAccountAgreements).toHaveBeenCalledWith(
638
+ { headers: { cookie: "session=abc" } },
639
+ { locale: "fr" },
640
+ );
641
+ });
642
+
643
+ it("complete: true → the frame and its children, and no interstitial (AC-3)", async () => {
644
+ await renderLayout();
645
+ expect(screen.getByTestId("account-shell")).toBeInTheDocument();
646
+ expect(screen.getByTestId("account-child")).toBeInTheDocument();
647
+ expect(screen.queryByTestId("account-agreements-interstitial")).not.toBeInTheDocument();
648
+ });
649
+
650
+ it("complete: false → the interstitial in place of the frame and the page (AC-2)", async () => {
651
+ mockAccountAgreements.mockResolvedValue(agreementsAnswer({ acceptedVersion: null, current: false }));
652
+ await renderLayout();
653
+ expectInterstitialAlone("Please accept our terms");
654
+ });
655
+
656
+ it("Accept calls the action once with every id, then refreshes (AC-2)", async () => {
657
+ mockAccountAgreements.mockResolvedValue(agreementsAnswer({ acceptedVersion: null, current: false }));
658
+ mockAcceptAction.mockResolvedValue({ ok: true });
659
+ await renderLayout();
660
+ fireEvent.click(screen.getByTestId("account-agreements-accept"));
661
+ await waitFor(() => expect(mockRefresh).toHaveBeenCalledTimes(1));
662
+ expect(mockAcceptAction).toHaveBeenCalledTimes(1);
663
+ expect(mockAcceptAction).toHaveBeenCalledWith(["account_terms"]);
664
+ });
665
+
666
+ it("an older accepted version → 'We've updated our terms' (AC-4)", async () => {
667
+ mockAccountAgreements.mockResolvedValue(agreementsAnswer({ acceptedVersion: 1, current: false }));
668
+ await renderLayout();
669
+ expectInterstitialAlone("We've updated our terms");
670
+ });
671
+
672
+ it("gates the settings routes too — no account page loads behind it", async () => {
673
+ mockPathname = "/account/general";
674
+ mockAccountAgreements.mockResolvedValue(agreementsAnswer({ acceptedVersion: null, current: false }));
675
+ await renderLayout();
676
+ expectInterstitialAlone("Please accept our terms");
677
+ });
678
+
679
+ describe("fails closed (SEC-8765-C)", () => {
680
+ let errorSpy: jest.SpyInstance;
681
+ beforeEach(() => {
682
+ errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
683
+ });
684
+ afterEach(() => errorSpy.mockRestore());
685
+
686
+ it.each([
687
+ ["a 503", () => mockAccountAgreements.mockResolvedValue(PAGE_REFUSAL(503))],
688
+ ["a 429", () => mockAccountAgreements.mockResolvedValue(PAGE_REFUSAL(429))],
689
+ ["a 401", () => mockAccountAgreements.mockResolvedValue(PAGE_REFUSAL(401))],
690
+ ["a 403", () => mockAccountAgreements.mockResolvedValue(PAGE_REFUSAL(403))],
691
+ ["a thrown read", () => mockAccountAgreements.mockRejectedValue(new Error("down"))],
692
+ ])("%s → the interstitial from the registry, with no account behind it", async (_label, arrange) => {
693
+ arrange();
694
+ await renderLayout();
695
+ expectInterstitialAlone("Please accept our terms");
696
+ const statement = screen.getByTestId("account-agreements-statement-account_terms");
697
+ expect(statement.textContent).toBe(consentStatementText(CONTACT_FORM_TERMS_STATEMENT));
698
+ expect(errorSpy).toHaveBeenCalledWith(
699
+ expect.any(String),
700
+ { event: "account-agreements", outcome: "read_failed" },
701
+ );
702
+ });
703
+
704
+ it("an older hub's 404 → the account as today, and no log line", async () => {
705
+ mockAccountAgreements.mockResolvedValue(PAGE_REFUSAL(404));
706
+ await renderLayout();
707
+ expect(screen.getByTestId("account-shell")).toBeInTheDocument();
708
+ expect(screen.getByTestId("account-child")).toBeInTheDocument();
709
+ expect(screen.queryByTestId("account-agreements-interstitial")).not.toBeInTheDocument();
710
+ expect(errorSpy).not.toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ event: "account-agreements" }));
711
+ });
712
+ });
713
+
714
+ it("with no session there is no read", async () => {
715
+ mockGetSession.mockResolvedValue(null);
716
+ await renderLayout();
717
+ expect(mockAccountAgreements).not.toHaveBeenCalled();
718
+ });
719
+ });
@@ -75,6 +75,10 @@ jest.mock("@working-theory/api-client", () => ({
75
75
  meetingTypes: (...args: unknown[]) => mockMemberMeetingTypes(...args),
76
76
  requestForm: (...args: unknown[]) => mockMemberRequestForm(...args),
77
77
  },
78
+ // #8765 D7 — the layout's agreements read: complete, so the frame renders.
79
+ consent: {
80
+ accountAgreements: async () => ({ ok: true, body: { agreements: [], complete: true } }),
81
+ },
78
82
  }));
79
83
 
80
84
  jest.mock("better-auth/react", () => ({