create-ailk 0.19.0 → 0.20.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 (197) hide show
  1. package/README.md +64 -7
  2. package/dist/add-module.js +7 -2
  3. package/dist/cli.js +35 -0
  4. package/dist/copy.d.ts +41 -0
  5. package/dist/copy.js +87 -21
  6. package/dist/index.d.ts +1 -1
  7. package/dist/index.js +1 -1
  8. package/dist/lib/fetch-template-snapshot.d.ts +21 -0
  9. package/dist/lib/fetch-template-snapshot.js +205 -0
  10. package/dist/lib/merge-three-way.d.ts +31 -0
  11. package/dist/lib/merge-three-way.js +65 -0
  12. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  13. package/dist/lib/scaffold-render-sweep.js +52 -0
  14. package/dist/module-architecture.js +64 -5
  15. package/dist/programmatic.d.ts +7 -1
  16. package/dist/programmatic.js +40 -3
  17. package/dist/site-roster.js +21 -4
  18. package/dist/spoke-checkout.d.ts +7 -0
  19. package/dist/spoke-checkout.js +8 -1
  20. package/dist/surfaces.d.ts +14 -0
  21. package/dist/surfaces.js +98 -3
  22. package/dist/template-record.d.ts +59 -0
  23. package/dist/template-record.js +183 -0
  24. package/dist/topology.js +29 -1
  25. package/dist/update.d.ts +48 -0
  26. package/dist/update.js +504 -0
  27. package/package.json +3 -3
  28. package/templates/.claude/cloudparity-repo-hooks.json +9 -0
  29. package/templates/.claude/hooks/architecture-check.sh +8 -0
  30. package/templates/.claude/rules/loop-contract.md +129 -0
  31. package/templates/.claude/settings.json +3 -2
  32. package/templates/.env.example +13 -0
  33. package/templates/CLAUDE.md +1 -1
  34. package/templates/CONVENTIONS.md +3 -2
  35. package/templates/_gitignore +162 -0
  36. package/templates/apps/api/.env.example +17 -1
  37. package/templates/apps/api/CLAUDE.md +18 -8
  38. package/templates/apps/api/package.json +2 -1
  39. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +41 -2
  40. package/templates/apps/api/src/__tests__/setup.ts +7 -0
  41. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  42. package/templates/apps/api/src/bin/__tests__/rate-limit-probe.test.ts +184 -0
  43. package/templates/apps/api/src/bin/rate-limit-probe.ts +53 -0
  44. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  45. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  46. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  47. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  48. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  49. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  50. package/templates/apps/api/src/lib/proposal-paid-store.ts +49 -0
  51. package/templates/apps/api/src/lib/rate-limit-probe.ts +165 -0
  52. package/templates/apps/api/src/lib/rate-limit-store.ts +142 -0
  53. package/templates/apps/api/src/lib/stripe.ts +97 -5
  54. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +13 -4
  55. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +2 -0
  56. package/templates/apps/api/src/middleware/__tests__/rate-limit-cross-isolate.db.test.ts +220 -0
  57. package/templates/apps/api/src/middleware/__tests__/rate-limit-store.test.ts +430 -0
  58. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +72 -14
  59. package/templates/apps/api/src/middleware/auth.ts +16 -4
  60. package/templates/apps/api/src/middleware/default-tenant.ts +9 -0
  61. package/templates/apps/api/src/middleware/rate-limit.ts +295 -82
  62. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  63. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  64. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  65. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  66. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  67. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  68. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  69. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  70. package/templates/apps/api/src/routes/webhooks/README.md +33 -5
  71. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  72. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  73. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  74. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  75. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-proposal-paid.test.ts +369 -0
  76. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  77. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +442 -0
  78. package/templates/apps/api/src/routes/webhooks/stripe.ts +201 -6
  79. package/templates/apps/api/src/server.ts +125 -18
  80. package/templates/apps/api/src/services/__tests__/audit-log.test.ts +91 -0
  81. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  82. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  83. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  84. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  85. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  86. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  87. package/templates/apps/api/src/services/audit-log.ts +72 -10
  88. package/templates/apps/api/src/services/consent.ts +488 -3
  89. package/templates/apps/api/src/services/default-tenant.ts +12 -3
  90. package/templates/apps/api/src/services/hub-sign-in.ts +2 -1
  91. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  92. package/templates/apps/api/src/services/person.ts +501 -1
  93. package/templates/apps/api/src/services/project-listings.ts +7 -2
  94. package/templates/apps/api/src/services/public-site-resolver.ts +21 -2
  95. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  96. package/templates/apps/api/src/services/revalidation.ts +11 -1
  97. package/templates/apps/api/vercel.json +1 -1
  98. package/templates/apps/mcp/CLAUDE.md +19 -7
  99. package/templates/apps/mcp/__tests__/catalog-drift.test.ts +95 -8
  100. package/templates/apps/mcp/src/tools/person_notes.ts +25 -0
  101. package/templates/apps/mcp/src/tools/proposals.ts +27 -0
  102. package/templates/apps/mcp/src/workspace-server.ts +16 -0
  103. package/templates/apps/web/_gitignore +6 -0
  104. package/templates/apps/web/app/[locale]/(auth)/sign-up/__tests__/sign-up-card.test.tsx +165 -0
  105. package/templates/apps/web/app/[locale]/(auth)/sign-up/page.tsx +19 -0
  106. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up-card.tsx +106 -23
  107. package/templates/apps/web/app/[locale]/(auth)/sign-up/sign-up.actions.ts +125 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/agreements-prompt.test.tsx +281 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +154 -53
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +116 -27
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +57 -2
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +124 -18
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +116 -18
  117. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/onboarding.test.tsx +323 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/privacy.test.tsx +365 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  120. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +56 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  122. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +8 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/agreements-actions.ts +170 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/agreements-prompt.tsx +181 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +88 -3
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/pay-form.tsx +65 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +27 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +176 -3
  129. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/pay.test.tsx +193 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +188 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +65 -4
  132. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +17 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +15 -9
  134. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  135. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +70 -27
  137. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  138. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +65 -19
  139. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/actions.ts +120 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/onboarding-form.tsx +322 -0
  141. package/templates/apps/web/app/[locale]/(authed)/account/onboarding/page.tsx +126 -0
  142. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +43 -1
  143. package/templates/apps/web/app/[locale]/(authed)/account/privacy/actions.ts +65 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/privacy/page.tsx +226 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  146. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  148. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +44 -34
  149. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  150. package/templates/apps/web/app/[locale]/consent-banner.tsx +32 -0
  151. package/templates/apps/web/app/[locale]/consent.actions.ts +71 -0
  152. package/templates/apps/web/app/[locale]/contact/contact.actions.ts +13 -1
  153. package/templates/apps/web/app/[locale]/flows/[slug]/flow.actions.ts +25 -3
  154. package/templates/apps/web/app/[locale]/layout.tsx +23 -2
  155. package/templates/apps/web/app/[locale]/newsletter/newsletter.actions.ts +12 -1
  156. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe-rsc-boundary.test.tsx +209 -0
  157. package/templates/apps/web/app/[locale]/unsubscribe/__tests__/unsubscribe.test.tsx +158 -0
  158. package/templates/apps/web/app/[locale]/unsubscribe/page.tsx +54 -0
  159. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe-button.tsx +73 -0
  160. package/templates/apps/web/app/[locale]/unsubscribe/unsubscribe.actions.ts +55 -0
  161. package/templates/apps/web/app/robots.txt/route.ts +13 -10
  162. package/templates/apps/web/auth.ts +20 -1
  163. package/templates/apps/web/jest.config.cjs +3 -0
  164. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  165. package/templates/apps/web/lib/__tests__/consent-visitor.test.ts +163 -0
  166. package/templates/apps/web/lib/__tests__/relay.test.ts +49 -0
  167. package/templates/apps/web/lib/__tests__/structured-data.test.tsx +96 -7
  168. package/templates/apps/web/lib/account-config.ts +31 -16
  169. package/templates/apps/web/lib/auth-email.ts +45 -10
  170. package/templates/apps/web/lib/consent-visitor.ts +103 -0
  171. package/templates/apps/web/lib/relay.ts +20 -0
  172. package/templates/apps/web/lib/topology.generated.ts +9 -0
  173. package/templates/apps/web/messages/en.json +121 -18
  174. package/templates/apps/web/next.config.mjs +8 -2
  175. package/templates/database/CHANGELOG.md +55 -0
  176. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  177. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  178. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  179. package/templates/database/__tests__/outbound-message-action.db.test.ts +80 -0
  180. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  181. package/templates/database/__tests__/person-notes.db.test.ts +249 -0
  182. package/templates/database/__tests__/person.db.test.ts +3 -1
  183. package/templates/database/__tests__/piece-kind-migration.test.ts +67 -0
  184. package/templates/database/__tests__/piece-kind.db.test.ts +208 -0
  185. package/templates/database/_gitignore +8 -0
  186. package/templates/database/inbox/schema.prisma +114 -22
  187. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  188. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  189. package/templates/database/migrations/20261015120000_person_notes/migration.sql +77 -0
  190. package/templates/database/migrations/20261015120000_rate_limit_counters/migration.sql +43 -0
  191. package/templates/database/migrations/20261016120000_piece_kind/migration.sql +95 -0
  192. package/templates/database/migrations/20261017120000_outbound_message_action/migration.sql +30 -0
  193. package/templates/database/ops/schema.prisma +16 -0
  194. package/templates/database/package.json +1 -1
  195. package/templates/project.yaml +6 -3
  196. package/versions.json +35 -35
  197. package/templates/.claude/rules/preserve-existing-style.md +0 -51
@@ -25,11 +25,16 @@
25
25
  * there is no subscription without reading `subscription.me`.
26
26
  * - #8217 AC-1 (spec docs/specs/8217-spoke-billing-spoke-checkout-reads-spec.md
27
27
  * D10–D12): Billing reads the seam's `loadSpokeBilling` — the plan, the
28
- * card and Manage in Stripe for a subscription; the plain none line with
28
+ * card and Manage billing for a subscription; the plain none line with
29
29
  * no button for none or a spoke without the action (`null`); the
30
30
  * unreadable line; the error row for a failed read — and never calls
31
31
  * `subscription.me` or `member.billing` itself. `openBillingPortal`
32
32
  * redirects to the loader's portal URL, and to nothing without one.
33
+ * - #8431 AC-4 (spec docs/specs/8431-account-security-tab-human-sign-spec.md
34
+ * D1–D3): Security says the hub manages sign-in and links to the hub's
35
+ * own Security page, offers no password control and never reads the
36
+ * seam's accounts; Log out POSTs the spoke's sign-out endpoint, never
37
+ * Better Auth, and lands on `/account` only when it succeeded.
33
38
  */
34
39
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
35
40
  import { tmpdir } from "node:os";
@@ -73,6 +78,7 @@ jest.mock("next/headers", () => ({
73
78
 
74
79
  const mockReadSession = jest.fn();
75
80
  const mockLoadSpokeBilling = jest.fn();
81
+ const mockReadOwnAccounts = jest.fn();
76
82
  // eslint-disable-next-line no-restricted-syntax -- #507: the seam is generated per build (#7894 D8); its `signIn: "hub"` render is the boundary under test, and no env or HTTP surface selects it.
77
83
  jest.mock("../../../../../lib/topology.generated", () => ({
78
84
  __esModule: true,
@@ -86,6 +92,8 @@ jest.mock("../../../../../lib/topology.generated", () => ({
86
92
  registerSiteStores: () => Promise.resolve(),
87
93
  // #8217 D7 — the billing render's loader; `null` is a spoke without the action.
88
94
  loadSpokeBilling: (...args: unknown[]) => mockLoadSpokeBilling(...args),
95
+ // #8431 D2a — a spoke has no local accounts; Security must never ask.
96
+ readOwnAccounts: (...args: unknown[]) => mockReadOwnAccounts(...args),
89
97
  }));
90
98
 
91
99
  const mockLeadsSubmit = jest.fn();
@@ -126,18 +134,20 @@ jest.mock("@working-theory/api-client", () => ({
126
134
  }));
127
135
 
128
136
  const mockUpdateUser = jest.fn();
137
+ const mockSignOut = jest.fn();
129
138
  jest.mock("better-auth/react", () => ({
130
139
  __esModule: true,
131
140
  createAuthClient: () => ({
132
- signOut: jest.fn(),
141
+ signOut: (...args: unknown[]) => mockSignOut(...args),
133
142
  updateUser: (...args: unknown[]) => mockUpdateUser(...args),
134
143
  }),
135
144
  }));
136
145
 
137
146
  const mockRedirect = jest.fn();
147
+ const mockPush = jest.fn();
138
148
  jest.mock("next/navigation", () => ({
139
149
  __esModule: true,
140
- useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() }),
150
+ useRouter: () => ({ push: mockPush, refresh: jest.fn(), replace: jest.fn() }),
141
151
  usePathname: () => "/account/general",
142
152
  redirect: (...args: unknown[]) => mockRedirect(...args),
143
153
  }));
@@ -196,6 +206,9 @@ beforeEach(() => {
196
206
  mockRedirect.mockReset();
197
207
  mockCookieSet.mockReset();
198
208
  mockUpdateUser.mockReset();
209
+ mockReadOwnAccounts.mockReset().mockResolvedValue(null);
210
+ mockSignOut.mockReset();
211
+ mockPush.mockReset();
199
212
  });
200
213
 
201
214
  afterEach(() => {
@@ -235,27 +248,17 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
235
248
  mockMemberRead.mockResolvedValue({ ok: true, body: [] });
236
249
  });
237
250
 
238
- it("renders the name and photo form and the deletion button, with no link to the hub's settings", async () => {
251
+ it("renders the name and photo form, with no link to the hub's settings", async () => {
239
252
  await renderGeneral();
240
253
  expect(screen.getByTestId("account-general-form")).toBeInTheDocument();
241
254
  expect(screen.getByLabelText("Name")).toHaveValue("Ada Lovelace");
242
255
  // No hub read carries the photo, so the field starts empty (C5).
243
256
  expect(screen.getByLabelText("Photo")).toHaveValue("");
244
- expect(screen.getByTestId("account-deletion-request")).toBeInTheDocument();
245
257
  expect(screen.queryByTestId("account-settings-manage-on-hub")).not.toBeInTheDocument();
246
258
  expect(screen.queryByTestId("account-privacy-manage-on-hub")).not.toBeInTheDocument();
247
259
  expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
248
260
  });
249
261
 
250
- it("the Security tab's sign-in line reads the hub, by the same name read (D7, moved from General)", async () => {
251
- const { default: SecurityPage } = await import("../security/page");
252
- render(await SecurityPage({ params: Promise.resolve({ locale: "en" }) }));
253
- expect(screen.getByTestId("account-signin-method")).toHaveTextContent(
254
- "You sign in through hub.example.com.",
255
- );
256
- expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
257
- });
258
-
259
262
  it("saves the name through member.updateProfile with the Bearer credential, then re-seals the session", async () => {
260
263
  mockUpdateProfile.mockResolvedValue({ ok: true, body: { name: "Ada King", image: null } });
261
264
  mockHubMe.mockResolvedValue({ ok: true, body: { user: { ...USER, name: "Ada King" }, billingCustomerId: null } });
@@ -275,10 +278,32 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
275
278
  user: { name: "Ada King" },
276
279
  });
277
280
  });
281
+ });
278
282
 
279
- it("the deletion request goes through the member API and names the hub operator", async () => {
280
- mockRequestDeletion.mockResolvedValue({ ok: true, body: { sentTo: "hub-operator" } });
283
+ describe("a hub that answers no member route (the fixture's 404)", () => {
284
+ it("shows the name read-only and offers no save", async () => {
281
285
  await renderGeneral();
286
+ expect(screen.getByTestId("account-general-name-text")).toHaveTextContent("Ada Lovelace");
287
+ expect(screen.queryByTestId("account-general-form")).not.toBeInTheDocument();
288
+ expect(screen.queryByLabelText("Photo")).not.toBeInTheDocument();
289
+ expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
290
+ expect(mockUpdateUser).not.toHaveBeenCalled();
291
+ });
292
+ });
293
+
294
+ // #8438 D16 — Request account deletion moved to Privacy; the hub-mode
295
+ // deletion coverage above moves with it, expectations unchanged.
296
+ describe("Privacy's deletion block on a hub-mode spoke (moved from General, #8438 D16)", () => {
297
+ async function renderPrivacy(): Promise<void> {
298
+ const { default: PrivacyPage } = await import("../privacy/page");
299
+ render(await PrivacyPage({ params: Promise.resolve({ locale: "en" }) }));
300
+ }
301
+
302
+ it("a hub that answers the member routes: the deletion request goes through the member API and names the hub operator", async () => {
303
+ mockMemberRead.mockResolvedValue({ ok: true, body: [] });
304
+ mockRequestDeletion.mockResolvedValue({ ok: true, body: { sentTo: "hub-operator" } });
305
+ await renderPrivacy();
306
+ expect(screen.getByTestId("account-deletion-request")).toBeInTheDocument();
282
307
  await act(async () => {
283
308
  fireEvent.click(screen.getByTestId("account-deletion-request"));
284
309
  });
@@ -286,19 +311,12 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
286
311
  await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Sent to the hub operator."));
287
312
  expect(mockLeadsSubmit).not.toHaveBeenCalled();
288
313
  });
289
- });
290
314
 
291
- describe("a hub that answers no member route (the fixture's 404)", () => {
292
- it("shows the name read-only and offers no save and no deletion path", async () => {
293
- await renderGeneral();
294
- expect(screen.getByTestId("account-general-name-text")).toHaveTextContent("Ada Lovelace");
295
- expect(screen.queryByTestId("account-general-form")).not.toBeInTheDocument();
296
- expect(screen.queryByLabelText("Photo")).not.toBeInTheDocument();
315
+ it("a hub that answers no member route: offers no deletion path", async () => {
316
+ await renderPrivacy();
297
317
  expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
298
318
  expect(screen.queryByTestId("account-deletion-support")).not.toBeInTheDocument();
299
319
  expect(screen.getByText("This site can't take deletion requests right now.")).toBeInTheDocument();
300
- expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
301
- expect(mockUpdateUser).not.toHaveBeenCalled();
302
320
  });
303
321
  });
304
322
 
@@ -326,6 +344,77 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
326
344
  });
327
345
  });
328
346
 
347
+ describe("#8431 AC-4 — Security on a hub-mode spoke", () => {
348
+ const realFetch = Object.getOwnPropertyDescriptor(globalThis, "fetch");
349
+ const mockFetch = jest.fn();
350
+
351
+ beforeEach(() => {
352
+ mockFetch.mockReset();
353
+ Object.defineProperty(globalThis, "fetch", { value: mockFetch, writable: true, configurable: true });
354
+ });
355
+
356
+ afterEach(() => {
357
+ if (realFetch) Object.defineProperty(globalThis, "fetch", realFetch);
358
+ else Reflect.deleteProperty(globalThis, "fetch");
359
+ });
360
+
361
+ async function renderSecurity(): Promise<void> {
362
+ const { default: SecurityPage } = await import("../security/page");
363
+ render(await SecurityPage({ params: Promise.resolve({ locale: "en" }) }));
364
+ }
365
+
366
+ it("says the hub manages sign-in and links to the hub's Security page — no password control", async () => {
367
+ await renderSecurity();
368
+ expect(screen.getByTestId("account-security-hub").textContent).toBe(
369
+ "Your sign-in is managed at hub.example.com.",
370
+ );
371
+ const links = screen.getAllByTestId("account-security-hub-link");
372
+ expect(links).toHaveLength(1);
373
+ expect(links[0]).toHaveAttribute("href", `${HUB}/account/security`);
374
+ expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
375
+ expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
376
+ expect(screen.queryByTestId("account-security-sentence")).not.toBeInTheDocument();
377
+ expect(screen.queryByText(/other devices/i)).toBeNull();
378
+ expect(mockReadOwnAccounts).not.toHaveBeenCalled();
379
+ });
380
+
381
+ it("with the hub config absent says 'our main site' and links nowhere", async () => {
382
+ delete process.env.AILK_HUB_CLIENT_SECRET;
383
+ await renderSecurity();
384
+ expect(screen.getByTestId("account-security-hub").textContent).toBe(
385
+ "Your sign-in is managed by our main site.",
386
+ );
387
+ expect(screen.queryByTestId("account-security-hub-link")).not.toBeInTheDocument();
388
+ expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
389
+ });
390
+
391
+ it("Log out POSTs the spoke's sign-out endpoint, never Better Auth, and on a 204 lands on /account", async () => {
392
+ mockFetch.mockResolvedValue({ ok: true, status: 204 });
393
+ await renderSecurity();
394
+ await act(async () => {
395
+ fireEvent.click(screen.getByTestId("account-security-log-out"));
396
+ });
397
+ expect(mockFetch).toHaveBeenCalledTimes(1);
398
+ expect(mockFetch).toHaveBeenCalledWith("/api/hub-session/sign-out", {
399
+ method: "POST",
400
+ credentials: "same-origin",
401
+ });
402
+ expect(mockSignOut).not.toHaveBeenCalled();
403
+ expect(mockPush).toHaveBeenCalledWith("/account");
404
+ });
405
+
406
+ it("a refused sign-out (403) navigates nowhere and shows the error", async () => {
407
+ mockFetch.mockResolvedValue({ ok: false, status: 403 });
408
+ await renderSecurity();
409
+ await act(async () => {
410
+ fireEvent.click(screen.getByTestId("account-security-log-out"));
411
+ });
412
+ expect(mockPush).not.toHaveBeenCalled();
413
+ expect(mockSignOut).not.toHaveBeenCalled();
414
+ expect(screen.getByTestId("account-security-log-out-error")).toBeInTheDocument();
415
+ });
416
+ });
417
+
329
418
  describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke billing (D10–D12)", () => {
330
419
  const VISA = { brand: "visa", last4: "4242", expMonth: 4, expYear: 2030 };
331
420
  const PORTAL_URL = "https://billing.stripe.com/p/session/test_fake";
@@ -347,7 +436,7 @@ describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke bill
347
436
  expect(mockMemberBilling).not.toHaveBeenCalled();
348
437
  });
349
438
 
350
- it("a subscription renders the plan, the card and Manage in Stripe", async () => {
439
+ it("a subscription renders the plan, the card and Manage billing", async () => {
351
440
  mockLoadSpokeBilling.mockResolvedValue(
352
441
  stub({ kind: "subscription", status: "active", planName: "Pro", paymentMethod: VISA, unreadable: false }),
353
442
  );
@@ -356,7 +445,7 @@ describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke bill
356
445
  expect(card).toHaveTextContent("Pro");
357
446
  expect(card).toHaveTextContent("Active");
358
447
  expect(card).toHaveTextContent("visa ending in 4242, expires 04/2030");
359
- expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage in Stripe");
448
+ expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage billing");
360
449
  expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
361
450
  expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
362
451
  });
@@ -9,8 +9,12 @@
9
9
  * 2. An unknown kind, or an id the kind did not load, is a not-found —
10
10
  * never an empty frame.
11
11
  * 3. A failed read renders an error with Retry (D12's posture).
12
+ * 4. A message whose `action` is `onboarding` has an Open onboarding
13
+ * action to the locale's `/account/onboarding`; a message with no
14
+ * action has none (#8515 D6, AC-4).
12
15
  *
13
- * The `purchase` kind reads `member.purchases` (#8231 D5).
16
+ * The `purchase` kind reads `member.purchases` (#8231 D5); the `message`
17
+ * kind reads `member.messages`.
14
18
  */
15
19
  import { fireEvent, render, screen, within } from "@testing-library/react";
16
20
 
@@ -52,11 +56,15 @@ jest.mock("../../../../../auth", () => ({
52
56
 
53
57
  const mockSignupMe = jest.fn();
54
58
  const mockMemberPurchases = jest.fn();
59
+ const mockMemberMessages = jest.fn();
55
60
  jest.mock("@working-theory/api-client", () => ({
56
61
  __esModule: true,
57
62
  waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
58
63
  subscription: { me: jest.fn() },
59
- member: { purchases: (...args: unknown[]) => mockMemberPurchases(...args) },
64
+ member: {
65
+ purchases: (...args: unknown[]) => mockMemberPurchases(...args),
66
+ messages: (...args: unknown[]) => mockMemberMessages(...args),
67
+ },
60
68
  }));
61
69
 
62
70
  const mockNotFound = jest.fn(() => {
@@ -85,9 +93,30 @@ const PURCHASE = {
85
93
  purchasedAt: "2026-09-04T10:00:00.000Z",
86
94
  };
87
95
 
96
+ const MESSAGE = {
97
+ id: "int_plain",
98
+ subject: "Hello",
99
+ body: "A note.",
100
+ file: null,
101
+ action: null,
102
+ createdAt: "2026-10-14T09:00:00.000Z",
103
+ readAt: null,
104
+ };
105
+ const MESSAGES = [
106
+ MESSAGE,
107
+ {
108
+ ...MESSAGE,
109
+ id: "int_onboarding",
110
+ subject: "Your onboarding questions",
111
+ body: "Open this message in your account and choose Open onboarding.",
112
+ action: "onboarding",
113
+ },
114
+ ];
115
+
88
116
  beforeEach(() => {
89
117
  mockGetSession.mockReset().mockResolvedValue(SESSION);
90
118
  mockMemberPurchases.mockReset().mockResolvedValue({ ok: true, body: { items: [PURCHASE] } });
119
+ mockMemberMessages.mockReset().mockResolvedValue({ ok: true, body: { items: MESSAGES } });
91
120
  mockSignupMe.mockReset();
92
121
  mockNotFound.mockClear();
93
122
  mockRefresh.mockReset();
@@ -139,3 +168,29 @@ describe("one Inbox item", () => {
139
168
  expect(mockRefresh).toHaveBeenCalledTimes(1);
140
169
  });
141
170
  });
171
+
172
+ describe("a message's onboarding action (#8515 D6, AC-4)", () => {
173
+ it("an onboarding message links to the locale's onboarding page", async () => {
174
+ await renderItem("message", "int_onboarding", "fr");
175
+ const links = screen
176
+ .getAllByRole("link", { name: "Open onboarding" })
177
+ .map((link) => link.getAttribute("href"));
178
+ expect(links.length).toBeGreaterThan(0);
179
+ expect(links.every((href) => href === "/fr/account/onboarding")).toBe(true);
180
+ expect(screen.getByTestId("account-item-action-onboarding-desk")).toHaveAttribute(
181
+ "href",
182
+ "/fr/account/onboarding",
183
+ );
184
+ // The body stays plain text.
185
+ expect(
186
+ screen.getByText("Open this message in your account and choose Open onboarding.").closest("a"),
187
+ ).toBeNull();
188
+ });
189
+
190
+ it("a message with no action has no onboarding link", async () => {
191
+ await renderItem("message", "int_plain", "fr");
192
+ expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("Hello");
193
+ expect(screen.queryByRole("link", { name: "Open onboarding" })).not.toBeInTheDocument();
194
+ expect(screen.queryByTestId("account-item-action-onboarding-desk")).not.toBeInTheDocument();
195
+ });
196
+ });
@@ -0,0 +1,146 @@
1
+ /**
2
+ * The account layout hands the generated seam's sign-out wiring to the client
3
+ * frame (#8465): a client component cannot import `topology.generated.ts`, so
4
+ * the server layout reads `SIGN_OUT_ENDPOINT` / `SIGN_IN_PATH` and passes them
5
+ * as props — the signed-in Nav's own pattern. Also pins that the Help center
6
+ * href is rendered only for an https URL (the #8467 review's Low finding).
7
+ */
8
+ import { render } from "@testing-library/react";
9
+ import type { ReactElement } from "react";
10
+
11
+ jest.mock("next-intl/server", () => ({
12
+ __esModule: true,
13
+ getTranslations: async (arg: { namespace?: string }) => (key: string) =>
14
+ `${arg.namespace}.${key}`,
15
+ }));
16
+
17
+ let mockSeam: {
18
+ SIGN_IN_PATH: string | null;
19
+ SIGN_OUT_ENDPOINT: string | null;
20
+ } = {
21
+ SIGN_IN_PATH: "/sign-in",
22
+ SIGN_OUT_ENDPOINT: null,
23
+ };
24
+ // eslint-disable-next-line no-restricted-syntax -- #507: the seam is generated per build (#7894 D8); its `signIn: "hub"` render is the boundary under test, and no env or HTTP surface selects it.
25
+ jest.mock("../../../../../lib/topology.generated", () => ({
26
+ __esModule: true,
27
+ get SIGN_IN_PATH() {
28
+ return mockSeam.SIGN_IN_PATH;
29
+ },
30
+ get SIGN_OUT_ENDPOINT() {
31
+ return mockSeam.SIGN_OUT_ENDPOINT;
32
+ },
33
+ }));
34
+
35
+ // eslint-disable-next-line no-restricted-syntax -- #507: test-seam mock; the layout under test composes these account modules and the suite asserts only the sign-out endpoint it forwards
36
+ jest.mock("../account-data", () => ({
37
+ __esModule: true,
38
+ accountSession: async () => ({
39
+ ctx: { user: { name: "Ada", email: "ada@example.com" }, t: {} },
40
+ }),
41
+ loadForYou: async () => ({ items: [], now: "2026-10-12T00:00:00.000Z" }),
42
+ }));
43
+
44
+ // eslint-disable-next-line no-restricted-syntax -- #507: test-seam mock; the layout under test composes these account modules and the suite asserts only the sign-out endpoint it forwards
45
+ jest.mock("../for-you-labels", () => ({
46
+ __esModule: true,
47
+ forYouLabels: () => ({}),
48
+ }));
49
+ // eslint-disable-next-line no-restricted-syntax -- #507: test-seam mock; the layout under test composes these account modules and the suite asserts only the sign-out endpoint it forwards
50
+ jest.mock("../../../../../lib/site-logo", () => ({
51
+ __esModule: true,
52
+ siteLogo: () => null,
53
+ }));
54
+
55
+ const mockFrame = jest.fn();
56
+ // eslint-disable-next-line no-restricted-syntax -- #507: test-seam mock; the layout under test composes these account modules and the suite asserts only the sign-out endpoint it forwards
57
+ jest.mock("../account-frame", () => ({
58
+ __esModule: true,
59
+ AccountFrame: (props: Record<string, unknown>) => {
60
+ mockFrame(props);
61
+ return null;
62
+ },
63
+ }));
64
+
65
+ import AccountLayout from "../layout";
66
+
67
+ const ENV = process.env;
68
+
69
+ async function renderLayout(locale = "en"): Promise<Record<string, unknown>> {
70
+ const node = (await AccountLayout({
71
+ children: null,
72
+ params: Promise.resolve({ locale }),
73
+ })) as ReactElement;
74
+ render(node);
75
+ return mockFrame.mock.calls[0]![0] as Record<string, unknown>;
76
+ }
77
+
78
+ beforeEach(() => {
79
+ mockFrame.mockReset();
80
+ mockSeam = { SIGN_IN_PATH: "/sign-in", SIGN_OUT_ENDPOINT: null };
81
+ process.env = { ...ENV };
82
+ delete process.env.AILK_HELP_CENTER_URL;
83
+ });
84
+
85
+ afterEach(() => {
86
+ process.env = ENV;
87
+ });
88
+
89
+ describe("sign-out wiring (#8465)", () => {
90
+ it("passes the endpoint and the localized sign-in path on a hub sign-in spoke", async () => {
91
+ mockSeam = {
92
+ SIGN_IN_PATH: "/account",
93
+ SIGN_OUT_ENDPOINT: "/api/hub-session/sign-out",
94
+ };
95
+ const props = await renderLayout("fr");
96
+ expect(props.signOutEndpoint).toBe("/api/hub-session/sign-out");
97
+ expect(props.signInHref).toBe("/fr/account");
98
+ });
99
+
100
+ it("falls back to the home when the seam names no sign-in path", async () => {
101
+ mockSeam = {
102
+ SIGN_IN_PATH: null,
103
+ SIGN_OUT_ENDPOINT: "/api/hub-session/sign-out",
104
+ };
105
+ expect((await renderLayout()).signInHref).toBe("/");
106
+ });
107
+
108
+ it("passes neither on a hub or a single site (no endpoint)", async () => {
109
+ const props = await renderLayout();
110
+ expect(props).not.toHaveProperty("signOutEndpoint");
111
+ expect(props).not.toHaveProperty("signInHref");
112
+ });
113
+
114
+ it("carries the failed-sign-out label to the frame", async () => {
115
+ const props = await renderLayout();
116
+ expect((props.labels as Record<string, string>).signOutFailed).toBe(
117
+ "account.frame.signOutFailed",
118
+ );
119
+ });
120
+ });
121
+
122
+ describe("Help center href (#8467 review, Low)", () => {
123
+ const helpItems = (
124
+ props: Record<string, unknown>,
125
+ ): { id: string; href: string }[] =>
126
+ props.helpItems as { id: string; href: string }[];
127
+
128
+ it("renders an https URL", async () => {
129
+ process.env.AILK_HELP_CENTER_URL = "https://help.example.com";
130
+ expect(helpItems(await renderLayout()).map((i) => i.id)).toContain(
131
+ "help-center",
132
+ );
133
+ });
134
+
135
+ it.each([
136
+ "javascript:alert(1)",
137
+ "http://help.example.com",
138
+ "not a url",
139
+ "data:text/html,x",
140
+ ])("drops %s", async (value) => {
141
+ process.env.AILK_HELP_CENTER_URL = value;
142
+ expect(helpItems(await renderLayout()).map((i) => i.id)).not.toContain(
143
+ "help-center",
144
+ );
145
+ });
146
+ });