create-ailk 0.18.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 (218) hide show
  1. package/LICENSE +6 -5
  2. package/NOTICE +2 -0
  3. package/dist/add-module.js +11 -3
  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/index.d.ts +1 -1
  9. package/dist/index.js +1 -1
  10. package/dist/lib/license-key-files.d.ts +6 -3
  11. package/dist/lib/license-key-files.js +8 -2
  12. package/dist/lib/scaffold-render-sweep.d.ts +32 -0
  13. package/dist/lib/scaffold-render-sweep.js +76 -3
  14. package/dist/license-set.d.ts +1 -1
  15. package/dist/license-set.js +13 -8
  16. package/dist/module-architecture.js +128 -3
  17. package/dist/programmatic.js +11 -1
  18. package/dist/site-roster.js +21 -4
  19. package/dist/spoke-checkout.d.ts +11 -3
  20. package/dist/spoke-checkout.js +62 -12
  21. package/dist/starter-grid.d.ts +1 -1
  22. package/dist/surfaces.d.ts +14 -0
  23. package/dist/surfaces.js +170 -1
  24. package/dist/template-manifest.d.ts +4 -3
  25. package/dist/topology.d.ts +7 -2
  26. package/dist/topology.js +88 -3
  27. package/package.json +5 -4
  28. package/templates/.env.example +3 -3
  29. package/templates/.github/workflows/ci.yml +5 -0
  30. package/templates/apps/api/.env.example +17 -1
  31. package/templates/apps/api/CLAUDE.md +31 -7
  32. package/templates/apps/api/LICENSE +6 -5
  33. package/templates/apps/api/NOTICE +2 -0
  34. package/templates/apps/api/jest.config.cjs +4 -29
  35. package/templates/apps/api/package.json +1 -0
  36. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +19 -0
  37. package/templates/apps/api/src/__tests__/stripe-boot.test.ts +132 -0
  38. package/templates/apps/api/src/lib/__mocks__/prisma.ts +17 -0
  39. package/templates/apps/api/src/lib/__tests__/consent-ledger-double.ts +237 -0
  40. package/templates/apps/api/src/lib/__tests__/consent-store.test.ts +334 -0
  41. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  42. package/templates/apps/api/src/lib/__tests__/stripe-admission.test.ts +217 -0
  43. package/templates/apps/api/src/lib/__tests__/stripe.test.ts +7 -0
  44. package/templates/apps/api/src/lib/consent-store.ts +335 -0
  45. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  46. package/templates/apps/api/src/lib/stripe.ts +176 -3
  47. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +272 -39
  48. package/templates/apps/api/src/middleware/__tests__/rate-limit.test.ts +51 -0
  49. package/templates/apps/api/src/middleware/auth.ts +214 -46
  50. package/templates/apps/api/src/middleware/rate-limit.ts +5 -0
  51. package/templates/apps/api/src/routes/checkout/__tests__/live-key.test.ts +167 -0
  52. package/templates/apps/api/src/routes/checkout/session.ts +3 -3
  53. package/templates/apps/api/src/routes/checkout/sessions.ts +6 -4
  54. package/templates/apps/api/src/routes/consent/cookie.ts +90 -0
  55. package/templates/apps/api/src/routes/consent/index.ts +18 -0
  56. package/templates/apps/api/src/routes/consent/unsubscribe.ts +127 -0
  57. package/templates/apps/api/src/routes/leads/index.ts +19 -0
  58. package/templates/apps/api/src/routes/project-listings/__tests__/drafts.test.ts +5 -0
  59. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +17 -4
  60. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-idempotency.test.ts +2 -0
  61. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode-live.test.ts +159 -0
  62. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-livemode.test.ts +158 -0
  63. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-rate-limit.test.ts +2 -0
  64. package/templates/apps/api/src/routes/webhooks/stripe.ts +17 -0
  65. package/templates/apps/api/src/server.ts +218 -16
  66. package/templates/apps/api/src/services/__tests__/consent-ledger.test.ts +503 -0
  67. package/templates/apps/api/src/services/__tests__/consent.test.ts +18 -3
  68. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +173 -4
  69. package/templates/apps/api/src/services/__tests__/person.test.ts +392 -0
  70. package/templates/apps/api/src/services/__tests__/request-confirm.test.ts +87 -0
  71. package/templates/apps/api/src/services/__tests__/revalidation.test.ts +57 -17
  72. package/templates/apps/api/src/services/consent.ts +488 -3
  73. package/templates/apps/api/src/services/lead-routing.ts +135 -14
  74. package/templates/apps/api/src/services/person.ts +501 -1
  75. package/templates/apps/api/src/services/project-listings.ts +7 -2
  76. package/templates/apps/api/src/services/request-confirm.ts +52 -0
  77. package/templates/apps/api/src/services/revalidation.ts +11 -1
  78. package/templates/apps/api/vercel.json +2 -2
  79. package/templates/apps/mcp/.env.example +10 -0
  80. package/templates/apps/mcp/CLAUDE.md +9 -4
  81. package/templates/apps/mcp/LICENSE +6 -5
  82. package/templates/apps/mcp/NOTICE +2 -0
  83. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  84. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  85. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  86. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  87. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  88. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  89. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  90. package/templates/apps/mcp/jest.config.cjs +3 -44
  91. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  92. package/templates/apps/mcp/src/boot.ts +5 -23
  93. package/templates/apps/mcp/src/http-transport.ts +93 -73
  94. package/templates/apps/mcp/src/index.ts +2 -0
  95. package/templates/apps/mcp/src/request-context.ts +52 -0
  96. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  97. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  98. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  99. package/templates/apps/web/LICENSE +6 -5
  100. package/templates/apps/web/NOTICE +2 -0
  101. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  102. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/account-frame-sign-out.test.tsx +175 -0
  103. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +302 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +724 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +346 -41
  106. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  107. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout-sign-out.test.tsx +146 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +232 -29
  109. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +140 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  113. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +409 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +65 -380
  116. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  117. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  118. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +188 -54
  119. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  120. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  125. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  126. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  127. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +174 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  132. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  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/deletion-request.tsx +132 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +193 -0
  138. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  139. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  140. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  141. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  142. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +30 -4
  143. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  144. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  145. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  146. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  147. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  148. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  149. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +132 -0
  150. package/templates/apps/web/app/[locale]/(authed)/account/security/security-panel.tsx +102 -0
  151. package/templates/apps/web/app/[locale]/(authed)/account/security/security-source.ts +62 -0
  152. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  153. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +116 -0
  154. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  155. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  156. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  157. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  158. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  159. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  160. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  161. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  162. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  163. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  164. package/templates/apps/web/auth.ts +25 -2
  165. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  166. package/templates/apps/web/lib/__tests__/auth-email.test.ts +84 -0
  167. package/templates/apps/web/lib/account-config.ts +188 -52
  168. package/templates/apps/web/lib/auth-email.ts +45 -10
  169. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  170. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  171. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  172. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  173. package/templates/apps/web/lib/topology.generated.ts +33 -0
  174. package/templates/apps/web/messages/en.json +194 -33
  175. package/templates/database/CHANGELOG.md +37 -0
  176. package/templates/database/LICENSE +6 -5
  177. package/templates/database/NOTICE +2 -0
  178. package/templates/database/README.md +1 -1
  179. package/templates/database/__tests__/consent-ledger.db.test.ts +345 -0
  180. package/templates/database/__tests__/fence-fan-out.db.test.ts +6 -2
  181. package/templates/database/__tests__/fixtures/person-contact-backfill.json +182 -0
  182. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  183. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  184. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  185. package/templates/database/__tests__/person-contact.db.test.ts +563 -0
  186. package/templates/database/__tests__/person.db.test.ts +3 -1
  187. package/templates/database/__tests__/seed.test.ts +54 -2
  188. package/templates/database/inbox/schema.prisma +166 -25
  189. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  190. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  191. package/templates/database/migrations/20261014120000_consent_ledger/migration.sql +122 -0
  192. package/templates/database/migrations/20261014120000_person_contact/migration.sql +231 -0
  193. package/templates/database/package.json +3 -2
  194. package/templates/database/seed.ts +34 -2
  195. package/versions.json +36 -36
  196. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  197. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  198. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  199. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  200. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  201. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  202. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  203. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  204. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  205. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  206. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  207. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  208. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  209. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  210. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  211. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  212. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  213. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  214. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  215. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  216. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  217. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  218. package/templates/database/__tests__/build.test.ts +0 -131
@@ -194,8 +194,12 @@ function buildSocialProviders(): SocialProviders {
194
194
  * "up" — and `apps/web/.env.example` has always documented the variable as
195
195
  * REQUIRED in production. The change makes the file honest rather than adding
196
196
  * a requirement.
197
+ *
198
+ * Exported (#8146 D5) so the hub's sign-in continuation seals its ref under the
199
+ * same resolved secret instead of re-reading the variable: the second consumer
200
+ * after `betterAuth({ secret })`.
197
201
  */
198
- function resolveSecret(): string {
202
+ export function resolveSecret(): string {
199
203
  const configured = process.env.BETTER_AUTH_SECRET;
200
204
  if (configured) return configured;
201
205
 
@@ -320,6 +324,24 @@ export function createAuth(deps: CreateAuthDeps = {}) {
320
324
  const sendResetPassword = makeSendResetPassword({
321
325
  prisma,
322
326
  send: authEmailTransport,
327
+ /**
328
+ * Who is signed in on the reset request (#8431 D4) — the Security tab's
329
+ * Set password, for a person with no password yet. Read through THIS
330
+ * instance (assigned below; the callback runs only during a request, by
331
+ * which time it exists), never a second one, and never by verifying the
332
+ * cookie by hand. `disableRefresh`: the mail callback runs in the
333
+ * background after the response, where a refresh's cookie write would
334
+ * throw and drop the mail. No request (a server-side call carries no
335
+ * cookie) or no `api` (a test double of `betterAuth`) is no session.
336
+ */
337
+ sessionUserId: async (request) => {
338
+ if (request === undefined || Reflect.get(instance, "api") === undefined) return null;
339
+ const session = await instance.api.getSession({
340
+ headers: request.headers,
341
+ query: { disableRefresh: true },
342
+ });
343
+ return session?.user.id ?? null;
344
+ },
323
345
  });
324
346
  const sendVerificationEmail = makeSendVerificationEmail({
325
347
  send: authEmailTransport,
@@ -415,7 +437,7 @@ export function createAuth(deps: CreateAuthDeps = {}) {
415
437
  }
416
438
  };
417
439
 
418
- return betterAuth({
440
+ const instance = betterAuth({
419
441
  database: prismaAdapter(prisma, {
420
442
  provider: "postgresql",
421
443
  }),
@@ -639,6 +661,7 @@ export function createAuth(deps: CreateAuthDeps = {}) {
639
661
  ]
640
662
  : [nextCookies()],
641
663
  });
664
+ return instance;
642
665
  }
643
666
 
644
667
  // Lazy singleton: resolved on first access, not at module-load time.
@@ -1,31 +1,55 @@
1
1
  /**
2
2
  * Tests for lib/account-config.ts — the base account area's contributions
3
- * (#7429, spec 7423 U3 · AC-6; amended by #7448, U5 · D6a/D8a/D9).
3
+ * (#7429, spec 7423 U3 · AC-6; amended by #7448, U5 · D6a/D8a/D9; by #8231,
4
+ * spec 8231 D4–D7).
4
5
  *
5
6
  * Covers:
6
- * 1. The three contribution arrays are exported; the base area ships no
7
- * Settings section (modules add those).
8
- * 2. The base Inbox kinds are exactly `result` and `purchase` — rows of
9
- * the Recents and All tabs; none fills Scheduled (the old Upcoming).
7
+ * 1. The five contribution arrays are exported; the base area ships no
8
+ * Settings section or Billing section (modules add those), and the
9
+ * three New actions (#8233 D2).
10
+ * 2. The Inbox kinds: `result`, `piece` and `message` in `updates` (All
11
+ * only), `purchase` in `purchases`, `appointment` in `upcoming`.
10
12
  * 3. `email-unverified` and `payment-due` are For-you sources — needs-you
11
13
  * items with an Open action to User settings, never Inbox rows.
12
- * 4. Each contribution maps its existing read: the session for
13
- * `email-unverified`, `waitlistSignups.me` per configured key for
14
- * `result`, `flowCheckouts.me` for `purchase`, and `subscription.me`'s
15
- * own `actionRequired` for `payment-due`.
14
+ * 4. Each contribution maps its read: the session for `email-unverified`,
15
+ * `waitlistSignups.me` per configured key for `result`,
16
+ * `member.purchases` for `purchase` (never `flowCheckouts.me`),
17
+ * `member.appointments` for `appointment`, and `subscription.me`'s own
18
+ * `actionRequired` for `payment-due`.
16
19
  * 5. A 404 — or a 401/403 while the email is unverified — is "nothing for
17
20
  * this caller" (`[]`); any other failed read rejects, a 401/403 for a
18
21
  * verified email included, so the Inbox can show an error row (D12).
22
+ * 6. The member kinds call on a cookie-session site — no hub-credential
23
+ * gate (#8231 D7).
24
+ * 7. #8233 D1, D2 — each New action is offered while its member read
25
+ * answers: a 404 (or a switched-off request form, or an unverified
26
+ * 401/403) is `false`, any other failure rejects. The popups are bound
27
+ * in the route (`account/new-popups.tsx`, tested beside it): this file
28
+ * imports nothing under `(authed)`.
19
29
  */
20
30
 
21
31
  const mockSignupMe = jest.fn();
22
32
  const mockCheckoutsMe = jest.fn();
23
33
  const mockSubscriptionMe = jest.fn();
34
+ const mockMemberPieces = jest.fn();
35
+ const mockMemberMessages = jest.fn();
36
+ const mockMemberPurchases = jest.fn();
37
+ const mockMemberAppointments = jest.fn();
38
+ const mockMemberMeetingTypes = jest.fn();
39
+ const mockMemberRequestForm = jest.fn();
24
40
  jest.mock("@working-theory/api-client", () => ({
25
41
  __esModule: true,
26
42
  waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
27
43
  flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
28
44
  subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args) },
45
+ member: {
46
+ pieces: (...args: unknown[]) => mockMemberPieces(...args),
47
+ messages: (...args: unknown[]) => mockMemberMessages(...args),
48
+ purchases: (...args: unknown[]) => mockMemberPurchases(...args),
49
+ appointments: (...args: unknown[]) => mockMemberAppointments(...args),
50
+ meetingTypes: (...args: unknown[]) => mockMemberMeetingTypes(...args),
51
+ requestForm: (...args: unknown[]) => mockMemberRequestForm(...args),
52
+ },
29
53
  }));
30
54
 
31
55
  import type {
@@ -33,12 +57,16 @@ import type {
33
57
  AccountForYouSource,
34
58
  AccountInboxKind,
35
59
  } from "@working-theory/templates/account";
60
+ import { requestQuoteFlowConfig } from "@working-theory/validation";
36
61
 
37
62
  import {
63
+ accountBillingSections,
38
64
  accountExperimentKeys,
39
65
  accountForYouSources,
40
66
  accountInboxKinds,
67
+ accountNewActions,
41
68
  accountSettingsSections,
69
+ type AccountNewRead,
42
70
  } from "../account-config";
43
71
 
44
72
  const INIT = { headers: { cookie: "session=abc" } };
@@ -79,17 +107,39 @@ const SIGNUP = {
79
107
  answers: { q3: null },
80
108
  };
81
109
 
82
- const CHECKOUT = {
83
- id: "flchk_1",
84
- flowSlug: "buy",
85
- experimentKey: "diag",
86
- offerKey: "pro",
87
- status: "completed",
110
+ const PURCHASE = {
111
+ id: "pur_1",
112
+ offering: "Pro plan",
88
113
  amountMinor: 4900,
89
114
  currency: "USD",
90
115
  period: "month",
91
- completedAt: "2026-09-02T10:00:00.000Z",
92
- createdAt: "2026-09-02T09:00:00.000Z",
116
+ purchasedAt: "2026-09-02T10:00:00.000Z",
117
+ };
118
+
119
+ const APPOINTMENT = {
120
+ id: "appt_1",
121
+ title: "Kickoff",
122
+ startTime: "2026-10-20T15:00:00.000Z",
123
+ endTime: "2026-10-20T15:30:00.000Z",
124
+ status: "confirmed" as const,
125
+ };
126
+
127
+ const PIECE = {
128
+ id: "piece_1",
129
+ title: "Brand guide",
130
+ stage: "in_progress",
131
+ needsClient: true,
132
+ createdAt: "2026-09-01T10:00:00.000Z",
133
+ updatedAt: "2026-09-03T10:00:00.000Z",
134
+ };
135
+
136
+ const MESSAGE = {
137
+ id: "intx_1",
138
+ subject: "Your draft",
139
+ body: "Attached.",
140
+ createdAt: "2026-09-04T10:00:00.000Z",
141
+ file: null,
142
+ readAt: null,
93
143
  };
94
144
 
95
145
  const ok = <T>(body: T) => ({ ok: true as const, body });
@@ -101,22 +151,30 @@ afterEach(() => {
101
151
  });
102
152
 
103
153
  describe("account-config exports (AC-6)", () => {
104
- it("exports the three contribution arrays", () => {
154
+ it("exports the five contribution arrays", () => {
105
155
  expect(Array.isArray(accountInboxKinds)).toBe(true);
106
156
  expect(accountSettingsSections).toEqual([]);
107
157
  expect(Array.isArray(accountForYouSources)).toBe(true);
108
158
  });
109
159
 
110
- it("ships exactly the base Inbox kinds and the member kinds (#7920), all Recents rows (D6a, D9)", () => {
160
+ it("ships the three New actions, by message key, and no Billing section (#8233 D2)", () => {
161
+ expect(accountNewActions.map((a) => [a.id, a.label])).toEqual([
162
+ ["schedule-service", "new.actions.scheduleService"],
163
+ ["request-quote", "new.actions.requestQuote"],
164
+ ["send-message", "new.actions.sendMessage"],
165
+ ]);
166
+ expect(accountBillingSections).toEqual([]);
167
+ });
168
+
169
+ it("ships the base kinds and the member kinds in their sections (#8231 D4–D6)", () => {
111
170
  expect(accountInboxKinds.map((k) => [k.kind, k.section])).toEqual([
112
171
  ["result", "updates"],
113
- ["purchase", "updates"],
172
+ ["purchase", "purchases"],
114
173
  ["piece", "updates"],
115
174
  ["message", "updates"],
175
+ ["appointment", "upcoming"],
116
176
  ]);
117
- // Nothing in the base area fills Scheduled (the old Upcoming), and no
118
- // Inbox row asks for a decision — that is the For-you panel's job.
119
- expect(accountInboxKinds.some((k) => k.section === "upcoming")).toBe(false);
177
+ // No Inbox row asks for a decision — that is the For-you panel's job.
120
178
  expect(accountInboxKinds.some((k) => k.section === "needs-you")).toBe(false);
121
179
  });
122
180
 
@@ -152,7 +210,7 @@ describe("email-unverified — a For-you item from the session", () => {
152
210
  id: "email-unverified",
153
211
  type: { id: "email", label: "t:profile.email", tone: "needs-you" },
154
212
  title: "t:profile.unverified",
155
- action: { kind: "open", href: "/account/settings#general" },
213
+ action: { kind: "open", href: "/account/general" },
156
214
  dismissible: true,
157
215
  });
158
216
  });
@@ -191,25 +249,30 @@ describe("result — waitlistSignups.me per accountExperimentKeys", () => {
191
249
  });
192
250
  });
193
251
 
194
- describe("purchase — flowCheckouts.me", () => {
195
- it("is one item per checkout, at its completion time", async () => {
196
- mockCheckoutsMe.mockResolvedValue(ok([CHECKOUT]));
252
+ describe("purchase — member.purchases (#8231 D5)", () => {
253
+ const ITEM = { id: "pur_1", title: "Pro plan", at: PURCHASE.purchasedAt, data: PURCHASE };
254
+
255
+ it("sits in the purchases section", () => {
256
+ expect(kind("purchase").section).toBe("purchases");
257
+ });
258
+
259
+ it("is one item per purchase, at its purchase time, and never reads flowCheckouts.me", async () => {
260
+ mockMemberPurchases.mockResolvedValue(ok({ items: [PURCHASE] }));
197
261
  const items = await kind("purchase").load(ctx());
198
- expect(mockCheckoutsMe).toHaveBeenCalledWith(INIT);
199
- expect(items).toEqual([
200
- { id: "flchk_1", title: "pro", at: CHECKOUT.completedAt, data: CHECKOUT },
201
- ]);
262
+ expect(mockMemberPurchases).toHaveBeenCalledWith(INIT);
263
+ expect(mockCheckoutsMe).not.toHaveBeenCalled();
264
+ expect(items).toEqual([ITEM]);
202
265
  });
203
266
 
204
267
  it("is empty on a 404", async () => {
205
- mockCheckoutsMe.mockResolvedValue(fail(404));
268
+ mockMemberPurchases.mockResolvedValue(fail(404));
206
269
  await expect(kind("purchase").load(ctx())).resolves.toEqual([]);
207
270
  });
208
271
 
209
272
  it.each([401, 403])(
210
273
  "is empty on a %i while the email is unverified",
211
274
  async (status) => {
212
- mockCheckoutsMe.mockResolvedValue(fail(status));
275
+ mockMemberPurchases.mockResolvedValue(fail(status));
213
276
  await expect(
214
277
  kind("purchase").load(ctx({ emailVerified: false })),
215
278
  ).resolves.toEqual([]);
@@ -219,33 +282,114 @@ describe("purchase — flowCheckouts.me", () => {
219
282
  it.each([401, 403])(
220
283
  "rejects on a %i for a verified email",
221
284
  async (status) => {
222
- mockCheckoutsMe.mockResolvedValue(fail(status));
285
+ mockMemberPurchases.mockResolvedValue(fail(status));
223
286
  await expect(kind("purchase").load(ctx())).rejects.toMatchObject({
224
287
  status,
225
288
  });
226
289
  },
227
290
  );
228
291
 
229
- it("rejects on a failed read", async () => {
230
- mockCheckoutsMe.mockResolvedValue(fail(503));
292
+ it("rejects on a 503", async () => {
293
+ mockMemberPurchases.mockResolvedValue(fail(503));
231
294
  await expect(kind("purchase").load(ctx())).rejects.toThrow();
232
295
  });
233
296
 
234
- it("details the checkout's fields", () => {
235
- const item = {
236
- id: "flchk_1",
237
- title: "pro",
238
- at: CHECKOUT.completedAt,
239
- data: CHECKOUT,
240
- };
241
- const detail = kind("purchase").renderDetail(item, ctx());
242
- expect(detail.status).toBe("completed");
297
+ it("details the purchase's fields, with no status", () => {
298
+ const detail = kind("purchase").renderDetail(ITEM, ctx());
299
+ expect(detail.status).toBeUndefined();
243
300
  expect(detail.fields?.items.map((i) => i.label)).toEqual([
244
301
  "t:purchases.offer",
245
302
  "t:purchases.amount",
246
303
  "t:purchases.period",
247
304
  "t:purchases.date",
248
305
  ]);
306
+ expect(detail.fields?.items[0]?.value).toBe("Pro plan");
307
+ });
308
+
309
+ it("shows Not recorded for a null amount or period", () => {
310
+ const data = { ...PURCHASE, amountMinor: null, currency: null, period: null };
311
+ const detail = kind("purchase").renderDetail({ ...ITEM, data }, ctx());
312
+ const values = detail.fields?.items.map((i) => i.value);
313
+ expect(values?.[1]).toBe("t:purchases.unavailable");
314
+ expect(values?.[2]).toBe("t:purchases.unavailable");
315
+ });
316
+ });
317
+
318
+ describe("appointment — member.appointments (#8231 D6)", () => {
319
+ const PAST = {
320
+ ...APPOINTMENT,
321
+ id: "appt_0",
322
+ title: null,
323
+ startTime: "2026-09-10T15:00:00.000Z",
324
+ endTime: "2026-09-10T15:30:00.000Z",
325
+ };
326
+
327
+ it("sits in the upcoming section, named by its message key", () => {
328
+ expect(kind("appointment").section).toBe("upcoming");
329
+ expect(kind("appointment").label).toBe("appointments.heading");
330
+ });
331
+
332
+ it("returns the upcoming items then the past, flagged, at their start time", async () => {
333
+ mockMemberAppointments.mockResolvedValue(ok({ upcoming: [APPOINTMENT], past: [PAST] }));
334
+ const items = await kind("appointment").load(ctx());
335
+ expect(mockMemberAppointments).toHaveBeenCalledWith(INIT);
336
+ expect(items).toEqual([
337
+ { id: "appt_1", title: "Kickoff", at: APPOINTMENT.startTime, upcoming: true, data: APPOINTMENT },
338
+ { id: "appt_0", title: "t:appointments.untitled", at: PAST.startTime, upcoming: false, data: PAST },
339
+ ]);
340
+ });
341
+
342
+ it("is empty on a 404", async () => {
343
+ mockMemberAppointments.mockResolvedValue(fail(404));
344
+ await expect(kind("appointment").load(ctx())).resolves.toEqual([]);
345
+ });
346
+
347
+ it("rejects on a 503", async () => {
348
+ mockMemberAppointments.mockResolvedValue(fail(503));
349
+ await expect(kind("appointment").load(ctx())).rejects.toThrow();
350
+ });
351
+
352
+ it("details the status and the start and end", () => {
353
+ const item = { id: "appt_1", title: "Kickoff", at: APPOINTMENT.startTime, upcoming: true, data: APPOINTMENT };
354
+ const detail = kind("appointment").renderDetail(item, ctx());
355
+ expect(detail.status).toBe("t:appointments.statuses.confirmed");
356
+ expect(detail.fields?.items.map((i) => i.label)).toEqual([
357
+ "t:appointments.starts",
358
+ "t:appointments.ends",
359
+ ]);
360
+ });
361
+ });
362
+
363
+ describe("member kinds on a cookie-session site — no gate (#8231 D7)", () => {
364
+ it("piece reads member.pieces with the cookie init and returns its rows", async () => {
365
+ mockMemberPieces.mockResolvedValue(ok({ items: [PIECE] }));
366
+ const items = await kind("piece").load(ctx());
367
+ expect(mockMemberPieces).toHaveBeenCalledWith(INIT);
368
+ expect(items.map((i) => i.id)).toEqual(["piece_1"]);
369
+ });
370
+
371
+ it("message reads member.messages with the cookie init and returns its rows", async () => {
372
+ mockMemberMessages.mockResolvedValue(ok({ items: [MESSAGE] }));
373
+ const items = await kind("message").load(ctx());
374
+ expect(mockMemberMessages).toHaveBeenCalledWith(INIT);
375
+ expect(items.map((i) => i.id)).toEqual(["intx_1"]);
376
+ });
377
+
378
+ it("piece-needs-client reads member.pieces with the cookie init", async () => {
379
+ mockMemberPieces.mockResolvedValue(ok({ items: [PIECE] }));
380
+ const items = await source("piece-needs-client").load(ctx());
381
+ expect(mockMemberPieces).toHaveBeenCalledWith(INIT);
382
+ expect(items.map((i) => i.id)).toEqual(["piece:piece_1"]);
383
+ });
384
+
385
+ it.each([
386
+ ["piece", mockMemberPieces],
387
+ ["message", mockMemberMessages],
388
+ ] as const)("%s loads [] on a 404 and rejects on a 500", async (id, mock) => {
389
+ mock.mockResolvedValue(fail(404));
390
+ await expect(kind(id).load(ctx())).resolves.toEqual([]);
391
+ mock.mockResolvedValue(fail(500));
392
+ await expect(kind(id).load(ctx())).rejects.toThrow();
249
393
  });
250
394
  });
251
395
 
@@ -266,7 +410,7 @@ describe("payment-due — a For-you item from subscription.me's actionRequired",
266
410
  id: "payment-due",
267
411
  type: { id: "payment", label: "t:subscription.action", tone: "needs-you" },
268
412
  title: "t:subscription.headline",
269
- action: { kind: "open", href: "/account/settings#billing" },
413
+ action: { kind: "open", href: "/account/billing" },
270
414
  dismissible: true,
271
415
  });
272
416
  });
@@ -286,3 +430,71 @@ describe("payment-due — a For-you item from subscription.me's actionRequired",
286
430
  await expect(source("payment-due").load(ctx())).rejects.toThrow();
287
431
  });
288
432
  });
433
+
434
+ // ── #8233 — the New actions ──────────────────────────────────────────────────
435
+
436
+ describe("New actions — offered while their member read answers (#8233 D1, D2)", () => {
437
+ function action(id: string): AccountNewRead {
438
+ const found = accountNewActions.find((a) => a.id === id);
439
+ if (!found) throw new Error(`no New action ${id}`);
440
+ return found;
441
+ }
442
+
443
+ const TYPE = { slug: "consult", title: "Consultation", durationMinutes: 30 };
444
+ const FORM = {
445
+ form: {
446
+ title: "Request a quote",
447
+ defaultLocale: "en",
448
+ steps: requestQuoteFlowConfig.steps,
449
+ },
450
+ };
451
+ const failure = (status: number) => ({
452
+ ok: false as const,
453
+ status,
454
+ body: { ok: false, error: { code: "failed", message: "x" } },
455
+ });
456
+
457
+ it.each([
458
+ ["{ items: [] }", true, ok({ items: [] })],
459
+ ["one type", true, ok({ items: [TYPE] })],
460
+ ["404", false, fail(404)],
461
+ ])("schedule-service: meetingTypes %s → %s", async (_name, offered, answer) => {
462
+ mockMemberMeetingTypes.mockResolvedValue(answer);
463
+ await expect(action("schedule-service").available(ctx())).resolves.toBe(offered);
464
+ expect(mockMemberMeetingTypes).toHaveBeenCalledWith(INIT);
465
+ });
466
+
467
+ it.each([
468
+ ["a form", true, ok(FORM)],
469
+ ["{ form: null }", false, ok({ form: null })],
470
+ ["404", false, fail(404)],
471
+ ])("request-quote: requestForm %s → %s", async (_name, offered, answer) => {
472
+ mockMemberRequestForm.mockResolvedValue(answer);
473
+ await expect(action("request-quote").available(ctx())).resolves.toBe(offered);
474
+ expect(mockMemberRequestForm).toHaveBeenCalledWith(INIT);
475
+ });
476
+
477
+ it("send-message: a messages 200 → true, read with ctx.init", async () => {
478
+ mockMemberMessages.mockResolvedValue(ok({ items: [] }));
479
+ await expect(action("send-message").available(ctx())).resolves.toBe(true);
480
+ expect(mockMemberMessages).toHaveBeenCalledWith(INIT);
481
+ });
482
+
483
+ it("send-message: 404 → false; 403 while the email is unverified → false", async () => {
484
+ mockMemberMessages.mockResolvedValue(fail(404));
485
+ await expect(action("send-message").available(ctx())).resolves.toBe(false);
486
+ mockMemberMessages.mockResolvedValue(fail(403));
487
+ await expect(action("send-message").available(ctx({ emailVerified: false }))).resolves.toBe(
488
+ false,
489
+ );
490
+ });
491
+
492
+ it.each([
493
+ ["schedule-service", 503, mockMemberMeetingTypes],
494
+ ["request-quote", 500, mockMemberRequestForm],
495
+ ["send-message", 503, mockMemberMessages],
496
+ ] as const)("%s rejects on a %i — a failed read is never reported as absent", async (id, status, mock) => {
497
+ mock.mockResolvedValue(failure(status));
498
+ await expect(action(id).available(ctx())).rejects.toMatchObject({ status });
499
+ });
500
+ });
@@ -152,6 +152,90 @@ describe("sendResetPassword (§ D14 — the credential-account check)", () => {
152
152
  });
153
153
  });
154
154
 
155
+ describe("sendResetPassword — the signed-in carve-out (#8431 D4)", () => {
156
+ const USER = { id: "user_1", email: "person@example.com" };
157
+ const RESET_URL = "https://site.example/api/auth/reset-password/tok";
158
+ const REQUEST = { headers: new Headers({ cookie: "session=abc" }) } as Request;
159
+
160
+ it("sends ONE password-reset to the account's own address when the session is that person's", async () => {
161
+ const send = jest.fn().mockResolvedValue({ ok: true, body: { ok: true } });
162
+ const { lines, log } = makeLog();
163
+ const sessionUserId = jest.fn().mockResolvedValue("user_1");
164
+ const cb = makeSendResetPassword({ prisma: makePrisma(false), send, log, sessionUserId });
165
+
166
+ await cb({ user: USER, url: RESET_URL }, REQUEST);
167
+
168
+ expect(sessionUserId).toHaveBeenCalledWith(REQUEST);
169
+ expect(send).toHaveBeenCalledTimes(1);
170
+ const [rawBody] = send.mock.calls[0] as [string];
171
+ expect(JSON.parse(rawBody)).toEqual({
172
+ kind: "password-reset",
173
+ userId: "user_1",
174
+ to: "person@example.com",
175
+ url: RESET_URL,
176
+ expiresInMinutes: 60,
177
+ });
178
+ expect(lines.some((l) => l.fields.reason === "self_service_set")).toBe(true);
179
+ expect(JSON.stringify(lines)).not.toContain("person@example.com");
180
+ });
181
+
182
+ it.each<[string, () => Promise<string | null>]>([
183
+ ["another person's id", () => Promise.resolve("user_2")],
184
+ ["null (no session)", () => Promise.resolve(null)],
185
+ ["a rejection", () => Promise.reject(new Error("db down"))],
186
+ ])("sends NOTHING and logs no address when the session lookup answers %s", async (_label, impl) => {
187
+ const send = jest.fn();
188
+ const { lines, log } = makeLog();
189
+ const cb = makeSendResetPassword({ prisma: makePrisma(false), send, log, sessionUserId: jest.fn(impl) });
190
+
191
+ await expect(cb({ user: USER, url: RESET_URL }, REQUEST)).resolves.toBeUndefined();
192
+
193
+ expect(send).not.toHaveBeenCalled();
194
+ expect(lines.some((l) => l.fields.reason === "no_credential_account")).toBe(true);
195
+ expect(JSON.stringify(lines)).not.toContain("person@example.com");
196
+ });
197
+
198
+ it("logs the failed lookup's kind only", async () => {
199
+ const { lines, log } = makeLog();
200
+ const cb = makeSendResetPassword({
201
+ prisma: makePrisma(false),
202
+ send: jest.fn(),
203
+ log,
204
+ sessionUserId: jest.fn().mockRejectedValue(new Error("person@example.com broke")),
205
+ });
206
+ await cb({ user: USER, url: RESET_URL }, REQUEST);
207
+ expect(lines).toEqual([
208
+ {
209
+ level: "warn",
210
+ fields: {
211
+ userId: "user_1",
212
+ kind: "password-reset",
213
+ reason: "no_credential_account",
214
+ errorKind: "session_lookup_failed",
215
+ },
216
+ },
217
+ ]);
218
+ });
219
+
220
+ it("with no sessionUserId it sends nothing, as before", async () => {
221
+ const send = jest.fn();
222
+ const { log } = makeLog();
223
+ const cb = makeSendResetPassword({ prisma: makePrisma(false), send, log });
224
+ await cb({ user: USER, url: RESET_URL }, REQUEST);
225
+ expect(send).not.toHaveBeenCalled();
226
+ });
227
+
228
+ it("a person WITH a password never needs the session lookup", async () => {
229
+ const send = jest.fn().mockResolvedValue({ ok: true, body: { ok: true } });
230
+ const { log } = makeLog();
231
+ const sessionUserId = jest.fn();
232
+ const cb = makeSendResetPassword({ prisma: makePrisma(true), send, log, sessionUserId });
233
+ await cb({ user: USER, url: RESET_URL });
234
+ expect(sessionUserId).not.toHaveBeenCalled();
235
+ expect(send).toHaveBeenCalledTimes(1);
236
+ });
237
+ });
238
+
155
239
  describe("the § D12 PII boundary", () => {
156
240
  it("never logs the address, the token or the assembled link", async () => {
157
241
  const send = jest.fn().mockResolvedValue({ ok: true, body: { ok: true } });