@vincentt-xr/cli 0.7.0 → 0.9.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 (49) hide show
  1. package/dist/cli/args.d.ts +18 -1
  2. package/dist/cli/args.js +40 -1
  3. package/dist/cli/index.js +208 -10
  4. package/dist/dx/command.d.ts +59 -2
  5. package/dist/dx/command.js +150 -11
  6. package/dist/dx/enablement.d.ts +51 -0
  7. package/dist/dx/enablement.js +97 -0
  8. package/dist/dx/qa.f7.fixtures.d.ts +73 -0
  9. package/dist/dx/qa.f7.fixtures.js +58 -0
  10. package/dist/dx/skeleton.js +1 -0
  11. package/dist/lib/initCommand.d.ts +28 -1
  12. package/dist/lib/initCommand.js +91 -16
  13. package/dist/lib/initFrom.d.ts +48 -0
  14. package/dist/lib/initFrom.js +198 -0
  15. package/dist/lib/sourceRef.d.ts +46 -0
  16. package/dist/lib/sourceRef.js +107 -0
  17. package/dist/lib/sourceRefCopy.d.ts +11 -0
  18. package/dist/lib/sourceRefCopy.js +80 -0
  19. package/dist/lib/version.d.ts +20 -1
  20. package/dist/lib/version.js +41 -5
  21. package/dist/library/assetsCommand.d.ts +32 -0
  22. package/dist/library/assetsCommand.js +264 -0
  23. package/dist/library/client.d.ts +42 -0
  24. package/dist/library/client.js +137 -0
  25. package/dist/library/download.d.ts +46 -0
  26. package/dist/library/download.js +98 -0
  27. package/dist/library/parse.d.ts +43 -0
  28. package/dist/library/parse.js +109 -0
  29. package/dist/library/qa.f5-b32.spec.d.ts +1 -0
  30. package/dist/library/qa.f5-b32.spec.js +202 -0
  31. package/dist/library/qa.f5-cli.spec.d.ts +1 -0
  32. package/dist/library/qa.f5-cli.spec.js +433 -0
  33. package/dist/library/qa.f5-contract.spec.d.ts +1 -0
  34. package/dist/library/qa.f5-contract.spec.js +206 -0
  35. package/dist/library/render.d.ts +39 -0
  36. package/dist/library/render.js +305 -0
  37. package/dist/library/wire.d.ts +96 -0
  38. package/dist/library/wire.js +167 -0
  39. package/dist/publish/api.d.ts +20 -0
  40. package/dist/publish/command.d.ts +3 -0
  41. package/dist/publish/command.js +39 -4
  42. package/dist/publish/copy.d.ts +29 -0
  43. package/dist/publish/copy.js +86 -0
  44. package/dist/publish/prompt.js +17 -2
  45. package/dist/scaffold/index.d.ts +9 -0
  46. package/dist/scaffold/index.js +13 -1
  47. package/dist/shared/config.d.ts +27 -0
  48. package/dist/shared/config.js +64 -9
  49. package/package.json +1 -1
@@ -0,0 +1,98 @@
1
+ // `--download` — the NON-DEFAULT opt-in. `add` prints URLs and writes nothing by
2
+ // default (D-add-prints-urls); this path exists for offline demos and for a creator
3
+ // who wants the byte in their repo to edit.
4
+ //
5
+ // The filename is composed from `subject` + `id`, both of which the corpus schema
6
+ // validated server-side, and NEVER from a wire string like `url` or `key`. That is
7
+ // what makes path traversal on the derived filename impossible rather than merely
8
+ // unlikely — but the composition is defended here too, because "the server
9
+ // validated it" is a property of a system the CLI is versioned separately from.
10
+ import { promises as fs } from "node:fs";
11
+ import path from "node:path";
12
+ import { isLibraryId } from "./wire.js";
13
+ /** Where downloads land, relative to the project root. */
14
+ export const DOWNLOAD_DIR = path.join("public", "assets", "library");
15
+ /**
16
+ * Slugify a subject for use in a filename.
17
+ *
18
+ * Everything outside [a-z0-9-] is dropped rather than escaped, which removes `/`,
19
+ * `\`, `.` and `%` as a class instead of enumerating them. A subject that reduces
20
+ * to nothing yields "asset", so the filename never starts with the separator or
21
+ * collapses into the extension.
22
+ */
23
+ export function safeSubject(subject) {
24
+ const s = (subject ?? "")
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9]+/g, "-")
27
+ .replace(/^-+|-+$/g, "")
28
+ .slice(0, 40)
29
+ .replace(/-+$/g, "");
30
+ return s.length > 0 ? s : "asset";
31
+ }
32
+ /** The extension, from `format` — admitted only as png/webp, else png. */
33
+ export function safeExt(format) {
34
+ return format === "webp" ? "webp" : "png";
35
+ }
36
+ /**
37
+ * `<subject>-<id>.<ext>`, e.g. `pumpkin-lib_7f3a2c67d4e5b6a7.png`.
38
+ *
39
+ * Throws when `id` is not id-shaped. The id is the one component that could carry
40
+ * a separator if a compromised or drifted API returned something unexpected, and a
41
+ * throw here is strictly better than composing a path from it: the id has ALREADY
42
+ * been validated against the same regex on the way in, so a failure means the wire
43
+ * disagrees with the contract and writing anything at that point is guesswork.
44
+ */
45
+ export function downloadFilename(a) {
46
+ if (!isLibraryId(a.id)) {
47
+ throw new Error(`refusing to derive a filename from a non-id: ${a.id}`);
48
+ }
49
+ return `${safeSubject(a.subject)}-${a.id}.${safeExt(a.format)}`;
50
+ }
51
+ /**
52
+ * The absolute path a record downloads to, under `dir`.
53
+ *
54
+ * The join is re-checked against the resolved base: even with a validated filename,
55
+ * asserting containment is one line and it is the assertion that stays true if
56
+ * someone later relaxes downloadFilename.
57
+ */
58
+ export function downloadPath(baseDir, a) {
59
+ const base = path.resolve(baseDir);
60
+ const full = path.resolve(base, downloadFilename(a));
61
+ if (full !== path.join(base, path.basename(full))) {
62
+ throw new Error(`refusing to write outside ${base}`);
63
+ }
64
+ return full;
65
+ }
66
+ /** Does the file already exist? (Used for the refuse-to-overwrite gate.) */
67
+ export async function exists(p) {
68
+ try {
69
+ await fs.stat(p);
70
+ return true;
71
+ }
72
+ catch {
73
+ return false;
74
+ }
75
+ }
76
+ export async function writeAsset(p, bytes) {
77
+ await fs.mkdir(path.dirname(p), { recursive: true });
78
+ await fs.writeFile(p, bytes);
79
+ return { path: p, bytes: bytes.byteLength };
80
+ }
81
+ /**
82
+ * The overwrite refusal names the INTERESTING reason, not "file exists": B37 means
83
+ * the bytes at a library URL never change, so the copy on disk is already identical
84
+ * and re-downloading achieves nothing. That teaches immutability in passing and is
85
+ * more useful than a bare flag suggestion.
86
+ */
87
+ export function overwriteRefusalLines(relPath) {
88
+ return [
89
+ "",
90
+ `✗ ${relPath} already exists.`,
91
+ "",
92
+ " The library never changes a file at a URL, so the copy you have is the",
93
+ " same bytes. Pass --force to overwrite it anyway.",
94
+ "",
95
+ " Nothing was written.",
96
+ "",
97
+ ];
98
+ }
@@ -0,0 +1,43 @@
1
+ /** An argument that is unmistakably a URL rather than an id. */
2
+ export declare function looksLikeUrl(arg: string): boolean;
3
+ export interface IdArgsOk {
4
+ ok: true;
5
+ /** The ids AS GIVEN, duplicates intact — the server charges pre-dedup. */
6
+ ids: string[];
7
+ }
8
+ export interface IdArgsError {
9
+ ok: false;
10
+ lines: string[];
11
+ }
12
+ /**
13
+ * Validate the positional ids for `show` / `add`.
14
+ *
15
+ * A URL is a USAGE error (exit 1, zero network calls). A merely malformed id
16
+ * (`pastel-pumpkin.png`) is NOT: it goes to the server and comes back in
17
+ * `unknown[]`, so the other ids in the same call still resolve. Those two cases
18
+ * look similar and are deliberately different — one is a shape the CLI must never
19
+ * transmit, the other is a lookup that must not destroy its siblings.
20
+ */
21
+ export declare function parseIdArgs(args: string[]): IdArgsOk | IdArgsError;
22
+ /** De-duplicate for DISPLAY, preserving first-occurrence order. */
23
+ export declare function dedupeForDisplay<T>(items: T[], key: (t: T) => string): T[];
24
+ export interface SearchFlags {
25
+ kind?: string;
26
+ render?: string;
27
+ palette?: string;
28
+ limit?: number;
29
+ }
30
+ export type SearchFlagResult = {
31
+ ok: true;
32
+ flags: SearchFlags;
33
+ } | {
34
+ ok: false;
35
+ lines: string[];
36
+ };
37
+ /** `--limit` is clamped by the SERVER at 50; the CLI only rejects nonsense. */
38
+ export declare function parseSearchFlags(raw: {
39
+ kind?: string;
40
+ render?: string;
41
+ palette?: string;
42
+ limit?: string;
43
+ }): SearchFlagResult;
@@ -0,0 +1,109 @@
1
+ // Argument parsing for the `assets` verb group.
2
+ //
3
+ // THE CONTROL LIVES HERE. Every f5 verb accepts ids and only ids: there is no
4
+ // `--url` flag anywhere in the CLI, and an argument that looks like a URL is
5
+ // refused by the PARSER, before any network call is made. Under
6
+ // D-add-prints-urls the agent handles URLs as strings by design, so a parser that
7
+ // refuses one is the only thing standing between a hallucinated URL and a fetch.
8
+ // The AGENTS.md prose is advice; this function is the control.
9
+ /** An argument that is unmistakably a URL rather than an id. */
10
+ export function looksLikeUrl(arg) {
11
+ // Scheme-ful and scheme-relative forms both count. This is deliberately broad:
12
+ // a false positive costs one clear error message, while a false negative puts a
13
+ // caller-supplied string on the path to a fetch.
14
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(arg) || arg.startsWith("//");
15
+ }
16
+ /**
17
+ * Validate the positional ids for `show` / `add`.
18
+ *
19
+ * A URL is a USAGE error (exit 1, zero network calls). A merely malformed id
20
+ * (`pastel-pumpkin.png`) is NOT: it goes to the server and comes back in
21
+ * `unknown[]`, so the other ids in the same call still resolve. Those two cases
22
+ * look similar and are deliberately different — one is a shape the CLI must never
23
+ * transmit, the other is a lookup that must not destroy its siblings.
24
+ */
25
+ export function parseIdArgs(args) {
26
+ if (args.length === 0) {
27
+ return {
28
+ ok: false,
29
+ lines: [
30
+ "",
31
+ "✗ No asset ids given.",
32
+ "",
33
+ " Ids look like lib_7f3a2c67d4e5b6a7. Search returns them; nothing else does.",
34
+ "",
35
+ ' Find one: vincentt assets search "<what you want>"',
36
+ "",
37
+ ],
38
+ };
39
+ }
40
+ const urls = args.filter(looksLikeUrl);
41
+ if (urls.length > 0) {
42
+ return {
43
+ ok: false,
44
+ lines: [
45
+ "",
46
+ "✗ This command takes asset ids, not URLs.",
47
+ "",
48
+ ` ${urls[0]}`,
49
+ "",
50
+ " Ids look like lib_7f3a2c67d4e5b6a7. Search returns them; nothing else does.",
51
+ "",
52
+ ' Find one: vincentt assets search "<what you want>"',
53
+ "",
54
+ ],
55
+ };
56
+ }
57
+ // Duplicates are NOT trimmed here. The CLI de-duplicates for DISPLAY only,
58
+ // because the server charges pre-dedup and pre-trimming would hide that cost
59
+ // from the operator.
60
+ return { ok: true, ids: args };
61
+ }
62
+ /** De-duplicate for DISPLAY, preserving first-occurrence order. */
63
+ export function dedupeForDisplay(items, key) {
64
+ const seen = new Set();
65
+ const out = [];
66
+ for (const item of items) {
67
+ const k = key(item);
68
+ if (seen.has(k))
69
+ continue;
70
+ seen.add(k);
71
+ out.push(item);
72
+ }
73
+ return out;
74
+ }
75
+ /** `--limit` is clamped by the SERVER at 50; the CLI only rejects nonsense. */
76
+ export function parseSearchFlags(raw) {
77
+ const flags = {};
78
+ if (raw.kind !== undefined) {
79
+ if (raw.kind !== "prop" && raw.kind !== "frame") {
80
+ return {
81
+ ok: false,
82
+ lines: [
83
+ "",
84
+ `✗ --kind takes prop or frame, not "${raw.kind}".`,
85
+ "",
86
+ " --kind prop objects to place in a scene",
87
+ " --kind frame full-screen camera overlays",
88
+ "",
89
+ ],
90
+ };
91
+ }
92
+ flags.kind = raw.kind;
93
+ }
94
+ if (raw.render !== undefined)
95
+ flags.render = raw.render;
96
+ if (raw.palette !== undefined)
97
+ flags.palette = raw.palette;
98
+ if (raw.limit !== undefined) {
99
+ const n = Number(raw.limit);
100
+ if (!Number.isInteger(n) || n < 1) {
101
+ return {
102
+ ok: false,
103
+ lines: ["", `✗ --limit takes a whole number, not "${raw.limit}".`, ""],
104
+ };
105
+ }
106
+ flags.limit = n;
107
+ }
108
+ return { ok: true, flags };
109
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,202 @@
1
+ // QA-F5-C3 · B36's narrowed boundary — zero `/library` requests, and the deny
2
+ // predicate is pinned.
3
+ //
4
+ // THIS IS THE CLAIM f5 IS JUDGED ON. B36: a project builds, previews and
5
+ // publishes with the library unreachable, and no platform capability works
6
+ // better because an asset came from us. The creator loses a catalogue, not a
7
+ // toolchain.
8
+ //
9
+ // dev's b32.test.ts owns layers 1 and 4 (the static import check and the
10
+ // predicate's four URLs) and says so. THIS FILE OWNS LAYERS 2 AND 3 — the live
11
+ // arms — plus the predicate's behavioral half.
12
+ //
13
+ // ⚠ THE NEGATIVE ASSERTION IS THE CASE. A publish that succeeds while the
14
+ // library is unreachable is ALSO what you observe if the call was attempted,
15
+ // timed out and was swallowed by a try/catch — which would make f5 a dependency
16
+ // WITH A TIMEOUT, silently adding seconds to every publish. Only "zero /library
17
+ // requests recorded" distinguishes NOT A DEPENDENCY from A DEPENDENCY THAT
18
+ // FAILS SOFT.
19
+ //
20
+ // ⚠ AND IT ONLY MEANS THAT BECAUSE THE API IS LIVE. Under a host-level deny
21
+ // everything fails, and the fixture cannot tell "publish never called f5" from
22
+ // "publish failed because nothing was reachable"
23
+ // (D-B36-fixture-denies-a-path-prefix-not-a-host).
24
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
25
+ import { httpLibraryClient, LibraryUnreachableError } from "./client.js";
26
+ function denyPath(prefix, host) {
27
+ const attempts = [];
28
+ const denied = [];
29
+ const realFetch = globalThis.fetch;
30
+ // TWO SEPARATE CONDITIONS on the PARSED url. Written this way deliberately —
31
+ // see the header, and layer 4 below, which is the case that fails when
32
+ // somebody "simplifies" it into `raw.includes(prefix)`.
33
+ const predicate = (u) => u.host === host && u.pathname.startsWith(prefix);
34
+ globalThis.fetch = (async (input, init) => {
35
+ const raw = typeof input === "string"
36
+ ? input
37
+ : input instanceof URL
38
+ ? input.href
39
+ : input.url;
40
+ const u = new URL(raw);
41
+ const attempt = { host: u.host, pathname: u.pathname };
42
+ attempts.push(attempt);
43
+ if (predicate(u)) {
44
+ denied.push(attempt);
45
+ // A refused connection, exactly as an unreachable host produces. The CLI
46
+ // must map this to LibraryUnreachableError → exit 4, never to an answer.
47
+ throw new TypeError("fetch failed");
48
+ }
49
+ return realFetch(input, init);
50
+ });
51
+ return {
52
+ attempts,
53
+ denied,
54
+ predicate,
55
+ restore: () => {
56
+ globalThis.fetch = realFetch;
57
+ },
58
+ };
59
+ }
60
+ // ---------------------------------------------------------------------------
61
+ // the live stack
62
+ // ---------------------------------------------------------------------------
63
+ //
64
+ // Inherited from dev, never built here. The endpoint comes from the environment
65
+ // so a port change is a config change rather than an edit to this file — the
66
+ // ports are DERIVED from the task slug, not chosen.
67
+ const API_URL = process.env.QA_F5_API_URL ?? "http://localhost:8088";
68
+ const API_HOST = new URL(API_URL).host;
69
+ let apiLive = false;
70
+ beforeEach(async () => {
71
+ if (apiLive)
72
+ return;
73
+ try {
74
+ const res = await fetch(`${API_URL}/health`, { signal: AbortSignal.timeout(2000) });
75
+ apiLive = res.ok;
76
+ }
77
+ catch {
78
+ apiLive = false;
79
+ }
80
+ });
81
+ let fixture;
82
+ afterEach(() => {
83
+ fixture?.restore();
84
+ fixture = undefined;
85
+ });
86
+ describe("QA-F5-C3 · B36 layer 4 — the deny predicate is /library-on-the-API-host only", () => {
87
+ it("(c) drives the predicate with four URLs, including the anti-drift pin", () => {
88
+ const f = denyPath("/library", "api.dev.vincentt.studio");
89
+ fixture = f;
90
+ const denied = f.predicate;
91
+ // 1. the f5 route on the API host — DENIED.
92
+ expect(denied(new URL("https://api.dev.vincentt.studio/library/assets"))).toBe(true);
93
+ expect(denied(new URL("https://api.dev.vincentt.studio/library/assets:batch"))).toBe(true);
94
+ // 2. every OTHER route on the SAME host — ALLOWED. f5 has no host of its
95
+ // own, so a host deny takes publish down with it.
96
+ expect(denied(new URL("https://api.dev.vincentt.studio/publishes"))).toBe(false);
97
+ expect(denied(new URL("https://api.dev.vincentt.studio/projects"))).toBe(false);
98
+ // 3. THE ANTI-DRIFT PIN. The assets CDN serves library/v1/, so its paths
99
+ // contain "/library" too. A predicate written as url.includes("/library")
100
+ // denies the CDN and asserts a property f5 NO LONGER HAS
101
+ // (D-add-prints-urls). This url is what fails when someone "simplifies"
102
+ // the predicate.
103
+ expect(denied(new URL("https://assets.dev.vincentt.studio/library/v1/props/x.png")), "the assets CDN was DENIED. Under D-add-prints-urls it is on every published viewer's " +
104
+ "critical path; denying it asserts f5-independence of a published experience, which is " +
105
+ "explicitly NOT a claim f5 makes any more.").toBe(false);
106
+ // 4. the CDN at any other path — ALLOWED.
107
+ expect(denied(new URL("https://assets.dev.vincentt.studio/anything"))).toBe(false);
108
+ // The weaker form, demonstrated failing, so it is not silently reintroduced.
109
+ const substring = (raw) => raw.includes("/library");
110
+ expect(substring("https://assets.dev.vincentt.studio/library/v1/props/x.png")).toBe(true);
111
+ expect(denied(new URL("https://assets.dev.vincentt.studio/library/v1/props/x.png"))).toBe(false);
112
+ });
113
+ });
114
+ // The live arms need a running API. They are opt-in, mirroring the Go 4E tier's
115
+ // QA_F5_RUN_SERVE_TIER: absent a stack they report BLOCKED rather than failing,
116
+ // because a red CI check for "no stack here" trains people to ignore the check.
117
+ // They must NEVER silently pass: B36's claim only means something against a
118
+ // REACHABLE route, or the fixture cannot tell "publish never called f5" from
119
+ // "publish failed because nothing was reachable".
120
+ const RUN_LIVE = process.env.QA_F5_RUN_LIVE === "1";
121
+ describe.skipIf(!RUN_LIVE)("QA-F5-C3 · B36 layers 2 and 3 — against the LIVE api", () => {
122
+ it("(b) an f5 call under the deny fixture is REFUSED and maps to exit 4, not to an answer", async () => {
123
+ if (!apiLive) {
124
+ throw new Error(`the api at ${API_URL} is not reachable, so QA-F5-C3's live arms cannot run.\n\n` +
125
+ `This case is BLOCKED, not passed. B36's assertion only means what it claims when ` +
126
+ `the API is LIVE: a stray f5 call must be a real request to a REACHABLE route, or ` +
127
+ `the fixture cannot tell "publish never called f5" from "publish failed because ` +
128
+ `nothing was reachable".\n\n` +
129
+ `Boot it: dev-stack.sh up (then re-run; override with QA_F5_API_URL)`);
130
+ }
131
+ const f = denyPath("/library", API_HOST);
132
+ fixture = f;
133
+ const client = httpLibraryClient({ apiUrl: API_URL, pat: "vct_qa_f5_not_a_real_token" });
134
+ let threw;
135
+ try {
136
+ await client.search({ q: "pastel pumpkin" });
137
+ }
138
+ catch (err) {
139
+ threw = err;
140
+ }
141
+ expect(threw, "the library search RESOLVED under a deny fixture. A denied request must surface as a " +
142
+ "transport failure, never as an answer.").toBeInstanceOf(LibraryUnreachableError);
143
+ // The attempt WAS made and WAS recorded — a fixture that recorded nothing
144
+ // would make every "zero attempts" assertion below vacuous.
145
+ expect(f.denied.length, "the deny fixture recorded ZERO denials. A fixture that never fires cannot prove an " +
146
+ "absence, and every assertion built on it passes for free.").toBeGreaterThan(0);
147
+ expect(f.denied[0].pathname.startsWith("/library")).toBe(true);
148
+ expect(f.denied[0].host).toBe(API_HOST);
149
+ });
150
+ it("(a) the NON-/library API surface is unaffected — publish's origin still answers", async () => {
151
+ if (!apiLive) {
152
+ throw new Error(`the api at ${API_URL} is not reachable; QA-F5-C3 is BLOCKED, not passed`);
153
+ }
154
+ const f = denyPath("/library", API_HOST);
155
+ fixture = f;
156
+ // The publish path's own origin, under the SAME fixture. This is what makes
157
+ // layer 2 STRONGER than a host deny: the chain runs against a LIVE, WORKING
158
+ // API, so a stray f5 call would be a real request to a reachable route
159
+ // rather than one failure among many.
160
+ const health = await fetch(`${API_URL}/health`);
161
+ expect(health.ok, "the API's own /health failed under the deny fixture. The fixture is denying more than " +
162
+ "the /library path prefix — probably the HOST — which takes publish down with it and " +
163
+ "makes the whole case fail for a reason unrelated to B36.").toBe(true);
164
+ const readyz = await fetch(`${API_URL}/readyz`);
165
+ expect([200, 503]).toContain(readyz.status);
166
+ // THE ASSERTION THAT ACTUALLY TESTS B36: across every non-f5 request made
167
+ // above, ZERO have a path beginning with /library.
168
+ const strays = f.attempts.filter((a) => a.host === API_HOST && a.pathname.startsWith("/library"));
169
+ expect(strays, `the non-f5 surface issued ${strays.length} request(s) under /library: ` +
170
+ `${JSON.stringify(strays)}.\n\n` +
171
+ `B36 says the library is not a dependency of the toolchain's core loop. A green ` +
172
+ `publish alone is ALSO what you observe if the call was attempted, timed out and was ` +
173
+ `swallowed — which would make f5 a dependency WITH A TIMEOUT, silently adding seconds ` +
174
+ `to every publish. Only the empty recorded list distinguishes the two.`).toEqual([]);
175
+ });
176
+ it("the f5 route EXISTS and is reachable when the fixture is NOT installed — the control", async () => {
177
+ if (!apiLive) {
178
+ throw new Error(`the api at ${API_URL} is not reachable; QA-F5-C3 is BLOCKED, not passed`);
179
+ }
180
+ // WITHOUT the fixture. If /library did not exist on this build, every
181
+ // "zero /library requests" assertion above would pass against a router that
182
+ // simply has no such route — certifying the boundary while proving nothing.
183
+ const res = await fetch(`${API_URL}/library/assets?q=pumpkin`);
184
+ expect(res.status, `GET /library/assets returned ${res.status}. A 404 means the route is NOT MOUNTED on ` +
185
+ `this build — which makes B36's negative assertion vacuous. The surface is gated on ` +
186
+ `LIBRARY_ASSETS_BASE_URL; unset, /library never mounts.`).not.toBe(404);
187
+ // 401 is the expected answer to an unauthenticated read: the route exists
188
+ // and refuses anonymous callers.
189
+ expect([200, 401, 403, 503]).toContain(res.status);
190
+ });
191
+ });
192
+ describe("QA-F5-C3 · live tier status", () => {
193
+ it("reports whether B36's live arms ran", () => {
194
+ if (!RUN_LIVE) {
195
+ console.warn("\nQA-F5-C3 live arms: BLOCKED (not passed, not failed).\n" +
196
+ " B36 layers 2 and 3 need a running API. Layer 4 (the deny predicate and the\n" +
197
+ " anti-drift pin) DID run and is green.\n" +
198
+ " To run them: dev-stack.sh up then QA_F5_RUN_LIVE=1 npm test\n");
199
+ }
200
+ expect(true).toBe(true);
201
+ });
202
+ });
@@ -0,0 +1 @@
1
+ export {};