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.
- package/LICENSE +6 -5
- package/NOTICE +2 -0
- package/dist/add-module.js +4 -1
- package/dist/category-profile.d.ts +10 -0
- package/dist/category-profile.js +21 -0
- package/dist/derive-architecture.d.ts +5 -1
- package/dist/derive-architecture.js +8 -3
- package/dist/lib/license-key-files.d.ts +6 -3
- package/dist/lib/license-key-files.js +8 -2
- package/dist/lib/scaffold-render-sweep.d.ts +9 -0
- package/dist/lib/scaffold-render-sweep.js +24 -3
- package/dist/license-set.d.ts +1 -1
- package/dist/license-set.js +13 -8
- package/dist/module-architecture.js +119 -0
- package/dist/programmatic.js +3 -0
- package/dist/spoke-checkout.d.ts +11 -3
- package/dist/spoke-checkout.js +62 -12
- package/dist/starter-grid.d.ts +1 -1
- package/dist/surfaces.js +140 -1
- package/dist/template-manifest.d.ts +4 -3
- package/dist/topology.d.ts +7 -2
- package/dist/topology.js +60 -3
- package/package.json +5 -4
- package/templates/.env.example +3 -3
- package/templates/.github/workflows/ci.yml +5 -0
- package/templates/apps/api/CLAUDE.md +25 -7
- package/templates/apps/api/LICENSE +6 -5
- package/templates/apps/api/NOTICE +2 -0
- package/templates/apps/api/jest.config.cjs +4 -29
- package/templates/apps/api/package.json +1 -0
- package/templates/apps/api/src/lib/__mocks__/prisma.ts +9 -0
- package/templates/apps/api/src/lib/__tests__/deliverable-asset-store.test.ts +38 -0
- package/templates/apps/api/src/lib/deliverable-asset-store.ts +5 -4
- package/templates/apps/api/src/lib/stripe.ts +81 -0
- package/templates/apps/api/src/middleware/__tests__/auth.hub-session-path.test.ts +266 -39
- package/templates/apps/api/src/middleware/auth.ts +206 -46
- package/templates/apps/api/src/routes/leads/index.ts +4 -0
- package/templates/apps/api/src/server.ts +107 -0
- package/templates/apps/api/src/services/__tests__/lead-routing.test.ts +58 -0
- package/templates/apps/api/src/services/lead-routing.ts +30 -2
- package/templates/apps/api/vercel.json +2 -2
- package/templates/apps/mcp/.env.example +10 -0
- package/templates/apps/mcp/CLAUDE.md +9 -4
- package/templates/apps/mcp/LICENSE +6 -5
- package/templates/apps/mcp/NOTICE +2 -0
- package/templates/apps/mcp/__tests__/abuse/guard.test.ts +198 -0
- package/templates/apps/mcp/__tests__/audit-single-page.test.ts +15 -3
- package/templates/apps/mcp/__tests__/boot.test.ts +78 -1
- package/templates/apps/mcp/__tests__/http-transport.test.ts +291 -2
- package/templates/apps/mcp/__tests__/request_callback.test.ts +69 -7
- package/templates/apps/mcp/__tests__/submit_lead.test.ts +69 -8
- package/templates/apps/mcp/__tests__/subscribe_newsletter.test.ts +69 -7
- package/templates/apps/mcp/jest.config.cjs +3 -44
- package/templates/apps/mcp/src/abuse/index.ts +117 -21
- package/templates/apps/mcp/src/boot.ts +5 -23
- package/templates/apps/mcp/src/http-transport.ts +93 -73
- package/templates/apps/mcp/src/index.ts +2 -0
- package/templates/apps/mcp/src/request-context.ts +52 -0
- package/templates/apps/mcp/src/tools/capture_lead.ts +9 -1
- package/templates/apps/mcp/src/tools/request_callback.ts +9 -1
- package/templates/apps/mcp/src/tools/subscribe_newsletter.ts +9 -1
- package/templates/apps/web/LICENSE +6 -5
- package/templates/apps/web/NOTICE +2 -0
- package/templates/apps/web/app/.well-known/ai-plugin.json/route.ts +3 -3
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/billing.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/general.test.tsx +568 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/hub-mode.test.tsx +272 -42
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/item.test.tsx +11 -13
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/layout.test.tsx +115 -16
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-menu.test.tsx +305 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/new-popups.test.tsx +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/notifications.test.tsx +126 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/page.test.tsx +163 -72
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/popups.test.tsx +451 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/security.test.tsx +139 -0
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/settings.test.tsx +20 -390
- package/templates/apps/web/app/[locale]/(authed)/account/__tests__/support.test.tsx +16 -11
- package/templates/apps/web/app/[locale]/(authed)/account/account-data.ts +1 -1
- package/templates/apps/web/app/[locale]/(authed)/account/account-frame.tsx +114 -50
- package/templates/apps/web/app/[locale]/(authed)/account/actions.ts +24 -45
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/page.tsx +253 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/review-form.tsx +145 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/[pieceId]/versions/[version]/file/route.ts +100 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts +179 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/page.test.tsx +514 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/review.test.tsx +288 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-actions.ts +123 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/artifact-data.ts +82 -0
- package/templates/apps/web/app/[locale]/(authed)/account/artifacts/page.tsx +177 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing/page.tsx +172 -0
- package/templates/apps/web/app/[locale]/(authed)/account/billing-source.ts +147 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/request-quote.tsx +229 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/schedule-service.tsx +276 -0
- package/templates/apps/web/app/[locale]/(authed)/account/components/send-message.tsx +128 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/billing-anchor.tsx +27 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/deletion-request.tsx +132 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/page.tsx +131 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-form.tsx +104 -0
- package/templates/apps/web/app/[locale]/(authed)/account/general/profile-source.ts +73 -0
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/__tests__/route.test.ts +18 -8
- package/templates/apps/web/app/[locale]/(authed)/account/inbox/message/[id]/file/route.ts +4 -6
- package/templates/apps/web/app/[locale]/(authed)/account/layout.tsx +4 -1
- package/templates/apps/web/app/[locale]/(authed)/account/new-actions.ts +219 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-menu.tsx +340 -0
- package/templates/apps/web/app/[locale]/(authed)/account/new-popups.tsx +107 -0
- package/templates/apps/web/app/[locale]/(authed)/account/notifications/page.tsx +57 -0
- package/templates/apps/web/app/[locale]/(authed)/account/page.tsx +85 -26
- package/templates/apps/web/app/[locale]/(authed)/account/profile-actions.ts +116 -0
- package/templates/apps/web/app/[locale]/(authed)/account/security/page.tsx +80 -0
- package/templates/apps/web/app/[locale]/(authed)/account/settings/page.tsx +13 -354
- package/templates/apps/web/app/[locale]/(authed)/account/settings-frame.tsx +108 -0
- package/templates/apps/web/app/[locale]/(authed)/account/sign-in-method.ts +37 -0
- package/templates/apps/web/app/[locale]/(authed)/account/support-actions.ts +9 -10
- package/templates/apps/web/app/[locale]/layout.tsx +23 -4
- package/templates/apps/web/app/api/auth/hub/__tests__/continuation.test.ts +143 -0
- package/templates/apps/web/app/api/auth/hub/authorize/__tests__/route.test.ts +62 -10
- package/templates/apps/web/app/api/auth/hub/authorize/route.ts +22 -7
- package/templates/apps/web/app/api/auth/hub/continuation.ts +73 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/__tests__/route.test.ts +188 -0
- package/templates/apps/web/app/api/auth/hub/continue/[ref]/route.ts +53 -0
- package/templates/apps/web/app/robots.txt/route.ts +10 -4
- package/templates/apps/web/auth.ts +5 -1
- package/templates/apps/web/lib/__tests__/account-config.test.ts +258 -46
- package/templates/apps/web/lib/account-config.ts +188 -52
- package/templates/apps/web/lib/hub-session/__tests__/hub-session.test.ts +143 -0
- package/templates/apps/web/lib/hub-session/index.ts +1 -0
- package/templates/apps/web/lib/hub-session/reseal.ts +90 -0
- package/templates/apps/web/lib/hub-session/seal.ts +13 -9
- package/templates/apps/web/lib/topology.generated.ts +24 -0
- package/templates/apps/web/messages/en.json +156 -35
- package/templates/database/CHANGELOG.md +21 -0
- package/templates/database/LICENSE +6 -5
- package/templates/database/NOTICE +2 -0
- package/templates/database/README.md +1 -1
- package/templates/database/__tests__/member-area.db.test.ts +15 -3
- package/templates/database/__tests__/member-contract-migration.test.ts +85 -0
- package/templates/database/__tests__/member-contract.db.test.ts +413 -0
- package/templates/database/__tests__/seed.test.ts +54 -2
- package/templates/database/inbox/schema.prisma +90 -15
- package/templates/database/migrations/20261012120000_member_contract/migration.sql +192 -0
- package/templates/database/migrations/20261013120000_lead_spoke_checkout/migration.sql +20 -0
- package/templates/database/package.json +3 -2
- package/templates/database/seed.ts +34 -2
- package/versions.json +36 -36
- package/templates/apps/api/src/__tests__/content-write-production-boot.test.ts +0 -104
- package/templates/apps/api/src/__tests__/cors.test.ts +0 -276
- package/templates/apps/api/src/__tests__/module-exclusion.test.ts +0 -279
- package/templates/apps/api/src/__tests__/openapi-keyless-hub.test.ts +0 -122
- package/templates/apps/api/src/__tests__/review-surface-registration.test.ts +0 -167
- package/templates/apps/api/src/__tests__/root-modules.test.ts +0 -175
- package/templates/apps/api/src/__tests__/schedule-forwarded-rate-limit.test.ts +0 -117
- package/templates/apps/api/src/__tests__/server.test.ts +0 -591
- package/templates/apps/api/src/__tests__/workspace-module-registration.test.ts +0 -175
- package/templates/apps/api/src/openapi/__tests__/surface-drift.test.ts +0 -293
- package/templates/apps/api/src/routes/leads/__tests__/index.test.ts +0 -1620
- package/templates/apps/api/src/routes/project-listings/__tests__/directory.test.ts +0 -528
- package/templates/apps/api/src/routes/workspaces/__tests__/inbox-wire-schemas.test.ts +0 -198
- package/templates/apps/api/src/services/__tests__/aeo-score.test.ts +0 -652
- package/templates/apps/api/src/services/__tests__/lead-promotion.test.ts +0 -408
- package/templates/apps/api/src/services/__tests__/leads-inbox.test.ts +0 -476
- package/templates/apps/mcp/__tests__/audit.test.ts +0 -346
- package/templates/apps/mcp/__tests__/parity.test.ts +0 -325
- package/templates/apps/mcp/__tests__/workspace-surface.test.ts +0 -364
- package/templates/apps/web/app/[locale]/(authed)/account/settings/color-mode.tsx +0 -68
- package/templates/apps/web/app/[locale]/(authed)/account/settings/deletion-request.tsx +0 -55
- package/templates/apps/web/app/[locale]/(authed)/account/settings/name-form.tsx +0 -118
- 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
|
+
}
|
package/templates/apps/web/app/[locale]/(authed)/account/artifacts/__tests__/file-route.test.ts
ADDED
|
@@ -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
|
+
});
|