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,145 @@
1
+ "use client"; // submits through the review Server Action and shows its outcome
2
+
3
+ /**
4
+ * ReviewForm — Approve or Request changes on an artifact's latest version
5
+ * (#8234, spec docs/specs/8234-member-artifacts-account-artifacts-and-spec.md
6
+ * D7). The artifact page renders it only while that version is reviewable
7
+ * (not approved, no review yet — `isReviewable`).
8
+ *
9
+ * The comment is controlled, so a refused review keeps what was typed
10
+ * (AC-MAA-07). A review that went through shows the sent line and refreshes
11
+ * the page once, which re-reads the artifact and shows Approved or the
12
+ * waiting line in the form's place. Every string arrives through `labels`.
13
+ */
14
+
15
+ import {
16
+ Button,
17
+ Heading,
18
+ InlineMessage,
19
+ Label,
20
+ Text,
21
+ Textarea,
22
+ } from "@working-theory/ui/primitives";
23
+ import { REVIEW_COMMENT_MAX } from "@working-theory/validation";
24
+ import { useRouter } from "next/navigation";
25
+ import {
26
+ useActionState,
27
+ useEffect,
28
+ useId,
29
+ useState,
30
+ type ReactElement,
31
+ } from "react";
32
+
33
+ import {
34
+ submitArtifactReview,
35
+ type ArtifactReviewReason,
36
+ type ArtifactReviewState,
37
+ } from "../artifact-actions";
38
+
39
+ export interface ReviewFormLabels {
40
+ heading: string;
41
+ comment: string;
42
+ commentHint: string;
43
+ approve: string;
44
+ requestChanges: string;
45
+ submitting: string;
46
+ sent: string;
47
+ errors: Record<ArtifactReviewReason, string>;
48
+ }
49
+
50
+ export function ReviewForm({
51
+ pieceId,
52
+ version,
53
+ labels,
54
+ }: {
55
+ pieceId: string;
56
+ version: number;
57
+ labels: ReviewFormLabels;
58
+ }): ReactElement {
59
+ const commentId = useId();
60
+ const hintId = useId();
61
+ const router = useRouter();
62
+ const [comment, setComment] = useState("");
63
+ const [state, formAction, pending] = useActionState<
64
+ ArtifactReviewState,
65
+ FormData
66
+ >(submitArtifactReview, { kind: "idle" });
67
+ const reviewed = state.kind === "reviewed";
68
+
69
+ // Once per sent review: the page re-reads and replaces this form.
70
+ useEffect(() => {
71
+ if (reviewed) router.refresh();
72
+ }, [reviewed, router]);
73
+
74
+ if (reviewed) {
75
+ return (
76
+ <InlineMessage
77
+ variant="success"
78
+ role="status"
79
+ className="mt-block-tight text-body-sm"
80
+ data-testid="artifact-review-sent"
81
+ >
82
+ {labels.sent}
83
+ </InlineMessage>
84
+ );
85
+ }
86
+
87
+ return (
88
+ <form
89
+ action={formAction}
90
+ className="mt-block-tight flex flex-col gap-4"
91
+ data-testid="artifact-review-form"
92
+ >
93
+ <Heading level={3} visual={4} className="!text-body font-semibold">
94
+ {labels.heading}
95
+ </Heading>
96
+ <input type="hidden" name="pieceId" value={pieceId} />
97
+ <input type="hidden" name="version" value={String(version)} />
98
+ <div className="flex flex-col gap-2">
99
+ <Label htmlFor={commentId}>{labels.comment}</Label>
100
+ <Textarea
101
+ id={commentId}
102
+ name="comment"
103
+ value={comment}
104
+ rows={4}
105
+ maxLength={REVIEW_COMMENT_MAX}
106
+ aria-describedby={hintId}
107
+ onChange={(event) => setComment(event.target.value)}
108
+ data-testid="artifact-review-comment"
109
+ />
110
+ <Text as="p" id={hintId} variant="caption" tone="muted">
111
+ {labels.commentHint}
112
+ </Text>
113
+ </div>
114
+ {state.kind === "error" ? (
115
+ <InlineMessage variant="error" data-testid="artifact-review-error">
116
+ {labels.errors[state.reason]}
117
+ </InlineMessage>
118
+ ) : null}
119
+ <div className="flex flex-wrap gap-2">
120
+ <Button
121
+ type="submit"
122
+ name="review"
123
+ value="approved"
124
+ disabled={pending}
125
+ // 44px on a phone (a touch-first context); the 36px default from md up.
126
+ className="max-md:min-h-11"
127
+ data-testid="artifact-review-approve"
128
+ >
129
+ {pending ? labels.submitting : labels.approve}
130
+ </Button>
131
+ <Button
132
+ type="submit"
133
+ name="review"
134
+ value="changes-requested"
135
+ variant="outline"
136
+ disabled={pending}
137
+ className="max-md:min-h-11"
138
+ data-testid="artifact-review-request-changes"
139
+ >
140
+ {labels.requestChanges}
141
+ </Button>
142
+ </div>
143
+ </form>
144
+ );
145
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * A delivered version's file, re-served (#8234, spec
3
+ * docs/specs/8234-member-artifacts-account-artifacts-and-spec.md D5) —
4
+ * `GET /[locale]/account/artifacts/:pieceId/versions/:version/file`.
5
+ *
6
+ * A file version's Download on the artifact page links here. The browser
7
+ * holds neither the client's hub credential nor this site's spoke key, so
8
+ * this route reads `GET /v1/member/artifacts/:pieceId/versions/:version/file`
9
+ * with `forwardedCookieInit()` — the caller's own credential, so a client
10
+ * only ever gets their own file — and streams the bytes back unread. It
11
+ * mirrors `inbox/message/[id]/file/route.ts`: an app route file is not a
12
+ * shared module, so the header map and the refusal mapping are copied.
13
+ *
14
+ * SEC-7877-D (the obligation in `@working-theory/api-client`'s `member.ts`):
15
+ * the bytes go out with all five hub headers UNCHANGED — `Content-Type`,
16
+ * `Content-Disposition` (attachment), `X-Content-Type-Options: nosniff`, the
17
+ * sandbox `Content-Security-Policy` and `Cache-Control: private, no-store` —
18
+ * so the file is never a document on this origin and never cached on the way.
19
+ * A hub answer missing any of the five is refused 502 rather than served
20
+ * without it.
21
+ *
22
+ * The params are shape-checked before any call (a malformed piece id or a
23
+ * version that is not a whole number from 1 is a 404 with no call). A
24
+ * refusal has no body: no credential (signed out) is a 401, a hub 4xx keeps
25
+ * its status, and a hub 5xx, a transport the api-client refuses or a hub call
26
+ * that throws is a 502.
27
+ */
28
+ import { member, type MemberFileHeaders } from "@working-theory/api-client";
29
+ import { memberArtifactFileParamsSchema } from "@working-theory/validation";
30
+
31
+ import { forwardedCookieInit } from "../../../../../../../../../lib/server-api";
32
+
33
+ interface Context {
34
+ params: Promise<{ locale: string; pieceId: string; version: string }>;
35
+ }
36
+
37
+ /** The response header each of the five hub headers goes out under. */
38
+ const FILE_HEADER_NAMES: Record<keyof MemberFileHeaders, string> = {
39
+ contentType: "Content-Type",
40
+ contentDisposition: "Content-Disposition",
41
+ contentTypeOptions: "X-Content-Type-Options",
42
+ contentSecurityPolicy: "Content-Security-Policy",
43
+ cacheControl: "Cache-Control",
44
+ };
45
+
46
+ function refusal(status: number): Response {
47
+ return new Response(null, {
48
+ status,
49
+ headers: { "Cache-Control": "private, no-store" },
50
+ });
51
+ }
52
+
53
+ /** The refusal's status, from the api-client's `{ ok: false, status, body }`. */
54
+ function refusalStatus(status: number, body: unknown): number {
55
+ if (status === 0) {
56
+ const code = (body as { error?: { code?: unknown } } | null)?.error?.code;
57
+ return code === "unauthorized" ? 401 : 502;
58
+ }
59
+ return status >= 500 ? 502 : status;
60
+ }
61
+
62
+ // 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/artifacts/:pieceId/versions/:version/file
63
+ export async function GET(
64
+ _request: Request,
65
+ { params }: Context,
66
+ ): Promise<Response> {
67
+ // Exactly the two segments the strict schema takes — `params` also carries `locale`.
68
+ const { pieceId, version } = await params;
69
+ const parsed = memberArtifactFileParamsSchema.safeParse({ pieceId, version });
70
+ if (!parsed.success) return refusal(404);
71
+
72
+ // The api-client throws, rather than answering `{ ok: false }`, on a
73
+ // network failure or a redirect — that is a 502 too, never Next's own 500.
74
+ let res: Awaited<ReturnType<typeof member.artifactFile>>;
75
+ try {
76
+ res = await member.artifactFile(
77
+ parsed.data.pieceId,
78
+ parsed.data.version,
79
+ await forwardedCookieInit(),
80
+ );
81
+ } catch {
82
+ return refusal(502);
83
+ }
84
+ if (!res.ok) return refusal(refusalStatus(res.status, res.body));
85
+
86
+ const { stream, headers } = res.body;
87
+ const out = new Headers();
88
+ for (const [key, name] of Object.entries(FILE_HEADER_NAMES) as [
89
+ keyof MemberFileHeaders,
90
+ string,
91
+ ][]) {
92
+ const value = headers[key];
93
+ if (value === null) {
94
+ await stream.cancel();
95
+ return refusal(502);
96
+ }
97
+ out.set(name, value);
98
+ }
99
+ return new Response(stream, { status: 200, headers: out });
100
+ }
@@ -0,0 +1,179 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+
5
+ /**
6
+ * A delivered version's file, re-served (#8234, spec 8234 D5, AC-3;
7
+ * SEC-7877-D) — `GET /[locale]/account/artifacts/:pieceId/versions/:version/file`.
8
+ *
9
+ * The api-client's `member.artifactFile` is the one external boundary
10
+ * mocked; `forwardedCookieInit()` and the route's decisions are real (the
11
+ * build's own sign-in, a single site: the caller's session cookie). Covers:
12
+ * - the read carries exactly the piece id, the version as a number and the
13
+ * caller's credential, and the bytes come back with all five hub headers
14
+ * unchanged;
15
+ * - a hub answer missing any of the five is refused 502, its stream
16
+ * cancelled — never bytes on this origin without them;
17
+ * - malformed params are a 404 with no call — and `locale`, which Next
18
+ * always passes, never makes a real request fail;
19
+ * - refusals map to a status with no body and `private, no-store`;
20
+ * - the route reads no topology constant.
21
+ */
22
+
23
+ import { readFileSync } from "node:fs";
24
+ import { join } from "node:path";
25
+
26
+ const mockArtifactFile = jest.fn();
27
+ jest.mock("@working-theory/api-client", () => ({
28
+ __esModule: true,
29
+ member: { artifactFile: (...args: unknown[]) => mockArtifactFile(...args) },
30
+ }));
31
+
32
+ const mockHeaders = jest.fn();
33
+ const mockCookies = jest.fn();
34
+ jest.mock("next/headers", () => ({
35
+ __esModule: true,
36
+ headers: (...args: unknown[]) => mockHeaders(...args),
37
+ cookies: (...args: unknown[]) => mockCookies(...args),
38
+ }));
39
+
40
+ import { GET } from "../[pieceId]/versions/[version]/file/route";
41
+
42
+ const SITE = "https://site.example.com";
43
+ const PIECE = "piece_01920000-0000-7000-8000-000000000001";
44
+ const ROUTE_FILE = join(
45
+ __dirname,
46
+ "..",
47
+ "[pieceId]",
48
+ "versions",
49
+ "[version]",
50
+ "file",
51
+ "route.ts",
52
+ );
53
+
54
+ const FILE_HEADERS = {
55
+ contentType: "application/pdf",
56
+ contentDisposition: 'attachment; filename="brief-v2.pdf"',
57
+ contentTypeOptions: "nosniff",
58
+ contentSecurityPolicy: "default-src 'none'; sandbox",
59
+ cacheControl: "private, no-store",
60
+ };
61
+
62
+ function fileResult(
63
+ headers: Partial<Record<keyof typeof FILE_HEADERS, string | null>> = {},
64
+ ) {
65
+ return {
66
+ ok: true,
67
+ body: {
68
+ stream: new Response("%PDF-1.7 bytes").body,
69
+ headers: { ...FILE_HEADERS, ...headers },
70
+ },
71
+ };
72
+ }
73
+
74
+ function get(pieceId = PIECE, version = "2"): Promise<Response> {
75
+ return GET(
76
+ new Request(
77
+ `${SITE}/account/artifacts/${pieceId}/versions/${version}/file`,
78
+ ),
79
+ {
80
+ params: Promise.resolve({ locale: "en", pieceId, version }),
81
+ },
82
+ );
83
+ }
84
+
85
+ async function expectBodylessRefusal(
86
+ res: Response,
87
+ status: number,
88
+ ): Promise<void> {
89
+ expect(res.status).toBe(status);
90
+ expect(res.headers.get("cache-control")).toBe("private, no-store");
91
+ expect(res.headers.get("content-disposition")).toBeNull();
92
+ await expect(res.text()).resolves.toBe("");
93
+ }
94
+
95
+ beforeEach(() => {
96
+ mockHeaders.mockReset().mockResolvedValue(new Headers());
97
+ mockCookies.mockReset().mockResolvedValue({ toString: () => "session=abc" });
98
+ mockArtifactFile.mockReset().mockResolvedValue(fileResult());
99
+ });
100
+
101
+ describe("the version file read", () => {
102
+ it("reads the file with the piece id, the version as a number and the caller's credential, and re-serves it with all five headers unchanged", async () => {
103
+ const res = await get();
104
+ expect(mockArtifactFile).toHaveBeenCalledTimes(1);
105
+ expect(mockArtifactFile).toHaveBeenCalledWith(PIECE, 2, {
106
+ headers: { cookie: "session=abc" },
107
+ });
108
+ expect(res.status).toBe(200);
109
+ expect(res.headers.get("content-type")).toBe(FILE_HEADERS.contentType);
110
+ expect(res.headers.get("content-disposition")).toBe(
111
+ FILE_HEADERS.contentDisposition,
112
+ );
113
+ expect(res.headers.get("x-content-type-options")).toBe(
114
+ FILE_HEADERS.contentTypeOptions,
115
+ );
116
+ expect(res.headers.get("content-security-policy")).toBe(
117
+ FILE_HEADERS.contentSecurityPolicy,
118
+ );
119
+ expect(res.headers.get("cache-control")).toBe(FILE_HEADERS.cacheControl);
120
+ await expect(res.text()).resolves.toBe("%PDF-1.7 bytes");
121
+ });
122
+
123
+ it("forwards the request's Origin with the cookie, as forwardedCookieInit builds it", async () => {
124
+ mockHeaders.mockResolvedValue(new Headers({ origin: SITE }));
125
+ await get();
126
+ expect(mockArtifactFile).toHaveBeenCalledWith(PIECE, 2, {
127
+ headers: { cookie: "session=abc", origin: SITE },
128
+ });
129
+ });
130
+
131
+ it.each(Object.keys(FILE_HEADERS) as (keyof typeof FILE_HEADERS)[])(
132
+ "refuses 502 with no bytes when the hub's answer lacks %s",
133
+ async (name) => {
134
+ const result = fileResult({ [name]: null });
135
+ const cancel = jest.spyOn(result.body.stream as ReadableStream, "cancel");
136
+ mockArtifactFile.mockResolvedValue(result);
137
+ const res = await get();
138
+ expect(cancel).toHaveBeenCalled();
139
+ await expectBodylessRefusal(res, 502);
140
+ },
141
+ );
142
+
143
+ it.each([
144
+ ["a version of 0", PIECE, "0"],
145
+ ["a version that is not a number", PIECE, "abc"],
146
+ ["a fractional version", PIECE, "1.5"],
147
+ ["a malformed piece id", "x", "2"],
148
+ ])("%s is a 404 with no call", async (_case, pieceId, version) => {
149
+ const res = await get(pieceId, version);
150
+ expect(mockArtifactFile).not.toHaveBeenCalled();
151
+ await expectBodylessRefusal(res, 404);
152
+ });
153
+
154
+ it.each([
155
+ ["a status-0 unauthorized (signed out)", 0, "unauthorized", 401],
156
+ ["a status-0 insecure_transport", 0, "insecure_transport", 502],
157
+ ["a hub 404", 404, "not_found", 404],
158
+ ["a hub 403", 403, "forbidden", 403],
159
+ ["a hub 500", 500, "internal", 502],
160
+ ])("%s answers %#", async (_case, hubStatus, code, status) => {
161
+ mockArtifactFile.mockResolvedValue({
162
+ ok: false,
163
+ status: hubStatus,
164
+ body: { ok: false, error: { code, message: "x" } },
165
+ });
166
+ await expectBodylessRefusal(await get(), status);
167
+ });
168
+
169
+ it("a hub call that throws answers 502 with no body", async () => {
170
+ mockArtifactFile.mockRejectedValue(new TypeError("fetch failed"));
171
+ await expectBodylessRefusal(await get(), 502);
172
+ });
173
+
174
+ it("reads no topology constant", () => {
175
+ expect(readFileSync(ROUTE_FILE, "utf8")).not.toContain(
176
+ "topology.generated",
177
+ );
178
+ });
179
+ });