create-ailk 0.14.0 → 0.16.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 (184) hide show
  1. package/README.md +124 -0
  2. package/component-catalog.json +3 -3
  3. package/dist/add-module.d.ts +7 -0
  4. package/dist/add-module.js +80 -3
  5. package/dist/add-route.js +26 -2
  6. package/dist/adopt.d.ts +74 -0
  7. package/dist/adopt.js +314 -0
  8. package/dist/apply-design.d.ts +12 -1
  9. package/dist/apply-design.js +36 -4
  10. package/dist/cli.js +103 -3
  11. package/dist/derive-architecture.d.ts +37 -2
  12. package/dist/derive-architecture.js +92 -7
  13. package/dist/design-package.js +3 -2
  14. package/dist/index.d.ts +1 -1
  15. package/dist/index.js +1 -1
  16. package/dist/lib/chrome-readers.d.ts +21 -0
  17. package/dist/lib/chrome-readers.js +100 -0
  18. package/dist/lib/declared-env.d.ts +142 -0
  19. package/dist/lib/declared-env.js +985 -0
  20. package/dist/lib/env-catalog.d.ts +17 -0
  21. package/dist/lib/env-catalog.js +234 -0
  22. package/dist/lib/extract-module-bundle.d.ts +20 -0
  23. package/dist/lib/extract-module-bundle.js +53 -6
  24. package/dist/lib/license-key-files.d.ts +6 -1
  25. package/dist/lib/license-key-files.js +9 -4
  26. package/dist/lib/module-tier.d.ts +2 -0
  27. package/dist/lib/module-tier.js +4 -0
  28. package/dist/module-architecture.d.ts +9 -0
  29. package/dist/module-architecture.js +82 -0
  30. package/dist/parse-args.d.ts +27 -0
  31. package/dist/parse-args.js +89 -0
  32. package/dist/programmatic.d.ts +28 -0
  33. package/dist/programmatic.js +181 -11
  34. package/dist/seed-flows.d.ts +65 -0
  35. package/dist/seed-flows.js +350 -0
  36. package/dist/site-chrome.d.ts +4 -0
  37. package/dist/site-chrome.js +34 -3
  38. package/dist/site-manifest.d.ts +55 -5
  39. package/dist/site-manifest.js +121 -12
  40. package/dist/site-roster.d.ts +13 -0
  41. package/dist/site-roster.js +22 -8
  42. package/dist/spoke-checkout.d.ts +42 -0
  43. package/dist/spoke-checkout.js +359 -0
  44. package/dist/surfaces.js +7 -0
  45. package/dist/sync-routes.js +29 -5
  46. package/dist/topology.d.ts +246 -0
  47. package/dist/topology.js +942 -0
  48. package/package.json +3 -3
  49. package/site-templates/designs/minimalism/DESIGN.md +3 -3
  50. package/templates/.env.example +10 -0
  51. package/templates/apps/api/CLAUDE.md +21 -4
  52. package/templates/apps/api/package.json +1 -0
  53. package/templates/apps/api/src/__tests__/factories/agent-caller.ts +5 -1
  54. package/templates/apps/api/src/__tests__/factories/in-memory-prisma.ts +95 -16
  55. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +122 -0
  56. package/templates/apps/api/src/__tests__/support/hub-sign-in-fake-db.ts +362 -0
  57. package/templates/apps/api/src/bin/spokes.ts +62 -1
  58. package/templates/apps/api/src/config/__tests__/hub-require-spoke-key.test.ts +75 -0
  59. package/templates/apps/api/src/config/index.ts +35 -0
  60. package/templates/apps/api/src/lib/__mocks__/prisma.ts +99 -0
  61. package/templates/apps/api/src/lib/__tests__/agent-store.test.ts +114 -1
  62. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +66 -5
  63. package/templates/apps/api/src/lib/__tests__/hub-sign-in-store.test.ts +209 -0
  64. package/templates/apps/api/src/lib/__tests__/site.test.ts +54 -0
  65. package/templates/apps/api/src/lib/__tests__/spoke-store.test.ts +20 -0
  66. package/templates/apps/api/src/lib/agent-store.ts +95 -3
  67. package/templates/apps/api/src/lib/deliverable-asset-store.ts +24 -5
  68. package/templates/apps/api/src/lib/hub-sign-in-store.ts +419 -0
  69. package/templates/apps/api/src/lib/site.ts +37 -0
  70. package/templates/apps/api/src/lib/spoke-store.ts +41 -3
  71. package/templates/apps/api/src/middleware/__tests__/auth.bearer-path.test.ts +4 -0
  72. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +310 -0
  73. package/templates/apps/api/src/middleware/__tests__/default-tenant.test.ts +22 -0
  74. package/templates/apps/api/src/middleware/auth.ts +129 -0
  75. package/templates/apps/api/src/openapi/__tests__/openapi.test.ts +23 -0
  76. package/templates/apps/api/src/openapi/spec.ts +38 -2
  77. package/templates/apps/api/src/routes/auth/__tests__/hub-sign-in.test.ts +483 -0
  78. package/templates/apps/api/src/routes/auth/hub-sign-in.ts +201 -0
  79. package/templates/apps/api/src/routes/checkout/__tests__/sessions.test.ts +50 -2
  80. package/templates/apps/api/src/routes/checkout/sessions.ts +7 -0
  81. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +282 -0
  82. package/templates/apps/api/src/routes/leads/index.ts +69 -6
  83. package/templates/apps/api/src/routes/webhooks/README.md +39 -0
  84. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-flow-checkout.test.ts +105 -0
  85. package/templates/apps/api/src/routes/webhooks/__tests__/stripe-site-filter.test.ts +366 -0
  86. package/templates/apps/api/src/routes/webhooks/stripe.ts +133 -2
  87. package/templates/apps/api/src/server.ts +105 -0
  88. package/templates/apps/api/src/services/__tests__/deliverable-fulfillment.test.ts +4 -4
  89. package/templates/apps/api/src/services/__tests__/hub-sign-in.test.ts +854 -0
  90. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +492 -0
  91. package/templates/apps/api/src/services/__tests__/person.test.ts +553 -0
  92. package/templates/apps/api/src/services/default-tenant.ts +41 -7
  93. package/templates/apps/api/src/services/hub-sign-in.ts +471 -0
  94. package/templates/apps/api/src/services/lead-routing.ts +278 -30
  95. package/templates/apps/api/src/services/person.ts +541 -0
  96. package/templates/apps/api/src/services/public-site-resolver.ts +48 -0
  97. package/templates/apps/mcp/__tests__/index.test.ts +5 -5
  98. package/templates/apps/mcp/src/index.ts +6 -4
  99. package/templates/apps/mcp/src/lib/content-adapter.ts +2 -2
  100. package/templates/apps/mcp/src/lib/topology.generated.ts +24 -0
  101. package/templates/apps/web/.env.example +58 -5
  102. package/templates/apps/web/CLAUDE.md +1 -0
  103. package/templates/apps/web/app/[locale]/(auth)/sign-in/next-path.ts +0 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +206 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +256 -3
  106. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +5 -8
  107. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +205 -0
  108. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +82 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +98 -29
  110. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +6 -3
  111. package/templates/apps/web/app/[locale]/(authed)/layout.tsx +19 -101
  112. package/templates/apps/web/app/[locale]/(authed)/signed-out-gate.generated.tsx +90 -0
  113. package/templates/apps/web/app/[locale]/[slug]/[detail]/__tests__/page.test.tsx +3 -0
  114. package/templates/apps/web/app/[locale]/[slug]/__tests__/page.test.tsx +3 -0
  115. package/templates/apps/web/app/[locale]/contact/__tests__/page-renderer-migration.test.tsx +66 -14
  116. package/templates/apps/web/app/[locale]/contact/__tests__/site-contact-lines.test.ts +105 -11
  117. package/templates/apps/web/app/[locale]/contact/site-contact-lines.ts +12 -8
  118. package/templates/apps/web/app/[locale]/layout.tsx +83 -18
  119. package/templates/apps/web/app/[locale]/newsletter/__tests__/page.test.tsx +3 -0
  120. package/templates/apps/web/app/[locale]/projects/__tests__/page-renderer-migration.test.tsx +5 -2
  121. package/templates/apps/web/app/[locale]/thanks/[slug]/__tests__/page.test.tsx +3 -0
  122. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +295 -0
  123. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +156 -0
  124. package/templates/apps/web/app/api/hub-session/__tests__/routes.test.ts +341 -0
  125. package/templates/apps/web/app/api/hub-session/callback/route.ts +168 -0
  126. package/templates/apps/web/app/api/hub-session/sign-in/route.ts +76 -0
  127. package/templates/apps/web/app/api/hub-session/sign-out/route.ts +63 -0
  128. package/templates/apps/web/app/openapi.json/__tests__/route.test.ts +104 -1
  129. package/templates/apps/web/app/openapi.json/route.ts +72 -7
  130. package/templates/apps/web/auth.ts +29 -2
  131. package/templates/apps/web/components/sign-out-trigger.tsx +20 -1
  132. package/templates/apps/web/components/signed-in-nav.tsx +17 -3
  133. package/templates/apps/web/instrumentation.ts +7 -4
  134. package/templates/apps/web/lib/__tests__/account-config.test.ts +5 -2
  135. package/templates/apps/web/lib/__tests__/closing-footer-path.test.ts +96 -0
  136. package/templates/apps/web/lib/__tests__/listings-config.test.ts +83 -0
  137. package/templates/apps/web/lib/__tests__/listings.test.tsx +49 -0
  138. package/templates/apps/web/lib/__tests__/meeting-slugs.test.ts +49 -1
  139. package/templates/apps/web/lib/__tests__/relay.test.ts +317 -0
  140. package/templates/apps/web/lib/__tests__/site-brand.test.ts +52 -0
  141. package/templates/apps/web/lib/account-config.ts +130 -1
  142. package/templates/apps/web/lib/api-origin.generated.ts +11 -0
  143. package/templates/apps/web/lib/closing-footer-path.ts +50 -0
  144. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +262 -0
  145. package/templates/apps/web/lib/hub-session/config.ts +104 -0
  146. package/templates/apps/web/lib/hub-session/index.ts +23 -0
  147. package/templates/apps/web/lib/hub-session/seal.ts +82 -0
  148. package/templates/apps/web/lib/hub-session/session.ts +180 -0
  149. package/templates/apps/web/lib/hub-session/sign-in-gate.tsx +79 -0
  150. package/templates/apps/web/lib/listings-config.ts +31 -0
  151. package/templates/apps/web/lib/listings.tsx +7 -3
  152. package/templates/apps/web/lib/meeting-slugs.ts +11 -1
  153. package/templates/apps/web/lib/next-path.ts +35 -0
  154. package/templates/apps/web/lib/relay.ts +209 -3
  155. package/templates/apps/web/lib/server-api.ts +11 -1
  156. package/templates/apps/web/lib/site-brand.tsx +22 -1
  157. package/templates/apps/web/lib/site-chrome.ts +3 -0
  158. package/templates/apps/web/lib/topology.generated.ts +33 -0
  159. package/templates/apps/web/messages/en.json +32 -3
  160. package/templates/apps/web/package.json +1 -1
  161. package/templates/database/CHANGELOG.md +51 -0
  162. package/templates/database/__tests__/agents.db.test.ts +85 -0
  163. package/templates/database/__tests__/fixtures/person-backfill.json +264 -0
  164. package/templates/database/__tests__/lead-idempotency-key.db.test.ts +92 -0
  165. package/templates/database/__tests__/member-area.db.test.ts +245 -0
  166. package/templates/database/__tests__/person.db.test.ts +584 -0
  167. package/templates/database/auth/schema.prisma +24 -0
  168. package/templates/database/config/schema.prisma +13 -0
  169. package/templates/database/inbox/schema.prisma +111 -0
  170. package/templates/database/jest.config.cjs +7 -11
  171. package/templates/database/migrations/20261008120000_user_last_workspace/migration.sql +35 -0
  172. package/templates/database/migrations/20261008130000_lead_idempotency_key/migration.sql +20 -0
  173. package/templates/database/migrations/20261009120000_agent_grants/migration.sql +33 -0
  174. package/templates/database/migrations/20261010120000_person/migration.sql +106 -0
  175. package/templates/database/migrations/20261010121000_person_backfill/migration.sql +132 -0
  176. package/templates/database/migrations/20261010130000_agent_grants_people_read/migration.sql +20 -0
  177. package/templates/database/migrations/20261011120000_member_area/migration.sql +158 -0
  178. package/templates/database/migrations/20261011130000_agent_grants_member_pieces_manage/migration.sql +21 -0
  179. package/templates/database/package.json +1 -1
  180. package/templates/database/scripts/person-backfill.sql +132 -0
  181. package/templates/database/tenancy.ts +53 -0
  182. package/templates/tools/check-declared-env.ts +37 -0
  183. package/templates/tools/declared-env.ts +1163 -0
  184. package/versions.json +35 -34
@@ -0,0 +1,24 @@
1
+ // topology.generated.ts — how this MCP server resolves its workspace store.
2
+ //
3
+ // GENERATED by create-ailk for this site's topology (#7426). Do not edit:
4
+ // every scaffold rewrites it. A hub resolves its workspace through the
5
+ // database; a spoke (`--topology spoke`) has none, so its content tools
6
+ // read the spoke's own MDX and its writes go to the hub at AILK_API_URL.
7
+ import {
8
+ initMcpPublicWorkspace,
9
+ mcpPublicWorkspace,
10
+ type McpWorkspaceResolution,
11
+ } from "./public-workspace.js";
12
+
13
+ export { registerLicenseKeyStore } from "./license-key-store.js";
14
+ export type { McpWorkspaceResolution };
15
+
16
+ export const TOPOLOGY: "hub" | "spoke" = "hub";
17
+
18
+ export async function initPublicWorkspace(): Promise<void> {
19
+ await initMcpPublicWorkspace();
20
+ }
21
+
22
+ export function publicWorkspace(): McpWorkspaceResolution {
23
+ return mcpPublicWorkspace();
24
+ }
@@ -93,17 +93,26 @@ CONTENT_ADAPTER=mdx
93
93
  # NEXT_PUBLIC_AILK_API_URL=http://localhost:3002
94
94
 
95
95
  # =============================================================================
96
- # Listicle directory (#7521) — server-side only, never NEXT_PUBLIC_
96
+ # Listicle directory (#7521) — switched on by the site's listing key (#7872)
97
97
  # =============================================================================
98
98
  # The site's public listing key: the SAME value apps/api reads as
99
99
  # AILK_LISTING_PUBLIC_KEY (single-tenant) or holds as a SiteListingConfig
100
100
  # publicKey row (multi-tenant). Public by design — it names a site, it grants
101
- # nothing — but it is read on the server only. When it is set and the paid
102
- # @working-theory/ui-signal-collectors package is installed, the page a live
103
- # listicle names as its landing page shows that listicle's accepted entries,
104
- # and each entry gets a page at /<landingPage>/<slug>. Unset, no page reads
101
+ # nothing. When it is set and the paid @working-theory/ui-signal-collectors
102
+ # package is installed, the page a live listicle names as its landing page
103
+ # shows that listicle's accepted entries, and each entry gets a page at
104
+ # /<landingPage>/<slug>. Unset, the listings module is off and no page reads
105
105
  # the directory. One web deployment is one site: a hub's spokes are separate
106
106
  # web deployments, each with its own key.
107
+ #
108
+ # Two names, one reader (lib/listings-config.ts):
109
+ # 1. NEXT_PUBLIC_AILK_LISTING_SITE_KEY wins when set. It is inlined into the
110
+ # build, so changing or rotating it needs a rebuild — and while it is set,
111
+ # a runtime change to AILK_LISTING_PUBLIC_KEY has no effect.
112
+ # 2. AILK_LISTING_PUBLIC_KEY is the fallback (server-read), so a site
113
+ # configured before this name existed keeps its directory on upgrade.
114
+ # An empty value counts as unset.
115
+ # NEXT_PUBLIC_AILK_LISTING_SITE_KEY=
107
116
  # AILK_LISTING_PUBLIC_KEY=
108
117
 
109
118
  # =============================================================================
@@ -191,6 +200,36 @@ CONTENT_ADAPTER=mdx
191
200
  #
192
201
  # AILK_AUTH_TRUSTED_PROXIES=
193
202
 
203
+ # =============================================================================
204
+ # Sign-in through the hub — a spoke scaffolded with --hub-sign-in (#7894)
205
+ # =============================================================================
206
+ # A spoke has no database and no Better Auth of its own. With --hub-sign-in, a
207
+ # visitor signs in on the HUB's own sign-in page and returns to this site's
208
+ # /account; the hub-issued credential lives in a sealed, HttpOnly cookie. All
209
+ # four are server-only — never rename one with a NEXT_PUBLIC_ prefix — and all
210
+ # four must be set, or /account says sign-in is not available yet. The routes
211
+ # under /api/hub-session answer 404 on any site not scaffolded in this mode.
212
+ #
213
+ # The hub's WEB origin (where its /sign-in lives), https: or http: on
214
+ # localhost. create-ailk writes it to .env.local.
215
+ # AILK_HUB_SIGN_IN_URL=https://hub.example.com
216
+ #
217
+ # This site's client id and secret, issued on the hub with
218
+ # pnpm --filter @working-theory/api spokes sign-in-client --id <spoke_id> \
219
+ # --redirect-uri <this site's origin>/api/hub-session/callback
220
+ # The client id is the spoke's id. The secret is shown once; keep it out of git.
221
+ # AILK_HUB_CLIENT_ID=
222
+ # AILK_HUB_CLIENT_SECRET=
223
+ #
224
+ # This site's own cookie key material: at least 32 characters, generated per
225
+ # site (create-ailk writes one to .env.local; `openssl rand -base64 32` makes
226
+ # another). Never the client secret. Changing it signs every visitor out.
227
+ # AILK_HUB_SESSION_SECRET=
228
+ #
229
+ # The redirect URI is built from this site's NEXT_PUBLIC_BASE_URL (else the
230
+ # baseUrl in content/_site.mdx) — never from a request's Host — so set it to
231
+ # the origin you registered on the hub.
232
+
194
233
  # =============================================================================
195
234
  # Observability
196
235
  # =============================================================================
@@ -228,3 +267,17 @@ CONTENT_ADAPTER=mdx
228
267
  # (see the relay block above, #7644), so the baseline CSP's
229
268
  # `connect-src 'self'` already covers them — no CSP extension is needed for
230
269
  # them, whatever NEXT_PUBLIC_AILK_API_URL is set to.
270
+ #
271
+ # On a SPOKE (create-ailk --topology spoke, #7872) this site has no apps/api:
272
+ # /meet lists the meeting types the hub stores for this spoke's workspace and
273
+ # books through the hub at AILK_API_URL with AILK_SPOKE_KEY. Then:
274
+ # - the FIRST segment (the owner) must name the provider account the hub
275
+ # uses for this spoke's workspace (its scheduling connection). It composes
276
+ # each booker's calLink; it is not an access control — the hub checks a
277
+ # spoke booking's slug, never its owner.
278
+ # - the SECOND segment is ignored: a workspace with no stored meeting-type
279
+ # list shows no meetings, because the hub books only a slug it stores.
280
+ # - the booker's two calls count against the relay's per-visitor budget,
281
+ # which holds per visitor only behind an edge that overwrites
282
+ # x-forwarded-for and x-real-ip (Vercel does); elsewhere every visitor
283
+ # shares one budget.
@@ -139,6 +139,7 @@ A visitor's browser never calls the hub. Lead capture, newsletter signup, callba
139
139
  - **Only the list.** `RELAY_ROUTES` is the fixed set of (method, path) pairs: `POST /v1/leads/{submit,subscribe,callback}`, `GET /v1/schedule/availability`, `POST /v1/schedule/book`, `POST /v1/commerce/invoices`. Anything else is a 404 with no hub call. Adding a route is a reviewed change to that list, its test and `.env.example`.
140
140
  - **No cross-site POST.** A `Sec-Fetch-Site` other than `same-origin`, or an `Origin` not on this site's own host, is refused 403 before the hub is called.
141
141
  - **Headers are an allow-list.** The visitor's `x-forwarded-for` (else `x-real-ip`) and `user-agent` pass through so per-visitor rate limits still count each visitor; no `authorization`, client-sent `x-ailk-*` or `host` is forwarded, and a `cookie` goes only on `POST /v1/commerce/invoices`, the one session route.
142
+ - **The relay has its own per-visitor budget (#7707).** Each visitor gets 8 calls per 10 minutes across the five visitor routes, and the session route (`POST /v1/commerce/invoices`) gets its own, wider budget of 30 per 10 minutes; the call that exceeds either gets `429 rate_limited` before any hub call. The budget is held in this instance's own process (no shared store, no env variable) and holds only where the site's edge overwrites `x-forwarded-for` and `x-real-ip` — Vercel does; a bare self-hosted Next server does not.
142
143
  - `/v1` is exempt from next-intl's locale rewrite in `middleware.ts`, as `/api` is.
143
144
 
144
145
  ## AEO & performance
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The account area on a spoke that signs in through its hub (#7894 D10, D12;
3
+ * spec docs/specs/7894-the-hub-signs-in-its-spec.md AC-5, AC-7).
4
+ *
5
+ * The generated web seam is mocked to its `signIn: "hub"` render: the
6
+ * session comes from `readSession`, `forwardedCookieInit()` re-targets to the
7
+ * hub credential, and `SIGN_IN_PROVIDER` is `"hub"`. It sits under
8
+ * `account/__tests__`, which a hub-mode spoke drops with the other account
9
+ * suites (spec 7894 D9), so it runs in this monorepo only.
10
+ *
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).
19
+ */
20
+ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
21
+ import { tmpdir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ import { render, screen } from "@testing-library/react";
25
+
26
+ jest.mock("next-intl/server", () => {
27
+ const msgs: unknown = require("../../../../../messages/en.json");
28
+ const dig = (obj: unknown, path: string): unknown =>
29
+ path.split(".").reduce<unknown>(
30
+ (acc, k) => (acc !== null && typeof acc === "object" ? (acc as Record<string, unknown>)[k] : undefined),
31
+ obj,
32
+ );
33
+ return {
34
+ __esModule: true,
35
+ getTranslations: async (arg: string | { namespace?: string; locale?: string }) => {
36
+ const ns = typeof arg === "string" ? arg : (arg.namespace ?? "");
37
+ const messages = dig(msgs, ns);
38
+ return (key: string): string => {
39
+ const value = dig(messages, key);
40
+ return typeof value === "string" ? value : `${ns}.${key}`;
41
+ };
42
+ },
43
+ };
44
+ });
45
+
46
+ const mockHeaders = jest.fn();
47
+ jest.mock("next/headers", () => ({
48
+ __esModule: true,
49
+ headers: (...args: unknown[]) => mockHeaders(...args),
50
+ cookies: jest.fn().mockResolvedValue({ toString: () => "" }),
51
+ }));
52
+
53
+ const mockReadSession = jest.fn();
54
+ // 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
+ jest.mock("../../../../../lib/topology.generated", () => ({
56
+ __esModule: true,
57
+ TOPOLOGY: "spoke",
58
+ SIGN_IN_ENABLED: true,
59
+ SIGN_IN_PROVIDER: "hub",
60
+ SIGN_IN_PATH: "/account",
61
+ SIGN_UP_PATH: null,
62
+ SIGN_OUT_ENDPOINT: "/api/hub-session/sign-out",
63
+ readSession: (...args: unknown[]) => mockReadSession(...args),
64
+ registerSiteStores: () => Promise.resolve(),
65
+ }));
66
+
67
+ const mockLeadsSubmit = jest.fn();
68
+ const notFound = { ok: false, status: 404, body: { ok: false, error: { code: "not_found", message: "Not found" } } };
69
+ const mockWaitlistMe = jest.fn();
70
+ const mockFlowCheckoutsMe = jest.fn();
71
+ const mockSubscriptionMe = jest.fn();
72
+ const mockMemberRead = jest.fn();
73
+ jest.mock("@working-theory/api-client", () => ({
74
+ __esModule: true,
75
+ waitlistSignups: { me: (...args: unknown[]) => mockWaitlistMe(...args) },
76
+ flowCheckouts: { me: (...args: unknown[]) => mockFlowCheckoutsMe(...args) },
77
+ subscription: {
78
+ me: (...args: unknown[]) => mockSubscriptionMe(...args),
79
+ portalSession: jest.fn(),
80
+ },
81
+ leads: { submit: (...args: unknown[]) => mockLeadsSubmit(...args) },
82
+ // #7920 — the member kinds read the hub with the credential; 404 here.
83
+ member: {
84
+ pieces: (...args: unknown[]) => mockMemberRead(...args),
85
+ messages: (...args: unknown[]) => mockMemberRead(...args),
86
+ },
87
+ visitorForwardHeaders: () => null,
88
+ hubSignIn: { me: jest.fn() },
89
+ }));
90
+
91
+ jest.mock("better-auth/react", () => ({
92
+ __esModule: true,
93
+ createAuthClient: () => ({ signOut: jest.fn(), updateUser: jest.fn() }),
94
+ }));
95
+
96
+ jest.mock("next/navigation", () => ({
97
+ __esModule: true,
98
+ useRouter: () => ({ push: jest.fn(), refresh: jest.fn() }),
99
+ usePathname: () => "/account/settings",
100
+ redirect: jest.fn(),
101
+ }));
102
+
103
+ import { seal } from "../../../../../lib/hub-session/seal";
104
+ import { accountSession, loadInboxItems } from "../account-data";
105
+ import { submitSupportRequest } from "../support-actions";
106
+ import Page from "../settings/page";
107
+
108
+ const HUB = "https://hub.example.com";
109
+ const SESSION_SECRET = "s".repeat(43);
110
+ const CREDENTIAL = `ailk_hst_${"t".repeat(43)}`;
111
+ const USER = {
112
+ name: "Ada Lovelace",
113
+ email: "ada@example.com",
114
+ emailVerified: true,
115
+ createdAt: "2026-09-01T00:00:00.000Z",
116
+ };
117
+
118
+ const ENV = process.env;
119
+ let contentRoot: string;
120
+
121
+ beforeEach(() => {
122
+ contentRoot = mkdtempSync(join(tmpdir(), "ailk-hub-mode-"));
123
+ writeFileSync(join(contentRoot, "_site.mdx"), '---\nsiteName: "Test"\n---\n');
124
+ process.env = {
125
+ ...ENV,
126
+ AILK_CONTENT_ROOT: contentRoot,
127
+ AILK_HUB_SIGN_IN_URL: HUB,
128
+ AILK_HUB_CLIENT_ID: "spoke_0001",
129
+ AILK_HUB_CLIENT_SECRET: `ailk_hcs_${"c".repeat(43)}`,
130
+ AILK_HUB_SESSION_SECRET: SESSION_SECRET,
131
+ NEXT_PUBLIC_BASE_URL: "https://spoke.example.com",
132
+ };
133
+ const sealed = seal(
134
+ { v: 1, accessToken: CREDENTIAL, expiresAt: Date.now() + 60_000, user: USER },
135
+ SESSION_SECRET,
136
+ "session",
137
+ "__Host-ailk-hub-session",
138
+ );
139
+ mockHeaders.mockReset().mockResolvedValue(new Headers({ cookie: `__Host-ailk-hub-session=${sealed}` }));
140
+ mockReadSession.mockReset().mockResolvedValue({ user: USER });
141
+ mockWaitlistMe.mockReset().mockResolvedValue(notFound);
142
+ mockFlowCheckoutsMe.mockReset().mockResolvedValue(notFound);
143
+ mockSubscriptionMe.mockReset().mockResolvedValue(notFound);
144
+ mockMemberRead.mockReset().mockResolvedValue(notFound);
145
+ });
146
+
147
+ afterEach(() => {
148
+ rmSync(contentRoot, { recursive: true, force: true });
149
+ process.env = ENV;
150
+ });
151
+
152
+ 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 () => {
154
+ const session = await accountSession("en");
155
+ if (session === null) throw new Error("expected a session");
156
+ expect(session.ctx.init).toEqual({ headers: { authorization: `Bearer ${CREDENTIAL}` } });
157
+
158
+ const inbox = await loadInboxItems(session.ctx);
159
+ expect(inbox).toEqual({ items: [], failed: false });
160
+ expect(mockFlowCheckoutsMe).toHaveBeenCalledWith({
161
+ headers: { authorization: `Bearer ${CREDENTIAL}` },
162
+ });
163
+ });
164
+
165
+ it("has no session when the seam reads none", async () => {
166
+ mockReadSession.mockResolvedValue(null);
167
+ await expect(accountSession("en")).resolves.toBeNull();
168
+ });
169
+ });
170
+
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" }) }));
174
+ }
175
+
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();
185
+ });
186
+
187
+ it("files a posted deletion request as a support request — the hub owns the account (SEC-7894-11)", async () => {
188
+ mockLeadsSubmit.mockResolvedValue({ ok: true, body: {} });
189
+ const form = new FormData();
190
+ form.set("kind", "deletion");
191
+ form.set("subject", "Delete me");
192
+ form.set("message", "Please delete my account.");
193
+ await expect(submitSupportRequest({ kind: "idle" }, form)).resolves.toEqual({ kind: "success" });
194
+ expect(mockLeadsSubmit).toHaveBeenCalledWith(
195
+ expect.objectContaining({ email: USER.email, payload: expect.objectContaining({ kind: "support" }) }),
196
+ );
197
+ });
198
+
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
+ });
205
+ });
206
+ });
@@ -17,6 +17,11 @@
17
17
  * only: no For-you stack, no bottom sheet, no large title. The For you
18
18
  * tab lists every For-you item in one inset card, and ✕ dismisses one.
19
19
  * At `md`+ only the Inbox panel shows (the aside holds For you).
20
+ * 7. #7920 — the member area's `piece` and `message` kinds and the
21
+ * needs-client For-you source. Each reads the hub with `ctx.init`
22
+ * unchanged (the caller's own credential); 404 and an unverified 401/403
23
+ * load `[]`, any other failure rejects (D12). With no hub credential on
24
+ * `ctx.init` — a cookie-session site — they make no call and load `[]`.
20
25
  */
21
26
  import { fireEvent, render, screen, within } from "@testing-library/react";
22
27
 
@@ -64,11 +69,17 @@ jest.mock("../../../../../auth", () => ({
64
69
  const mockSignupMe = jest.fn();
65
70
  const mockCheckoutsMe = jest.fn();
66
71
  const mockSubscriptionMe = jest.fn();
72
+ const mockMemberPieces = jest.fn();
73
+ const mockMemberMessages = jest.fn();
67
74
  jest.mock("@working-theory/api-client", () => ({
68
75
  __esModule: true,
69
76
  waitlistSignups: { me: (...args: unknown[]) => mockSignupMe(...args) },
70
77
  flowCheckouts: { me: (...args: unknown[]) => mockCheckoutsMe(...args) },
71
78
  subscription: { me: (...args: unknown[]) => mockSubscriptionMe(...args) },
79
+ member: {
80
+ pieces: (...args: unknown[]) => mockMemberPieces(...args),
81
+ messages: (...args: unknown[]) => mockMemberMessages(...args),
82
+ },
72
83
  }));
73
84
 
74
85
  const mockRefresh = jest.fn();
@@ -78,9 +89,18 @@ jest.mock("next/navigation", () => ({
78
89
  usePathname: () => "/account",
79
90
  }));
80
91
 
81
- import { PhoneTabProvider } from "@working-theory/templates/account";
82
-
83
- import { accountExperimentKeys } from "../../../../../lib/account-config";
92
+ import {
93
+ PhoneTabProvider,
94
+ type AccountContext,
95
+ type AccountTranslate,
96
+ } from "@working-theory/templates/account";
97
+ import { getTranslations } from "next-intl/server";
98
+
99
+ import {
100
+ accountExperimentKeys,
101
+ accountForYouSources,
102
+ accountInboxKinds,
103
+ } from "../../../../../lib/account-config";
84
104
  import Page from "../page";
85
105
 
86
106
  const SESSION = {
@@ -121,6 +141,80 @@ const NO_SUBSCRIPTION = {
121
141
  body: { actionRequired: false, status: null, accessRevokesAt: null, portalAvailable: false },
122
142
  };
123
143
 
144
+ const PIECE = {
145
+ id: "piece_01920000-0000-7000-8000-000000000001",
146
+ title: "Spring newsletter",
147
+ stage: "Drafting",
148
+ needsClient: false,
149
+ createdAt: "2026-09-05T09:00:00.000Z",
150
+ updatedAt: "2026-09-06T09:00:00.000Z",
151
+ };
152
+
153
+ const PIECE_NEEDS_CLIENT = {
154
+ id: "piece_01920000-0000-7000-8000-000000000002",
155
+ title: "About page",
156
+ stage: "Review",
157
+ needsClient: true,
158
+ createdAt: "2026-09-02T09:00:00.000Z",
159
+ updatedAt: "2026-09-07T09:00:00.000Z",
160
+ };
161
+
162
+ const MESSAGE = {
163
+ id: "intx_01920000-0000-7000-8000-000000000003",
164
+ subject: "Kickoff notes",
165
+ body: "Thanks for the call.",
166
+ file: null,
167
+ createdAt: "2026-09-08T09:00:00.000Z",
168
+ readAt: null,
169
+ };
170
+
171
+ const MESSAGE_WITH_FILE = {
172
+ id: "intx_01920000-0000-7000-8000-000000000004",
173
+ subject: "Your brand guide",
174
+ body: "Attached.",
175
+ file: { filename: "brand-guide.pdf", contentType: "application/pdf" },
176
+ createdAt: "2026-09-09T09:00:00.000Z",
177
+ readAt: "2026-09-09T10:00:00.000Z",
178
+ };
179
+
180
+ /** The hub credential a spoke signed in through its hub forwards (#7894 D12). */
181
+ const BEARER = { headers: { authorization: `Bearer ailk_hst_${"t".repeat(43)}` } };
182
+
183
+ const notFound = { ok: false, status: 404, body: { ok: false, error: { code: "not_found", message: "Not found" } } };
184
+
185
+ /** The context a hub-mode spoke hands every contribution, signed in. */
186
+ async function memberCtx(
187
+ overrides: Partial<AccountContext> = {},
188
+ ): Promise<AccountContext> {
189
+ const locale = overrides.locale ?? "en";
190
+ // The contract's translator takes any key (account-data.ts's reasoning):
191
+ // forward through the mock's runtime signature.
192
+ const translate = (await getTranslations({ locale, namespace: "account" })) as (
193
+ key: string,
194
+ values?: Record<string, unknown>,
195
+ ) => string;
196
+ const t: AccountTranslate = (key, values) => translate(key, values);
197
+ return {
198
+ locale,
199
+ user: { name: "Ada", email: "ada@example.com", emailVerified: true },
200
+ t,
201
+ init: BEARER,
202
+ ...overrides,
203
+ };
204
+ }
205
+
206
+ function kindOf(id: string): (typeof accountInboxKinds)[number] {
207
+ const kind = accountInboxKinds.find((k) => k.kind === id);
208
+ if (!kind) throw new Error(`no ${id} kind`);
209
+ return kind;
210
+ }
211
+
212
+ function needsClientSource(): (typeof accountForYouSources)[number] {
213
+ const source = accountForYouSources.find((s) => s.id === "piece-needs-client");
214
+ if (!source) throw new Error("no piece-needs-client source");
215
+ return source;
216
+ }
217
+
124
218
  /** Renders the page on a phone tab — the frame's PhoneTabProvider. */
125
219
  async function renderPage(locale = "en", tab = "inbox"): Promise<void> {
126
220
  const element = await Page({ params: Promise.resolve({ locale }) });
@@ -144,6 +238,8 @@ beforeEach(() => {
144
238
  mockSignupMe.mockReset().mockResolvedValue({ ok: true, body: SIGNUP });
145
239
  mockCheckoutsMe.mockReset().mockResolvedValue({ ok: true, body: [CHECKOUT] });
146
240
  mockSubscriptionMe.mockReset().mockResolvedValue(NO_SUBSCRIPTION);
241
+ mockMemberPieces.mockReset().mockResolvedValue({ ok: true, body: { items: [PIECE, PIECE_NEEDS_CLIENT] } });
242
+ mockMemberMessages.mockReset().mockResolvedValue({ ok: true, body: { items: [MESSAGE, MESSAGE_WITH_FILE] } });
147
243
  mockRefresh.mockReset();
148
244
  });
149
245
 
@@ -375,3 +471,160 @@ describe("Hub unavailable (D12)", () => {
375
471
  expect(within(panel).queryByTestId("inbox-error")).not.toBeInTheDocument();
376
472
  });
377
473
  });
474
+
475
+ describe("the member kinds — piece and message (#7920, spec 7423 D8 · D12)", () => {
476
+ it("declares piece and message as Inbox kinds in the updates section, labelled from the account namespace", async () => {
477
+ const ctx = await memberCtx();
478
+ expect(kindOf("piece").section).toBe("updates");
479
+ expect(kindOf("message").section).toBe("updates");
480
+ expect(ctx.t(kindOf("piece").label)).toBe("My pieces");
481
+ expect(ctx.t(kindOf("message").label)).toBe("My messages");
482
+ });
483
+
484
+ it("each kind reads the hub with ctx.init unchanged — the caller's own credential", async () => {
485
+ const ctx = await memberCtx();
486
+ const pieces = await kindOf("piece").load(ctx);
487
+ const messages = await kindOf("message").load(ctx);
488
+ expect(mockMemberPieces).toHaveBeenCalledWith(BEARER);
489
+ expect(mockMemberMessages).toHaveBeenCalledWith(BEARER);
490
+ expect(pieces.map((p) => [p.id, p.title, p.at])).toEqual([
491
+ [PIECE.id, "Spring newsletter", PIECE.updatedAt],
492
+ [PIECE_NEEDS_CLIENT.id, "About page", PIECE_NEEDS_CLIENT.updatedAt],
493
+ ]);
494
+ expect(messages.map((m) => [m.id, m.title, m.at])).toEqual([
495
+ [MESSAGE.id, "Kickoff notes", MESSAGE.createdAt],
496
+ [MESSAGE_WITH_FILE.id, "Your brand guide", MESSAGE_WITH_FILE.createdAt],
497
+ ]);
498
+ });
499
+
500
+ it.each(["piece", "message"])("%s loads [] on a 404 — nothing for this caller", async (id) => {
501
+ mockMemberPieces.mockResolvedValue(notFound);
502
+ mockMemberMessages.mockResolvedValue(notFound);
503
+ await expect(kindOf(id).load(await memberCtx())).resolves.toEqual([]);
504
+ });
505
+
506
+ it.each([
507
+ ["piece", 401],
508
+ ["piece", 403],
509
+ ["message", 401],
510
+ ["message", 403],
511
+ ])("%s loads [] on a %i while the email is unverified", async (id, status) => {
512
+ const refusal = { ok: false, status, body: { ok: false, error: { code: "email_unverified" } } };
513
+ mockMemberPieces.mockResolvedValue(refusal);
514
+ mockMemberMessages.mockResolvedValue(refusal);
515
+ const ctx = await memberCtx({ user: { name: "Ada", email: "ada@example.com", emailVerified: false } });
516
+ await expect(kindOf(id).load(ctx)).resolves.toEqual([]);
517
+ });
518
+
519
+ it.each([
520
+ ["piece", 401],
521
+ ["piece", 403],
522
+ ["piece", 500],
523
+ ["piece", 0],
524
+ ["message", 401],
525
+ ["message", 403],
526
+ ["message", 502],
527
+ ["message", 0],
528
+ ])("%s rejects on a %i for a verified email — never a partial list shown as whole", async (id, status) => {
529
+ const failure = {
530
+ ok: false,
531
+ status,
532
+ body: { ok: false, error: { code: status === 0 ? "insecure_transport" : "failed", message: "x" } },
533
+ };
534
+ mockMemberPieces.mockResolvedValue(failure);
535
+ mockMemberMessages.mockResolvedValue(failure);
536
+ await expect(kindOf(id).load(await memberCtx())).rejects.toMatchObject({ status });
537
+ });
538
+
539
+ it("with no hub credential on ctx.init (a cookie-session site) both kinds and the source load [] without a call", async () => {
540
+ const ctx = await memberCtx({ init: { headers: { cookie: "session=abc" } } });
541
+ await expect(kindOf("piece").load(ctx)).resolves.toEqual([]);
542
+ await expect(kindOf("message").load(ctx)).resolves.toEqual([]);
543
+ await expect(needsClientSource().load(ctx)).resolves.toEqual([]);
544
+ expect(mockMemberPieces).not.toHaveBeenCalled();
545
+ expect(mockMemberMessages).not.toHaveBeenCalled();
546
+ });
547
+
548
+ it("the cookie-session Inbox stays as it was: no member rows, no error row, no member call", async () => {
549
+ await renderPage();
550
+ expect(screen.queryByTestId("inbox-error")).not.toBeInTheDocument();
551
+ expect(screen.queryByText("Spring newsletter")).not.toBeInTheDocument();
552
+ expect(screen.queryByText("Kickoff notes")).not.toBeInTheDocument();
553
+ expect(mockMemberPieces).not.toHaveBeenCalled();
554
+ expect(mockMemberMessages).not.toHaveBeenCalled();
555
+ });
556
+
557
+ it("a piece's detail shows its stage and dates", async () => {
558
+ const ctx = await memberCtx();
559
+ const [first] = await kindOf("piece").load(ctx);
560
+ const detail = kindOf("piece").renderDetail(first!, ctx);
561
+ expect(detail.status).toBe("Drafting");
562
+ expect(detail.fields?.heading).toBe("My pieces");
563
+ expect(detail.fields?.items.map((i) => i.label)).toEqual(["Stage", "Started", "Updated"]);
564
+ });
565
+
566
+ it("a message with an asset shows a file action that opens the member file read", async () => {
567
+ const ctx = await memberCtx();
568
+ const items = await kindOf("message").load(ctx);
569
+ const withFile = items.find((m) => m.id === MESSAGE_WITH_FILE.id)!;
570
+ const detail = kindOf("message").renderDetail(withFile, ctx);
571
+ expect(detail.actions).toEqual([
572
+ {
573
+ key: "file",
574
+ label: "Download brand-guide.pdf",
575
+ variant: "default",
576
+ href: `/account/inbox/message/${MESSAGE_WITH_FILE.id}/file`,
577
+ },
578
+ ]);
579
+ expect(detail.fields?.items).toContainEqual({ label: "File", value: "brand-guide.pdf" });
580
+ });
581
+
582
+ it("the file action carries the locale prefix off the default locale", async () => {
583
+ const ctx = await memberCtx({ locale: "fr" });
584
+ const items = await kindOf("message").load(ctx);
585
+ const withFile = items.find((m) => m.id === MESSAGE_WITH_FILE.id)!;
586
+ expect(kindOf("message").renderDetail(withFile, ctx).actions?.[0]?.href).toBe(
587
+ `/fr/account/inbox/message/${MESSAGE_WITH_FILE.id}/file`,
588
+ );
589
+ });
590
+
591
+ it("a message without an asset has no file action", async () => {
592
+ const ctx = await memberCtx();
593
+ const [plain] = await kindOf("message").load(ctx);
594
+ const detail = kindOf("message").renderDetail(plain!, ctx);
595
+ expect(detail.actions).toBeUndefined();
596
+ expect(detail.fields?.items.map((i) => i.label)).not.toContain("File");
597
+ });
598
+ });
599
+
600
+ describe("the needs-client For-you source (#7920)", () => {
601
+ it("a needsClient piece is a needs-you For-you item with an Open action to its Inbox row", async () => {
602
+ const items = await needsClientSource().load(await memberCtx());
603
+ expect(items).toEqual([
604
+ {
605
+ id: `piece:${PIECE_NEEDS_CLIENT.id}`,
606
+ type: { id: "piece", label: "Piece", tone: "needs-you" },
607
+ title: "About page",
608
+ time: { kind: "at", at: PIECE_NEEDS_CLIENT.updatedAt },
609
+ action: { kind: "open", href: `/account/inbox/piece/${PIECE_NEEDS_CLIENT.id}` },
610
+ dismissible: true,
611
+ },
612
+ ]);
613
+ expect(mockMemberPieces).toHaveBeenCalledWith(BEARER);
614
+ });
615
+
616
+ it("off the default locale the Open href carries the prefix", async () => {
617
+ const [item] = await needsClientSource().load(await memberCtx({ locale: "fr" }));
618
+ expect(item?.action).toEqual({
619
+ kind: "open",
620
+ href: `/fr/account/inbox/piece/${PIECE_NEEDS_CLIENT.id}`,
621
+ });
622
+ });
623
+
624
+ it("loads [] on a 404 and rejects on any other failure", async () => {
625
+ mockMemberPieces.mockResolvedValue(notFound);
626
+ await expect(needsClientSource().load(await memberCtx())).resolves.toEqual([]);
627
+ mockMemberPieces.mockResolvedValue({ ok: false, status: 500, body: {} });
628
+ await expect(needsClientSource().load(await memberCtx())).rejects.toMatchObject({ status: 500 });
629
+ });
630
+ });
@@ -34,9 +34,9 @@ import { headers } from "next/headers";
34
34
  import { getTranslations } from "next-intl/server";
35
35
  import { cache, createElement } from "react";
36
36
 
37
- import { auth } from "../../../../auth";
38
37
  import { accountForYouSources, accountInboxKinds } from "../../../../lib/account-config";
39
38
  import { forwardedCookieInit } from "../../../../lib/server-api";
39
+ import { readSession } from "../../../../lib/topology.generated";
40
40
 
41
41
  /** The session's person, plus what the frame shows beyond the contract. */
42
42
  export interface AccountSession {
@@ -48,18 +48,15 @@ export interface AccountSession {
48
48
  /** The `AccountContext` for this request, or `null` when there is no session. */
49
49
  export const accountSession = cache(
50
50
  async (locale: string): Promise<AccountSession | null> => {
51
- const session = await auth.api.getSession({ headers: await headers() });
51
+ // Through the generated seam (#7894 D10): Better Auth on a hub, the sealed
52
+ // hub-session cookie on a spoke that signs in through its hub.
53
+ const session = await readSession(await headers());
52
54
  if (!session) return null;
53
55
  // The contract's translator takes any key: a contribution's keys (a
54
56
  // kind's `label`, a section's `title`) are data, which next-intl's
55
57
  // literal-key typing cannot see. The runtime call is the same function.
56
58
  const t = (await getTranslations({ locale, namespace: "account" })) as unknown as AccountTranslate;
57
- const { name, email, emailVerified, createdAt } = session.user as {
58
- name?: string | null;
59
- email: string;
60
- emailVerified: boolean;
61
- createdAt?: Date | string | null;
62
- };
59
+ const { name, email, emailVerified, createdAt } = session.user;
63
60
  return {
64
61
  ctx: {
65
62
  locale,