create-ailk 0.19.0 → 0.19.1

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 (91) hide show
  1. package/dist/add-module.js +7 -2
  2. package/dist/index.d.ts +1 -1
  3. package/dist/index.js +1 -1
  4. package/dist/lib/scaffold-render-sweep.d.ts +23 -0
  5. package/dist/lib/scaffold-render-sweep.js +52 -0
  6. package/dist/module-architecture.js +9 -3
  7. package/dist/programmatic.js +8 -1
  8. package/dist/site-roster.js +21 -4
  9. package/dist/surfaces.d.ts +14 -0
  10. package/dist/surfaces.js +31 -1
  11. package/dist/topology.js +29 -1
  12. package/package.json +3 -3
  13. package/templates/apps/api/.env.example +17 -1
  14. package/templates/apps/api/CLAUDE.md +10 -4
  15. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  16. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  17. package/templates/apps/api/src/lib/__mocks__/prisma.ts +8 -0
  18. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  19. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  20. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  21. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  22. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  23. package/templates/apps/api/src/lib/stripe.ts +97 -5
  24. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +10 -4
  25. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  26. package/templates/apps/api/src/middleware/auth.ts +11 -3
  27. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  28. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  29. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  30. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  31. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  32. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  33. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  34. package/templates/apps/api/src/routes/leads/index.ts +15 -0
  35. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  36. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  37. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  38. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  39. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  40. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  41. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  42. package/templates/apps/api/src/server.ts +111 -16
  43. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  44. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  45. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +115 -4
  46. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  47. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  48. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  49. package/templates/apps/api/src/services/consent.ts +488 -3
  50. package/templates/apps/api/src/services/lead-routing.ts +105 -12
  51. package/templates/apps/api/src/services/person.ts +501 -1
  52. package/templates/apps/api/src/services/project-listings.ts +7 -2
  53. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  54. package/templates/apps/api/src/services/revalidation.ts +11 -1
  55. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  56. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +17 -3
  57. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +156 -0
  58. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +89 -14
  59. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  60. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +122 -18
  61. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +15 -1
  62. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +290 -20
  63. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +55 -0
  64. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +74 -4
  65. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +4 -7
  66. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +3 -3
  67. package/templates/apps/web/app/[locale]/(authed)/account/general/contact-actions.ts +81 -0
  68. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +66 -4
  69. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +26 -3
  70. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +2 -2
  71. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +89 -37
  72. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  74. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +41 -33
  75. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +1 -1
  76. package/templates/apps/web/auth.ts +20 -1
  77. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  78. package/templates/apps/web/lib/auth-email.ts +45 -10
  79. package/templates/apps/web/lib/topology.generated.ts +9 -0
  80. package/templates/apps/web/messages/en.json +54 -14
  81. package/templates/database/CHANGELOG.md +16 -0
  82. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  83. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  84. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  85. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  86. package/templates/database/__tests__/person.db.test.ts +3 -1
  87. package/templates/database/inbox/schema.prisma +76 -10
  88. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  89. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  90. package/templates/database/package.json +1 -1
  91. package/versions.json +35 -35
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Log out in AccountFrame (#8465).
3
+ *
4
+ * A spoke that signs in through its hub holds its session in the sealed
5
+ * hub-session cookie, which only `POST /api/hub-session/sign-out` clears
6
+ * (and which also revokes the hub credential); Better Auth's `signOut()` hits
7
+ * a route that spoke does not serve. The server layout hands the seam's
8
+ * `SIGN_OUT_ENDPOINT` down as `signOutEndpoint`:
9
+ *
10
+ * - with an endpoint, Log out POSTs it (same-origin), never calls Better
11
+ * Auth, and lands on the sign-in page once the answer is 2xx;
12
+ * - a refused or failed sign-out (403 on an Origin mismatch, 5xx, a
13
+ * rejected fetch) stays on the page, shows the translated error, and
14
+ * navigates nowhere — the cookie is still valid (#8467 review addendum);
15
+ * - without an endpoint (a hub or a single site) it calls Better Auth and
16
+ * routes home, as before.
17
+ */
18
+ import { fireEvent, render, screen, waitFor } from "@testing-library/react";
19
+ import type { ReactNode } from "react";
20
+
21
+ const mockSignOut = jest.fn();
22
+ jest.mock("better-auth/react", () => ({
23
+ __esModule: true,
24
+ createAuthClient: () => ({
25
+ signOut: (...args: unknown[]) => mockSignOut(...args),
26
+ }),
27
+ }));
28
+
29
+ const mockPush = jest.fn();
30
+ const mockRefresh = jest.fn();
31
+ jest.mock("next/navigation", () => ({
32
+ __esModule: true,
33
+ useRouter: () => ({ push: mockPush, refresh: mockRefresh }),
34
+ usePathname: () => "/account",
35
+ useSearchParams: () => new URLSearchParams(""),
36
+ }));
37
+
38
+ jest.mock("@working-theory/templates/shells", () => ({
39
+ __esModule: true,
40
+ SignedInShell: ({
41
+ onSignOut,
42
+ children,
43
+ }: {
44
+ onSignOut: () => void;
45
+ children: ReactNode;
46
+ }) => (
47
+ <div>
48
+ <button type="button" data-testid="sign-out" onClick={onSignOut} />
49
+ {children}
50
+ </div>
51
+ ),
52
+ }));
53
+
54
+ jest.mock("@working-theory/templates/account", () => ({
55
+ __esModule: true,
56
+ ForYouPanel: () => null,
57
+ PhoneTabProvider: ({ children }: { children: ReactNode }) => children,
58
+ useForYouDismissed: () => [new Set<string>(), jest.fn()],
59
+ }));
60
+
61
+ import { AccountFrame, type AccountFrameProps } from "../account-frame";
62
+
63
+ const ENDPOINT = "/api/hub-session/sign-out";
64
+ const FAILED = "We couldn't log you out. You're still signed in; try again.";
65
+ const mockFetch = jest.fn();
66
+
67
+ function renderFrame(extra: Partial<AccountFrameProps> = {}): void {
68
+ render(
69
+ <AccountFrame
70
+ locale="en"
71
+ user={{ name: "Ada", email: "ada@example.com" }}
72
+ logo={null}
73
+ helpItems={[]}
74
+ // eslint-disable-next-line no-restricted-syntax -- partial fixture: the suite exercises only the sign-out form, so the full forYou/labels shapes are not built
75
+ forYou={{ items: [] } as unknown as AccountFrameProps["forYou"]}
76
+ labels={
77
+ // eslint-disable-next-line no-restricted-syntax -- partial fixture (see forYou above)
78
+ {
79
+ home: "Home",
80
+ signOutFailed: FAILED,
81
+ } as unknown as AccountFrameProps["labels"]
82
+ }
83
+ {...extra}
84
+ >
85
+ <p>page</p>
86
+ </AccountFrame>,
87
+ );
88
+ }
89
+
90
+ beforeEach(() => {
91
+ jest.clearAllMocks();
92
+ globalThis.fetch = mockFetch.mockResolvedValue({ ok: true, status: 204 });
93
+ });
94
+
95
+ describe("AccountFrame Log out — a spoke that signs in through its hub", () => {
96
+ it("POSTs the hub sign-out endpoint, never Better Auth, then lands on the sign-in page and refreshes", async () => {
97
+ renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
98
+ fireEvent.click(screen.getByTestId("sign-out"));
99
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/account"));
100
+ expect(mockFetch).toHaveBeenCalledWith(ENDPOINT, {
101
+ method: "POST",
102
+ credentials: "same-origin",
103
+ });
104
+ expect(mockSignOut).not.toHaveBeenCalled();
105
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
106
+ expect(
107
+ screen.queryByTestId("account-sign-out-error"),
108
+ ).not.toBeInTheDocument();
109
+ });
110
+
111
+ it("falls back to the site home when no sign-in href is given", async () => {
112
+ renderFrame({ signOutEndpoint: ENDPOINT });
113
+ fireEvent.click(screen.getByTestId("sign-out"));
114
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
115
+ });
116
+
117
+ it.each([
118
+ ["a 403 (Origin mismatch)", { ok: false, status: 403 }],
119
+ ["a 500", { ok: false, status: 500 }],
120
+ ["a 404", { ok: false, status: 404 }],
121
+ ])(
122
+ "%s stays on the page with the error and routes nowhere",
123
+ async (_label, response) => {
124
+ mockFetch.mockResolvedValue(response);
125
+ renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
126
+ fireEvent.click(screen.getByTestId("sign-out"));
127
+ expect(
128
+ await screen.findByTestId("account-sign-out-error"),
129
+ ).toHaveTextContent(FAILED);
130
+ expect(mockPush).not.toHaveBeenCalled();
131
+ expect(mockRefresh).not.toHaveBeenCalled();
132
+ expect(mockSignOut).not.toHaveBeenCalled();
133
+ },
134
+ );
135
+
136
+ it("a rejected request stays on the page with the error and routes nowhere", async () => {
137
+ mockFetch.mockRejectedValue(new TypeError("network down"));
138
+ renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
139
+ fireEvent.click(screen.getByTestId("sign-out"));
140
+ expect(
141
+ await screen.findByTestId("account-sign-out-error"),
142
+ ).toHaveTextContent(FAILED);
143
+ expect(mockPush).not.toHaveBeenCalled();
144
+ expect(mockRefresh).not.toHaveBeenCalled();
145
+ });
146
+
147
+ it("a retry that succeeds clears the error and lands on the sign-in page", async () => {
148
+ mockFetch.mockResolvedValueOnce({ ok: false, status: 500 });
149
+ renderFrame({ signOutEndpoint: ENDPOINT, signInHref: "/account" });
150
+ fireEvent.click(screen.getByTestId("sign-out"));
151
+ await screen.findByTestId("account-sign-out-error");
152
+ fireEvent.click(screen.getByTestId("sign-out"));
153
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/account"));
154
+ expect(
155
+ screen.queryByTestId("account-sign-out-error"),
156
+ ).not.toBeInTheDocument();
157
+ });
158
+ });
159
+
160
+ describe("AccountFrame Log out — a hub or a single site", () => {
161
+ it.each([
162
+ ["absent", {}],
163
+ ["null", { signOutEndpoint: null }],
164
+ ])(
165
+ "with the endpoint %s calls Better Auth, then routes home and refreshes",
166
+ async (_label, extra) => {
167
+ renderFrame(extra);
168
+ fireEvent.click(screen.getByTestId("sign-out"));
169
+ await waitFor(() => expect(mockPush).toHaveBeenCalledWith("/"));
170
+ expect(mockSignOut).toHaveBeenCalledTimes(1);
171
+ expect(mockFetch).not.toHaveBeenCalled();
172
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
173
+ },
174
+ );
175
+ });
@@ -131,8 +131,22 @@ describe("the frame (D7)", () => {
131
131
  });
132
132
  });
133
133
 
134
+ describe("the standalone shape (#8430)", () => {
135
+ it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
136
+ await renderPage();
137
+ const settings = screen.getByTestId("account-settings");
138
+ expect(settings).toHaveClass("min-h-screen");
139
+ const column = within(settings).getByRole("complementary");
140
+ expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
141
+ expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
142
+ expect(within(settings).getByRole("main")).toContainElement(
143
+ screen.getByRole("heading", { level: 1 }),
144
+ );
145
+ });
146
+ });
147
+
134
148
  describe("AC-5 — Billing as the current-plan card (D8)", () => {
135
- it("(a) shows the plan, the status, the card and Manage in Stripe under the Current plan heading", async () => {
149
+ it("(a) shows the plan, the status, the card and Manage billing under the Current plan heading", async () => {
136
150
  await renderPage();
137
151
  expect(screen.getByText("Current plan")).toBeInTheDocument();
138
152
  const card = screen.getByTestId("account-billing-card");
@@ -141,7 +155,7 @@ describe("AC-5 — Billing as the current-plan card (D8)", () => {
141
155
  expect(within(card).getByTestId("account-billing-payment")).toHaveTextContent(
142
156
  "visa ending in 4242, expires 04/2027",
143
157
  );
144
- expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage in Stripe");
158
+ expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage billing");
145
159
  expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
146
160
  expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
147
161
  });
@@ -262,7 +276,7 @@ describe("AC-2 (e) — contributed billing sections render after the base conten
262
276
  });
263
277
  });
264
278
 
265
- describe("#8217 AC-1 — the single site's Manage in Stripe (D10, D11)", () => {
279
+ describe("#8217 AC-1 — the single site's Manage billing (D10, D11)", () => {
266
280
  const PORTAL_URL = "https://billing.stripe.com/p/session/test_single_site";
267
281
 
268
282
  it("mints the portal once through the hub's route with the cookie init, then redirects there", async () => {
@@ -71,6 +71,8 @@ jest.mock("../../../../../auth", () => ({
71
71
  const mockPieces = jest.fn();
72
72
  const mockUpdateProfile = jest.fn();
73
73
  const mockRequestDeletion = jest.fn();
74
+ const mockContact = jest.fn();
75
+ const mockUpdateContact = jest.fn();
74
76
  const mockHubMe = jest.fn();
75
77
  const mockLeadsSubmit = jest.fn();
76
78
  jest.mock("@working-theory/api-client", () => ({
@@ -83,6 +85,8 @@ jest.mock("@working-theory/api-client", () => ({
83
85
  pieces: (...args: unknown[]) => mockPieces(...args),
84
86
  updateProfile: (...args: unknown[]) => mockUpdateProfile(...args),
85
87
  requestDeletion: (...args: unknown[]) => mockRequestDeletion(...args),
88
+ contact: (...args: unknown[]) => mockContact(...args),
89
+ updateContact: (...args: unknown[]) => mockUpdateContact(...args),
86
90
  },
87
91
  hubSignIn: { me: (...args: unknown[]) => mockHubMe(...args) },
88
92
  visitorForwardHeaders: () => null,
@@ -120,6 +124,7 @@ import type { AccountSettingsSection } from "@working-theory/templates/account";
120
124
  import { accountSettingsSections } from "../../../../../lib/account-config";
121
125
  import { seal } from "../../../../../lib/hub-session/seal";
122
126
  import { BillingAnchor } from "../general/billing-anchor";
127
+ import { saveContact } from "../general/contact-actions";
123
128
  import Page from "../general/page";
124
129
  import { requestAccountDeletion, saveProfile } from "../profile-actions";
125
130
 
@@ -179,6 +184,8 @@ beforeEach(() => {
179
184
  mockPieces.mockReset().mockResolvedValue(ok([]));
180
185
  mockUpdateProfile.mockReset().mockResolvedValue(ok({ name: "Ada King", image: PHOTO }));
181
186
  mockRequestDeletion.mockReset().mockResolvedValue(ok({ sentTo: "site-operator" }));
187
+ mockContact.mockReset().mockResolvedValue(ok({ contact: null }));
188
+ mockUpdateContact.mockReset().mockResolvedValue(ok({ contact: null }));
182
189
  mockHubMe.mockReset();
183
190
  mockLeadsSubmit.mockReset();
184
191
  mockUpdateUser.mockReset().mockResolvedValue({ data: { status: true }, error: null });
@@ -296,6 +303,20 @@ describe("AC-1 — the frame (D2, D7)", () => {
296
303
  });
297
304
  });
298
305
 
306
+ describe("the standalone shape (#8430)", () => {
307
+ it("takes the screen as the Admin's settings do: the rail column and the pane are the page's own landmarks", async () => {
308
+ await renderPage();
309
+ const settings = screen.getByTestId("account-settings");
310
+ expect(settings).toHaveClass("min-h-screen");
311
+ const column = within(settings).getByRole("complementary");
312
+ expect(column.firstElementChild).toBe(screen.getByTestId("account-settings-back"));
313
+ expect(column).toContainElement(screen.getByTestId("account-settings-rail"));
314
+ expect(within(settings).getByRole("main")).toContainElement(
315
+ screen.getByRole("heading", { level: 1 }),
316
+ );
317
+ });
318
+ });
319
+
299
320
  // ── 2. The profile form on the member path (AC-3) ─────────────────────────────
300
321
 
301
322
  describe("AC-3 — name and photo through the member API (D4, D8)", () => {
@@ -482,6 +503,141 @@ describe("AC-3 — a single site with no member API (a 404 from the pieces read)
482
503
  });
483
504
  });
484
505
 
506
+ // ── #8435 D13 (AC-6) — the contact details on this site ───────────────────────
507
+
508
+ describe("AC-6 — the contact details (#8435 D13)", () => {
509
+ const CONTACT = {
510
+ givenName: "Ada",
511
+ familyName: "Lovelace",
512
+ telephone: "+442071234567",
513
+ address: {
514
+ addressLine1: "1 Analytical Way",
515
+ addressLine2: null,
516
+ city: "London",
517
+ region: "LDN",
518
+ postalCode: "N1 1AA",
519
+ country: "GB",
520
+ },
521
+ companyName: "Engines Ltd",
522
+ jobTitle: "Countess",
523
+ };
524
+
525
+ it("with the member API, renders the contact section seeded from member.contact", async () => {
526
+ mockContact.mockResolvedValue(ok({ contact: CONTACT }));
527
+ await renderPage();
528
+ const section = screen.getByTestId("account-contact-section");
529
+ expect(mockContact).toHaveBeenCalledWith(COOKIE_INIT);
530
+ expect(within(section).getByTestId("account-contact-given-name")).toHaveValue("Ada");
531
+ expect(within(section).getByTestId("account-contact-company")).toHaveValue("Engines Ltd");
532
+ expect(within(section).getByTestId("field-addressLine1")).toHaveValue("1 Analytical Way");
533
+ // The profile form's single "Save changes" button stays unique.
534
+ expect(screen.getAllByRole("button", { name: "Save changes" })).toHaveLength(1);
535
+ expect(within(section).getByTestId("account-contact-save")).toHaveTextContent("Save contact details");
536
+ });
537
+
538
+ it("with no confirmed contact yet, the form starts blank", async () => {
539
+ await renderPage();
540
+ expect(screen.getByTestId("account-contact-given-name")).toHaveValue("");
541
+ });
542
+
543
+ it("a failed read renders the section's unavailable line, not the form", async () => {
544
+ mockContact.mockResolvedValue(refusal(503, "tenant_unresolved"));
545
+ await renderPage();
546
+ expect(screen.queryByTestId("account-contact-form")).not.toBeInTheDocument();
547
+ expect(screen.getByTestId("account-contact-unavailable")).toHaveTextContent(
548
+ "Contact details aren't available on this site right now.",
549
+ );
550
+ });
551
+
552
+ it("a read that throws is the same unavailable line", async () => {
553
+ mockContact.mockRejectedValue(new Error("down"));
554
+ await renderPage();
555
+ expect(screen.getByTestId("account-contact-unavailable")).toBeInTheDocument();
556
+ });
557
+
558
+ it("with no member API (a 404 from the pieces read) the section does not render", async () => {
559
+ mockPieces.mockResolvedValue(refusal(404, "not_found"));
560
+ await renderPage();
561
+ expect(screen.queryByTestId("account-contact-section")).not.toBeInTheDocument();
562
+ expect(mockContact).not.toHaveBeenCalled();
563
+ });
564
+
565
+ it("saving posts all six keys through saveContact and answers Saved", async () => {
566
+ mockContact.mockResolvedValue(ok({ contact: CONTACT }));
567
+ await renderPage();
568
+ fireEvent.change(screen.getByTestId("account-contact-company"), { target: { value: "Acme" } });
569
+ await act(async () => {
570
+ fireEvent.submit(screen.getByTestId("account-contact-form"));
571
+ });
572
+ await waitFor(() => expect(mockUpdateContact).toHaveBeenCalledTimes(1));
573
+ expect(mockUpdateContact).toHaveBeenCalledWith(
574
+ {
575
+ givenName: "Ada",
576
+ familyName: "Lovelace",
577
+ telephone: "+442071234567",
578
+ address: CONTACT.address,
579
+ companyName: "Acme",
580
+ jobTitle: "Countess",
581
+ },
582
+ COOKIE_INIT,
583
+ );
584
+ expect(await screen.findByTestId("account-contact-message")).toHaveTextContent("Saved.");
585
+ });
586
+
587
+ it("a refused telephone shows its own line and keeps the fields as typed", async () => {
588
+ mockUpdateContact.mockResolvedValue(refusal(400, "telephone_invalid"));
589
+ await renderPage();
590
+ fireEvent.change(screen.getByTestId("account-contact-company"), { target: { value: "Acme" } });
591
+ await act(async () => {
592
+ fireEvent.submit(screen.getByTestId("account-contact-form"));
593
+ });
594
+ expect(await screen.findByTestId("account-contact-message")).toHaveTextContent(
595
+ "Enter the phone number with its country code.",
596
+ );
597
+ expect(screen.getByTestId("account-contact-company")).toHaveValue("Acme");
598
+ });
599
+ });
600
+
601
+ describe("AC-6 — saveContact (#8435 D13)", () => {
602
+ const BODY = {
603
+ givenName: "Ada",
604
+ familyName: null,
605
+ telephone: null,
606
+ address: null,
607
+ companyName: null,
608
+ jobTitle: null,
609
+ };
610
+
611
+ it("calls member.updateContact with forwardedCookieInit()'s init", async () => {
612
+ mockHeaders.mockResolvedValue(new Headers({ origin: "https://site.example.com" }));
613
+ await expect(saveContact(BODY)).resolves.toEqual({ ok: true });
614
+ expect(mockUpdateContact).toHaveBeenCalledWith(BODY, {
615
+ headers: { cookie: "session=abc", origin: "https://site.example.com" },
616
+ });
617
+ });
618
+
619
+ it.each([
620
+ [refusal(400, "telephone_invalid"), "telephone"],
621
+ [refusal(400, "invalid_input"), "invalid"],
622
+ [refusal(403, "email_unverified"), "unverified"],
623
+ [refusal(404, "not_found"), "unavailable"],
624
+ [refusal(409, "contact_unavailable"), "unavailable"],
625
+ [refusal(503, "tenant_unresolved"), "unavailable"],
626
+ [refusal(500, "internal_error"), "failed"],
627
+ ])("maps %j to %s", async (res, reason) => {
628
+ mockUpdateContact.mockResolvedValue(res);
629
+ await expect(saveContact(BODY)).resolves.toEqual({ ok: false, reason });
630
+ });
631
+
632
+ it("a throw is failed, and a non-object body makes no call", async () => {
633
+ mockUpdateContact.mockRejectedValue(new Error("down"));
634
+ await expect(saveContact(BODY)).resolves.toEqual({ ok: false, reason: "failed" });
635
+ mockUpdateContact.mockClear();
636
+ await expect(saveContact(null as never)).resolves.toEqual({ ok: false, reason: "invalid" });
637
+ expect(mockUpdateContact).not.toHaveBeenCalled();
638
+ });
639
+ });
640
+
485
641
  // ── 4. The deletion request (AC-4) ────────────────────────────────────────────
486
642
 
487
643
  describe("AC-4 — Request account deletion through the member API (D5)", () => {
@@ -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(() => {
@@ -247,15 +260,6 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
247
260
  expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
248
261
  });
249
262
 
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
263
  it("saves the name through member.updateProfile with the Bearer credential, then re-seals the session", async () => {
260
264
  mockUpdateProfile.mockResolvedValue({ ok: true, body: { name: "Ada King", image: null } });
261
265
  mockHubMe.mockResolvedValue({ ok: true, body: { user: { ...USER, name: "Ada King" }, billingCustomerId: null } });
@@ -326,6 +330,77 @@ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
326
330
  });
327
331
  });
328
332
 
333
+ describe("#8431 AC-4 — Security on a hub-mode spoke", () => {
334
+ const realFetch = Object.getOwnPropertyDescriptor(globalThis, "fetch");
335
+ const mockFetch = jest.fn();
336
+
337
+ beforeEach(() => {
338
+ mockFetch.mockReset();
339
+ Object.defineProperty(globalThis, "fetch", { value: mockFetch, writable: true, configurable: true });
340
+ });
341
+
342
+ afterEach(() => {
343
+ if (realFetch) Object.defineProperty(globalThis, "fetch", realFetch);
344
+ else Reflect.deleteProperty(globalThis, "fetch");
345
+ });
346
+
347
+ async function renderSecurity(): Promise<void> {
348
+ const { default: SecurityPage } = await import("../security/page");
349
+ render(await SecurityPage({ params: Promise.resolve({ locale: "en" }) }));
350
+ }
351
+
352
+ it("says the hub manages sign-in and links to the hub's Security page — no password control", async () => {
353
+ await renderSecurity();
354
+ expect(screen.getByTestId("account-security-hub").textContent).toBe(
355
+ "Your sign-in is managed at hub.example.com.",
356
+ );
357
+ const links = screen.getAllByTestId("account-security-hub-link");
358
+ expect(links).toHaveLength(1);
359
+ expect(links[0]).toHaveAttribute("href", `${HUB}/account/security`);
360
+ expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
361
+ expect(screen.queryByTestId("account-security-password")).not.toBeInTheDocument();
362
+ expect(screen.queryByTestId("account-security-sentence")).not.toBeInTheDocument();
363
+ expect(screen.queryByText(/other devices/i)).toBeNull();
364
+ expect(mockReadOwnAccounts).not.toHaveBeenCalled();
365
+ });
366
+
367
+ it("with the hub config absent says 'our main site' and links nowhere", async () => {
368
+ delete process.env.AILK_HUB_CLIENT_SECRET;
369
+ await renderSecurity();
370
+ expect(screen.getByTestId("account-security-hub").textContent).toBe(
371
+ "Your sign-in is managed by our main site.",
372
+ );
373
+ expect(screen.queryByTestId("account-security-hub-link")).not.toBeInTheDocument();
374
+ expect(screen.getByTestId("account-security-log-out")).toBeInTheDocument();
375
+ });
376
+
377
+ it("Log out POSTs the spoke's sign-out endpoint, never Better Auth, and on a 204 lands on /account", async () => {
378
+ mockFetch.mockResolvedValue({ ok: true, status: 204 });
379
+ await renderSecurity();
380
+ await act(async () => {
381
+ fireEvent.click(screen.getByTestId("account-security-log-out"));
382
+ });
383
+ expect(mockFetch).toHaveBeenCalledTimes(1);
384
+ expect(mockFetch).toHaveBeenCalledWith("/api/hub-session/sign-out", {
385
+ method: "POST",
386
+ credentials: "same-origin",
387
+ });
388
+ expect(mockSignOut).not.toHaveBeenCalled();
389
+ expect(mockPush).toHaveBeenCalledWith("/account");
390
+ });
391
+
392
+ it("a refused sign-out (403) navigates nowhere and shows the error", async () => {
393
+ mockFetch.mockResolvedValue({ ok: false, status: 403 });
394
+ await renderSecurity();
395
+ await act(async () => {
396
+ fireEvent.click(screen.getByTestId("account-security-log-out"));
397
+ });
398
+ expect(mockPush).not.toHaveBeenCalled();
399
+ expect(mockSignOut).not.toHaveBeenCalled();
400
+ expect(screen.getByTestId("account-security-log-out-error")).toBeInTheDocument();
401
+ });
402
+ });
403
+
329
404
  describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke billing (D10–D12)", () => {
330
405
  const VISA = { brand: "visa", last4: "4242", expMonth: 4, expYear: 2030 };
331
406
  const PORTAL_URL = "https://billing.stripe.com/p/session/test_fake";
@@ -347,7 +422,7 @@ describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke bill
347
422
  expect(mockMemberBilling).not.toHaveBeenCalled();
348
423
  });
349
424
 
350
- it("a subscription renders the plan, the card and Manage in Stripe", async () => {
425
+ it("a subscription renders the plan, the card and Manage billing", async () => {
351
426
  mockLoadSpokeBilling.mockResolvedValue(
352
427
  stub({ kind: "subscription", status: "active", planName: "Pro", paymentMethod: VISA, unreadable: false }),
353
428
  );
@@ -356,7 +431,7 @@ describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke bill
356
431
  expect(card).toHaveTextContent("Pro");
357
432
  expect(card).toHaveTextContent("Active");
358
433
  expect(card).toHaveTextContent("visa ending in 4242, expires 04/2030");
359
- expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage in Stripe");
434
+ expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage billing");
360
435
  expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
361
436
  expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
362
437
  });