create-ailk 0.18.0 → 0.19.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 (167) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +4 -1
  4. package/dist/category-profile.d.ts +10 -0
  5. package/dist/category-profile.js +21 -0
  6. package/dist/derive-architecture.d.ts +5 -1
  7. package/dist/derive-architecture.js +8 -3
  8. package/dist/lib/license-key-files.d.ts +6 -3
  9. package/dist/lib/license-key-files.js +8 -2
  10. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  11. package/dist/lib/scaffold-render-sweep.js +24 -3
  12. package/dist/license-set.d.ts +1 -1
  13. package/dist/license-set.js +13 -8
  14. package/dist/module-architecture.js +119 -0
  15. package/dist/programmatic.js +3 -0
  16. package/dist/spoke-checkout.d.ts +11 -3
  17. package/dist/spoke-checkout.js +62 -12
  18. package/dist/starter-grid.d.ts +1 -1
  19. package/dist/surfaces.js +140 -1
  20. package/dist/template-manifest.d.ts +4 -3
  21. package/dist/topology.d.ts +7 -2
  22. package/dist/topology.js +60 -3
  23. package/package.json +5 -4
  24. package/templates/.env.example +3 -3
  25. package/templates/.github/workflows/ci.yml +5 -0
  26. package/templates/apps/api/CLAUDE.md +25 -7
  27. package/templates/apps/api/LICENSE +6 -5
  28. package/templates/apps/api/NOTICE +2 -0
  29. package/templates/apps/api/jest.config.cjs +4 -29
  30. package/templates/apps/api/package.json +1 -0
  31. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  32. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  33. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  34. package/templates/apps/api/src/lib/stripe.ts +81 -0
  35. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  36. package/templates/apps/api/src/middleware/auth.ts +206 -46
  37. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  38. package/templates/apps/api/src/server.ts +107 -0
  39. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  40. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  41. package/templates/apps/api/vercel.json +2 -2
  42. package/templates/apps/mcp/.env.example +10 -0
  43. package/templates/apps/mcp/CLAUDE.md +9 -4
  44. package/templates/apps/mcp/LICENSE +6 -5
  45. package/templates/apps/mcp/NOTICE +2 -0
  46. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  47. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  48. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  49. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  50. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  51. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  52. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  53. package/templates/apps/mcp/jest.config.cjs +3 -44
  54. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  55. package/templates/apps/mcp/src/boot.ts +5 -23
  56. package/templates/apps/mcp/src/http-transport.ts +93 -73
  57. package/templates/apps/mcp/src/index.ts +2 -0
  58. package/templates/apps/mcp/src/request-context.ts +52 -0
  59. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  60. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  61. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  62. package/templates/apps/web/LICENSE +6 -5
  63. package/templates/apps/web/NOTICE +2 -0
  64. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  65. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  78. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  79. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  80. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  87. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  88. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  93. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  98. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  101. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  102. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  108. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  114. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  115. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  116. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  117. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  118. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  119. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  120. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  121. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  122. package/templates/apps/web/auth.ts +5 -1
  123. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  124. package/templates/apps/web/lib/account-config.ts +188 -52
  125. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  126. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  127. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  128. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  129. package/templates/apps/web/lib/topology.generated.ts +24 -0
  130. package/templates/apps/web/messages/en.json +156 -35
  131. package/templates/database/CHANGELOG.md +21 -0
  132. package/templates/database/LICENSE +6 -5
  133. package/templates/database/NOTICE +2 -0
  134. package/templates/database/README.md +1 -1
  135. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  136. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  137. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  138. package/templates/database/__tests__/seed.test.ts +54 -2
  139. package/templates/database/inbox/schema.prisma +90 -15
  140. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  141. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  142. package/templates/database/package.json +3 -2
  143. package/templates/database/seed.ts +34 -2
  144. package/versions.json +36 -36
  145. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  146. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  147. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  148. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  149. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  150. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  151. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  152. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  153. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  154. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  155. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  156. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  157. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  158. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  159. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  160. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  161. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  162. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  163. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  164. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  165. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  166. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  167. package/templates/database/__tests__/build.test.ts +0 -131
@@ -9,19 +9,33 @@
9
9
  * suites (spec 7894 D9), so it runs in this monorepo only.
10
10
  *
11
11
  * Covers:
12
- * - AC-7: with the hub answering 404 to the base reads (the hub credential
13
- * is admitted on none of them, D6), `loadInboxItems` loads `[]` for
14
- * `result` and `purchase` with `failed` false — an empty Inbox, never an
15
- * error row — and every read carries the Bearer credential, not a cookie.
16
- * - AC-5: the settings page renders no `NameForm` and no deletion request,
17
- * and links to `<AILK_HUB_SIGN_IN_URL>/account/settings` instead; Billing
18
- * is absent (its read answers 404).
12
+ * - AC-7: with the hub answering 404 to every read (the hub credential
13
+ * is admitted on none of them, D6), `loadInboxItems` loads `[]` with
14
+ * `failed` false — an empty Inbox, never an error row — and every read
15
+ * carries the Bearer credential, not a cookie. The purchase and
16
+ * appointment kinds read `member.purchases` and `member.appointments`,
17
+ * never `flowCheckouts.me` (#8231 D5–D7).
18
+ * - AC-5, as #8232 rewrites it (spec
19
+ * docs/specs/8232-member-settings-account-general-and-spec.md D2–D7, D9;
20
+ * its AC-2 and AC-7): General no longer links to the hub's settings.
21
+ * Where the hub answers the member routes, the name, photo and deletion
22
+ * go through the member API with the Bearer credential, and a save
23
+ * re-seals the session cookie; where it answers none (the fixture's 404),
24
+ * the name is read-only and no deletion path is offered. Billing says
25
+ * there is no subscription without reading `subscription.me`.
26
+ * - #8217 AC-1 (spec docs/specs/8217-spoke-billing-spoke-checkout-reads-spec.md
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
29
+ * no button for none or a spoke without the action (`null`); the
30
+ * unreadable line; the error row for a failed read — and never calls
31
+ * `subscription.me` or `member.billing` itself. `openBillingPortal`
32
+ * redirects to the loader's portal URL, and to nothing without one.
19
33
  */
20
34
  import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
21
35
  import { tmpdir } from "node:os";
22
36
  import { join } from "node:path";
23
37
 
24
- import { render, screen } from "@testing-library/react";
38
+ import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
25
39
 
26
40
  jest.mock("next-intl/server", () => {
27
41
  const msgs: unknown = require("../../../../../messages/en.json");
@@ -35,22 +49,30 @@ jest.mock("next-intl/server", () => {
35
49
  getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
36
50
  const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
37
51
  const messages = dig(msgs, ns);
38
- return (key: string): string => {
52
+ return (key: string, values?: Record<string, unknown>): string => {
39
53
  const value = dig(messages, key);
40
- return typeof value === "string" ? value : `${ns}.${key}`;
54
+ if (typeof value !== "string") return `${ns}.${key}`;
55
+ return value.replace(/\{(\w+)\}/g, (m, name: string) =>
56
+ values && name in values ? String(values[name]) : m,
57
+ );
41
58
  };
42
59
  },
43
60
  };
44
61
  });
45
62
 
46
63
  const mockHeaders = jest.fn();
64
+ const mockCookieSet = jest.fn();
47
65
  jest.mock("next/headers", () => ({
48
66
  __esModule: true,
49
67
  headers: (...args: unknown[]) => mockHeaders(...args),
50
- cookies: jest.fn().mockResolvedValue({ toString: () => "" }),
68
+ cookies: jest.fn().mockImplementation(async () => ({
69
+ toString: () => "",
70
+ set: (...args: unknown[]) => mockCookieSet(...args),
71
+ })),
51
72
  }));
52
73
 
53
74
  const mockReadSession = jest.fn();
75
+ const mockLoadSpokeBilling = jest.fn();
54
76
  // 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.
55
77
  jest.mock("../../../../../lib/topology.generated", () => ({
56
78
  __esModule: true,
@@ -62,6 +84,8 @@ jest.mock("../../../../../lib/topology.generated", () => ({
62
84
  SIGN_OUT_ENDPOINT: "/api/hub-session/sign-out",
63
85
  readSession: (...args: unknown[]) => mockReadSession(...args),
64
86
  registerSiteStores: () => Promise.resolve(),
87
+ // #8217 D7 — the billing render's loader; `null` is a spoke without the action.
88
+ loadSpokeBilling: (...args: unknown[]) => mockLoadSpokeBilling(...args),
65
89
  }));
66
90
 
67
91
  const mockLeadsSubmit = jest.fn();
@@ -70,40 +94,59 @@ const mockWaitlistMe = jest.fn();
70
94
  const mockFlowCheckoutsMe = jest.fn();
71
95
  const mockSubscriptionMe = jest.fn();
72
96
  const mockMemberRead = jest.fn();
97
+ const mockMemberPurchases = jest.fn();
98
+ const mockMemberAppointments = jest.fn();
99
+ const mockUpdateProfile = jest.fn();
100
+ const mockRequestDeletion = jest.fn();
101
+ const mockHubMe = jest.fn();
102
+ const mockMemberBilling = jest.fn();
103
+ const mockPortalSession = jest.fn();
73
104
  jest.mock("@working-theory/api-client", () => ({
74
105
  __esModule: true,
75
106
  waitlistSignups: { me: (...args: unknown[]) => mockWaitlistMe(...args) },
76
107
  flowCheckouts: { me: (...args: unknown[]) => mockFlowCheckoutsMe(...args) },
77
108
  subscription: {
78
109
  me: (...args: unknown[]) => mockSubscriptionMe(...args),
79
- portalSession: jest.fn(),
110
+ portalSession: (...args: unknown[]) => mockPortalSession(...args),
80
111
  },
81
112
  leads: { submit: (...args: unknown[]) => mockLeadsSubmit(...args) },
82
113
  // #7920 — the member kinds read the hub with the credential; 404 here.
83
114
  member: {
84
115
  pieces: (...args: unknown[]) => mockMemberRead(...args),
85
116
  messages: (...args: unknown[]) => mockMemberRead(...args),
117
+ purchases: (...args: unknown[]) => mockMemberPurchases(...args),
118
+ appointments: (...args: unknown[]) => mockMemberAppointments(...args),
119
+ updateProfile: (...args: unknown[]) => mockUpdateProfile(...args),
120
+ requestDeletion: (...args: unknown[]) => mockRequestDeletion(...args),
121
+ // #8217 — only the rendered billing action reads it; billing-source.ts never does.
122
+ billing: (...args: unknown[]) => mockMemberBilling(...args),
86
123
  },
87
124
  visitorForwardHeaders: () => null,
88
- hubSignIn: { me: jest.fn() },
125
+ hubSignIn: { me: (...args: unknown[]) => mockHubMe(...args) },
89
126
  }));
90
127
 
128
+ const mockUpdateUser = jest.fn();
91
129
  jest.mock("better-auth/react", () => ({
92
130
  __esModule: true,
93
- createAuthClient: () => ({ signOut: jest.fn(), updateUser: jest.fn() }),
131
+ createAuthClient: () => ({
132
+ signOut: jest.fn(),
133
+ updateUser: (...args: unknown[]) => mockUpdateUser(...args),
134
+ }),
94
135
  }));
95
136
 
137
+ const mockRedirect = jest.fn();
96
138
  jest.mock("next/navigation", () => ({
97
139
  __esModule: true,
98
- useRouter: () => ({ push: jest.fn(), refresh: jest.fn() }),
99
- usePathname: () => "/account/settings",
100
- redirect: jest.fn(),
140
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn(), replace: jest.fn() }),
141
+ usePathname: () => "/account/general",
142
+ redirect: (...args: unknown[]) => mockRedirect(...args),
101
143
  }));
102
144
 
103
- import { seal } from "../../../../../lib/hub-session/seal";
145
+ import { seal, unseal } from "../../../../../lib/hub-session/seal";
104
146
  import { accountSession, loadInboxItems } from "../account-data";
147
+ import { openBillingPortal } from "../actions";
148
+ import GeneralPage from "../general/page";
105
149
  import { submitSupportRequest } from "../support-actions";
106
- import Page from "../settings/page";
107
150
 
108
151
  const HUB = "https://hub.example.com";
109
152
  const SESSION_SECRET = "s".repeat(43);
@@ -142,6 +185,17 @@ beforeEach(() => {
142
185
  mockFlowCheckoutsMe.mockReset().mockResolvedValue(notFound);
143
186
  mockSubscriptionMe.mockReset().mockResolvedValue(notFound);
144
187
  mockMemberRead.mockReset().mockResolvedValue(notFound);
188
+ mockMemberPurchases.mockReset().mockResolvedValue(notFound);
189
+ mockMemberAppointments.mockReset().mockResolvedValue(notFound);
190
+ mockUpdateProfile.mockReset();
191
+ mockRequestDeletion.mockReset();
192
+ mockHubMe.mockReset();
193
+ mockLoadSpokeBilling.mockReset().mockResolvedValue(null);
194
+ mockMemberBilling.mockReset();
195
+ mockPortalSession.mockReset();
196
+ mockRedirect.mockReset();
197
+ mockCookieSet.mockReset();
198
+ mockUpdateUser.mockReset();
145
199
  });
146
200
 
147
201
  afterEach(() => {
@@ -150,16 +204,17 @@ afterEach(() => {
150
204
  });
151
205
 
152
206
  describe("AC-7 — the base Inbox kinds on a hub-mode spoke", () => {
153
- it("loads [] for result and purchase, with failed false, when the hub answers 404", async () => {
207
+ it("loads [] for every kind, with failed false, when the hub answers 404", async () => {
154
208
  const session = await accountSession("en");
155
209
  if (session === null) throw new Error("expected a session");
156
210
  expect(session.ctx.init).toEqual({ headers: { authorization: `Bearer ${CREDENTIAL}` } });
157
211
 
158
212
  const inbox = await loadInboxItems(session.ctx);
159
213
  expect(inbox).toEqual({ items: [], failed: false });
160
- expect(mockFlowCheckoutsMe).toHaveBeenCalledWith({
161
- headers: { authorization: `Bearer ${CREDENTIAL}` },
162
- });
214
+ const bearer = { headers: { authorization: `Bearer ${CREDENTIAL}` } };
215
+ expect(mockMemberPurchases).toHaveBeenCalledWith(bearer);
216
+ expect(mockMemberAppointments).toHaveBeenCalledWith(bearer);
217
+ expect(mockFlowCheckoutsMe).not.toHaveBeenCalled();
163
218
  });
164
219
 
165
220
  it("has no session when the seam reads none", async () => {
@@ -168,23 +223,86 @@ describe("AC-7 — the base Inbox kinds on a hub-mode spoke", () => {
168
223
  });
169
224
  });
170
225
 
171
- describe("AC-5 — the settings page on a hub-mode spoke", () => {
172
- async function renderPage(): Promise<void> {
173
- render(await Page({ params: Promise.resolve({ locale: "en" }) }));
226
+ describe("AC-5 — General and Billing on a hub-mode spoke (#8232)", () => {
227
+ const bearer = { headers: { authorization: `Bearer ${CREDENTIAL}` } };
228
+
229
+ async function renderGeneral(): Promise<void> {
230
+ render(await GeneralPage({ params: Promise.resolve({ locale: "en" }) }));
174
231
  }
175
232
 
176
- it("renders no NameForm and no deletion request, and links to the hub's account settings", async () => {
177
- await renderPage();
178
- expect(screen.queryByTestId("account-name-form")).not.toBeInTheDocument();
179
- expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
180
- const href = `${HUB}/account/settings`;
181
- expect(screen.getByTestId("account-settings-manage-on-hub")).toHaveAttribute("href", href);
182
- expect(screen.getByTestId("account-privacy-manage-on-hub")).toHaveAttribute("href", href);
183
- expect(screen.getByTestId("account-settings-name")).toHaveTextContent("Ada Lovelace");
184
- expect(screen.getByText(/You sign in through our main site/)).toBeInTheDocument();
233
+ describe("a hub that answers the member routes", () => {
234
+ beforeEach(() => {
235
+ mockMemberRead.mockResolvedValue({ ok: true, body: [] });
236
+ });
237
+
238
+ it("renders the name and photo form and the deletion button, with no link to the hub's settings", async () => {
239
+ await renderGeneral();
240
+ expect(screen.getByTestId("account-general-form")).toBeInTheDocument();
241
+ expect(screen.getByLabelText("Name")).toHaveValue("Ada Lovelace");
242
+ // No hub read carries the photo, so the field starts empty (C5).
243
+ expect(screen.getByLabelText("Photo")).toHaveValue("");
244
+ expect(screen.getByTestId("account-deletion-request")).toBeInTheDocument();
245
+ expect(screen.queryByTestId("account-settings-manage-on-hub")).not.toBeInTheDocument();
246
+ expect(screen.queryByTestId("account-privacy-manage-on-hub")).not.toBeInTheDocument();
247
+ expect(document.querySelector(`a[href^="${HUB}"]`)).toBeNull();
248
+ });
249
+
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
+ it("saves the name through member.updateProfile with the Bearer credential, then re-seals the session", async () => {
260
+ mockUpdateProfile.mockResolvedValue({ ok: true, body: { name: "Ada King", image: null } });
261
+ mockHubMe.mockResolvedValue({ ok: true, body: { user: { ...USER, name: "Ada King" }, billingCustomerId: null } });
262
+ await renderGeneral();
263
+ fireEvent.change(screen.getByLabelText("Name"), { target: { value: "Ada King" } });
264
+ await act(async () => {
265
+ fireEvent.click(screen.getByTestId("account-general-save"));
266
+ });
267
+ expect(mockUpdateProfile).toHaveBeenCalledWith({ name: "Ada King" }, bearer);
268
+ expect(mockUpdateUser).not.toHaveBeenCalled();
269
+ expect(mockHubMe).toHaveBeenCalledWith(bearer);
270
+ expect(mockCookieSet).toHaveBeenCalledTimes(1);
271
+ const [name, value] = mockCookieSet.mock.calls[0] as [string, string];
272
+ expect(name).toBe("__Host-ailk-hub-session");
273
+ expect(unseal(value, SESSION_SECRET, "session", name)).toMatchObject({
274
+ accessToken: CREDENTIAL,
275
+ user: { name: "Ada King" },
276
+ });
277
+ });
278
+
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" } });
281
+ await renderGeneral();
282
+ await act(async () => {
283
+ fireEvent.click(screen.getByTestId("account-deletion-request"));
284
+ });
285
+ expect(mockRequestDeletion).toHaveBeenCalledWith({}, bearer);
286
+ await waitFor(() => expect(screen.getByRole("status")).toHaveTextContent("Sent to the hub operator."));
287
+ expect(mockLeadsSubmit).not.toHaveBeenCalled();
288
+ });
185
289
  });
186
290
 
187
- it("files a posted deletion request as a support request — the hub owns the account (SEC-7894-11)", async () => {
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();
297
+ expect(screen.queryByTestId("account-deletion-request")).not.toBeInTheDocument();
298
+ expect(screen.queryByTestId("account-deletion-support")).not.toBeInTheDocument();
299
+ 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
+ });
303
+ });
304
+
305
+ it("files a posted deletion request as a support request — Support files support only (SEC-7894-11)", async () => {
188
306
  mockLeadsSubmit.mockResolvedValue({ ok: true, body: {} });
189
307
  const form = new FormData();
190
308
  form.set("kind", "deletion");
@@ -196,11 +314,123 @@ describe("AC-5 — the settings page on a hub-mode spoke", () => {
196
314
  );
197
315
  });
198
316
 
199
- it("omits Billing, whose read answers 404 for the hub credential", async () => {
200
- await renderPage();
201
- expect(document.getElementById("billing")).not.toBeInTheDocument();
202
- expect(mockSubscriptionMe).toHaveBeenCalledWith({
203
- headers: { authorization: `Bearer ${CREDENTIAL}` },
204
- });
317
+ it("Billing says there is no subscription, with no button, without reading subscription.me", async () => {
318
+ // The Billing route's own page, loaded with `import()`: architecture.yaml bans
319
+ // a static import of a relative billing/ path to keep OSS code out of the
320
+ // apps-paid/billing service's source, and this route directory shares the name.
321
+ const { default: BillingPage } = await import("../billing/page");
322
+ render(await BillingPage({ params: Promise.resolve({ locale: "en" }) }));
323
+ expect(screen.getByTestId("account-billing-none")).toHaveTextContent("You don't have a subscription.");
324
+ expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
325
+ expect(mockSubscriptionMe).not.toHaveBeenCalled();
326
+ });
327
+ });
328
+
329
+ describe("#8217 AC-1 — Billing on a hub-mode spoke reads the seam's spoke billing (D10–D12)", () => {
330
+ const VISA = { brand: "visa", last4: "4242", expMonth: 4, expYear: 2030 };
331
+ const PORTAL_URL = "https://billing.stripe.com/p/session/test_fake";
332
+
333
+ /** A `SpokeBilling` stub: what the rendered action returns. */
334
+ function stub(read: unknown, portalUrl: string | null = PORTAL_URL) {
335
+ return { read: jest.fn().mockResolvedValue(read), portalUrl: jest.fn().mockResolvedValue(portalUrl) };
336
+ }
337
+
338
+ async function renderBilling(): Promise<void> {
339
+ // The Billing route's own page, loaded with `import()` — see the AC-5 case above.
340
+ const { default: BillingPage } = await import("../billing/page");
341
+ render(await BillingPage({ params: Promise.resolve({ locale: "en" }) }));
342
+ }
343
+
344
+ afterEach(() => {
345
+ // Every case: billing-source.ts reads neither the hub's subscription nor its billing ids.
346
+ expect(mockSubscriptionMe).not.toHaveBeenCalled();
347
+ expect(mockMemberBilling).not.toHaveBeenCalled();
348
+ });
349
+
350
+ it("a subscription renders the plan, the card and Manage in Stripe", async () => {
351
+ mockLoadSpokeBilling.mockResolvedValue(
352
+ stub({ kind: "subscription", status: "active", planName: "Pro", paymentMethod: VISA, unreadable: false }),
353
+ );
354
+ await renderBilling();
355
+ const card = screen.getByTestId("account-billing-card");
356
+ expect(card).toHaveTextContent("Pro");
357
+ expect(card).toHaveTextContent("Active");
358
+ expect(card).toHaveTextContent("visa ending in 4242, expires 04/2030");
359
+ expect(screen.getByTestId("account-billing-manage")).toHaveTextContent("Manage in Stripe");
360
+ expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
361
+ expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
362
+ });
363
+
364
+ it("none renders the plain none line and no button", async () => {
365
+ mockLoadSpokeBilling.mockResolvedValue(stub({ kind: "none", unreadable: false }, null));
366
+ await renderBilling();
367
+ expect(screen.getByTestId("account-billing-none")).toHaveTextContent("You don't have a subscription.");
368
+ expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
369
+ expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
370
+ });
371
+
372
+ it.each([
373
+ ["none", { kind: "none", unreadable: true }, "account-billing-card"],
374
+ [
375
+ "a subscription",
376
+ { kind: "subscription", status: "past_due", planName: null, paymentMethod: null, unreadable: true },
377
+ "account-billing-card",
378
+ ],
379
+ ])("unreadable: true on %s adds the unreadable line after it", async (_label, read, baseTestId) => {
380
+ mockLoadSpokeBilling.mockResolvedValue(stub(read));
381
+ await renderBilling();
382
+ const line = screen.getByTestId("account-billing-unreadable");
383
+ expect(line).toHaveTextContent("Some of your billing is with another Stripe account, so it isn't shown here.");
384
+ const base = screen.getByTestId(baseTestId);
385
+ expect(base.compareDocumentPosition(line) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
386
+ });
387
+
388
+ it("a failed read renders the error row, never 'no subscription'", async () => {
389
+ mockLoadSpokeBilling.mockResolvedValue(stub({ kind: "failed" }));
390
+ await renderBilling();
391
+ expect(screen.getByTestId("inbox-error")).toHaveTextContent("We couldn't load your billing details.");
392
+ expect(screen.queryByTestId("account-billing-none")).not.toBeInTheDocument();
393
+ expect(screen.queryByTestId("account-billing-unreadable")).not.toBeInTheDocument();
394
+ });
395
+
396
+ it("a loader that fails to load renders the error row", async () => {
397
+ const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
398
+ mockLoadSpokeBilling.mockRejectedValue(new SyntaxError("checkout-config.ts is malformed"));
399
+ await renderBilling();
400
+ expect(screen.getByTestId("inbox-error")).toBeInTheDocument();
401
+ expect(errorSpy.mock.calls.map((args) => args.join(" "))).toEqual([
402
+ "[account] read failed source=spoke-billing errName=SyntaxError",
403
+ ]);
404
+ errorSpy.mockRestore();
405
+ });
406
+
407
+ it("a spoke without the action (the loader resolves null) renders none and no button", async () => {
408
+ await renderBilling();
409
+ expect(screen.getByTestId("account-billing-none")).toBeInTheDocument();
410
+ expect(screen.queryByTestId("account-billing-manage")).not.toBeInTheDocument();
411
+ expect(mockLoadSpokeBilling).toHaveBeenCalledTimes(1);
412
+ });
413
+
414
+ it("openBillingPortal redirects to the loader's portal URL", async () => {
415
+ const billing = stub({ kind: "none", unreadable: false });
416
+ mockLoadSpokeBilling.mockResolvedValue(billing);
417
+ await openBillingPortal();
418
+ expect(billing.portalUrl).toHaveBeenCalledWith();
419
+ expect(mockRedirect).toHaveBeenCalledTimes(1);
420
+ expect(mockRedirect).toHaveBeenCalledWith(PORTAL_URL);
421
+ expect(mockPortalSession).not.toHaveBeenCalled();
422
+ });
423
+
424
+ it.each([
425
+ ["the loader's portal is null", () => mockLoadSpokeBilling.mockResolvedValue(stub({ kind: "none", unreadable: false }, null))],
426
+ ["there is no loader", () => mockLoadSpokeBilling.mockResolvedValue(null)],
427
+ ["the loader fails to load", () => mockLoadSpokeBilling.mockRejectedValue(new Error("load failed"))],
428
+ ])("openBillingPortal redirects nowhere when %s", async (_label, arrange) => {
429
+ const errorSpy = jest.spyOn(console, "error").mockImplementation(() => {});
430
+ arrange();
431
+ await openBillingPortal();
432
+ expect(mockRedirect).not.toHaveBeenCalled();
433
+ expect(mockPortalSession).not.toHaveBeenCalled();
434
+ errorSpy.mockRestore();
205
435
  });
206
436
  });
@@ -9,6 +9,8 @@
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
+ *
13
+ * The `purchase` kind reads `member.purchases` (#8231 D5).
12
14
  */
13
15
  import { fireEvent, render, screen, within } from "@testing-library/react";
14
16
 
@@ -49,12 +51,12 @@ jest.mock("../../../../../auth", () => ({
49
51
  }));
50
52
 
51
53
  const mockSignupMe = jest.fn();
52
- const mockCheckoutsMe = jest.fn();
54
+ const mockMemberPurchases = jest.fn();
53
55
  jest.mock("@working-theory/api-client", () => ({
54
56
  __esModule: true,
55
57
  waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
56
- flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
57
58
  subscription: { me: jest.fn() },
59
+ member: { purchases: (...args: unknown[]) => mockMemberPurchases(...args) },
58
60
  }));
59
61
 
60
62
  const mockNotFound = jest.fn(() => {
@@ -74,22 +76,18 @@ const SESSION = {
74
76
  user: { id: "user_1", name: "Ada", email: "ada@example.com", emailVerified: true },
75
77
  };
76
78
 
77
- const CHECKOUT = {
79
+ const PURCHASE = {
78
80
  id: "fc_done",
79
- flowSlug: "founder-flow",
80
- experimentKey: "ailk-founder-diagnostic",
81
- offerKey: "pro",
82
- status: "completed",
81
+ offering: "pro",
83
82
  amountMinor: 4900,
84
83
  currency: "usd",
85
84
  period: "monthly",
86
- completedAt: "2026-09-04T10:00:00.000Z",
87
- createdAt: "2026-09-04T09:58:00.000Z",
85
+ purchasedAt: "2026-09-04T10:00:00.000Z",
88
86
  };
89
87
 
90
88
  beforeEach(() => {
91
89
  mockGetSession.mockReset().mockResolvedValue(SESSION);
92
- mockCheckoutsMe.mockReset().mockResolvedValue({ ok: true, body: [CHECKOUT] });
90
+ mockMemberPurchases.mockReset().mockResolvedValue({ ok: true, body: { items: [PURCHASE] } });
93
91
  mockSignupMe.mockReset();
94
92
  mockNotFound.mockClear();
95
93
  mockRefresh.mockReset();
@@ -113,7 +111,7 @@ describe("one Inbox item", () => {
113
111
  });
114
112
 
115
113
  it("decodes an encoded id segment", async () => {
116
- mockCheckoutsMe.mockResolvedValue({ ok: true, body: [{ ...CHECKOUT, id: "fc done" }] });
114
+ mockMemberPurchases.mockResolvedValue({ ok: true, body: { items: [{ ...PURCHASE, id: "fc done" }] } });
117
115
  await renderItem("purchase", "fc%20done");
118
116
  expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent("pro");
119
117
  });
@@ -125,7 +123,7 @@ describe("one Inbox item", () => {
125
123
 
126
124
  it("an unknown kind is a not-found", async () => {
127
125
  await expect(renderItem("nope", "fc_done")).rejects.toThrow("NEXT_NOT_FOUND");
128
- expect(mockCheckoutsMe).not.toHaveBeenCalled();
126
+ expect(mockMemberPurchases).not.toHaveBeenCalled();
129
127
  });
130
128
 
131
129
  it("an id the kind did not load is a not-found", async () => {
@@ -133,7 +131,7 @@ describe("one Inbox item", () => {
133
131
  });
134
132
 
135
133
  it("a failed read renders an error with Retry, not a not-found", async () => {
136
- mockCheckoutsMe.mockResolvedValue({ ok: false, status: 500, body: {} });
134
+ mockMemberPurchases.mockResolvedValue({ ok: false, status: 500, body: {} });
137
135
  await renderItem("purchase", "fc_done");
138
136
  expect(mockNotFound).not.toHaveBeenCalled();
139
137
  expect(screen.getByTestId("inbox-error")).toHaveTextContent("We couldn't load this item.");