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
@@ -1,4 +1,4 @@
1
- "use client"; // the shell owns collapse state; sign-out is a client-side Better Auth call + router navigation
1
+ "use client"; // the shell owns collapse state; sign-out is a client-side hub-session POST or Better Auth call + router navigation
2
2
 
3
3
  /**
4
4
  * AccountFrame — the account area's SignedInShell composition (#7430, spec
@@ -7,8 +7,28 @@
7
7
  * what only the client has: the current path and the sign-out call.
8
8
  *
9
9
  * `createAuthClient()` at module scope is the `sign-in-gate.tsx` idiom. Log
10
- * out ends the session, then navigates home and refreshes so every RSC under
11
- * the `(authed)` gate re-reads the (now absent) session.
10
+ * out ends the session, then navigates and refreshes so every RSC under the
11
+ * `(authed)` gate re-reads the (now absent) session.
12
+ *
13
+ * #8465 — a spoke that signs in through its hub (#7894) holds its session in
14
+ * a sealed cookie only `POST /api/hub-session/sign-out` clears (it also
15
+ * revokes the credential at the hub); Better Auth's `signOut()` hits a route
16
+ * that spoke does not serve. The server layout reads the generated seam's
17
+ * `SIGN_OUT_ENDPOINT` and hands it down as `signOutEndpoint` — the signed-in
18
+ * Nav's own pattern, because a client component cannot import the seam. With
19
+ * an endpoint, Log out POSTs it and navigates to the sign-in page only when
20
+ * the answer was 2xx; a refused or failed sign-out (a 403 on an Origin
21
+ * mismatch, a 5xx, a network error) leaves the cookie valid, so the frame
22
+ * stays on the page and says so, never routing away with a live session.
23
+ * Without an endpoint (a hub or a single site) nothing changes: Better Auth,
24
+ * then home.
25
+ *
26
+ * #8231 D1, as amended by #8334 — the rail reads Inbox, a Library group
27
+ * holding Artifacts, and the footer's ?. Settings is not a rail row: it lives
28
+ * in the avatar menu, as on the Admin (the operator's 2026-10-11 Phase 6
29
+ * read), so no row is selected on a Settings page. A row is selected on its
30
+ * own path and the paths under it; the Inbox also on an item's page
31
+ * (/account/inbox/…).
12
32
  *
13
33
  * #7469 (DESIGN.md §2b as amended 2026-10-01, matching the Admin's §5.15) —
14
34
  * below `md` the bar reads avatar · Inbox · For you · Help, with no count
@@ -16,6 +36,27 @@
16
36
  * frame reads the URL and hands the tab to the page (`PhoneTabProvider`),
17
37
  * which shows one panel per tab. At `md`+ nothing changes — For you stays
18
38
  * the aside.
39
+ *
40
+ * #8233 D4 — on the Inbox route itself the phone top bar's trailing cell is
41
+ * an empty node handed to the page (`NewMenuSlotProvider`), which portals
42
+ * its compact New into it; every other path has no trailing cell.
43
+ *
44
+ * #8337 D4 — the `data-ailk-shell="account"` marker on a `display: contents`
45
+ * wrapper around `SignedInShell`: the layout's `shell-vars` reset
46
+ * (`:root:has([data-ailk-shell])`) matches only while this frame is in the
47
+ * document, so the Admin's colour tokens reach the shell and everything it
48
+ * portals to `document.body` (the avatar/help menus, For-you, New, the phone
49
+ * sheets) without the wrapper adding a box — `contents` keeps the shell's own
50
+ * layout (`min-h-screen`) unchanged.
51
+ *
52
+ * #8430 — the User settings routes take the screen as the Admin's do: the
53
+ * frame renders the page alone, inside the marker only — no rail, no Inbox,
54
+ * no AppShell — and the page draws the shared `AccountSettingsFrame` in its
55
+ * standalone shape, its own column flush and full height with Back to the
56
+ * Inbox at its top. The marker stays, so the Admin's tokens still reach the
57
+ * settings in both modes. Which routes those are is `isSettingsPath` — an
58
+ * exact match on `SETTINGS_ROUTES`, the list the settings tabs read too
59
+ * (#8441), so a sub-route stays inside the shell.
19
60
  */
20
61
 
21
62
  import {
@@ -31,13 +72,15 @@ import {
31
72
  type SignedInShellProps,
32
73
  } from "@working-theory/templates/shells";
33
74
  import { localeHref } from "@working-theory/ui";
34
- import { Link } from "@working-theory/ui/primitives";
75
+ import { InlineMessage, Link } from "@working-theory/ui/primitives";
35
76
  import { createAuthClient } from "better-auth/react";
36
- import { Inbox, Settings, Sparkles } from "lucide-react";
77
+ import { Inbox, Layers, Settings, Sparkles } from "lucide-react";
37
78
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
38
- import type { ReactElement, ReactNode } from "react";
79
+ import { useState, type ReactElement, type ReactNode } from "react";
39
80
 
40
81
  import { ACCOUNT_PERSIST_KEY } from "./for-you-labels";
82
+ import { NewMenuSlotProvider } from "./new-menu";
83
+ import { isSettingsPath } from "./settings-frame";
41
84
 
42
85
  const authClient = createAuthClient();
43
86
 
@@ -51,6 +94,11 @@ export interface AccountFrameLabels extends SignedInShellLabels {
51
94
  /** The phone's For you tab label (#7469). */
52
95
  forYou: string;
53
96
  settings: string;
97
+ /** The rail's Library group heading (#8231 D1). */
98
+ library: string;
99
+ artifacts: string;
100
+ /** Shown when a hub sign-out is refused or fails (#8465). */
101
+ signOutFailed: string;
54
102
  }
55
103
 
56
104
  export interface AccountFrameProps {
@@ -61,6 +109,14 @@ export interface AccountFrameProps {
61
109
  helpItems: NonNullable<SignedInShellProps["helpItems"]>;
62
110
  forYou: { items: readonly ForYouItem[]; now: string; labels: ForYouLabels };
63
111
  labels: AccountFrameLabels;
112
+ /**
113
+ * Where Log out POSTs on a spoke that signs in through its hub (the seam's
114
+ * `SIGN_OUT_ENDPOINT`, read by the server layout). Absent or `null`: Better
115
+ * Auth's client signs out (#8465).
116
+ */
117
+ signOutEndpoint?: string | null;
118
+ /** Where Log out lands after a hub sign-out; defaults to the site home. */
119
+ signInHref?: string;
64
120
  children: ReactNode;
65
121
  }
66
122
 
@@ -71,23 +127,61 @@ export function AccountFrame({
71
127
  helpItems,
72
128
  forYou,
73
129
  labels,
130
+ signOutEndpoint,
131
+ signInHref,
74
132
  children,
75
133
  }: AccountFrameProps): ReactElement {
76
134
  const router = useRouter();
77
135
  const pathname = usePathname();
78
136
  const searchParams = useSearchParams();
79
137
  const [dismissed] = useForYouDismissed(ACCOUNT_PERSIST_KEY);
138
+ const [topBarActions, setTopBarActions] = useState<HTMLElement | null>(null);
139
+ const [signOutFailed, setSignOutFailed] = useState(false);
80
140
 
81
141
  const homeHref = localeHref(locale, "/");
82
142
  const inboxHref = localeHref(locale, "/account");
143
+ const artifactsHref = localeHref(locale, "/account/artifacts");
144
+ const settingsHref = localeHref(locale, "/account/general");
145
+ const within = (href: string): boolean => pathname === href || pathname.startsWith(`${href}/`);
146
+
147
+ // #8430 — a Settings page draws its own standalone frame: the marker only.
148
+ if (isSettingsPath(pathname, locale)) {
149
+ return (
150
+ <div className="contents" data-ailk-shell="account" data-testid="account-shell">
151
+ {children}
152
+ </div>
153
+ );
154
+ }
155
+
83
156
  // The Inbox row stays selected on an item's own page (/account/inbox/…).
84
157
  const onInbox = pathname === inboxHref || pathname.startsWith(`${inboxHref}/inbox/`);
158
+ const activePath = onInbox ? inboxHref : within(artifactsHref) ? artifactsHref : undefined;
85
159
  // #7469 — the phone tab: For you only on the Inbox route itself.
86
160
  const phoneTab =
87
161
  pathname === inboxHref && searchParams?.get("tab") === FOR_YOU_TAB ? FOR_YOU_TAB : "inbox";
162
+ // #8233 D4 — the Inbox route's New lives in the top bar; nowhere else.
163
+ const onInboxRoute = pathname === inboxHref;
88
164
 
89
165
  const onSignOut = (): void => {
90
166
  void (async () => {
167
+ setSignOutFailed(false);
168
+ if (signOutEndpoint) {
169
+ // A refused or failed sign-out leaves the session cookie valid, so the
170
+ // page must not pretend otherwise by navigating away (#8465).
171
+ try {
172
+ const res = await fetch(signOutEndpoint, { method: "POST", credentials: "same-origin" });
173
+ if (!res.ok) {
174
+ setSignOutFailed(true);
175
+ return;
176
+ }
177
+ } catch {
178
+ setSignOutFailed(true);
179
+ return;
180
+ }
181
+ router.push(signInHref ?? homeHref);
182
+ router.refresh();
183
+ return;
184
+ }
91
185
  try {
92
186
  await authClient.signOut();
93
187
  } catch {
@@ -99,7 +193,16 @@ export function AccountFrame({
99
193
  })();
100
194
  };
101
195
 
102
- const { home, inbox, forYou: forYouLabel, settings, ...shellLabels } = labels;
196
+ const {
197
+ home,
198
+ inbox,
199
+ forYou: forYouLabel,
200
+ settings,
201
+ library,
202
+ artifacts,
203
+ signOutFailed: signOutFailedLabel,
204
+ ...shellLabels
205
+ } = labels;
103
206
 
104
207
  // D13 — the logo only: no box, no name, no popover; it links home. The
105
208
  // same link is the phone top bar's leading cell.
@@ -118,52 +221,83 @@ export function AccountFrame({
118
221
  const forYouLeft = forYou.items.filter((item) => !dismissed.has(item.id));
119
222
 
120
223
  return (
121
- <SignedInShell
122
- persistKey={ACCOUNT_PERSIST_KEY}
123
- locale={locale}
124
- navItems={[{ labelKey: "inbox", label: inbox, path: "/account", icon: Inbox }]}
125
- tabItems={[
126
- { key: "inbox", label: inbox, href: inboxHref, icon: Inbox, isActive: onInbox && phoneTab === "inbox" },
127
- {
128
- key: FOR_YOU_TAB,
129
- label: forYouLabel,
130
- href: `${inboxHref}?tab=${FOR_YOU_TAB}`,
131
- icon: Sparkles,
132
- isActive: phoneTab === FOR_YOU_TAB,
133
- },
134
- ]}
135
- {...(onInbox ? { activePath: inboxHref } : {})}
136
- user={user}
137
- userMenuItems={[
138
- {
139
- id: "settings",
140
- label: settings,
141
- icon: Settings,
142
- href: localeHref(locale, "/account/settings"),
143
- },
144
- ]}
145
- onSignOut={onSignOut}
146
- userDisplay="avatar"
147
- accountMenuLayout="card"
148
- helpItems={helpItems}
149
- railHeader={logoLink}
150
- topBarStart={logoLink}
151
- {...(forYouLeft.length > 0
152
- ? {
153
- aside: (
154
- <ForYouPanel
155
- items={forYouLeft}
156
- persistKey={ACCOUNT_PERSIST_KEY}
157
- labels={forYou.labels}
158
- locale={locale}
159
- now={forYou.now}
160
- />
161
- ),
162
- }
163
- : {})}
164
- labels={shellLabels}
165
- >
166
- <PhoneTabProvider tab={phoneTab}>{children}</PhoneTabProvider>
167
- </SignedInShell>
224
+ <div className="contents" data-ailk-shell="account" data-testid="account-shell">
225
+ <SignedInShell
226
+ persistKey={ACCOUNT_PERSIST_KEY}
227
+ locale={locale}
228
+ navItems={[{ labelKey: "inbox", label: inbox, path: "/account", icon: Inbox }]}
229
+ railGroups={[
230
+ {
231
+ key: "library",
232
+ heading: library,
233
+ items: [
234
+ { labelKey: "artifacts", label: artifacts, path: "/account/artifacts", icon: Layers },
235
+ ],
236
+ },
237
+ ]}
238
+ tabItems={[
239
+ { key: "inbox", label: inbox, href: inboxHref, icon: Inbox, isActive: onInbox && phoneTab === "inbox" },
240
+ {
241
+ key: FOR_YOU_TAB,
242
+ label: forYouLabel,
243
+ href: `${inboxHref}?tab=${FOR_YOU_TAB}`,
244
+ icon: Sparkles,
245
+ isActive: phoneTab === FOR_YOU_TAB,
246
+ },
247
+ ]}
248
+ {...(activePath ? { activePath } : {})}
249
+ user={user}
250
+ userMenuItems={[
251
+ {
252
+ id: "settings",
253
+ label: settings,
254
+ icon: Settings,
255
+ href: settingsHref,
256
+ },
257
+ ]}
258
+ onSignOut={onSignOut}
259
+ userDisplay="avatar"
260
+ accountMenuLayout="card"
261
+ helpItems={helpItems}
262
+ railHeader={logoLink}
263
+ topBarStart={logoLink}
264
+ {...(onInboxRoute
265
+ ? {
266
+ topBarEnd: (
267
+ <span
268
+ ref={setTopBarActions}
269
+ className="flex items-center"
270
+ data-testid="account-top-bar-actions"
271
+ />
272
+ ),
273
+ }
274
+ : {})}
275
+ {...(forYouLeft.length > 0
276
+ ? {
277
+ aside: (
278
+ <ForYouPanel
279
+ items={forYouLeft}
280
+ persistKey={ACCOUNT_PERSIST_KEY}
281
+ labels={forYou.labels}
282
+ locale={locale}
283
+ now={forYou.now}
284
+ />
285
+ ),
286
+ }
287
+ : {})}
288
+ labels={shellLabels}
289
+ >
290
+ {signOutFailed ? (
291
+ <InlineMessage variant="error" className="px-4 pt-3" data-testid="account-sign-out-error">
292
+ {signOutFailedLabel}
293
+ </InlineMessage>
294
+ ) : null}
295
+ <PhoneTabProvider tab={phoneTab}>
296
+ <NewMenuSlotProvider node={onInboxRoute ? topBarActions : null}>
297
+ {children}
298
+ </NewMenuSlotProvider>
299
+ </PhoneTabProvider>
300
+ </SignedInShell>
301
+ </div>
168
302
  );
169
303
  }
@@ -13,62 +13,41 @@
13
13
  * navigation telemetry for a Server Action, which is inherent to any hosted-
14
14
  * portal handoff, not something this design avoids).
15
15
  *
16
- * CSRF protection depends on the render (`forwardedCookieInit()` branches on
17
- * `SIGN_IN_PROVIDER`, `lib/server-api.ts`):
16
+ * Where the portal comes from is `billing-source.ts`'s `billingPortalUrl()`
17
+ * (#8217, spec docs/specs/8217-spoke-billing-spoke-checkout-reads-spec.md
18
+ * D10, D11), the account tree's billing seam — this file tests no topology:
18
19
  *
19
- * - Hub render (`SIGN_IN_PROVIDER === "site"`): `forwardedCookieInit()`
20
- * forwards the incoming request's real `Origin` alongside the Better Auth
21
- * cookie, and `apps/api`'s `authPreHandler` requires that `Origin` to be one
22
- * of its trusted origins on this cookie-authenticated mutating request
23
- * (`isOriginAllowed`, `middleware/auth.ts`). The portal-session route also
24
- * accepts only a cookie session (`request.sessionTenant`), never a bearer
25
- * (D3.1/D5).
26
- * - Spoke render, sign-in through the hub (`SIGN_IN_PROVIDER === "hub"`):
27
- * there is no cookie and no `Origin` forward. `hubSessionInit()`
28
- * (`lib/hub-session/session.ts`) sends the sealed session's credential as
29
- * `Authorization: Bearer`, and `authPreHandler` skips its origin check for a
30
- * bearer. This action does not complete on a spoke: the hub admits a hub
31
- * credential on four read routes only (`HUB_SESSION_ROUTES`), so
32
- * `POST /v1/subscription/portal-session` is a 404 there, and Settings hides
33
- * the Billing button because `subscription.me()` is a 404 too.
20
+ * - A single site: the hub's `POST /v1/subscription/portal-session`, with
21
+ * the caller's cookie and real `Origin` forwarded, which `apps/api`'s
22
+ * `authPreHandler` checks against its trusted origins on this
23
+ * cookie-authenticated mutating request; the route accepts only a cookie
24
+ * session, never a bearer (D3.1/D5).
25
+ * - A spoke that signs in through its hub and carries commerce: the spoke's
26
+ * own Stripe key, for the customer its hub vouches for — read server-side
27
+ * by the rendered billing action, never from this action's input.
28
+ * - A spoke without that action: no portal, so nothing to redirect to.
34
29
  *
35
- * `redirect()` throws `NEXT_REDIRECT` by design — called OUTSIDE the
36
- * try/catch (mirrors `checkout.actions.ts`'s idiom) so the catch never
30
+ * It takes no argument, so nothing a browser sends can name a customer.
31
+ * Next's Server Action origin check applies as before.
32
+ *
33
+ * `redirect()` throws `NEXT_REDIRECT` by design — called OUTSIDE any
34
+ * try/catch (mirrors `checkout.actions.ts`'s idiom) so a catch never
37
35
  * swallows the navigation this action exists to perform.
38
36
  */
39
37
 
40
- import { subscription } from "@working-theory/api-client";
41
38
  import { redirect } from "next/navigation";
42
39
 
43
- import { forwardedCookieInit } from "../../../../lib/server-api";
44
-
45
- /** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
46
- function errorName(err: unknown): string {
47
- return err instanceof Error ? err.name : "unknown";
48
- }
40
+ import { billingPortalUrl } from "./billing-source";
49
41
 
50
42
  /**
51
43
  * Opens the caller's own Stripe billing-portal session and redirects there.
52
- * Never returns on success. On a non-2xx or thrown error, logs a PII-safe
53
- * status/name and redirects back to `/account` unchanged (the banner's
54
- * action-unavailable posture — there is no error UI slot on this button).
44
+ * Never returns on success. With no portal (a failed mint, logged PII-safe by
45
+ * `billingPortalUrl`, or none to open), it returns and the page stays as it
46
+ * is — there is no error UI slot on this button.
55
47
  */
56
48
  export async function openBillingPortal(): Promise<void> {
57
- const init = await forwardedCookieInit();
58
-
59
- let url: string;
60
- try {
61
- const res = await subscription.portalSession(init);
62
- if (!res.ok) {
63
- console.error(`[openBillingPortal] status=${res.status}`);
64
- return;
65
- }
66
- url = res.body.url;
67
- } catch (err) {
68
- console.error(`[openBillingPortal] errName=${errorName(err)}`);
69
- return;
70
- }
71
-
72
- // Outside the try/catch on purpose — see the file header.
49
+ const url = await billingPortalUrl();
50
+ if (url === null) return;
51
+ // Outside any try/catch on purpose — see the file header.
73
52
  redirect(url);
74
53
  }
@@ -0,0 +1,253 @@
1
+ /**
2
+ * /[locale]/account/artifacts/[pieceId] — one artifact (#8234, spec
3
+ * docs/specs/8234-member-artifacts-account-artifacts-and-spec.md D4).
4
+ *
5
+ * RSC, inside the account layout's SignedInShell, through the shared
6
+ * `InboxItemFrame`: back to Artifacts, the title, the review state, and —
7
+ * once approved — "Approved <date>". The body lists every served version in
8
+ * the read's order, newest first: its number, its delivery date, its note,
9
+ * the member's own review when there is one, and one action — Download for a
10
+ * file (re-served by `versions/[version]/file/route.ts`, never shown inline
11
+ * on this origin) or View for an https link, opened in a new tab. A link
12
+ * that is not https renders no action. The read serves no deliverer (8169
13
+ * D6), so none is shown.
14
+ *
15
+ * Inside the latest version only: the review form while it is reviewable
16
+ * (not approved, no review yet — `isReviewable`), else the waiting line once
17
+ * the member asked for changes; nothing once approved. Nothing limits how
18
+ * often: every new latest version is reviewable again (#8140 disposition 3).
19
+ *
20
+ * The artifact is found the way the Inbox item page finds its item: the
21
+ * artifacts read runs (deduped per request) and `[pieceId]` is matched raw or
22
+ * decoded. No session, no match or a 404 read is a not-found; any other
23
+ * failed read renders the error row with Retry (spec 7423 D12). Titles,
24
+ * notes and comments render as text only.
25
+ */
26
+
27
+ import { InboxItemFrame } from "@working-theory/templates/account";
28
+ import { localeHref } from "@working-theory/ui";
29
+ import { Button, Heading, Link, Text } from "@working-theory/ui/primitives";
30
+ import {
31
+ isHttpsLink,
32
+ type MemberArtifact,
33
+ type MemberPieceVersion,
34
+ } from "@working-theory/validation";
35
+ import type { Metadata } from "next";
36
+ import { notFound } from "next/navigation";
37
+ import { getTranslations } from "next-intl/server";
38
+ import type { ReactElement } from "react";
39
+
40
+ import { accountSession, logReadFailure } from "../../account-data";
41
+ import { ErrorRow } from "../../error-row";
42
+ import {
43
+ artifactState,
44
+ isReviewable,
45
+ loadArtifacts,
46
+ STATE_KEYS,
47
+ versionFileHref,
48
+ } from "../artifact-data";
49
+
50
+ import { ReviewForm, type ReviewFormLabels } from "./review-form";
51
+
52
+ interface Props {
53
+ params: Promise<{ locale: string; pieceId: string }>;
54
+ }
55
+
56
+ /**
57
+ * A segment, percent-decoded when it decodes — matched in both forms, so an
58
+ * id is found whether or not the framework already decoded it.
59
+ */
60
+ function decodeSegment(segment: string): string {
61
+ try {
62
+ return decodeURIComponent(segment);
63
+ } catch {
64
+ return segment;
65
+ }
66
+ }
67
+
68
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
69
+ const { locale } = await params;
70
+ const t = await getTranslations({ locale, namespace: "account" });
71
+ return { title: t("artifacts.title") };
72
+ }
73
+
74
+ export default async function AccountArtifactPage({
75
+ params,
76
+ }: Props): Promise<ReactElement> {
77
+ const { locale, pieceId: segment } = await params;
78
+ const t = await getTranslations({ locale, namespace: "account" });
79
+ const session = await accountSession(locale);
80
+ if (!session) notFound();
81
+
82
+ let artifacts: MemberArtifact[];
83
+ try {
84
+ artifacts = await loadArtifacts(session.ctx);
85
+ } catch (err) {
86
+ logReadFailure("artifacts", err);
87
+ return (
88
+ <div className="max-w-3xl py-8" data-testid="artifact">
89
+ <ErrorRow
90
+ message={t("artifacts.itemError")}
91
+ retryLabel={t("artifacts.retry")}
92
+ />
93
+ </div>
94
+ );
95
+ }
96
+
97
+ const artifact = artifacts.find(
98
+ (candidate) =>
99
+ candidate.id === segment || candidate.id === decodeSegment(segment),
100
+ );
101
+ if (!artifact) notFound();
102
+
103
+ const date = (iso: string): string =>
104
+ new Intl.DateTimeFormat(locale, {
105
+ dateStyle: "medium",
106
+ timeZone: "UTC",
107
+ }).format(new Date(iso));
108
+ const state = artifactState(artifact);
109
+ const reviewable = isReviewable(artifact);
110
+ const latest = artifact.versions[0]!.version;
111
+ const labels: ReviewFormLabels = {
112
+ heading: t("artifacts.review.heading", { version: latest }),
113
+ comment: t("artifacts.review.comment"),
114
+ commentHint: t("artifacts.review.commentHint"),
115
+ approve: t("artifacts.review.approve"),
116
+ requestChanges: t("artifacts.review.requestChanges"),
117
+ submitting: t("artifacts.review.submitting"),
118
+ sent: t("artifacts.review.sent"),
119
+ errors: {
120
+ comment_required: t("artifacts.review.errors.commentRequired"),
121
+ closed: t("artifacts.review.errors.closed"),
122
+ not_latest: t("artifacts.review.errors.notLatest"),
123
+ rate_limited: t("artifacts.review.errors.rateLimited"),
124
+ failed: t("artifacts.review.errors.failed"),
125
+ },
126
+ };
127
+
128
+ // The one action of a version: Download a file, View an https link, else none.
129
+ const versionAction = (v: MemberPieceVersion): ReactElement | null => {
130
+ if (v.file !== null) {
131
+ return (
132
+ <Button asChild variant="outline" className="max-md:min-h-11">
133
+ <Link
134
+ href={versionFileHref(locale, artifact.id, v.version)}
135
+ variant="bare"
136
+ data-testid={`artifact-version-${v.version}-download`}
137
+ >
138
+ {t("artifacts.download", { filename: v.file.filename })}
139
+ </Link>
140
+ </Button>
141
+ );
142
+ }
143
+ if (isHttpsLink(v.link)) {
144
+ return (
145
+ <Button asChild variant="outline" className="max-md:min-h-11">
146
+ <Link
147
+ href={v.link}
148
+ external
149
+ variant="bare"
150
+ data-testid={`artifact-version-${v.version}-view`}
151
+ >
152
+ {t("artifacts.view")}
153
+ </Link>
154
+ </Button>
155
+ );
156
+ }
157
+ return null;
158
+ };
159
+
160
+ return (
161
+ <InboxItemFrame
162
+ back={{
163
+ href: localeHref(locale, "/account/artifacts"),
164
+ label: t("artifacts.back"),
165
+ }}
166
+ caption={t("artifacts.caption")}
167
+ title={artifact.title}
168
+ status={t(STATE_KEYS[state])}
169
+ lead={
170
+ artifact.approvedAt !== null
171
+ ? t("artifacts.approvedOn", { date: date(artifact.approvedAt) })
172
+ : null
173
+ }
174
+ testIdPrefix="artifact"
175
+ >
176
+ <section className="mt-block-loose">
177
+ <Heading level={2} visual={4} className="!text-body font-semibold">
178
+ {t("artifacts.versions")}
179
+ </Heading>
180
+ <div className="mt-block-tight flex flex-col gap-4">
181
+ {artifact.versions.map((v) => {
182
+ const action = versionAction(v);
183
+ return (
184
+ <div
185
+ key={v.version}
186
+ className="rounded-md border-hairline border-(--color-border) p-card"
187
+ data-testid={`artifact-version-${v.version}`}
188
+ >
189
+ <Text as="p" variant="body" className="font-semibold">
190
+ {t("artifacts.version", { version: v.version })}
191
+ </Text>
192
+ <Text as="p" variant="body-sm" tone="muted">
193
+ {t("artifacts.deliveredOn", { date: date(v.deliveredAt) })}
194
+ </Text>
195
+ {v.note !== null ? (
196
+ <Text
197
+ as="p"
198
+ variant="body-sm"
199
+ className="mt-2 whitespace-pre-line"
200
+ >
201
+ {v.note}
202
+ </Text>
203
+ ) : null}
204
+ {v.review !== null && v.reviewedAt !== null ? (
205
+ <div className="mt-2">
206
+ <Text as="p" variant="body-sm" tone="muted">
207
+ {v.review === "approved"
208
+ ? t("artifacts.reviewApproved", {
209
+ date: date(v.reviewedAt),
210
+ })
211
+ : t("artifacts.reviewChangesRequested", {
212
+ date: date(v.reviewedAt),
213
+ })}
214
+ </Text>
215
+ {v.reviewComment !== null ? (
216
+ <Text
217
+ as="p"
218
+ variant="body-sm"
219
+ className="whitespace-pre-line"
220
+ >
221
+ {v.reviewComment}
222
+ </Text>
223
+ ) : null}
224
+ </div>
225
+ ) : null}
226
+ {action !== null ? (
227
+ <div className="mt-block-tight">{action}</div>
228
+ ) : null}
229
+ {v.version === latest && reviewable ? (
230
+ <ReviewForm
231
+ pieceId={artifact.id}
232
+ version={v.version}
233
+ labels={labels}
234
+ />
235
+ ) : null}
236
+ {v.version === latest && state === "changes-requested" ? (
237
+ <div
238
+ className="mt-block-tight"
239
+ data-testid="artifact-review-waiting"
240
+ >
241
+ <Text as="p" variant="body-sm">
242
+ {t("artifacts.review.waiting")}
243
+ </Text>
244
+ </div>
245
+ ) : null}
246
+ </div>
247
+ );
248
+ })}
249
+ </div>
250
+ </section>
251
+ </InboxItemFrame>
252
+ );
253
+ }