create-ailk 0.18.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 (167) 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/license-key-files.d.ts +6 -3
  9. package/dist/lib/license-key-files.js +8 -2
  10. package/dist/lib/scaffold-render-sweep.d.ts +9 -0
  11. package/dist/lib/scaffold-render-sweep.js +24 -3
  12. package/dist/license-set.d.ts +1 -1
  13. package/dist/license-set.js +13 -8
  14. package/dist/module-architecture.js +119 -0
  15. package/dist/programmatic.js +3 -0
  16. package/dist/spoke-checkout.d.ts +11 -3
  17. package/dist/spoke-checkout.js +62 -12
  18. package/dist/starter-grid.d.ts +1 -1
  19. package/dist/surfaces.js +140 -1
  20. package/dist/template-manifest.d.ts +4 -3
  21. package/dist/topology.d.ts +7 -2
  22. package/dist/topology.js +60 -3
  23. package/package.json +5 -4
  24. package/templates/.env.example +3 -3
  25. package/templates/.github/workflows/ci.yml +5 -0
  26. package/templates/apps/api/CLAUDE.md +25 -7
  27. package/templates/apps/api/LICENSE +6 -5
  28. package/templates/apps/api/NOTICE +2 -0
  29. package/templates/apps/api/jest.config.cjs +4 -29
  30. package/templates/apps/api/package.json +1 -0
  31. package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
  32. package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
  33. package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
  34. package/templates/apps/api/src/lib/stripe.ts +81 -0
  35. package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
  36. package/templates/apps/api/src/middleware/auth.ts +206 -46
  37. package/templates/apps/api/src/routes/leads/index.ts +4 -0
  38. package/templates/apps/api/src/server.ts +107 -0
  39. package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
  40. package/templates/apps/api/src/services/lead-routing.ts +30 -2
  41. package/templates/apps/api/vercel.json +2 -2
  42. package/templates/apps/mcp/.env.example +10 -0
  43. package/templates/apps/mcp/CLAUDE.md +9 -4
  44. package/templates/apps/mcp/LICENSE +6 -5
  45. package/templates/apps/mcp/NOTICE +2 -0
  46. package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
  47. package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
  48. package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
  49. package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
  50. package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
  51. package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
  52. package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
  53. package/templates/apps/mcp/jest.config.cjs +3 -44
  54. package/templates/apps/mcp/src/abuse/index.ts +117 -21
  55. package/templates/apps/mcp/src/boot.ts +5 -23
  56. package/templates/apps/mcp/src/http-transport.ts +93 -73
  57. package/templates/apps/mcp/src/index.ts +2 -0
  58. package/templates/apps/mcp/src/request-context.ts +52 -0
  59. package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
  60. package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
  61. package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
  62. package/templates/apps/web/LICENSE +6 -5
  63. package/templates/apps/web/NOTICE +2 -0
  64. package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
  65. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
  66. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
  67. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
  68. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
  69. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
  70. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
  71. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
  72. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
  73. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
  74. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
  75. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
  76. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
  77. package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
  78. package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
  79. package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
  80. package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
  81. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
  82. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
  83. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
  84. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
  85. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
  86. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
  87. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
  88. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
  89. package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
  90. package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
  91. package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
  92. package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
  93. package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
  94. package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
  95. package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
  96. package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
  97. package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
  98. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
  99. package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
  100. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
  101. package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
  102. package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
  103. package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
  104. package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
  105. package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
  106. package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
  107. package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
  108. package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
  109. package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
  110. package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
  111. package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
  112. package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
  113. package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
  114. package/templates/apps/web/app/[locale]/layout.tsx +23 -4
  115. package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
  116. package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
  117. package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
  118. package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
  119. package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
  120. package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
  121. package/templates/apps/web/app/robots.txt/route.ts +10 -4
  122. package/templates/apps/web/auth.ts +5 -1
  123. package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
  124. package/templates/apps/web/lib/account-config.ts +188 -52
  125. package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
  126. package/templates/apps/web/lib/hub-session/index.ts +1 -0
  127. package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
  128. package/templates/apps/web/lib/hub-session/seal.ts +13 -9
  129. package/templates/apps/web/lib/topology.generated.ts +24 -0
  130. package/templates/apps/web/messages/en.json +156 -35
  131. package/templates/database/CHANGELOG.md +21 -0
  132. package/templates/database/LICENSE +6 -5
  133. package/templates/database/NOTICE +2 -0
  134. package/templates/database/README.md +1 -1
  135. package/templates/database/__tests__/member-area.db.test.ts +15 -3
  136. package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
  137. package/templates/database/__tests__/member-contract.db.test.ts +413 -0
  138. package/templates/database/__tests__/seed.test.ts +54 -2
  139. package/templates/database/inbox/schema.prisma +90 -15
  140. package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
  141. package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
  142. package/templates/database/package.json +3 -2
  143. package/templates/database/seed.ts +34 -2
  144. package/versions.json +36 -36
  145. package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
  146. package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
  147. package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
  148. package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
  149. package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
  150. package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
  151. package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
  152. package/templates/apps/api/src/__tests__/server.test.ts +0 -591
  153. package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
  154. package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
  155. package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
  156. package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
  157. package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
  158. package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
  159. package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
  160. package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
  161. package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
  162. package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
  163. package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
  164. package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
  165. package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
  166. package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
  167. package/templates/database/__tests__/build.test.ts +0 -131
@@ -0,0 +1,104 @@
1
+ "use client"; // owns the save call and the router.refresh() that follows it
2
+
3
+ /**
4
+ * GeneralSettings — User settings › General's adapter over the shared
5
+ * `AccountSettings` composition (#8339 D8; builds on #8232 D3, D4).
6
+ *
7
+ * Rewritten in place from `ProfileForm` (#7430 → #8232), which rendered its
8
+ * own form. The form, the color logic and the markup now live in
9
+ * `@working-theory/templates/account`'s `AccountSettings` (spec 8339 D4);
10
+ * this file supplies `profile` and `onSave` from `via` (General's
11
+ * `loadProfileSource`, D3):
12
+ *
13
+ * - `member` — `saveProfile` (`PATCH /v1/member/profile`, D6). `image` is
14
+ * sent only when the photo field changed (an untouched field never
15
+ * clears a photo, 8232 D4/D6); a refusal keeps both fields as typed.
16
+ * - `session` — a build with no member API: the name saves through
17
+ * Better Auth's own `updateUser`, and no photo field shows
18
+ * (`profile="name-only"`).
19
+ * - `none` — a hub-mode spoke whose hub answers no member route: no form,
20
+ * the name as read-only text (`profile="read-only"`).
21
+ *
22
+ * On success, both paths call `router.refresh()` so the rail's name follows
23
+ * the save.
24
+ */
25
+
26
+ import {
27
+ AccountSettings,
28
+ type AccountGeneralLabels,
29
+ type AccountSaveResult,
30
+ type AccountSettingsFrameProps,
31
+ } from "@working-theory/templates/account";
32
+ import { createAuthClient } from "better-auth/react";
33
+ import { useRouter } from "next/navigation";
34
+ import type { ReactElement, ReactNode } from "react";
35
+
36
+ import { saveProfile } from "../profile-actions";
37
+
38
+ const authClient = createAuthClient();
39
+
40
+ export interface GeneralSettingsLabels extends AccountGeneralLabels {
41
+ /** The `saveProfile` refusal lines not already in `AccountGeneralLabels`. */
42
+ unverified: string;
43
+ unavailable: string;
44
+ }
45
+
46
+ export function GeneralSettings({
47
+ frame,
48
+ user,
49
+ labels,
50
+ colorMode,
51
+ via,
52
+ children,
53
+ }: {
54
+ frame: Omit<AccountSettingsFrameProps, "name" | "children">;
55
+ user: { name: string; email: string; image: string | null };
56
+ labels: GeneralSettingsLabels;
57
+ colorMode: { cookieName: string } | null;
58
+ via: "member" | "session" | "none";
59
+ children?: ReactNode;
60
+ }): ReactElement {
61
+ const router = useRouter();
62
+
63
+ async function onSave({
64
+ name,
65
+ image,
66
+ imageChanged,
67
+ }: {
68
+ name: string;
69
+ image: string | null;
70
+ imageChanged: boolean;
71
+ }): Promise<AccountSaveResult> {
72
+ if (via === "session") {
73
+ const { error } = await authClient.updateUser({ name });
74
+ if (error) return { ok: false, message: labels.saveError };
75
+ router.refresh();
76
+ return { ok: true };
77
+ }
78
+ const res = await saveProfile(imageChanged ? { name, image } : { name });
79
+ if (!res.ok) {
80
+ if (res.reason === "photo") return { ok: false, field: "photo", message: labels.photoInvalid };
81
+ if (res.reason === "required") return { ok: false, field: "name", message: labels.nameRequired };
82
+ if (res.reason === "unverified") return { ok: false, message: labels.unverified };
83
+ if (res.reason === "unavailable") return { ok: false, message: labels.unavailable };
84
+ return { ok: false, message: labels.saveError };
85
+ }
86
+ router.refresh();
87
+ // exactOptionalPropertyTypes: omit `name` entirely rather than set it to
88
+ // `undefined` — AccountSaveResult's `name?: string` has no `undefined` value.
89
+ return { ok: true, image: res.image, ...(res.name !== null ? { name: res.name } : {}) };
90
+ }
91
+
92
+ return (
93
+ <AccountSettings
94
+ frame={frame}
95
+ user={user}
96
+ labels={labels}
97
+ colorMode={colorMode}
98
+ profile={via === "member" ? "editable" : via === "session" ? "name-only" : "read-only"}
99
+ {...(via === "none" ? {} : { onSave })}
100
+ >
101
+ {children}
102
+ </AccountSettings>
103
+ );
104
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * general/profile-source.ts (server code) — who saves User settings ›
3
+ * General's profile and takes its deletion request, and the photo the
4
+ * profile form starts with (#8232, spec
5
+ * docs/specs/8232-member-settings-account-general-and-spec.md D3, C2, C5).
6
+ *
7
+ * `via` — whether the member API answers, read once per request from
8
+ * `member.pieces`, the cheapest member read:
9
+ *
10
+ * - `member` — any answer but a 404: a 2xx, any other refusal, a preflight
11
+ * refusal (status 0) or a throw. A refusal of another kind belongs to the
12
+ * write, which reports it honestly. A `503 tenant_unresolved` is
13
+ * `member` too: the writes answer the same 503, shown as unavailable.
14
+ * - on a 404, the sign-in seam (`signInMethod()`, D7) says who holds the
15
+ * account. A `site` sign-in gives `session`: this site's own sign-in
16
+ * holds it, and the 404 is a build with no tenancy module — every member
17
+ * read and write registers in one module block — so the name saves
18
+ * through Better Auth as before and deletion points at Support. A `hub`
19
+ * sign-in gives `none`: the hub holds the account and answers no member
20
+ * route for this spoke, and the spoke has no `/api/auth` of its own, so
21
+ * no write can reach the account from here.
22
+ *
23
+ * A 404 is a module-presence test, as `read()` in `lib/account-config.ts`
24
+ * already makes; this file names no topology constant itself.
25
+ *
26
+ * `image` — the session's photo, which Better Auth's session carries at run
27
+ * time though the generated `SiteSession` type does not declare it. A hub
28
+ * session carries none (C5), so on a spoke the form starts empty.
29
+ */
30
+
31
+ import { member } from "@working-theory/api-client";
32
+ import type { AccountContext } from "@working-theory/templates/account";
33
+ import { headers } from "next/headers";
34
+ import { cache } from "react";
35
+
36
+ import { readSession } from "../../../../../lib/topology.generated";
37
+ import { signInMethod } from "../sign-in-method";
38
+
39
+ export interface ProfileSource {
40
+ via: "member" | "session" | "none";
41
+ image: string | null;
42
+ }
43
+
44
+ /** `user.image` as a trimmed non-blank string, else `null`. */
45
+ function sessionImage(session: unknown): string | null {
46
+ if (typeof session !== "object" || session === null) return null;
47
+ const user = (session as { user?: unknown }).user;
48
+ if (typeof user !== "object" || user === null) return null;
49
+ const image = (user as { image?: unknown }).image;
50
+ return typeof image === "string" && image.trim() !== "" ? image.trim() : null;
51
+ }
52
+
53
+ async function memberApiAnswers(ctx: AccountContext): Promise<boolean> {
54
+ try {
55
+ const res = await member.pieces(ctx.init);
56
+ return res.ok || res.status !== 404;
57
+ } catch {
58
+ return true;
59
+ }
60
+ }
61
+
62
+ export const loadProfileSource = cache(async (ctx: AccountContext): Promise<ProfileSource> => {
63
+ // The photo is optional: a failed session read starts the field empty
64
+ // rather than failing the page.
65
+ const [answers, session] = await Promise.all([
66
+ memberApiAnswers(ctx),
67
+ headers()
68
+ .then((h) => readSession(h))
69
+ .catch(() => null),
70
+ ]);
71
+ const via = answers ? "member" : signInMethod().kind === "site" ? "session" : "none";
72
+ return { via, image: sessionImage(session) };
73
+ });
@@ -12,7 +12,9 @@
12
12
  * `forwardedCookieInit()` and streams the bytes back. The api-client's
13
13
  * `member.messageFile` is the one external boundary mocked; the seal, the
14
14
  * cookie read and the route's decisions are real. Covers:
15
- * - a 404 unless the web seam's `SIGN_IN_PROVIDER` is `"hub"`, no hub call;
15
+ * - on a single site (`SIGN_IN_PROVIDER` `"site"`) the read carries the
16
+ * caller's session cookie to the site's own API, and serves the same
17
+ * way (#8231 D9) — the route itself tests no topology;
16
18
  * - the read carries the signed-in caller's own credential, and the bytes
17
19
  * come back with all five hub headers unchanged;
18
20
  * - a hub answer missing any of the five is refused 502 — never bytes on
@@ -36,10 +38,11 @@ jest.mock("../../../../../../../../../lib/topology.generated", () => ({
36
38
  }));
37
39
 
38
40
  const mockHeaders = jest.fn();
41
+ const mockCookies = jest.fn();
39
42
  jest.mock("next/headers", () => ({
40
43
  __esModule: true,
41
44
  headers: (...args: unknown[]) => mockHeaders(...args),
42
- cookies: jest.fn().mockResolvedValue({ toString: () => "" }),
45
+ cookies: (...args: unknown[]) => mockCookies(...args),
43
46
  }));
44
47
 
45
48
  import { seal } from "../../../../../../../../../lib/hub-session/seal";
@@ -102,6 +105,7 @@ beforeEach(() => {
102
105
  };
103
106
  mockProvider.value = "hub";
104
107
  mockHeaders.mockReset().mockResolvedValue(new Headers());
108
+ mockCookies.mockReset().mockResolvedValue({ toString: () => "" });
105
109
  mockMessageFile.mockReset().mockResolvedValue(fileResult());
106
110
  });
107
111
 
@@ -194,12 +198,18 @@ describe("the member file read on a spoke that signs in through its hub", () =>
194
198
  });
195
199
  });
196
200
 
197
- describe("on a site that does not sign in through a hub", () => {
198
- it.each(["site", "none"] as const)("SIGN_IN_PROVIDER %s answers 404 and calls nothing", async (provider) => {
199
- mockProvider.value = provider;
200
- signedIn();
201
+ describe("on a single site — its own sign-in (#8231 D9)", () => {
202
+ it("reads the file with the caller's session cookie and serves it with all five headers unchanged", async () => {
203
+ mockProvider.value = "site";
204
+ mockCookies.mockResolvedValue({ toString: () => "session=abc" });
201
205
  const res = await get();
202
- expect(res.status).toBe(404);
203
- expect(mockMessageFile).not.toHaveBeenCalled();
206
+ expect(mockMessageFile).toHaveBeenCalledWith(ID, { headers: { cookie: "session=abc" } });
207
+ expect(res.status).toBe(200);
208
+ expect(res.headers.get("content-type")).toBe(FILE_HEADERS.contentType);
209
+ expect(res.headers.get("content-disposition")).toBe(FILE_HEADERS.contentDisposition);
210
+ expect(res.headers.get("x-content-type-options")).toBe(FILE_HEADERS.contentTypeOptions);
211
+ expect(res.headers.get("content-security-policy")).toBe(FILE_HEADERS.contentSecurityPolicy);
212
+ expect(res.headers.get("cache-control")).toBe(FILE_HEADERS.cacheControl);
213
+ await expect(res.text()).resolves.toBe("%PDF-1.7 bytes");
204
214
  });
205
215
  });
@@ -3,8 +3,8 @@
3
3
  *
4
4
  * A message's file action (the `message` Inbox kind, `lib/account-config.ts`)
5
5
  * links here. The browser holds neither the client's hub credential (sealed in
6
- * the `hub-session` cookie) nor this site's spoke key, so this route reads the
7
- * hub's `GET /v1/member/messages/:id/file` with `forwardedCookieInit()` — the
6
+ * the `hub-session` cookie) nor this site's spoke key, so this route reads
7
+ * `GET /v1/member/messages/:id/file` with `forwardedCookieInit()` — the
8
8
  * caller's own credential, so a client only ever gets their own file — and
9
9
  * streams the bytes back unread.
10
10
  *
@@ -16,8 +16,8 @@
16
16
  * A hub answer missing any of the five is refused 502 rather than served
17
17
  * without it.
18
18
  *
19
- * Only a spoke that signs in through its hub (`SIGN_IN_PROVIDER === "hub"`,
20
- * #7894) carries the credential; anywhere else this is a 404 with no call.
19
+ * On a single site the read carries the caller's session cookie; on a spoke
20
+ * that signs in through its hub, the client's hub credential (#8231 D9).
21
21
  * A refusal has no body: no credential (signed out) is a 401, a hub 4xx keeps
22
22
  * its status, and a hub 5xx, a transport the api-client refuses or a hub call
23
23
  * that throws is a 502.
@@ -25,7 +25,6 @@
25
25
  import { member, type MemberFileHeaders } from "@working-theory/api-client";
26
26
 
27
27
  import { forwardedCookieInit } from "../../../../../../../../lib/server-api";
28
- import { SIGN_IN_PROVIDER } from "../../../../../../../../lib/topology.generated";
29
28
 
30
29
  interface Context {
31
30
  params: Promise<{ locale: string; id: string }>;
@@ -55,7 +54,6 @@ function refusalStatus(status: number, body: unknown): number {
55
54
 
56
55
  // platform-decisions-waiver: api-versioning — a page-scoped download link on the site's own account area, not an API contract; it re-serves the versioned hub route /v1/member/messages/:id/file
57
56
  export async function GET(_request: Request, { params }: Context): Promise<Response> {
58
- if (SIGN_IN_PROVIDER !== "hub") return refusal(404);
59
57
  const { id } = await params;
60
58
 
61
59
  // The api-client throws, rather than answering `{ ok: false }`, on a
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * RSC. Every `/account` route renders inside `SignedInShell` (through the
6
6
  * client `AccountFrame`): the site's logo as the rail's top tile, linking
7
- * home; Inbox as the rail's only row; a footer of the avatar and ? alone;
7
+ * home; the rail's rows — Inbox, a Library group holding Artifacts, then
8
+ * Settings (#8231 D1); a footer of the avatar and ? alone;
8
9
  * the card avatar menu (name, then Settings and Log out); the ? help menu
9
10
  * (Contact support, and Help center when `AILK_HELP_CENTER_URL` is set);
10
11
  * and below `md` a tab bar reading avatar · Inbox · For you · Help (#7469).
@@ -69,6 +70,8 @@ export default async function AccountLayout({
69
70
  inbox: t("frame.inbox"),
70
71
  forYou: t("frame.forYou"),
71
72
  settings: t("frame.settings"),
73
+ library: t("frame.library"),
74
+ artifacts: t("frame.artifacts"),
72
75
  signOut: t("frame.signOut"),
73
76
  help: t("frame.help"),
74
77
  accountMenu: t("frame.accountMenu"),
@@ -0,0 +1,219 @@
1
+ "use server";
2
+
3
+ /**
4
+ * account/new-actions.ts — the New menu's reads and writes (#8233, spec
5
+ * docs/specs/8233-member-new-menu-the-new-spec.md D5, D6; the writes are
6
+ * #8170's).
7
+ *
8
+ * A popup reads its data when it opens (D5) and posts through a write here:
9
+ *
10
+ * - `readMeetingTypes` — `member.meetingTypes`, the types the provider
11
+ * offers its clients.
12
+ * - `readSlots` — `member.availability` for one type, from now to
13
+ * `SLOT_WINDOW_DAYS` later. The range is computed here; the browser
14
+ * sends only the slug and its time zone.
15
+ * - `readRequestForm` — `member.requestForm`, the request-quote magnet's
16
+ * current steps, or `form: null` while Request quote is switched off.
17
+ * - `sendMessage` · `requestQuote` · `bookService` — `member.sendMessage`,
18
+ * `member.requestQuote` and `member.book`.
19
+ *
20
+ * Every call sends `forwardedCookieInit()`'s init as built: on a single site
21
+ * the session cookie plus the request's `Origin` (`apps/api`'s cookie CSRF
22
+ * check), on a hub-mode spoke the hub credential — so nothing here tests the
23
+ * topology, and no identity is posted: the server takes the name and the
24
+ * verified email from the credential (8170 D7).
25
+ *
26
+ * A value is sent exactly as typed — never trimmed, never given a scheme: the
27
+ * server's `plainText` and `isHttpsLink` are the one rule. An optional value
28
+ * left empty or all-space is omitted, because the server refuses `""`. A
29
+ * refusal maps to its `error.code` when a popup has words for it, else to
30
+ * `failed`.
31
+ *
32
+ * Not a `*.actions.ts` file and no `*Action`-suffixed export: apps/mcp's
33
+ * parity test reads that suffix, and these member writes have no MCP twin by
34
+ * decision (8170's header). Logs carry the function, the status and the code
35
+ * only — never a typed value or a credential.
36
+ */
37
+
38
+ import { member } from "@working-theory/api-client";
39
+ import type {
40
+ MemberMeetingType,
41
+ MemberRequestFormResponse,
42
+ SchedulingSlot,
43
+ } from "@working-theory/validation";
44
+
45
+ import { forwardedCookieInit } from "../../../../lib/server-api";
46
+
47
+ /** How far ahead Schedule service offers times (the read's own cap is 60 days). */
48
+ const SLOT_WINDOW_DAYS = 14;
49
+ const DAY_MS = 24 * 60 * 60 * 1000;
50
+
51
+ /** The refusal codes a popup has its own words for; anything else is `failed`. */
52
+ const KNOWN_CODES = [
53
+ "link_not_https",
54
+ "answers_invalid",
55
+ "request_form_off",
56
+ "meeting_type_not_offered",
57
+ "slot_unavailable",
58
+ "too_many_requests",
59
+ "invalid_input",
60
+ ] as const;
61
+
62
+ export type NewActionErrorCode = (typeof KNOWN_CODES)[number] | "failed";
63
+
64
+ /** What a popup's form shows after a submit. */
65
+ export type NewActionState =
66
+ | { kind: "idle" }
67
+ | { kind: "success" }
68
+ | { kind: "error"; code: NewActionErrorCode };
69
+
70
+ export type NewReadResult<T> = ({ kind: "ok" } & T) | { kind: "error" };
71
+
72
+ /** Structural mirror of `@working-theory/api-client`'s `ApiResult<T>` (not re-exported). */
73
+ type ApiResult<T> = { ok: true; body: T } | { ok: false; status: number; body: unknown };
74
+
75
+ /** PII-safe error identity for logs (the `checkout.actions.ts` idiom: `err.name` only). */
76
+ function errorName(err: unknown): string {
77
+ return err instanceof Error ? err.name : "unknown";
78
+ }
79
+
80
+ /** The `error.code` of a member refusal body, when it has one. */
81
+ function errorCode(body: unknown): string | null {
82
+ if (typeof body !== "object" || body === null) return null;
83
+ const error = (body as { error?: unknown }).error;
84
+ if (typeof error !== "object" || error === null) return null;
85
+ const code = (error as { code?: unknown }).code;
86
+ return typeof code === "string" ? code : null;
87
+ }
88
+
89
+ function knownCode(code: string | null): NewActionErrorCode {
90
+ return KNOWN_CODES.find((known) => known === code) ?? "failed";
91
+ }
92
+
93
+ /** A posted value as typed; `undefined` when it is absent or not a string. */
94
+ function value(formData: FormData, name: string): string | undefined {
95
+ const entry = formData.get(name);
96
+ return typeof entry === "string" ? entry : undefined;
97
+ }
98
+
99
+ /** An optional posted value as typed, or `undefined` when it is empty or all-space. */
100
+ function optional(formData: FormData, name: string): string | undefined {
101
+ const entry = value(formData, name);
102
+ return entry !== undefined && entry.trim() !== "" ? entry : undefined;
103
+ }
104
+
105
+ async function runRead<T, R>(
106
+ name: string,
107
+ call: (init: { headers: Record<string, string> }) => Promise<ApiResult<T>>,
108
+ shape: (body: T) => R,
109
+ ): Promise<NewReadResult<R>> {
110
+ try {
111
+ const res = await call(await forwardedCookieInit());
112
+ if (res.ok) return { kind: "ok", ...shape(res.body) };
113
+ console.error(`[new-actions] ${name} status=${res.status} code=${errorCode(res.body) ?? "none"}`);
114
+ } catch (err) {
115
+ console.error(`[new-actions] ${name} errName=${errorName(err)}`);
116
+ }
117
+ return { kind: "error" };
118
+ }
119
+
120
+ /** Action input is untrusted: a hand-built call may post anything but FormData. */
121
+ function isFormData(input: unknown): input is FormData {
122
+ return input instanceof FormData;
123
+ }
124
+
125
+ async function runWrite(
126
+ name: string,
127
+ call: (init: { headers: Record<string, string> }) => Promise<ApiResult<unknown>>,
128
+ ): Promise<NewActionState> {
129
+ try {
130
+ const res = await call(await forwardedCookieInit());
131
+ if (res.ok) return { kind: "success" };
132
+ const code = knownCode(errorCode(res.body));
133
+ console.error(`[new-actions] ${name} status=${res.status} code=${code}`);
134
+ return { kind: "error", code };
135
+ } catch (err) {
136
+ console.error(`[new-actions] ${name} errName=${errorName(err)}`);
137
+ return { kind: "error", code: "failed" };
138
+ }
139
+ }
140
+
141
+ // ── Reads (D5) ───────────────────────────────────────────────────────────────
142
+
143
+ export async function readMeetingTypes(): Promise<NewReadResult<{ items: MemberMeetingType[] }>> {
144
+ return runRead("readMeetingTypes", (init) => member.meetingTypes(init), ({ items }) => ({ items }));
145
+ }
146
+
147
+ export async function readSlots(input: {
148
+ slug: string;
149
+ timeZone?: string;
150
+ }): Promise<NewReadResult<{ slots: SchedulingSlot[] }>> {
151
+ // Action input is untrusted: a hand-built call may send anything.
152
+ const { slug, timeZone } = (typeof input === "object" && input !== null ? input : {}) as {
153
+ slug?: unknown;
154
+ timeZone?: unknown;
155
+ };
156
+ if (typeof slug !== "string") return { kind: "error" };
157
+ const from = new Date();
158
+ const to = new Date(from.getTime() + SLOT_WINDOW_DAYS * DAY_MS);
159
+ const query = {
160
+ slug,
161
+ from: from.toISOString(),
162
+ to: to.toISOString(),
163
+ ...(typeof timeZone === "string" && timeZone !== "" ? { timeZone } : {}),
164
+ };
165
+ return runRead("readSlots", (init) => member.availability(query, init), ({ slots }) => ({ slots }));
166
+ }
167
+
168
+ export async function readRequestForm(): Promise<
169
+ NewReadResult<{ form: MemberRequestFormResponse["form"] }>
170
+ > {
171
+ return runRead("readRequestForm", (init) => member.requestForm(init), ({ form }) => ({ form }));
172
+ }
173
+
174
+ // ── Writes (D6) — `(prev, formData)` for `useActionState` ───────────────────
175
+
176
+ export async function sendMessage(
177
+ _prev: NewActionState,
178
+ formData: FormData,
179
+ ): Promise<NewActionState> {
180
+ if (!isFormData(formData)) return { kind: "error", code: "invalid_input" };
181
+ const link = optional(formData, "link");
182
+ const body = {
183
+ subject: value(formData, "subject") ?? "",
184
+ body: value(formData, "body") ?? "",
185
+ ...(link === undefined ? {} : { link }),
186
+ };
187
+ return runWrite("sendMessage", (init) => member.sendMessage(body, init));
188
+ }
189
+
190
+ /** The answers are the entries named `answer.<field name>`; a blank one is omitted. */
191
+ export async function requestQuote(
192
+ _prev: NewActionState,
193
+ formData: FormData,
194
+ ): Promise<NewActionState> {
195
+ if (!isFormData(formData)) return { kind: "error", code: "invalid_input" };
196
+ const answers: Record<string, string> = {};
197
+ for (const key of new Set(formData.keys())) {
198
+ if (!key.startsWith("answer.")) continue;
199
+ const answer = optional(formData, key);
200
+ if (answer !== undefined) answers[key.slice("answer.".length)] = answer;
201
+ }
202
+ return runWrite("requestQuote", (init) => member.requestQuote({ answers }, init));
203
+ }
204
+
205
+ export async function bookService(
206
+ _prev: NewActionState,
207
+ formData: FormData,
208
+ ): Promise<NewActionState> {
209
+ if (!isFormData(formData)) return { kind: "error", code: "invalid_input" };
210
+ const timeZone = optional(formData, "timeZone");
211
+ const notes = optional(formData, "notes");
212
+ const body = {
213
+ slug: value(formData, "slug") ?? "",
214
+ start: value(formData, "start") ?? "",
215
+ ...(timeZone === undefined ? {} : { timeZone }),
216
+ ...(notes === undefined ? {} : { notes }),
217
+ };
218
+ return runWrite("bookService", (init) => member.book(body, init));
219
+ }