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
@@ -10,12 +10,31 @@
10
10
  * out ends the session, then navigates home and refreshes so every RSC under
11
11
  * the `(authed)` gate re-reads the (now absent) session.
12
12
  *
13
+ * #8231 D1, as amended by #8334 — the rail reads Inbox, a Library group
14
+ * holding Artifacts, and the footer's ?. Settings is not a rail row: it lives
15
+ * in the avatar menu, as on the Admin (the operator's 2026-10-11 Phase 6
16
+ * read), so no row is selected on a Settings page. A row is selected on its
17
+ * own path and the paths under it; the Inbox also on an item's page
18
+ * (/account/inbox/…).
19
+ *
13
20
  * #7469 (DESIGN.md §2b as amended 2026-10-01, matching the Admin's §5.15) —
14
21
  * below `md` the bar reads avatar · Inbox · For you · Help, with no count
15
22
  * on any tab. For you is a page of the Inbox route (`?tab=for-you`): the
16
23
  * frame reads the URL and hands the tab to the page (`PhoneTabProvider`),
17
24
  * which shows one panel per tab. At `md`+ nothing changes — For you stays
18
25
  * the aside.
26
+ *
27
+ * #8233 D4 — on the Inbox route itself the phone top bar's trailing cell is
28
+ * an empty node handed to the page (`NewMenuSlotProvider`), which portals
29
+ * its compact New into it; every other path has no trailing cell.
30
+ *
31
+ * #8337 D4 — the `data-ailk-shell="account"` marker on a `display: contents`
32
+ * wrapper around `SignedInShell`: the layout's `shell-vars` reset
33
+ * (`:root:has([data-ailk-shell])`) matches only while this frame is in the
34
+ * document, so the Admin's colour tokens reach the shell and everything it
35
+ * portals to `document.body` (the avatar/help menus, For-you, New, the phone
36
+ * sheets) without the wrapper adding a box — `contents` keeps the shell's own
37
+ * layout (`min-h-screen`) unchanged.
19
38
  */
20
39
 
21
40
  import {
@@ -33,11 +52,12 @@ import {
33
52
  import { localeHref } from "@working-theory/ui";
34
53
  import { Link } from "@working-theory/ui/primitives";
35
54
  import { createAuthClient } from "better-auth/react";
36
- import { Inbox, Settings, Sparkles } from "lucide-react";
55
+ import { Inbox, Layers, Settings, Sparkles } from "lucide-react";
37
56
  import { usePathname, useRouter, useSearchParams } from "next/navigation";
38
- import type { ReactElement, ReactNode } from "react";
57
+ import { useState, type ReactElement, type ReactNode } from "react";
39
58
 
40
59
  import { ACCOUNT_PERSIST_KEY } from "./for-you-labels";
60
+ import { NewMenuSlotProvider } from "./new-menu";
41
61
 
42
62
  const authClient = createAuthClient();
43
63
 
@@ -51,6 +71,9 @@ export interface AccountFrameLabels extends SignedInShellLabels {
51
71
  /** The phone's For you tab label (#7469). */
52
72
  forYou: string;
53
73
  settings: string;
74
+ /** The rail's Library group heading (#8231 D1). */
75
+ library: string;
76
+ artifacts: string;
54
77
  }
55
78
 
56
79
  export interface AccountFrameProps {
@@ -77,14 +100,21 @@ export function AccountFrame({
77
100
  const pathname = usePathname();
78
101
  const searchParams = useSearchParams();
79
102
  const [dismissed] = useForYouDismissed(ACCOUNT_PERSIST_KEY);
103
+ const [topBarActions, setTopBarActions] = useState<HTMLElement | null>(null);
80
104
 
81
105
  const homeHref = localeHref(locale, "/");
82
106
  const inboxHref = localeHref(locale, "/account");
107
+ const artifactsHref = localeHref(locale, "/account/artifacts");
108
+ const settingsHref = localeHref(locale, "/account/general");
109
+ const within = (href: string): boolean => pathname === href || pathname.startsWith(`${href}/`);
83
110
  // The Inbox row stays selected on an item's own page (/account/inbox/…).
84
111
  const onInbox = pathname === inboxHref || pathname.startsWith(`${inboxHref}/inbox/`);
112
+ const activePath = onInbox ? inboxHref : within(artifactsHref) ? artifactsHref : undefined;
85
113
  // #7469 — the phone tab: For you only on the Inbox route itself.
86
114
  const phoneTab =
87
115
  pathname === inboxHref && searchParams?.get("tab") === FOR_YOU_TAB ? FOR_YOU_TAB : "inbox";
116
+ // #8233 D4 — the Inbox route's New lives in the top bar; nowhere else.
117
+ const onInboxRoute = pathname === inboxHref;
88
118
 
89
119
  const onSignOut = (): void => {
90
120
  void (async () => {
@@ -99,7 +129,15 @@ export function AccountFrame({
99
129
  })();
100
130
  };
101
131
 
102
- const { home, inbox, forYou: forYouLabel, settings, ...shellLabels } = labels;
132
+ const {
133
+ home,
134
+ inbox,
135
+ forYou: forYouLabel,
136
+ settings,
137
+ library,
138
+ artifacts,
139
+ ...shellLabels
140
+ } = labels;
103
141
 
104
142
  // D13 — the logo only: no box, no name, no popover; it links home. The
105
143
  // same link is the phone top bar's leading cell.
@@ -118,52 +156,78 @@ export function AccountFrame({
118
156
  const forYouLeft = forYou.items.filter((item) => !dismissed.has(item.id));
119
157
 
120
158
  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>
159
+ <div className="contents" data-ailk-shell="account" data-testid="account-shell">
160
+ <SignedInShell
161
+ persistKey={ACCOUNT_PERSIST_KEY}
162
+ locale={locale}
163
+ navItems={[{ labelKey: "inbox", label: inbox, path: "/account", icon: Inbox }]}
164
+ railGroups={[
165
+ {
166
+ key: "library",
167
+ heading: library,
168
+ items: [
169
+ { labelKey: "artifacts", label: artifacts, path: "/account/artifacts", icon: Layers },
170
+ ],
171
+ },
172
+ ]}
173
+ tabItems={[
174
+ { key: "inbox", label: inbox, href: inboxHref, icon: Inbox, isActive: onInbox && phoneTab === "inbox" },
175
+ {
176
+ key: FOR_YOU_TAB,
177
+ label: forYouLabel,
178
+ href: `${inboxHref}?tab=${FOR_YOU_TAB}`,
179
+ icon: Sparkles,
180
+ isActive: phoneTab === FOR_YOU_TAB,
181
+ },
182
+ ]}
183
+ {...(activePath ? { activePath } : {})}
184
+ user={user}
185
+ userMenuItems={[
186
+ {
187
+ id: "settings",
188
+ label: settings,
189
+ icon: Settings,
190
+ href: settingsHref,
191
+ },
192
+ ]}
193
+ onSignOut={onSignOut}
194
+ userDisplay="avatar"
195
+ accountMenuLayout="card"
196
+ helpItems={helpItems}
197
+ railHeader={logoLink}
198
+ topBarStart={logoLink}
199
+ {...(onInboxRoute
200
+ ? {
201
+ topBarEnd: (
202
+ <span
203
+ ref={setTopBarActions}
204
+ className="flex items-center"
205
+ data-testid="account-top-bar-actions"
206
+ />
207
+ ),
208
+ }
209
+ : {})}
210
+ {...(forYouLeft.length > 0
211
+ ? {
212
+ aside: (
213
+ <ForYouPanel
214
+ items={forYouLeft}
215
+ persistKey={ACCOUNT_PERSIST_KEY}
216
+ labels={forYou.labels}
217
+ locale={locale}
218
+ now={forYou.now}
219
+ />
220
+ ),
221
+ }
222
+ : {})}
223
+ labels={shellLabels}
224
+ >
225
+ <PhoneTabProvider tab={phoneTab}>
226
+ <NewMenuSlotProvider node={onInboxRoute ? topBarActions : null}>
227
+ {children}
228
+ </NewMenuSlotProvider>
229
+ </PhoneTabProvider>
230
+ </SignedInShell>
231
+ </div>
168
232
  );
169
233
  }
@@ -13,48 +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 is already handled: `forwardedCookieInit()` forwards the incoming
17
- * request's real `Origin` alongside the cookie, and `apps/api`'s
18
- * `authPreHandler` requires a matching `Origin` on this cookie-authenticated
19
- * mutating request (D3.1/D5).
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:
20
19
  *
21
- * `redirect()` throws `NEXT_REDIRECT` by design — called OUTSIDE the
22
- * try/catch (mirrors `checkout.actions.ts`'s idiom) so the catch never
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.
29
+ *
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
23
35
  * swallows the navigation this action exists to perform.
24
36
  */
25
37
 
26
- import { subscription } from "@working-theory/api-client";
27
38
  import { redirect } from "next/navigation";
28
39
 
29
- import { forwardedCookieInit } from "../../../../lib/server-api";
30
-
31
- /** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
32
- function errorName(err: unknown): string {
33
- return err instanceof Error ? err.name : "unknown";
34
- }
40
+ import { billingPortalUrl } from "./billing-source";
35
41
 
36
42
  /**
37
43
  * Opens the caller's own Stripe billing-portal session and redirects there.
38
- * Never returns on success. On a non-2xx or thrown error, logs a PII-safe
39
- * status/name and redirects back to `/account` unchanged (the banner's
40
- * 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.
41
47
  */
42
48
  export async function openBillingPortal(): Promise<void> {
43
- const init = await forwardedCookieInit();
44
-
45
- let url: string;
46
- try {
47
- const res = await subscription.portalSession(init);
48
- if (!res.ok) {
49
- console.error(`[openBillingPortal] status=${res.status}`);
50
- return;
51
- }
52
- url = res.body.url;
53
- } catch (err) {
54
- console.error(`[openBillingPortal] errName=${errorName(err)}`);
55
- return;
56
- }
57
-
58
- // 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.
59
52
  redirect(url);
60
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
+ }