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
@@ -0,0 +1,288 @@
1
+ /**
2
+ * The artifact review (#8234, spec 8234 D6, D7; AC-5, AC-6).
3
+ *
4
+ * Two layers:
5
+ * - `submitArtifactReview` (the Server Action), with the api-client's
6
+ * `member.reviewArtifact` as the one mocked boundary: the exact body it
7
+ * posts for each verdict, the comment check before any call, each
8
+ * refusal's reason, and logs that never carry the comment or the id.
9
+ * - `ReviewForm` (the client form) over the real action, spied on to read
10
+ * the `FormData` each button sends: the verdict and the comment, a
11
+ * refusal that keeps the typed comment, and a sent review that refreshes
12
+ * the page once.
13
+ */
14
+
15
+ import {
16
+ act,
17
+ fireEvent,
18
+ render,
19
+ screen,
20
+ waitFor,
21
+ } from "@testing-library/react";
22
+
23
+ const mockReviewArtifact = jest.fn();
24
+ jest.mock("@working-theory/api-client", () => ({
25
+ __esModule: true,
26
+ member: {
27
+ reviewArtifact: (...args: unknown[]) => mockReviewArtifact(...args),
28
+ },
29
+ }));
30
+
31
+ jest.mock("next/headers", () => ({
32
+ __esModule: true,
33
+ headers: jest
34
+ .fn()
35
+ .mockResolvedValue(new Headers({ origin: "https://site.example.com" })),
36
+ cookies: jest.fn().mockResolvedValue({ toString: () => "session=abc" }),
37
+ }));
38
+
39
+ const mockRefresh = jest.fn();
40
+ jest.mock("next/navigation", () => ({
41
+ __esModule: true,
42
+ useRouter: () => ({ push: jest.fn(), refresh: mockRefresh }),
43
+ }));
44
+
45
+ import messages from "../../../../../../messages/en.json";
46
+ import { ReviewForm, type ReviewFormLabels } from "../[pieceId]/review-form";
47
+ import * as actions from "../artifact-actions";
48
+ import { submitArtifactReview } from "../artifact-actions";
49
+
50
+ const PIECE = "piece_01920000-0000-7000-8000-000000000001";
51
+ const INIT = {
52
+ headers: { cookie: "session=abc", origin: "https://site.example.com" },
53
+ };
54
+ const COMMENT = "Tighten the logo";
55
+
56
+ function form(fields: Record<string, string>): FormData {
57
+ const data = new FormData();
58
+ for (const [key, value] of Object.entries(fields)) data.set(key, value);
59
+ return data;
60
+ }
61
+
62
+ function review(
63
+ fields: Partial<Record<"pieceId" | "version" | "review" | "comment", string>>,
64
+ ) {
65
+ return submitArtifactReview(
66
+ { kind: "idle" },
67
+ form({
68
+ pieceId: PIECE,
69
+ version: "3",
70
+ review: "approved",
71
+ comment: "",
72
+ ...fields,
73
+ }),
74
+ );
75
+ }
76
+
77
+ function refusal(status: number, code: string) {
78
+ return {
79
+ ok: false,
80
+ status,
81
+ body: { ok: false, error: { code, message: "x" } },
82
+ };
83
+ }
84
+
85
+ let errorSpy: jest.SpyInstance;
86
+
87
+ beforeEach(() => {
88
+ mockReviewArtifact
89
+ .mockReset()
90
+ .mockResolvedValue({ ok: true, body: { artifact: {} } });
91
+ mockRefresh.mockReset();
92
+ errorSpy = jest.spyOn(console, "error").mockImplementation(() => undefined);
93
+ });
94
+
95
+ afterEach(() => {
96
+ errorSpy.mockRestore();
97
+ });
98
+
99
+ describe("submitArtifactReview (D6, AC-5)", () => {
100
+ it("Approve posts { version, review } once with the caller's init and answers reviewed", async () => {
101
+ await expect(review({})).resolves.toEqual({ kind: "reviewed" });
102
+ expect(mockReviewArtifact).toHaveBeenCalledTimes(1);
103
+ expect(mockReviewArtifact).toHaveBeenCalledWith(
104
+ PIECE,
105
+ { version: 3, review: "approved" },
106
+ INIT,
107
+ );
108
+ });
109
+
110
+ it("Request changes posts the trimmed comment and no other key", async () => {
111
+ await expect(
112
+ review({ review: "changes-requested", comment: ` ${COMMENT} ` }),
113
+ ).resolves.toEqual({ kind: "reviewed" });
114
+ expect(mockReviewArtifact).toHaveBeenCalledTimes(1);
115
+ expect(mockReviewArtifact.mock.calls[0]![1]).toStrictEqual({
116
+ version: 3,
117
+ review: "changes-requested",
118
+ comment: COMMENT,
119
+ });
120
+ });
121
+
122
+ it("an Approve with a comment carries it", async () => {
123
+ await review({ comment: "Lovely" });
124
+ expect(mockReviewArtifact.mock.calls[0]![1]).toStrictEqual({
125
+ version: 3,
126
+ review: "approved",
127
+ comment: "Lovely",
128
+ });
129
+ });
130
+
131
+ it.each(["", " "])(
132
+ "Request changes with a blank comment (%j) is comment_required with no call",
133
+ async (comment) => {
134
+ await expect(
135
+ review({ review: "changes-requested", comment }),
136
+ ).resolves.toEqual({
137
+ kind: "error",
138
+ reason: "comment_required",
139
+ });
140
+ expect(mockReviewArtifact).not.toHaveBeenCalled();
141
+ },
142
+ );
143
+
144
+ it("Request changes with a comment the server would strip to nothing is comment_required with no call", async () => {
145
+ await expect(
146
+ review({ review: "changes-requested", comment: "\u200e\u202e" }),
147
+ ).resolves.toEqual({ kind: "error", reason: "comment_required" });
148
+ expect(mockReviewArtifact).not.toHaveBeenCalled();
149
+ });
150
+
151
+ it.each([
152
+ ["a malformed piece id", { pieceId: "x" }],
153
+ ["a version that is not a whole number", { version: "abc" }],
154
+ ["an unknown verdict", { review: "rejected" }],
155
+ ])("%s is failed with no call", async (_case, fields) => {
156
+ await expect(review(fields)).resolves.toEqual({
157
+ kind: "error",
158
+ reason: "failed",
159
+ });
160
+ expect(mockReviewArtifact).not.toHaveBeenCalled();
161
+ });
162
+
163
+ it.each([
164
+ ["review_closed", refusal(409, "review_closed"), "closed"],
165
+ ["version_not_latest", refusal(409, "version_not_latest"), "not_latest"],
166
+ ["too_many_requests", refusal(429, "too_many_requests"), "rate_limited"],
167
+ ["a 429 rate_limited", refusal(429, "rate_limited"), "rate_limited"],
168
+ ["invalid_input (400)", refusal(400, "invalid_input"), "failed"],
169
+ ["a status-0 refusal", refusal(0, "unauthorized"), "failed"],
170
+ ])("a %s refusal is %s", async (_case, res, reason) => {
171
+ mockReviewArtifact.mockResolvedValue(res);
172
+ await expect(
173
+ review({ review: "changes-requested", comment: COMMENT }),
174
+ ).resolves.toEqual({
175
+ kind: "error",
176
+ reason,
177
+ });
178
+ expect(mockReviewArtifact).toHaveBeenCalledTimes(1);
179
+ });
180
+
181
+ it("a throw is failed, logged by err.name only — never the comment or the piece id", async () => {
182
+ mockReviewArtifact.mockRejectedValue(
183
+ new TypeError(`fetch failed ${COMMENT} ${PIECE}`),
184
+ );
185
+ await expect(
186
+ review({ review: "changes-requested", comment: COMMENT }),
187
+ ).resolves.toEqual({
188
+ kind: "error",
189
+ reason: "failed",
190
+ });
191
+ expect(mockReviewArtifact).toHaveBeenCalledTimes(1);
192
+ expect(errorSpy).toHaveBeenCalledWith(
193
+ "[account] review failed errName=TypeError",
194
+ );
195
+ const logged = JSON.stringify(errorSpy.mock.calls);
196
+ expect(logged).not.toContain(COMMENT);
197
+ expect(logged).not.toContain(PIECE);
198
+ });
199
+ });
200
+
201
+ const r = messages.account.artifacts.review;
202
+ const LABELS: ReviewFormLabels = {
203
+ heading: r.heading.replace("{version}", "3"),
204
+ comment: r.comment,
205
+ commentHint: r.commentHint,
206
+ approve: r.approve,
207
+ requestChanges: r.requestChanges,
208
+ submitting: r.submitting,
209
+ sent: r.sent,
210
+ errors: {
211
+ comment_required: r.errors.commentRequired,
212
+ closed: r.errors.closed,
213
+ not_latest: r.errors.notLatest,
214
+ rate_limited: r.errors.rateLimited,
215
+ failed: r.errors.failed,
216
+ },
217
+ };
218
+
219
+ describe("ReviewForm (D7, AC-6)", () => {
220
+ // A pass-through spy: the real action runs; the spy reads what it was sent.
221
+ let submitSpy: jest.SpyInstance;
222
+
223
+ beforeEach(() => {
224
+ submitSpy = jest.spyOn(actions, "submitArtifactReview");
225
+ render(<ReviewForm pieceId={PIECE} version={3} labels={LABELS} />);
226
+ });
227
+
228
+ afterEach(() => {
229
+ submitSpy.mockRestore();
230
+ });
231
+
232
+ function sent(): FormData {
233
+ expect(submitSpy).toHaveBeenCalledTimes(1);
234
+ return submitSpy.mock.calls[0]![1] as FormData;
235
+ }
236
+
237
+ it("Request changes sends the verdict, the comment, the piece and the version; a refusal shows its message and keeps the comment", async () => {
238
+ mockReviewArtifact.mockResolvedValue(refusal(409, "review_closed"));
239
+ fireEvent.change(screen.getByTestId("artifact-review-comment"), {
240
+ target: { value: COMMENT },
241
+ });
242
+ await act(async () => {
243
+ fireEvent.click(screen.getByTestId("artifact-review-request-changes"));
244
+ });
245
+ const data = sent();
246
+ expect(data.get("review")).toBe("changes-requested");
247
+ expect(data.get("comment")).toBe(COMMENT);
248
+ expect(data.get("pieceId")).toBe(PIECE);
249
+ expect(data.get("version")).toBe("3");
250
+ await waitFor(() =>
251
+ expect(screen.getByTestId("artifact-review-error")).toHaveTextContent(
252
+ "This version can't be reviewed now. It may already be approved.",
253
+ ),
254
+ );
255
+ expect(screen.getByTestId("artifact-review-comment")).toHaveValue(COMMENT);
256
+ expect(mockRefresh).not.toHaveBeenCalled();
257
+ });
258
+
259
+ it("Approve sends the approved verdict; a sent review shows the thanks line and refreshes once", async () => {
260
+ await act(async () => {
261
+ fireEvent.click(screen.getByTestId("artifact-review-approve"));
262
+ });
263
+ expect(sent().get("review")).toBe("approved");
264
+ expect(mockReviewArtifact).toHaveBeenCalledWith(
265
+ PIECE,
266
+ { version: 3, review: "approved" },
267
+ INIT,
268
+ );
269
+ await waitFor(() =>
270
+ expect(screen.getByTestId("artifact-review-sent")).toHaveTextContent(
271
+ "Thanks — your review was sent.",
272
+ ),
273
+ );
274
+ expect(mockRefresh).toHaveBeenCalledTimes(1);
275
+ });
276
+
277
+ it("labels the comment box, and gives both buttons a 44px phone target", () => {
278
+ expect(screen.getByLabelText("Comment")).toBe(
279
+ screen.getByTestId("artifact-review-comment"),
280
+ );
281
+ expect(screen.getByTestId("artifact-review-approve")).toHaveClass(
282
+ "max-md:min-h-11",
283
+ );
284
+ expect(screen.getByTestId("artifact-review-request-changes")).toHaveClass(
285
+ "max-md:min-h-11",
286
+ );
287
+ });
288
+ });
@@ -0,0 +1,123 @@
1
+ "use server";
2
+
3
+ /**
4
+ * artifacts/artifact-actions.ts — the review write (#8234, spec
5
+ * docs/specs/8234-member-artifacts-account-artifacts-and-spec.md D6; the
6
+ * write is #8170's `POST /v1/member/artifacts/:pieceId/review`).
7
+ *
8
+ * One export: `submitArtifactReview`, the review form's action. It posts
9
+ * Approve or Request changes on a piece's latest version through
10
+ * `member.reviewArtifact` with `forwardedCookieInit()`'s init as built — the
11
+ * session cookie plus the request's `Origin` on a single site, the hub
12
+ * credential on a hub-mode spoke — so nothing here tests the topology. The
13
+ * body is the strict `MemberReviewBody` (`version`, `review`, `comment`), so
14
+ * no title, stage, note or link can be posted (AC-MAA-05). The posted
15
+ * `pieceId` and `version` are untrusted: they are shape-checked here, and the
16
+ * API scopes them to the Person the credential resolves.
17
+ *
18
+ * Not a `*.actions.ts` file and no `*Action`-suffixed export: apps/mcp's
19
+ * parity test reads that suffix, and the member writes have no MCP twin by
20
+ * decision (8170's header). The reads the pages share live in
21
+ * `artifact-data.ts`, never here — every export of this module is a POST
22
+ * endpoint. A log line carries `err.name` only — never the comment or an id.
23
+ */
24
+
25
+ import { member } from "@working-theory/api-client";
26
+ import {
27
+ memberReviewBodySchema,
28
+ memberReviewParamsSchema,
29
+ type MemberReviewBody,
30
+ } from "@working-theory/validation";
31
+
32
+ import { forwardedCookieInit } from "../../../../../lib/server-api";
33
+
34
+ /** What the review form shows after a submit. */
35
+ export type ArtifactReviewState =
36
+ | { kind: "idle" }
37
+ | { kind: "reviewed" }
38
+ | {
39
+ kind: "error";
40
+ reason:
41
+ | "comment_required"
42
+ | "closed"
43
+ | "not_latest"
44
+ | "rate_limited"
45
+ | "failed";
46
+ };
47
+
48
+ export type ArtifactReviewReason = Extract<
49
+ ArtifactReviewState,
50
+ { kind: "error" }
51
+ >["reason"];
52
+
53
+ function error(reason: ArtifactReviewReason): ArtifactReviewState {
54
+ return { kind: "error", reason };
55
+ }
56
+
57
+ /** A form field as a string; a missing or file field is empty. */
58
+ function field(formData: FormData, name: string): string {
59
+ const value = formData.get(name);
60
+ return typeof value === "string" ? value : "";
61
+ }
62
+
63
+ /** The refusal's reason, from the api-client's `{ ok: false, status, body }`. */
64
+ function refusalReason(status: number, body: unknown): ArtifactReviewReason {
65
+ const code = (body as { error?: { code?: unknown } } | null)?.error?.code;
66
+ if (code === "review_closed") return "closed";
67
+ if (code === "version_not_latest") return "not_latest";
68
+ // The route's own budget answers `too_many_requests`; the hub's
69
+ // per-person budget and the per-IP limiter answer `429 rate_limited`.
70
+ if (code === "too_many_requests" || status === 429) return "rate_limited";
71
+ return "failed";
72
+ }
73
+
74
+ /** Approve, or request changes with a comment, on a piece's latest version. */
75
+ export async function submitArtifactReview(
76
+ _prev: ArtifactReviewState,
77
+ formData: FormData,
78
+ ): Promise<ArtifactReviewState> {
79
+ const pieceId = field(formData, "pieceId");
80
+ const review = field(formData, "review");
81
+ const comment = field(formData, "comment").trim();
82
+
83
+ if (review === "changes-requested" && comment === "")
84
+ return error("comment_required");
85
+
86
+ const candidate = {
87
+ version: Number(field(formData, "version")),
88
+ review,
89
+ ...(comment !== "" ? { comment } : {}),
90
+ };
91
+ if (!memberReviewParamsSchema.safeParse({ pieceId }).success)
92
+ return error("failed");
93
+ const parsed = memberReviewBodySchema.safeParse(candidate);
94
+ if (!parsed.success) {
95
+ // A comment of control or bidi characters only is stripped to nothing
96
+ // by the server's `plainText`; to the person that is a missing comment.
97
+ const onComment = parsed.error.issues.some(
98
+ (issue) => issue.path[0] === "comment",
99
+ );
100
+ return error(
101
+ review === "changes-requested" && onComment
102
+ ? "comment_required"
103
+ : "failed",
104
+ );
105
+ }
106
+ const body: MemberReviewBody = parsed.data;
107
+
108
+ let res: Awaited<ReturnType<typeof member.reviewArtifact>>;
109
+ try {
110
+ res = await member.reviewArtifact(
111
+ pieceId,
112
+ body,
113
+ await forwardedCookieInit(),
114
+ );
115
+ } catch (err) {
116
+ console.error(
117
+ `[account] review failed errName=${err instanceof Error ? err.name : "unknown"}`,
118
+ );
119
+ return error("failed");
120
+ }
121
+ if (!res.ok) return error(refusalReason(res.status, res.body));
122
+ return { kind: "reviewed" };
123
+ }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * artifacts/artifact-data.ts — the Artifacts pages' read and its helpers
3
+ * (#8234, spec docs/specs/8234-member-artifacts-account-artifacts-and-spec.md
4
+ * D2, C6).
5
+ *
6
+ * The list and the artifact page read the same artifacts, so the read lives
7
+ * here — not in `artifact-actions.ts`, where every export of a
8
+ * `"use server"` module is a POST endpoint, and not in
9
+ * `lib/account-config.ts`, whose `read()` stays private. The account area's
10
+ * read rule is restated: a 404 (no tenancy module, or a spoke route the hub
11
+ * does not admit) is "nothing delivered"; a 401 or 403 is the same while the
12
+ * person's email is unverified; anything else throws, so the page shows its
13
+ * error row with Retry (spec 7423 D12), never an empty list.
14
+ *
15
+ * The read serves versions newest first and at least one (8169 D6), so
16
+ * `versions[0]` is the latest version. Items keep the read's order — newest
17
+ * delivery first — and are never re-sorted here.
18
+ */
19
+
20
+ import { member } from "@working-theory/api-client";
21
+ import type { AccountContext } from "@working-theory/templates/account";
22
+ import { localeHref } from "@working-theory/ui";
23
+ import type { MemberArtifact } from "@working-theory/validation";
24
+ import { cache } from "react";
25
+
26
+ import { unwrapApiResult } from "../../../../../lib/server-api";
27
+
28
+ /** The member's artifacts, newest delivery first. One read per request. */
29
+ export const loadArtifacts = cache(
30
+ async (ctx: AccountContext): Promise<MemberArtifact[]> => {
31
+ const res = await member.artifacts(ctx.init);
32
+ if (!res.ok) {
33
+ if (res.status === 404) return [];
34
+ if (!ctx.user.emailVerified && (res.status === 401 || res.status === 403))
35
+ return [];
36
+ }
37
+ return (await unwrapApiResult(Promise.resolve(res))).items;
38
+ },
39
+ );
40
+
41
+ export type ArtifactState = "approved" | "changes-requested" | "ready";
42
+
43
+ /** The `account` catalog key that names each review state. */
44
+ export const STATE_KEYS = {
45
+ approved: "artifacts.states.approved",
46
+ "changes-requested": "artifacts.states.changesRequested",
47
+ ready: "artifacts.states.ready",
48
+ } as const satisfies Record<ArtifactState, string>;
49
+
50
+ /** The artifact's review state, from its approval and its latest version. */
51
+ export function artifactState(a: MemberArtifact): ArtifactState {
52
+ if (a.approvedAt !== null) return "approved";
53
+ return a.versions[0]?.review === "changes-requested"
54
+ ? "changes-requested"
55
+ : "ready";
56
+ }
57
+
58
+ /**
59
+ * True while the latest version can be reviewed: the artifact is not
60
+ * approved and that version has no review yet (8170 C7 — a reviewed version
61
+ * is `review_closed` until the provider delivers the next one).
62
+ */
63
+ export function isReviewable(a: MemberArtifact): boolean {
64
+ return a.approvedAt === null && a.versions[0]?.review === null;
65
+ }
66
+
67
+ /** The artifact page of one piece. */
68
+ export function artifactHref(locale: string, pieceId: string): string {
69
+ return localeHref(
70
+ locale,
71
+ `/account/artifacts/${encodeURIComponent(pieceId)}`,
72
+ );
73
+ }
74
+
75
+ /** The download route of one version's file. */
76
+ export function versionFileHref(
77
+ locale: string,
78
+ pieceId: string,
79
+ version: number,
80
+ ): string {
81
+ return `${artifactHref(locale, pieceId)}/versions/${version}/file`;
82
+ }
@@ -0,0 +1,177 @@
1
+ /**
2
+ * /[locale]/account/artifacts — Library › Artifacts (#8338, spec
3
+ * docs/specs/8338-account-artifacts-structured-like-the-spec.md D3; D3
4
+ * supersedes #8234's D3, which rendered the flat Inbox-style list.
5
+ *
6
+ * RSC, inside the account layout's SignedInShell (the rail's Artifacts row,
7
+ * #8231 D1, is selected here). Renders `AccountListFrame` — the Admin Docs
8
+ * page's structure: a second column with an "All artifacts" count and a
9
+ * Review facet group (Ready for review · Changes requested · Approved),
10
+ * each counted over every artifact with zeros kept, and a table of Title ·
11
+ * Latest · Delivered · Review in the read's order — newest delivery first.
12
+ * Below `md` the column becomes a filter select and the table becomes the
13
+ * phone row list.
14
+ *
15
+ * A 404 read (no tenancy module, or a spoke route the hub does not admit)
16
+ * shows the frame with every count at 0 and the plain empty line; any other
17
+ * failed read renders today's error row with Retry and no frame (spec 7423
18
+ * D12). Nothing here tests the topology: the same file serves a single site
19
+ * and a hub-mode spoke.
20
+ */
21
+
22
+ import {
23
+ AccountListFrame,
24
+ type AccountListFrameRow,
25
+ } from "@working-theory/templates/account";
26
+ import { Heading, Text } from "@working-theory/ui/primitives";
27
+ import type { MemberArtifact } from "@working-theory/validation";
28
+ import type { Metadata } from "next";
29
+ import { getTranslations } from "next-intl/server";
30
+ import type { ReactElement } from "react";
31
+
32
+ import { accountSession, logReadFailure } from "../account-data";
33
+ import { ErrorRow } from "../error-row";
34
+
35
+ import {
36
+ artifactHref,
37
+ artifactState,
38
+ loadArtifacts,
39
+ STATE_KEYS,
40
+ type ArtifactState,
41
+ } from "./artifact-data";
42
+
43
+ interface Props {
44
+ params: Promise<{ locale: string }>;
45
+ }
46
+
47
+ const REVIEW_FACETS: Array<{
48
+ state: ArtifactState;
49
+ glyph: "circle-dashed" | "message-square" | "circle-check";
50
+ }> = [
51
+ { state: "ready", glyph: "circle-dashed" },
52
+ { state: "changes-requested", glyph: "message-square" },
53
+ { state: "approved", glyph: "circle-check" },
54
+ ];
55
+
56
+ export async function generateMetadata({ params }: Props): Promise<Metadata> {
57
+ const { locale } = await params;
58
+ const t = await getTranslations({ locale, namespace: "account" });
59
+ return { title: t("artifacts.title") };
60
+ }
61
+
62
+ export default async function AccountArtifactsPage({
63
+ params,
64
+ }: Props): Promise<ReactElement> {
65
+ const { locale } = await params;
66
+ const t = await getTranslations({ locale, namespace: "account" });
67
+ const session = await accountSession(locale);
68
+
69
+ const heading = (
70
+ <>
71
+ <Heading level={1} visual={2}>
72
+ {t("artifacts.title")}
73
+ </Heading>
74
+ <Text as="p" variant="body-sm" tone="muted" className="mt-block-tight">
75
+ {t("artifacts.lead")}
76
+ </Text>
77
+ </>
78
+ );
79
+
80
+ // Unreachable behind the (authed) gate — the layout renders the sign-in
81
+ // gate instead — but the page still renders in parallel with it, so a
82
+ // `notFound()` here streams a 404 digest over the gate (the scaffold render
83
+ // sweep's `/account/artifacts -> streamed not-found`). Mirror
84
+ // `../general/page.tsx`: the heading alone.
85
+ if (!session) {
86
+ return (
87
+ <div className="max-w-3xl py-8" data-testid="artifacts">
88
+ {heading}
89
+ </div>
90
+ );
91
+ }
92
+
93
+ let artifacts: MemberArtifact[] | null;
94
+ try {
95
+ artifacts = await loadArtifacts(session.ctx);
96
+ } catch (err) {
97
+ logReadFailure("artifacts", err);
98
+ artifacts = null;
99
+ }
100
+
101
+ if (artifacts === null) {
102
+ return (
103
+ <div className="max-w-3xl py-8" data-testid="artifacts">
104
+ {heading}
105
+ <ErrorRow
106
+ className="mt-block-loose"
107
+ message={t("artifacts.error")}
108
+ retryLabel={t("artifacts.retry")}
109
+ />
110
+ </div>
111
+ );
112
+ }
113
+
114
+ const row = (artifact: MemberArtifact): AccountListFrameRow => {
115
+ const state = artifactState(artifact);
116
+ const latest = artifact.versions[0]!;
117
+ const delivered = new Intl.DateTimeFormat(locale, {
118
+ dateStyle: "medium",
119
+ timeZone: "UTC",
120
+ }).format(new Date(latest.deliveredAt));
121
+ const reviewWord = t(STATE_KEYS[state]);
122
+ return {
123
+ id: artifact.id,
124
+ title: artifact.title,
125
+ href: artifactHref(locale, artifact.id),
126
+ facet: state,
127
+ caption: `${t("artifacts.version", { version: latest.version })} · ${reviewWord}`,
128
+ cells: {
129
+ latest: t("artifacts.version", { version: latest.version }),
130
+ delivered,
131
+ review: reviewWord,
132
+ },
133
+ };
134
+ };
135
+
136
+ return (
137
+ <AccountListFrame
138
+ title={t("artifacts.title")}
139
+ lead={t("artifacts.lead")}
140
+ glyph="layers"
141
+ all={{ label: t("artifacts.all") }}
142
+ group={{
143
+ heading: t("artifacts.reviewGroup"),
144
+ facets: REVIEW_FACETS.map(({ state, glyph }) => ({
145
+ id: state,
146
+ label: t(STATE_KEYS[state]),
147
+ glyph,
148
+ })),
149
+ }}
150
+ columns={[
151
+ { key: "title", header: t("artifacts.columns.title") },
152
+ { key: "latest", header: t("artifacts.columns.latest"), width: "1/6" },
153
+ {
154
+ key: "delivered",
155
+ header: t("artifacts.columns.delivered"),
156
+ width: "1/6",
157
+ minWidth: "28",
158
+ },
159
+ {
160
+ key: "review",
161
+ header: t("artifacts.columns.review"),
162
+ width: "1/6",
163
+ minWidth: "32",
164
+ },
165
+ ]}
166
+ rows={artifacts.map(row)}
167
+ labels={{
168
+ nav: t("artifacts.facets"),
169
+ table: t("artifacts.title"),
170
+ filter: t("artifacts.filter"),
171
+ empty: t("artifacts.empty"),
172
+ facetEmpty: t("artifacts.facetEmpty"),
173
+ }}
174
+ testIdPrefix="artifacts"
175
+ />
176
+ );
177
+ }