create-ailk 0.17.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 (193) 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/env-catalog.js +5 -9
  9. package/dist/lib/license-key-files.d.ts +6 -3
  10. package/dist/lib/license-key-files.js +8 -2
  11. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  12. package/dist/lib/scaffold-render-sweep.js +24 -3
  13. package/dist/license-set.d.ts +1 -1
  14. package/dist/license-set.js +13 -8
  15. package/dist/module-architecture.js +119 -0
  16. package/dist/programmatic.js +3 -0
  17. package/dist/spoke-checkout.d.ts +11 -3
  18. package/dist/spoke-checkout.js +62 -12
  19. package/dist/starter-grid.d.ts +1 -1
  20. package/dist/surfaces.js +140 -1
  21. package/dist/template-manifest.d.ts +4 -3
  22. package/dist/topology.d.ts +15 -2
  23. package/dist/topology.js +83 -4
  24. package/package.json +5 -4
  25. package/templates/.env.example +3 -3
  26. package/templates/.github/workflows/ci.yml +5 -0
  27. package/templates/apps/api/CLAUDE.md +25 -7
  28. package/templates/apps/api/LICENSE +6 -5
  29. package/templates/apps/api/NOTICE +2 -0
  30. package/templates/apps/api/jest.config.cjs +4 -29
  31. package/templates/apps/api/package.json +1 -0
  32. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  33. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  34. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  35. package/templates/apps/api/src/lib/stripe.ts +81 -0
  36. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  37. package/templates/apps/api/src/middleware/auth.ts +206 -46
  38. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  39. package/templates/apps/api/src/server.ts +107 -0
  40. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  41. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  42. package/templates/apps/api/vercel.json +2 -2
  43. package/templates/apps/mcp/.env.example +10 -0
  44. package/templates/apps/mcp/CLAUDE.md +58 -7
  45. package/templates/apps/mcp/LICENSE +6 -5
  46. package/templates/apps/mcp/NOTICE +2 -0
  47. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  48. package/templates/apps/mcp/__tests__/app-entry.test.ts +325 -0
  49. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  50. package/templates/apps/mcp/__tests__/boot.test.ts +280 -0
  51. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  52. package/templates/apps/mcp/__tests__/index.test.ts +86 -47
  53. package/templates/apps/mcp/__tests__/lib/content-adapter.test.ts +5 -6
  54. package/templates/apps/mcp/__tests__/lib/content-root.test.ts +93 -0
  55. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  56. package/templates/apps/mcp/__tests__/server.test.ts +63 -6
  57. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  58. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  59. package/templates/apps/mcp/jest.config.cjs +3 -44
  60. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  61. package/templates/apps/mcp/src/app.ts +32 -0
  62. package/templates/apps/mcp/src/boot.ts +167 -0
  63. package/templates/apps/mcp/src/http-transport.ts +132 -88
  64. package/templates/apps/mcp/src/index.ts +19 -126
  65. package/templates/apps/mcp/src/lib/audit-single-page.ts +7 -6
  66. package/templates/apps/mcp/src/lib/content-adapter.ts +34 -5
  67. package/templates/apps/mcp/src/lib/topology.generated.ts +3 -0
  68. package/templates/apps/mcp/src/request-context.ts +52 -0
  69. package/templates/apps/mcp/src/server.ts +10 -2
  70. package/templates/apps/mcp/src/serverless-app.ts +88 -0
  71. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  72. package/templates/apps/mcp/src/tools/get_business_hours.ts +6 -5
  73. package/templates/apps/mcp/src/tools/get_faq.ts +6 -5
  74. package/templates/apps/mcp/src/tools/get_locations.ts +6 -5
  75. package/templates/apps/mcp/src/tools/get_offerings.ts +6 -5
  76. package/templates/apps/mcp/src/tools/get_pricing.ts +6 -5
  77. package/templates/apps/mcp/src/tools/get_site_info.ts +6 -5
  78. package/templates/apps/mcp/src/tools/list_pages.ts +8 -7
  79. package/templates/apps/mcp/src/tools/read_content.ts +7 -7
  80. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  81. package/templates/apps/mcp/src/tools/search_content.ts +6 -5
  82. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  83. package/templates/apps/mcp/tsconfig.json +2 -0
  84. package/templates/apps/web/LICENSE +6 -5
  85. package/templates/apps/web/NOTICE +2 -0
  86. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  87. package/templates/apps/web/app/.well-known/mcp.json/build-manifest.ts +16 -4
  88. package/templates/apps/web/app/.well-known/mcp.json/route.ts +3 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  92. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  93. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  94. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  98. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  101. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  102. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  104. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +25 -32
  105. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  111. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  114. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  115. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  116. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  117. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  118. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  119. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  120. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  121. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  122. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  123. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  124. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  125. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  126. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  127. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  128. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  129. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  130. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  131. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  132. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  133. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  134. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  135. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  136. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  137. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  138. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  139. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  140. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  141. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  142. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  143. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  144. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  145. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  146. package/templates/apps/web/auth.ts +5 -1
  147. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  148. package/templates/apps/web/lib/__tests__/site-brand.test.ts +42 -0
  149. package/templates/apps/web/lib/account-config.ts +188 -52
  150. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  151. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  152. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  153. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  154. package/templates/apps/web/lib/site-brand.tsx +22 -6
  155. package/templates/apps/web/lib/topology.generated.ts +27 -0
  156. package/templates/apps/web/messages/en.json +156 -35
  157. package/templates/database/CHANGELOG.md +21 -0
  158. package/templates/database/LICENSE +6 -5
  159. package/templates/database/NOTICE +2 -0
  160. package/templates/database/README.md +1 -1
  161. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  162. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  163. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  164. package/templates/database/__tests__/seed.test.ts +54 -2
  165. package/templates/database/inbox/schema.prisma +90 -15
  166. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  167. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  168. package/templates/database/package.json +3 -2
  169. package/templates/database/seed.ts +34 -2
  170. package/versions.json +36 -36
  171. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  172. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  173. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  174. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  175. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  176. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  177. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  178. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  179. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  180. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  181. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  182. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  183. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  184. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  185. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  186. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  187. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  188. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  189. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  190. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  191. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  192. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  193. package/templates/database/__tests__/build.test.ts +0 -131
@@ -1,11 +1,12 @@
1
1
  // account-config.ts — what the /account area is made of (#4984, #7429).
2
2
  //
3
- // The module contribution contract (spec 7423 D8, D8a): a module extends a
4
- // site's account area by adding entries to the three arrays below — Inbox
5
- // item kinds, User-settings sections and For-you sources — never a rail
6
- // entry. The contract's types live in `@working-theory/templates/account`; a
7
- // kind's label is a message key in the `account` namespace, and a For-you
8
- // item's strings are built through the context's `t`.
3
+ // The module contribution contract (spec 7423 D8, D8a; #8231 D4): a module
4
+ // extends a site's account area by adding entries to the five arrays below —
5
+ // Inbox item kinds, User-settings sections, For-you sources, New actions and
6
+ // Billing sections — never a rail entry. An absent module is an absent entry.
7
+ // The contract's types live in `@working-theory/templates/account`; a kind's
8
+ // label is a message key in the `account` namespace, and a For-you item's
9
+ // strings are built through the context's `t`.
9
10
  //
10
11
  // `accountExperimentKeys` — mirrors `nav-config.ts`: the OSS template
11
12
  // declares NO experiments — nothing in `apps/web` runs a diagnostic today, so
@@ -20,51 +21,93 @@
20
21
  // The base contributions (D9, amended 2026-10-01 by D6a/D8a — #7448) use
21
22
  // existing reads only, each with the caller's cookie forwarded (`ctx.init`):
22
23
  //
23
- // - Inbox kinds `result` and `purchase` — rows of the Recents and All tabs.
24
+ // - Inbox kind `result` — a row of the All tab.
24
25
  // - For-you sources `email-unverified` and `payment-due` — needs-you items
25
- // with an Open action to User settings (General, Billing), never Inbox
26
- // rows. Neither read carries the time the condition began, so their time
26
+ // with an Open action to User settings' two pages, `/account/general`
27
+ // and `/account/billing` (#8232 D10), never Inbox rows. Neither read carries the time the condition began, so their time
27
28
  // line is the date of the read rather than an invented "Waiting N".
28
29
  //
29
30
  // A 404 means "nothing for this caller" — no signup / no user row / commerce
30
31
  // module off — and loads `[]`; so does a 401/403 while the email is
31
32
  // unverified (the `email-unverified` item already says so). Any other
32
33
  // failure rejects — a 401/403 for a verified email included — so the area
33
- // shows an error row with Retry instead of an empty Inbox (D12). Scheduled
34
- // (the old Upcoming) stays empty in the base area; a module fills it.
34
+ // shows an error row with Retry instead of an empty Inbox (D12).
35
35
  //
36
- // The member area's spoke leg (#7920) — the client's own records on the hub,
37
- // read by a spoke that signs its clients in through the hub (#7877, #7894):
36
+ // The member reads (#7920, #8231 D5–D7) — the client's own records, through
37
+ // one member API with two callers (roadmap D1): a single site's server calls
38
+ // its own API with the caller's session cookie, and a spoke that signs its
39
+ // clients in through the hub calls the hub with the client's hub credential.
40
+ // `ctx.init` already carries the right one (`forwardedCookieInit()`), so no
41
+ // kind here tests the topology:
38
42
  //
43
+ // - Inbox kind `purchase` (`GET /v1/member/purchases`) — the Purchases tab.
44
+ // Only a purchase linked by the verified-email rule shows (roadmap D3).
45
+ // - Inbox kind `appointment` (`GET /v1/member/appointments`) — the
46
+ // Scheduled tab, upcoming first, each item flagged by the read's own split.
39
47
  // - Inbox kinds `piece` (`GET /v1/member/pieces`) and `message`
40
- // (`GET /v1/member/messages`). A message with a file has a file action to
41
- // this site's own member file read, `account/inbox/message/[id]/file`,
42
- // which re-serves the hub's bytes — the browser holds no hub credential.
48
+ // (`GET /v1/member/messages`) — rows of All. A message with a file has a
49
+ // file action to this site's own member file read,
50
+ // `account/inbox/message/[id]/file`, which re-serves the bytes — the
51
+ // browser holds no hub credential.
43
52
  // - For-you source `piece-needs-client` — each piece waiting on the client,
44
53
  // with an Open action to its Inbox row. It shares the pieces read.
45
54
  //
46
- // The hub admits these reads only with the client's hub credential, which
47
- // `ctx.init` carries as `Authorization` on such a spoke and nowhere else. On a
48
- // cookie-session site (a hub, or a site with its own sign-in) there is none,
49
- // so the three make no call and load `[]` — inert, never an error row.
55
+ // A build with no tenancy module registers no member route, so each of these
56
+ // 404s and loads `[]`. A site whose workspace does not resolve gets a 503
57
+ // from every member read — the error row — until it sets its own
58
+ // `AILK_SPOKE_KEY` (#7633).
59
+ //
60
+ // The New actions (#8233, spec 8233 D1, D2) — the New menu over the Inbox.
61
+ // Each is offered while its member read answers, read with `ctx.init` like
62
+ // the kinds above, so a scaffold without a module shows no action that 404s
63
+ // on click:
64
+ //
65
+ // - `schedule-service` — while `GET /v1/member/meeting-types` answers
66
+ // (registered with `scheduling` on), `{ items: [] }` included: the
67
+ // popup then says no times are offered.
68
+ // - `request-quote` — while `GET /v1/member/request-form` answers a form;
69
+ // `{ form: null }` (switched off) leaves it out like a 404.
70
+ // - `send-message` — while `GET /v1/member/messages` answers; the `message`
71
+ // kind shares that read (`memberMessages`), one call per request.
72
+ //
73
+ // A Free build registers none of the three, so it shows no New button; a
74
+ // failed read rejects, so the Inbox shows its error row. The popups, the
75
+ // menu and their server actions are BASE files, never a module slice's:
76
+ // gating is the read's job, and a slice owning a popup would pull this file,
77
+ // hence the whole /account area, out of every keyless scaffold (#8233 C1 —
78
+ // `paid-module-exclusions.test.ts` pins them).
79
+ //
80
+ // This file carries only each action's server half (`AccountNewRead`: id,
81
+ // label, `available`); the popup each one opens is bound in the route,
82
+ // `account/new-popups.tsx`, which composes the full `AccountNewAction` list
83
+ // the Inbox page consumes. The reason is create-ailk's spoke closure
84
+ // (`spokeExclusions`, `packages/create-ailk/src/topology.ts`): a spoke
85
+ // without sign-in drops the whole `(authed)` group and every file that
86
+ // imports a file in it, and this is a shared lib file every spoke keeps —
87
+ // so it may import nothing under `(authed)`, popups included.
50
88
 
51
89
  import {
52
- flowCheckouts,
53
90
  member,
54
91
  subscription,
55
92
  waitlistSignups,
56
- type FlowCheckoutMeItem,
57
93
  type WaitlistSignupMeResponse,
58
94
  } from "@working-theory/api-client";
59
95
  import type {
96
+ AccountBillingSection,
60
97
  AccountContext,
61
98
  AccountForYouSource,
62
99
  AccountInboxKind,
100
+ AccountNewAction,
63
101
  AccountSettingsSection,
64
102
  AccountUser,
65
103
  } from "@working-theory/templates/account";
66
104
  import { localeHref } from "@working-theory/ui";
67
- import type { MemberMessage, MemberPiece } from "@working-theory/validation";
105
+ import type {
106
+ MemberAppointment,
107
+ MemberMessage,
108
+ MemberPiece,
109
+ MemberPurchase,
110
+ } from "@working-theory/validation";
68
111
  import { formatMoney, formatNumber } from "@working-theory/web-i18n";
69
112
  import { cache } from "react";
70
113
 
@@ -152,53 +195,46 @@ const result: AccountInboxKind<WaitlistSignupMeResponse> = {
152
195
  },
153
196
  };
154
197
 
155
- const purchase: AccountInboxKind<FlowCheckoutMeItem> = {
198
+ // ── Member Inbox kinds (#7920, #8231) ────────────────────────────────────────
199
+
200
+ const purchase: AccountInboxKind<MemberPurchase> = {
156
201
  kind: "purchase",
157
202
  label: "purchases.heading",
158
- section: "updates",
203
+ section: "purchases",
159
204
  async load({ init, user }) {
160
- const checkouts = (await read(flowCheckouts.me(init), user)) ?? [];
161
- return checkouts.map((c) => ({
162
- id: c.id,
163
- title: c.offerKey,
164
- at: c.completedAt ?? c.createdAt,
165
- data: c,
205
+ const purchases = (await read(member.purchases(init), user))?.items ?? [];
206
+ return purchases.map((p) => ({
207
+ id: p.id,
208
+ title: p.offering,
209
+ at: p.purchasedAt,
210
+ data: p,
166
211
  }));
167
212
  },
168
213
  renderDetail({ data }, { locale, t }) {
169
214
  return {
170
- status: data.status,
171
215
  fields: {
172
216
  heading: t("purchases.heading"),
173
217
  items: [
174
- { label: t("purchases.offer"), value: data.offerKey },
218
+ { label: t("purchases.offer"), value: data.offering },
175
219
  {
176
220
  label: t("purchases.amount"),
177
- value: formatMoney(data.amountMinor, data.currency, locale),
178
- },
179
- { label: t("purchases.period"), value: data.period },
180
- {
181
- label: t("purchases.date"),
182
- value: formatWhen(data.completedAt ?? data.createdAt, locale),
221
+ value:
222
+ data.amountMinor !== null && data.currency !== null
223
+ ? formatMoney(data.amountMinor, data.currency, locale)
224
+ : t("purchases.unavailable"),
183
225
  },
226
+ { label: t("purchases.period"), value: data.period ?? t("purchases.unavailable") },
227
+ { label: t("purchases.date"), value: formatWhen(data.purchasedAt, locale) },
184
228
  ],
185
229
  },
186
230
  };
187
231
  },
188
232
  };
189
233
 
190
- // ── Member Inbox kinds (#7920) ───────────────────────────────────────────────
191
-
192
- /** Whether `init` carries the client's hub credential (see the header). */
193
- function hasHubCredential({ init }: AccountContext): boolean {
194
- return Object.keys(init.headers).some((name) => name.toLowerCase() === "authorization");
195
- }
196
-
197
234
  /** One pieces read per request — the `piece` kind and the For-you source share it. */
198
235
  const memberPieces = cache((init: AccountContext["init"]) => member.pieces(init));
199
236
 
200
237
  async function loadPieces(ctx: AccountContext): Promise<MemberPiece[]> {
201
- if (!hasHubCredential(ctx)) return [];
202
238
  return (await read(memberPieces(ctx.init), ctx.user))?.items ?? [];
203
239
  }
204
240
 
@@ -230,13 +266,15 @@ const piece: AccountInboxKind<MemberPiece> = {
230
266
  },
231
267
  };
232
268
 
269
+ /** One messages read per request — the `message` kind and the Send message action share it. */
270
+ const memberMessages = cache((init: AccountContext["init"]) => member.messages(init));
271
+
233
272
  const message: AccountInboxKind<MemberMessage> = {
234
273
  kind: "message",
235
274
  label: "messages.heading",
236
275
  section: "updates",
237
276
  async load(ctx) {
238
- if (!hasHubCredential(ctx)) return [];
239
- const messages = (await read(member.messages(ctx.init), ctx.user))?.items ?? [];
277
+ const messages = (await read(memberMessages(ctx.init), ctx.user))?.items ?? [];
240
278
  return messages.map((m) => ({
241
279
  id: m.id,
242
280
  title: m.subject,
@@ -273,6 +311,47 @@ const message: AccountInboxKind<MemberMessage> = {
273
311
  },
274
312
  };
275
313
 
314
+ /** Each appointment status's message key — a new status is a type error, never a raw key. */
315
+ const APPOINTMENT_STATUS_KEY = {
316
+ pending: "appointments.statuses.pending",
317
+ confirmed: "appointments.statuses.confirmed",
318
+ cancelled: "appointments.statuses.cancelled",
319
+ } satisfies Record<MemberAppointment["status"], string>;
320
+
321
+ const appointment: AccountInboxKind<MemberAppointment> = {
322
+ kind: "appointment",
323
+ label: "appointments.heading",
324
+ section: "upcoming",
325
+ async load({ init, t, user }) {
326
+ const appointments = await read(member.appointments(init), user);
327
+ if (appointments === null) return [];
328
+ // The read's own split decides upcoming — a meeting in progress is upcoming.
329
+ const toItem = (a: MemberAppointment, upcoming: boolean) => ({
330
+ id: a.id,
331
+ title: a.title ?? t("appointments.untitled"),
332
+ at: a.startTime,
333
+ upcoming,
334
+ data: a,
335
+ });
336
+ return [
337
+ ...appointments.upcoming.map((a) => toItem(a, true)),
338
+ ...appointments.past.map((a) => toItem(a, false)),
339
+ ];
340
+ },
341
+ renderDetail({ data }, { locale, t }) {
342
+ return {
343
+ status: t(APPOINTMENT_STATUS_KEY[data.status]),
344
+ fields: {
345
+ heading: t("appointments.heading"),
346
+ items: [
347
+ { label: t("appointments.starts"), value: formatWhen(data.startTime, locale) },
348
+ { label: t("appointments.ends"), value: formatWhen(data.endTime, locale) },
349
+ ],
350
+ },
351
+ };
352
+ },
353
+ };
354
+
276
355
  // ── Base For-you sources (D9 as amended, D8a) ────────────────────────────────
277
356
 
278
357
  const emailUnverified: AccountForYouSource = {
@@ -285,7 +364,7 @@ const emailUnverified: AccountForYouSource = {
285
364
  type: { id: "email", label: t("profile.email"), tone: "needs-you" },
286
365
  title: t("profile.unverified"),
287
366
  time: { kind: "at", at: new Date().toISOString() },
288
- action: { kind: "open", href: localeHref(locale, "/account/settings#general") },
367
+ action: { kind: "open", href: localeHref(locale, "/account/general") },
289
368
  dismissible: true,
290
369
  },
291
370
  ];
@@ -304,7 +383,7 @@ const paymentDue: AccountForYouSource = {
304
383
  type: { id: "payment", label: t("subscription.action"), tone: "needs-you" },
305
384
  title: t("subscription.headline"),
306
385
  time: { kind: "at", at: new Date().toISOString() },
307
- action: { kind: "open", href: localeHref(locale, "/account/settings#billing") },
386
+ action: { kind: "open", href: localeHref(locale, "/account/billing") },
308
387
  dismissible: true,
309
388
  },
310
389
  ];
@@ -334,14 +413,71 @@ const pieceNeedsClient: AccountForYouSource = {
334
413
  },
335
414
  };
336
415
 
416
+ // ── New actions (#8233) ──────────────────────────────────────────────────────
417
+
418
+ /**
419
+ * A New action's server half — everything but the popup it opens, which
420
+ * `account/new-popups.tsx` binds (see the header: this file imports nothing
421
+ * under `(authed)`).
422
+ */
423
+ export type AccountNewRead = Omit<AccountNewAction, "renderPopup">;
424
+
425
+ const scheduleService = {
426
+ id: "schedule-service",
427
+ label: "new.actions.scheduleService",
428
+ async available({ init, user }) {
429
+ return (await read(member.meetingTypes(init), user)) !== null;
430
+ },
431
+ } as const satisfies AccountNewRead;
432
+
433
+ const requestQuote = {
434
+ id: "request-quote",
435
+ label: "new.actions.requestQuote",
436
+ async available({ init, user }) {
437
+ // `form: null` is Request quote switched off — a state, never a 404.
438
+ return ((await read(member.requestForm(init), user))?.form ?? null) !== null;
439
+ },
440
+ } as const satisfies AccountNewRead;
441
+
442
+ const sendMessage = {
443
+ id: "send-message",
444
+ label: "new.actions.sendMessage",
445
+ async available({ init, user }) {
446
+ return (await read(memberMessages(init), user)) !== null;
447
+ },
448
+ } as const satisfies AccountNewRead;
449
+
337
450
  // ── Contributions ─────────────────────────────────────────────────────────────
338
451
 
339
452
  /** Inbox item kinds, in load order. A module appends its own. */
340
- export const accountInboxKinds: AccountInboxKind[] = [result, purchase, piece, message];
453
+ export const accountInboxKinds: AccountInboxKind[] = [
454
+ result,
455
+ purchase,
456
+ piece,
457
+ message,
458
+ appointment,
459
+ ];
341
460
 
342
461
  /** User-settings sections a module adds after the base ones. None in the base area. */
343
462
  export const accountSettingsSections: AccountSettingsSection[] = [];
344
463
 
464
+ /**
465
+ * New-menu actions' server halves, in menu order (#8233 D2). A module appends
466
+ * its own read here and binds its popup in `account/new-popups.tsx`, keyed on
467
+ * the id — `AccountNewActionId` makes a read with no popup a type error.
468
+ */
469
+ export const accountNewActions = [
470
+ scheduleService,
471
+ requestQuote,
472
+ sendMessage,
473
+ ] as const satisfies readonly AccountNewRead[];
474
+
475
+ /** The ids `accountNewActions` carries — the keys `new-popups.tsx` must bind. */
476
+ export type AccountNewActionId = (typeof accountNewActions)[number]["id"];
477
+
478
+ /** Billing-page sections a module adds after the base ones. `member-settings` (#8232) renders it. */
479
+ export const accountBillingSections: AccountBillingSection[] = [];
480
+
345
481
  /** For-you sources, in load order. A module appends its own. */
346
482
  export const accountForYouSources: AccountForYouSource[] = [
347
483
  emailUnverified,
@@ -34,8 +34,11 @@ jest.mock("../../topology.generated", () => ({
34
34
  SIGN_IN_PROVIDER: "hub",
35
35
  }));
36
36
 
37
+ import { cookies } from "next/headers";
38
+
37
39
  import { forwardedCookieInit } from "../../server-api";
38
40
  import { hubSessionConfig } from "../config";
41
+ import { resealHubSession } from "../reseal";
39
42
  import { seal, unseal } from "../seal";
40
43
  import {
41
44
  type HubSessionPayload,
@@ -115,6 +118,29 @@ describe("seal", () => {
115
118
  expect(unseal("", SESSION_SECRET, "session", COOKIE)).toBeNull();
116
119
  });
117
120
 
121
+ // #8146 AC-4: the `continue` purpose was added to seal.ts. These two
122
+ // ciphertexts were sealed on `main` BEFORE that edit and committed as
123
+ // literals, so any change to INFO.session, INFO.flow or deriveKey fails
124
+ // here — the round trips above cannot see a key change (a changed key still
125
+ // round-trips with itself, and would silently log out every live spoke).
126
+ it("still opens a `session` value sealed before the `continue` purpose existed", () => {
127
+ const sealed =
128
+ "T0OSblxM1tRcJ4f5OP4VDvQA5TvWQdW3K_GvZlFzETSgVCw1tzEEaDWfAhHz0HZgY1sg8vA2fy0au73t";
129
+ expect(unseal(sealed, SESSION_SECRET, "session", COOKIE)).toEqual({
130
+ v: 1,
131
+ fixed: "session-vector",
132
+ });
133
+ });
134
+
135
+ it("still opens a `flow` value sealed before the `continue` purpose existed", () => {
136
+ const sealed =
137
+ "TI60LmvzNlRIp6a4BexFT9zmNoOWn8ZYBhW2VtOklk6lwUtrEWwtJ15XSJeAfgeNpSc322z-PWlZ";
138
+ expect(unseal(sealed, SESSION_SECRET, "flow", "ailk-hub-flow")).toEqual({
139
+ v: 1,
140
+ fixed: "flow-vector",
141
+ });
142
+ });
143
+
118
144
  it("uses a fresh IV per seal", () => {
119
145
  expect(seal({ a: 1 }, SESSION_SECRET, "session", COOKIE)).not.toBe(
120
146
  seal({ a: 1 }, SESSION_SECRET, "session", COOKIE),
@@ -260,3 +286,120 @@ describe("hubNextPath", () => {
260
286
  expect(hubNextPath(raw)).toBe(expected);
261
287
  });
262
288
  });
289
+
290
+ // #8232 D11 (spec docs/specs/8232-member-settings-account-general-and-spec.md
291
+ // AC-5) — after a member's profile save, the spoke re-seals its session from
292
+ // the hub's /me. Only `user` changes; the credential and its expiry are kept,
293
+ // so a re-seal never extends a session (SEC-8232-1), and the cookie is
294
+ // written with the attributes the callback writes at sign-in (SEC-8232-2).
295
+ describe("#8232 AC-5 — resealHubSession", () => {
296
+ const mockSet = jest.fn();
297
+ const RENAMED = { ...USER, name: "Ada King" };
298
+
299
+ beforeEach(() => {
300
+ mockSet.mockReset();
301
+ jest.mocked(cookies).mockResolvedValue({ set: mockSet } as never);
302
+ });
303
+
304
+ function signedIn(overrides: Partial<HubSessionPayload> = {}, name = COOKIE): void {
305
+ mockHeaders.mockResolvedValue(headersWith(seal(payload(overrides), SESSION_SECRET, "session", name), name));
306
+ }
307
+
308
+ it("re-seals the session with /me's person, keeping the credential and expiry, once, with the sign-in attributes", async () => {
309
+ const expiresAt = Date.now() + 60_000;
310
+ signedIn({ expiresAt });
311
+ mockMe.mockResolvedValue({ ok: true, body: { user: RENAMED, billingCustomerId: null } });
312
+
313
+ await expect(resealHubSession()).resolves.toBe("resealed");
314
+
315
+ expect(mockMe).toHaveBeenCalledTimes(1);
316
+ expect(mockMe).toHaveBeenCalledWith({ headers: { authorization: `Bearer ${CREDENTIAL}` } });
317
+ expect(mockSet).toHaveBeenCalledTimes(1);
318
+ const [name, value, options] = mockSet.mock.calls[0] as [string, string, Record<string, unknown>];
319
+ expect(name).toBe(COOKIE);
320
+ expect(options).toEqual({
321
+ httpOnly: true,
322
+ sameSite: "lax",
323
+ path: "/",
324
+ secure: false,
325
+ maxAge: expect.any(Number),
326
+ });
327
+ expect(options["maxAge"]).toBeGreaterThan(0);
328
+ expect(options["maxAge"]).toBeLessThanOrEqual(60);
329
+ expect(unseal(value, SESSION_SECRET, "session", COOKIE)).toEqual({
330
+ v: 1,
331
+ accessToken: CREDENTIAL,
332
+ expiresAt,
333
+ user: RENAMED,
334
+ });
335
+ });
336
+
337
+ it("writes the __Host- cookie with Secure on an https site", async () => {
338
+ configure({ NEXT_PUBLIC_BASE_URL: "https://spoke.example.com" });
339
+ signedIn({}, "__Host-ailk-hub-session");
340
+ mockMe.mockResolvedValue({ ok: true, body: { user: RENAMED, billingCustomerId: null } });
341
+
342
+ await expect(resealHubSession()).resolves.toBe("resealed");
343
+ const [name, , options] = mockSet.mock.calls[0] as [string, string, Record<string, unknown>];
344
+ expect(name).toBe("__Host-ailk-hub-session");
345
+ expect(options).toMatchObject({ secure: true, httpOnly: true });
346
+ });
347
+
348
+ it("is skipped with no configuration (a single site), calling neither /me nor set", async () => {
349
+ signedIn();
350
+ configure({ AILK_HUB_SESSION_SECRET: undefined });
351
+ await expect(resealHubSession()).resolves.toBe("skipped");
352
+ expect(mockMe).not.toHaveBeenCalled();
353
+ expect(mockSet).not.toHaveBeenCalled();
354
+ });
355
+
356
+ it("is skipped with no session cookie, calling neither /me nor set", async () => {
357
+ mockHeaders.mockResolvedValue(new Headers());
358
+ await expect(resealHubSession()).resolves.toBe("skipped");
359
+ expect(mockMe).not.toHaveBeenCalled();
360
+ expect(mockSet).not.toHaveBeenCalled();
361
+ });
362
+
363
+ it.each([
364
+ ["a non-2xx", () => mockMe.mockResolvedValue({ ok: false, status: 401, body: null })],
365
+ ["a throw", () => mockMe.mockRejectedValue(new Error("network"))],
366
+ [
367
+ "a user with a non-string email",
368
+ () => mockMe.mockResolvedValue({ ok: true, body: { user: { ...RENAMED, email: 42 }, billingCustomerId: null } }),
369
+ ],
370
+ [
371
+ "a user with a non-boolean emailVerified",
372
+ () =>
373
+ mockMe.mockResolvedValue({ ok: true, body: { user: { ...RENAMED, emailVerified: "yes" }, billingCustomerId: null } }),
374
+ ],
375
+ [
376
+ "a user with a non-string createdAt",
377
+ () => mockMe.mockResolvedValue({ ok: true, body: { user: { ...RENAMED, createdAt: 1 }, billingCustomerId: null } }),
378
+ ],
379
+ [
380
+ "a user whose name is neither a string nor null",
381
+ () => mockMe.mockResolvedValue({ ok: true, body: { user: { ...RENAMED, name: 7 }, billingCustomerId: null } }),
382
+ ],
383
+ ])("fails on %s from /me and leaves the cookie as it is", async (_label, arrange) => {
384
+ signedIn();
385
+ arrange();
386
+ await expect(resealHubSession()).resolves.toBe("failed");
387
+ expect(mockSet).not.toHaveBeenCalled();
388
+ });
389
+
390
+ it("is skipped when the session has under a second left, with no call and no write", async () => {
391
+ signedIn({ expiresAt: Date.now() + 500 });
392
+ await expect(resealHubSession()).resolves.toBe("skipped");
393
+ expect(mockMe).not.toHaveBeenCalled();
394
+ expect(mockSet).not.toHaveBeenCalled();
395
+ });
396
+
397
+ it("fails, never throws, when the cookie write is refused", async () => {
398
+ signedIn();
399
+ mockMe.mockResolvedValue({ ok: true, body: { user: RENAMED, billingCustomerId: null } });
400
+ mockSet.mockImplementation(() => {
401
+ throw new Error("Cookies can only be modified in a Server Action or Route Handler");
402
+ });
403
+ await expect(resealHubSession()).resolves.toBe("failed");
404
+ });
405
+ });
@@ -21,3 +21,4 @@ export {
21
21
  hubSessionInit,
22
22
  readHubSession,
23
23
  } from "./session";
24
+ export { resealHubSession } from "./reseal";
@@ -0,0 +1,90 @@
1
+ /**
2
+ * The spoke's session re-seal (#8232, spec
3
+ * docs/specs/8232-member-settings-account-general-and-spec.md D11; roadmap
4
+ * D9).
5
+ *
6
+ * After a member saves their name through the hub (`PATCH /v1/member/profile`),
7
+ * the sealed session cookie still names the person as they were at sign-in.
8
+ * `resealHubSession()` re-reads the person from the hub's own record,
9
+ * `GET /v1/hub-sign-in/me`, with the session's credential, and writes the
10
+ * cookie again so the next render shows the new name:
11
+ *
12
+ * - no hub-session configuration (a single site) or no session cookie ⇒
13
+ * `"skipped"`, with no call;
14
+ * - `/me` refusing, throwing, or answering a person of the wrong shape ⇒
15
+ * `"failed"`, and the cookie is left as it is (sign-out is the sign-out
16
+ * route's job — a transient hub error never signs the member out);
17
+ * - otherwise only `user` changes. `v`, `accessToken` and `expiresAt` are
18
+ * kept exactly and `Max-Age` is the remaining lifetime, so a re-seal
19
+ * never extends a session (SEC-8232-1). The cookie's name, key and
20
+ * attributes are the ones the callback route writes at sign-in
21
+ * (`cookieHeader`: `HttpOnly`, `SameSite=Lax`, `Path=/`, `Secure` on an
22
+ * https site — SEC-8232-2).
23
+ *
24
+ * It tests no topology: a single site has no configuration and no cookie.
25
+ * Nothing here logs a credential, a cookie value, a name or an email.
26
+ */
27
+
28
+ import { hubSignIn } from "@working-theory/api-client";
29
+ import { cookies, headers } from "next/headers";
30
+
31
+ import { hubSessionConfig } from "./config";
32
+ import { seal } from "./seal";
33
+ import { type HubSessionUser, readHubSessionPayload, sessionCookieName } from "./session";
34
+
35
+ /**
36
+ * The person `/me` names, when every field is the type the session holds.
37
+ * It repeats `session.ts`'s private shape check on purpose: `sync-routes`
38
+ * brings this file to a scaffold whose own `session.ts` it never updates,
39
+ * so it cannot rely on a new export there.
40
+ */
41
+ function sessionUser(value: unknown): HubSessionUser | null {
42
+ if (typeof value !== "object" || value === null) return null;
43
+ const user = value as Record<string, unknown>;
44
+ const { name, email, emailVerified, createdAt } = user;
45
+ if (
46
+ typeof email !== "string" ||
47
+ typeof emailVerified !== "boolean" ||
48
+ typeof createdAt !== "string" ||
49
+ (name !== null && typeof name !== "string")
50
+ ) {
51
+ return null;
52
+ }
53
+ return { name, email, emailVerified, createdAt };
54
+ }
55
+
56
+ /** Re-seals the hub session cookie from the hub's `/me`. Never throws. */
57
+ export async function resealHubSession(): Promise<"resealed" | "skipped" | "failed"> {
58
+ const config = hubSessionConfig();
59
+ if (config === null) return "skipped";
60
+ const payload = readHubSessionPayload(await headers());
61
+ if (payload === null) return "skipped";
62
+ const maxAge = Math.floor((payload.expiresAt - Date.now()) / 1000);
63
+ if (maxAge <= 0) return "skipped";
64
+
65
+ let user: HubSessionUser | null;
66
+ try {
67
+ const res = await hubSignIn.me({
68
+ headers: { authorization: `Bearer ${payload.accessToken}` },
69
+ });
70
+ user = res.ok ? sessionUser(res.body.user) : null;
71
+ } catch {
72
+ user = null;
73
+ }
74
+ if (user === null) return "failed";
75
+
76
+ const name = sessionCookieName(config);
77
+ try {
78
+ (await cookies()).set(name, seal({ ...payload, user }, config.sessionSecret, "session", name), {
79
+ httpOnly: true,
80
+ sameSite: "lax",
81
+ path: "/",
82
+ maxAge,
83
+ secure: config.secure,
84
+ });
85
+ } catch {
86
+ // Outside a Server Action or route handler Next refuses a cookie write.
87
+ return "failed";
88
+ }
89
+ return "resealed";
90
+ }
@@ -1,12 +1,15 @@
1
1
  /**
2
- * Sealed cookie values for the spoke's hub session (#7894 D7).
2
+ * Sealed values for the hub sign-in (#7894 D7, #8146 D3).
3
3
  *
4
- * AES-256-GCM with a fresh 12-byte IV per seal. The key is HKDF-SHA256 of
5
- * `AILK_HUB_SESSION_SECRET` with one info string per cookie purpose, so the
6
- * session cookie and the flow cookie never share a key, and the cookie's NAME
7
- * is the additional authenticated data, so a value sealed for one cookie never
8
- * opens as the other. The key never derives from the client secret: a leaked
9
- * client secret forges no spoke session.
4
+ * AES-256-GCM with a fresh 12-byte IV per seal. The key is HKDF-SHA256 of the
5
+ * caller's secret with one info string per purpose, so the three purposes
6
+ * never share a key, and the caller-supplied name is the additional
7
+ * authenticated data, so a value sealed for one name never opens as another.
8
+ * Two secrets feed this module: `AILK_HUB_SESSION_SECRET` for the spoke's
9
+ * `session` and `flow` cookies, and `BETTER_AUTH_SECRET` for the hub's
10
+ * `continue` ref (`app/api/auth/hub/continuation.ts`, hub and standalone
11
+ * sites only; the entry is unused on a spoke). The key never derives from the
12
+ * client secret: a leaked client secret forges no spoke session.
10
13
  *
11
14
  * Wire form: base64url(iv ‖ tag ‖ ciphertext). Anything that does not open —
12
15
  * a wrong key, a wrong name, a truncated or altered value — reads as `null`,
@@ -20,12 +23,13 @@ import {
20
23
  randomBytes,
21
24
  } from "node:crypto";
22
25
 
23
- /** The two things this module seals, each under its own derived key. */
24
- export type SealPurpose = "session" | "flow";
26
+ /** The things this module seals, each under its own derived key. */
27
+ export type SealPurpose = "session" | "flow" | "continue";
25
28
 
26
29
  const INFO: Record<SealPurpose, string> = {
27
30
  session: "ailk-hub-session/v1",
28
31
  flow: "ailk-hub-flow/v1",
32
+ continue: "ailk-hub-continue/v1",
29
33
  };
30
34
 
31
35
  const IV_BYTES = 12;