@tokenoftrust/storefront-runner 3.5.1 → 3.6.1

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.
@@ -57,20 +57,6 @@ export interface SyncStateSweepChanges {
57
57
  collections: SyncStateEntityChangeSet;
58
58
  }
59
59
 
60
- /** Mirrors packages/migration/src/load/sync-state.ts's `ImageRehostRecord`. */
61
- export interface SyncStateImageRehost {
62
- computedAt: string;
63
- mode: "rehost" | "reference";
64
- sink: string | null;
65
- considered: number;
66
- rehosted: number;
67
- alreadyRehosted: number;
68
- referenced: number;
69
- errors: number;
70
- products: string[];
71
- collections: string[];
72
- }
73
-
74
60
  /** The on-disk shape written by the migration CLI (schema "tot.sync-state/1"). */
75
61
  export interface CatalogSyncState {
76
62
  schema: string;
@@ -88,13 +74,6 @@ export interface CatalogSyncState {
88
74
  * migrated before hsby-B2 (change detection) has no `lastChanges`.
89
75
  */
90
76
  lastChanges?: SyncStateSweepChanges;
91
- /**
92
- * What the most recent sync's image rehost moved. Absent when that run moved
93
- * no imagery. Recorded separately from `lastChanges` because a rehost rewrites
94
- * image urls without touching `source_checksum` — so the content-hash delta
95
- * reports those records as unchanged, and this is the only account of the move.
96
- */
97
- lastImageRehost?: SyncStateImageRehost;
98
77
  }
99
78
 
100
79
  // Vite resolves this glob at build time and bundles every tenant's sync-state
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.5.1",
3
+ "version": "3.6.1",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -22,6 +22,7 @@
22
22
  "./asset-registry": "./src/asset-registry.ts",
23
23
  "./responsive-renditions": "./src/responsive-renditions.ts",
24
24
  "./svg-sanitize": "./src/svg-sanitize.ts",
25
+ "./image-release-gate": "./src/image-release-gate.ts",
25
26
  "./pool-asset-headers": "./src/pool-asset-headers.ts",
26
27
  "./catalog-files": "./src/catalog-files.ts",
27
28
  "./embed-catalog": "./src/embed-catalog.mjs",
@@ -0,0 +1,267 @@
1
+ /**
2
+ * The release image gate (ADR 0038 §14): every image reference a release RENDERS — `src`,
3
+ * `srcset`, CSS `url()`, social metadata, catalog and scene registries as `scanImageDependencies`
4
+ * extracts them, and the JSON-LD structured data its pages carry ({@link structuredDataArtifacts})
5
+ * — must resolve to an exact pool version the release can prove. One pure decision shared by every gate that sees rendered output: the bundle validator
6
+ * (both CDNs' whole-site path), the CloudFront publisher inside the GC fence, and the Cloudflare
7
+ * publication-space stage. The caller supplies the facts it has proven (bytes hashed, files
8
+ * present); this module only decides.
9
+ *
10
+ * A reference resolves when it is:
11
+ * - a pool URL (`/_a/<sha256><ext>`) whose bytes the release carries (hashed to the address) or
12
+ * whose sealed capture the release records, in a pool format; an SVG only as sanitizer output;
13
+ * - a release-local tenant path (`/tenants/<id>/...`) whose bundle bytes are pinned to such a pool
14
+ * version, so the version the page names is registered and verified with the rest;
15
+ * - a platform build asset (`/_astro/`, `/shared/`) or an inline `data:` image.
16
+ * Third-party and unprefixed local references on rendered surfaces belong to the capture gate
17
+ * (preview captures them; a publish refuses `unaccepted-findings` until the owner accepts each),
18
+ * and security findings to the scanner, so neither is judged twice here. Structured data is not a
19
+ * capture surface, so this gate judges it whole: an unprefixed path resolves like a tenant path
20
+ * (the bundle file, pinned), and a third-party URL is `mutable`.
21
+ *
22
+ * Findings name the references (query and fragment stripped) and the remedy. The legacy
23
+ * allowlist is exact — tenant, reference and finding code — owned, and carries an exit date after
24
+ * which it no longer applies.
25
+ *
26
+ * Import-free (plain node runs it): the pool format table and the SVG sanitizer are passed in as
27
+ * `formats` (`hosted-image-media-types.ts`, `svg-sanitize.ts`).
28
+ */
29
+ import type { ImageDependency, ImageDependencyArtifact } from "./image-dependencies.js";
30
+
31
+ /**
32
+ * `unregistered` and `unsupported-format` are integrity: nothing may publish through them.
33
+ * `unresolved` and `mutable` judge the tenant's own references: an owner may accept them exactly.
34
+ */
35
+ export type ReleaseImageFindingCode = "unregistered" | "unsupported-format" | "unresolved" | "mutable";
36
+
37
+ export const BLOCKING_RELEASE_IMAGE_CODES: readonly ReleaseImageFindingCode[] = Object.freeze(["unregistered", "unsupported-format"]);
38
+
39
+ export interface ReleaseImageFinding {
40
+ code: ReleaseImageFindingCode;
41
+ /** The reference as rendered, query and fragment stripped. */
42
+ ref: string;
43
+ }
44
+
45
+ export type BundlePathResolution =
46
+ | { kind: "absent" }
47
+ | { kind: "unpinned" }
48
+ | { kind: "pinned"; entry: string };
49
+
50
+ /** The pool's formats: `PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION` and `sanitizeSvg`. */
51
+ export interface PoolImageFormats {
52
+ mediaTypeByExtension: Readonly<Record<string, string>>;
53
+ sanitizeSvg(bytes: Uint8Array): { ok: true; changed: boolean } | { ok: false };
54
+ }
55
+
56
+ /** What the caller has proven about the release it holds. */
57
+ export interface ReleaseImageFacts {
58
+ /** The release's own copy of pool entry `<sha256><ext>`, already hashed to its address; else null. */
59
+ heldPoolBytes(entry: string): Uint8Array | null;
60
+ /** True when the release records `entry` as a sealed capture (`capturedOriginals`). */
61
+ captured(entry: string): boolean;
62
+ /**
63
+ * A bundle path (decoded, no leading slash): absent from the release, present but not pinned, or
64
+ * pinned to the pool entry its bytes hash to.
65
+ */
66
+ bundlePath(relPath: string): BundlePathResolution;
67
+ }
68
+
69
+ export interface LegacyImageAllowance {
70
+ tenant: string;
71
+ ref: string;
72
+ code: Exclude<ReleaseImageFindingCode, "unsupported-format">;
73
+ /** Who answers for this entry and removes it by its exit date. */
74
+ owner: string;
75
+ reason: string;
76
+ added: string;
77
+ /** ISO date after which the entry no longer applies. */
78
+ exit: string;
79
+ }
80
+
81
+ export interface ReleaseImageVerdict {
82
+ findings: ReleaseImageFinding[];
83
+ /** Every pool entry the rendered output resolves to, sorted: the set to register and verify. */
84
+ poolEntries: string[];
85
+ /** Findings a current allowance passed. */
86
+ allowed: ReleaseImageFinding[];
87
+ /** Allowances that matched a finding but are past their exit date (the finding stands). */
88
+ expired: LegacyImageAllowance[];
89
+ }
90
+
91
+ const POOL_REF = /^\/_a\/([0-9a-f]{64})(\.[A-Za-z0-9]+)?$/;
92
+ const POOL_ENTRY = /^([0-9a-f]{64})(\.[A-Za-z0-9]+)?$/;
93
+
94
+ function refOf(dependency: ImageDependency): string {
95
+ return dependency.canonicalLocator.replace(/[?#].*$/, "");
96
+ }
97
+
98
+ /** `/tenants/<id>/a%20b.png` -> `tenants/<id>/a b.png`, or null when it does not decode exactly. */
99
+ export function bundleRelPath(ref: string): string | null {
100
+ if (!ref.startsWith("/") || ref.startsWith("//")) return null;
101
+ try {
102
+ const segments = new URL(ref, "https://release.invalid").pathname.split("/").slice(1).map(decodeURIComponent);
103
+ if (segments.some((segment) => !segment || segment === "." || segment === ".." || segment.includes("/"))) return null;
104
+ return segments.join("/");
105
+ } catch {
106
+ return null;
107
+ }
108
+ }
109
+
110
+ /** Is a pool entry in a pool format, and when an SVG, exactly the sanitizer's output? */
111
+ function supportedPoolEntry(entry: string, facts: ReleaseImageFacts, formats: PoolImageFormats): boolean {
112
+ const ext = POOL_ENTRY.exec(entry)?.[2] ?? "";
113
+ if (!Object.hasOwn(formats.mediaTypeByExtension, ext)) return false;
114
+ if (ext !== ".svg") return true;
115
+ const bytes = facts.heldPoolBytes(entry);
116
+ // A captured SVG is a sealed upload: it entered the pool only as sanitizer output (ADR 0038 §12).
117
+ if (!bytes) return facts.captured(entry);
118
+ const result = formats.sanitizeSvg(bytes);
119
+ return result.ok && !result.changed;
120
+ }
121
+
122
+ function allowanceFor(allowances: readonly LegacyImageAllowance[], tenantId: string, finding: ReleaseImageFinding) {
123
+ return allowances.find((allowance) => allowance.tenant === tenantId && allowance.ref === finding.ref &&
124
+ allowance.code === finding.code);
125
+ }
126
+
127
+ const LD_JSON = /<script\b[^>]*\btype\s*=\s*["']?application\/ld\+json["']?[^>]*>([\s\S]*?)<\/script\s*>/gi;
128
+
129
+ /**
130
+ * The JSON-LD blocks of each HTML artifact, as catalog artifacts at the page's own path (so a
131
+ * relative URL resolves against the page). Scan them with `scanImageDependencies` and pass the
132
+ * dependencies as `structuredDependencies`.
133
+ */
134
+ export function structuredDataArtifacts(artifacts: readonly ImageDependencyArtifact[]): ImageDependencyArtifact[] {
135
+ const out: ImageDependencyArtifact[] = [];
136
+ for (const artifact of artifacts) {
137
+ if ((artifact.kind ?? "html") !== "html") continue;
138
+ for (const match of artifact.content.matchAll(LD_JSON)) {
139
+ if (match[1]?.trim()) out.push({ path: artifact.path, kind: "catalog", content: match[1] });
140
+ }
141
+ }
142
+ return out;
143
+ }
144
+
145
+ export function judgeReleaseImages(input: {
146
+ tenantId: string;
147
+ dependencies: readonly ImageDependency[];
148
+ /** Dependencies scanned from {@link structuredDataArtifacts}. */
149
+ structuredDependencies?: readonly ImageDependency[];
150
+ facts: ReleaseImageFacts;
151
+ formats: PoolImageFormats;
152
+ allowances?: readonly LegacyImageAllowance[];
153
+ now?: Date;
154
+ }): ReleaseImageVerdict {
155
+ const raw = new Map<string, ReleaseImageFinding>();
156
+ const poolEntries = new Set<string>();
157
+ const add = (code: ReleaseImageFindingCode, ref: string) => raw.set(`${code}\0${ref}`, { code, ref });
158
+ // Each distinct reference is judged once, however many pages render it.
159
+ const seen = new Set<string>();
160
+ const resolveBundlePath = (ref: string) => {
161
+ const rel = bundleRelPath(ref);
162
+ const resolved = rel === null ? { kind: "absent" as const } : input.facts.bundlePath(rel);
163
+ if (resolved.kind === "absent") add("unresolved", ref);
164
+ else if (resolved.kind === "unpinned") add("mutable", ref);
165
+ else if (!supportedPoolEntry(resolved.entry, input.facts, input.formats)) add("unsupported-format", ref);
166
+ else poolEntries.add(resolved.entry);
167
+ };
168
+ const tagged = [
169
+ ...input.dependencies.map((dependency) => ({ dependency, structured: false })),
170
+ ...(input.structuredDependencies ?? []).map((dependency) => ({ dependency, structured: true })),
171
+ ];
172
+ for (const { dependency, structured } of tagged) {
173
+ const ref = refOf(dependency);
174
+ const key = `${structured}\0${dependency.disposition}\0${dependency.reason ?? ""}\0${ref}`;
175
+ if (seen.has(key)) continue;
176
+ seen.add(key);
177
+ if (structured && dependency.disposition === "capture-required") {
178
+ if (dependency.reason === "local") resolveBundlePath(ref);
179
+ else add("mutable", ref);
180
+ } else if (dependency.disposition === "platform-hosted") {
181
+ const pool = POOL_REF.exec(ref);
182
+ if (!pool) continue;
183
+ const entry = `${pool[1]}${pool[2] ?? ""}`;
184
+ if (!input.facts.heldPoolBytes(entry) && !input.facts.captured(entry)) add("unregistered", ref);
185
+ else if (!supportedPoolEntry(entry, input.facts, input.formats)) add("unsupported-format", ref);
186
+ else poolEntries.add(entry);
187
+ } else if (dependency.disposition === "tenant-hosted") {
188
+ resolveBundlePath(ref);
189
+ }
190
+ }
191
+ const now = (input.now ?? new Date()).getTime();
192
+ const findings: ReleaseImageFinding[] = [];
193
+ const allowed: ReleaseImageFinding[] = [];
194
+ const expired: LegacyImageAllowance[] = [];
195
+ for (const finding of [...raw.values()].sort(compareFindings)) {
196
+ const allowance = finding.code === "unsupported-format" ? undefined : allowanceFor(input.allowances ?? [], input.tenantId, finding);
197
+ if (allowance && Date.parse(allowance.exit) > now) allowed.push(finding);
198
+ else {
199
+ if (allowance) expired.push(allowance);
200
+ findings.push(finding);
201
+ }
202
+ }
203
+ return { findings, poolEntries: [...poolEntries].sort(), allowed, expired };
204
+ }
205
+
206
+ function compareFindings(a: ReleaseImageFinding, b: ReleaseImageFinding): number {
207
+ return a.code === b.code ? (a.ref < b.ref ? -1 : a.ref > b.ref ? 1 : 0) : a.code < b.code ? -1 : 1;
208
+ }
209
+
210
+ /** The validator prefix for each code; the bundle validator classifies them (`finding-severity.mjs`). */
211
+ export const RELEASE_IMAGE_FINDING_PREFIX: Readonly<Record<ReleaseImageFindingCode, string>> = Object.freeze({
212
+ unregistered: "IMAGE_UNREGISTERED",
213
+ "unsupported-format": "IMAGE_FORMAT",
214
+ unresolved: "IMAGE_UNRESOLVED",
215
+ mutable: "IMAGE_MUTABLE",
216
+ });
217
+
218
+ /**
219
+ * The one text of a finding, shared by every gate so an owner's exact acceptance of it in the
220
+ * validator is the same acceptance the publisher reads.
221
+ */
222
+ export function releaseImageFindingText(finding: ReleaseImageFinding): string {
223
+ const prefix = RELEASE_IMAGE_FINDING_PREFIX[finding.code];
224
+ switch (finding.code) {
225
+ case "unregistered":
226
+ return `${prefix}: ${finding.ref} is a pool URL this release neither carries nor captured, so no version of it is ` +
227
+ "registered to this store — upload the image with `tot image add <file>` and use the URL it prints, or reference its " +
228
+ "original so preview captures it.";
229
+ case "unsupported-format":
230
+ return `${prefix}: ${finding.ref} is not a pool image format (png, jpg, gif, webp, avif, or an SVG exactly as the ` +
231
+ "sanitizer outputs it) — convert it, or add an SVG with `tot image add <file>`, which sanitizes it.";
232
+ case "unresolved":
233
+ return `${prefix}: ${finding.ref} resolves to no image in this release — add the image with \`tot image add <file>\` ` +
234
+ "and reference the URL it prints, or fix the path.";
235
+ case "mutable":
236
+ return `${prefix}: ${finding.ref} is a release-local path whose bytes are not pinned to a pool version, so the image ` +
237
+ "it serves can change under the same name — add it with `tot image add <file>` and reference the URL it prints.";
238
+ }
239
+ }
240
+
241
+ const ALLOWANCE_CODES = new Set(["unregistered", "unresolved", "mutable"]);
242
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
243
+
244
+ /**
245
+ * Parse the legacy allowlist document `{version: 1, owner, entries: [...]}`. Null when it is not
246
+ * exactly that shape: a gate then refuses rather than guessing which entries were meant.
247
+ */
248
+ export function parseLegacyImageAllowlist(raw: string): { owner: string; entries: LegacyImageAllowance[] } | null {
249
+ let parsed: unknown;
250
+ try { parsed = JSON.parse(raw); } catch { return null; }
251
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
252
+ const doc = parsed as Record<string, unknown>;
253
+ if (doc.version !== 1 || typeof doc.owner !== "string" || !doc.owner || !Array.isArray(doc.entries)) return null;
254
+ const entries: LegacyImageAllowance[] = [];
255
+ for (const item of doc.entries) {
256
+ if (!item || typeof item !== "object" || Array.isArray(item)) return null;
257
+ const entry = item as Record<string, unknown>;
258
+ const strings = ["tenant", "ref", "code", "owner", "reason", "added", "exit"] as const;
259
+ if (strings.some((key) => typeof entry[key] !== "string" || !entry[key]) ||
260
+ Object.keys(entry).some((key) => !(strings as readonly string[]).includes(key)) ||
261
+ !ALLOWANCE_CODES.has(entry.code as string) || !DATE.test(entry.added as string) ||
262
+ !DATE.test(entry.exit as string) || !(entry.ref as string).startsWith("/") ||
263
+ /[?#]/.test(entry.ref as string) || (entry.exit as string) <= (entry.added as string)) return null;
264
+ entries.push(entry as unknown as LegacyImageAllowance);
265
+ }
266
+ return { owner: doc.owner, entries };
267
+ }