@tokenoftrust/storefront-runner 3.1.2 → 3.3.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.
@@ -168,6 +168,7 @@ export const CATALOG_MODEL = {
168
168
  created_at: "string",
169
169
  updated_at: "string",
170
170
  migrated_at: "string",
171
+ "source_created_at?": "string",
171
172
  source_checksum: "string",
172
173
  "catalog_overrides?": "CatalogOverrideField[]",
173
174
  },
@@ -9,11 +9,11 @@
9
9
  *
10
10
  * - one per entry of {@link FORBIDDEN_PUBLIC_DIRS}: any file under a directory a media download
11
11
  * writes (`public/catalog-images/` is `catalog-images-in-repo`, `public/blog/wp-content/uploads/`
12
- * is `blog-media-in-repo`). Decided from paths alone, so a caller without sizes still refuses
13
- * it. This list is the one source the validators and the reconcile read; the Gitea
14
- * pre-receive size guard names the same directories.
12
+ * is `blog-media-in-repo`), each with its own remedy. Decided from paths alone, so a caller
13
+ * without sizes still refuses it. This list is the one source the validators and the reconcile
14
+ * read; the Gitea pre-receive size guard names the same directories.
15
15
  * - `public-binary-oversized` — one raster or media file over {@link PUBLIC_BINARY_LIMITS}
16
- * `maxFileBytes`.
16
+ * `maxFileBytes`. An image's remedy is {@link IMAGE_ADD_COMMAND}.
17
17
  * - `public-tree-over-budget` — the whole `public/` tree over `maxTotalBytes`.
18
18
  *
19
19
  * The size findings need `bytes`; an entry without it is judged by path only.
@@ -31,14 +31,24 @@ export const PUBLIC_BINARY_LIMITS = Object.freeze({
31
31
  maxTotalBytes: 32 * 1024 * 1024,
32
32
  });
33
33
 
34
+ /** The kit command that moves downloaded catalog images and blog media back to their source URLs. */
35
+ export const UNHOST_IMAGES_COMMAND = "node agency-kit/tools/import-catalog.mjs --store <checkout> --unhost-images --write";
36
+
37
+ /** The command that puts an image the author holds into the platform pool and prints its `/_a/` URL. */
38
+ export const IMAGE_ADD_COMMAND = "tot image add <file>";
39
+
34
40
  /**
35
41
  * The directories under `public/` a media download writes. No file under one may be committed.
42
+ * `remedy` is the fix for files of that kind.
36
43
  *
37
- * @type {ReadonlyArray<Readonly<{ dir: string, code: string, noun: string }>>}
44
+ * @type {ReadonlyArray<Readonly<{ dir: string, code: string, noun: string, remedy: string }>>}
38
45
  */
39
46
  export const FORBIDDEN_PUBLIC_DIRS = Object.freeze([
40
- Object.freeze({ dir: "catalog-images/", code: "catalog-images-in-repo", noun: "catalog image" }),
41
- Object.freeze({ dir: "blog/wp-content/uploads/", code: "blog-media-in-repo", noun: "blog media" }),
47
+ Object.freeze({ dir: "catalog-images/", code: "catalog-images-in-repo", noun: "catalog image", remedy: UNHOST_IMAGES_COMMAND }),
48
+ Object.freeze({
49
+ dir: "blog/wp-content/uploads/", code: "blog-media-in-repo", noun: "blog media",
50
+ remedy: `a new post image: ${IMAGE_ADD_COMMAND}, then reference the /_a/ URL it prints; media a migration downloaded: ${UNHOST_IMAGES_COMMAND}`,
51
+ }),
42
52
  ]);
43
53
 
44
54
  /** The finding codes decided from paths alone. */
@@ -49,11 +59,11 @@ export function isForbiddenPublicPath(rel) {
49
59
  return FORBIDDEN_PUBLIC_DIRS.some((d) => rel.startsWith(d.dir));
50
60
  }
51
61
 
52
- /** The kit command that moves downloaded catalog images and blog media back to their source URLs. */
53
- export const UNHOST_IMAGES_COMMAND = "node agency-kit/tools/import-catalog.mjs --store <checkout> --unhost-images --write";
54
-
55
62
  const RASTER_OR_MEDIA = /\.(?:jpe?g|png|gif|webp|avif|bmp|tiff?|heic|heif|ico|jxl|mp4|m4v|mov|webm|mkv|avi|mpe?g|mp3|wav|ogg|oga|m4a|aac|flac|opus)$/i;
56
63
 
64
+ /** The formats {@link IMAGE_ADD_COMMAND} accepts. */
65
+ const UPLOADABLE_IMAGE = /\.(?:jpe?g|png|gif|webp|avif)$/i;
66
+
57
67
  const mb = (bytes) => `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
58
68
 
59
69
  /**
@@ -66,7 +76,7 @@ export function publicBinaryFindings(entries, limits = {}) {
66
76
  const maxFileBytes = limits.maxFileBytes ?? PUBLIC_BINARY_LIMITS.maxFileBytes;
67
77
  const maxTotalBytes = limits.maxTotalBytes ?? PUBLIC_BINARY_LIMITS.maxTotalBytes;
68
78
  const findings = [];
69
- for (const { dir, code, noun } of FORBIDDEN_PUBLIC_DIRS) {
79
+ for (const { dir, code, noun, remedy } of FORBIDDEN_PUBLIC_DIRS) {
70
80
  const files = entries.filter((e) => e.rel.startsWith(dir));
71
81
  if (files.length === 0) continue;
72
82
  const bytes = files.reduce((n, e) => n + (e.bytes ?? 0), 0);
@@ -74,8 +84,8 @@ export function publicBinaryFindings(entries, limits = {}) {
74
84
  code,
75
85
  path: `public/${dir}`,
76
86
  message: `${files.length} ${noun} file(s)${bytes ? ` (${mb(bytes)})` : ""} are committed to the store repo; ` +
77
- "images belong in the platform pool, not the repo; until platform capture owns them, keep each image at its source URL",
78
- remedy: UNHOST_IMAGES_COMMAND,
87
+ "images belong in the platform pool, not the repo",
88
+ remedy,
79
89
  });
80
90
  }
81
91
  for (const e of entries) {
@@ -86,7 +96,9 @@ export function publicBinaryFindings(entries, limits = {}) {
86
96
  path: `public/${e.rel}`,
87
97
  message: `${mb(e.bytes)} raster/media file exceeds the ${mb(maxFileBytes)} per-file limit for the store repo; ` +
88
98
  "images and media belong in the platform pool",
89
- remedy: "reference the file by its source URL, or compress it under the limit",
99
+ remedy: UPLOADABLE_IMAGE.test(e.rel)
100
+ ? `${IMAGE_ADD_COMMAND}, then reference the /_a/ URL it prints instead of the file`
101
+ : "reference the file by its source URL, or compress it under the limit",
90
102
  });
91
103
  }
92
104
  }
@@ -98,7 +110,8 @@ export function publicBinaryFindings(entries, limits = {}) {
98
110
  path: "public/",
99
111
  message: `public/ holds ${mb(total)} across ${entries.length} file(s), over the ${mb(maxTotalBytes)} store-repo budget; ` +
100
112
  "images and media belong in the platform pool",
101
- remedy: "reference large or bulk assets by their source URL instead of committing them",
113
+ remedy: `upload images with ${IMAGE_ADD_COMMAND} and reference the /_a/ URLs it prints; ` +
114
+ "reference other large assets by their source URL",
102
115
  });
103
116
  }
104
117
  }
@@ -98,7 +98,7 @@ export class StaticTenantResolver implements TenantResolver {
98
98
 
99
99
  export const tenantResolver: TenantResolver = new StaticTenantResolver();
100
100
 
101
- const COMPOSED_PLATFORM_FIELDS = ["access", "pathPrefixTenants"] as const;
101
+ const COMPOSED_PLATFORM_FIELDS = ["access", "pathPrefixTenants", "capabilities"] as const;
102
102
 
103
103
  /** Same contract as the real registry's withPlatformPolicy — kept in sync by hand. */
104
104
  export function withPlatformPolicy(record: TenantConfig): TenantConfig {
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The R2 asset registry document (`registry/assets.json` in `STATIC_BUNDLES`, ADR 0038 §11) for
3
+ * Worker writers: hosted capture seals and author uploads register the originals they prove a
4
+ * tenant holds. The record model and its upsert rules are `@tot/public-runtime/asset-registry`, the
5
+ * same ones the CI publisher applies.
6
+ *
7
+ * Each upsert is a compare-and-set on the document's ETag, retried a few times, so two Worker
8
+ * writers never overwrite each other. A writer that keeps losing gives up (`lost`): its facts are
9
+ * dropped, which only weakens the record, and the next publish re-registers what the tenant holds.
10
+ * A document that exists but does not parse is never overwritten from here.
11
+ */
12
+ import {
13
+ ASSET_REGISTRY_KEY,
14
+ MAX_ASSET_REGISTRY_BYTES,
15
+ applyAssetWrites,
16
+ emptyAssetRegistry,
17
+ parseAssetRegistry,
18
+ serializeAssetRegistry,
19
+ type AssetRegistry,
20
+ type AssetRegistryStore,
21
+ type AssetWrite,
22
+ } from "@tot/public-runtime/asset-registry";
23
+
24
+ /** The registry's store name in R2; the CI publisher writes the same document under this name. */
25
+ export const R2_ASSET_REGISTRY_STORE = "r2";
26
+ const ATTEMPTS = 4;
27
+
28
+ export interface AssetRegistryBucket {
29
+ get(key: string): Promise<{ etag: string; size: number; arrayBuffer(): Promise<ArrayBuffer> } | null>;
30
+ put(key: string, body: Uint8Array, options: {
31
+ httpMetadata: { contentType: string; cacheControl: string };
32
+ onlyIf: { etagMatches: string } | { etagDoesNotMatch: string };
33
+ }): Promise<unknown>;
34
+ }
35
+
36
+ async function readDocument(bucket: AssetRegistryBucket): Promise<{ registry: AssetRegistry | null; etag: string | null }> {
37
+ const object = await bucket.get(ASSET_REGISTRY_KEY);
38
+ if (!object) return { registry: emptyAssetRegistry(R2_ASSET_REGISTRY_STORE), etag: null };
39
+ if (object.size > MAX_ASSET_REGISTRY_BYTES) return { registry: null, etag: object.etag };
40
+ return { registry: parseAssetRegistry(new Uint8Array(await object.arrayBuffer()), R2_ASSET_REGISTRY_STORE), etag: object.etag };
41
+ }
42
+
43
+ export function createR2AssetRegistry(bucket: AssetRegistryBucket): AssetRegistryStore {
44
+ return {
45
+ async read() {
46
+ return (await readDocument(bucket)).registry;
47
+ },
48
+ async upsert(writes: readonly AssetWrite[]) {
49
+ if (writes.length === 0) return "unchanged";
50
+ for (let attempt = 0; attempt < ATTEMPTS; attempt++) {
51
+ const { registry, etag } = await readDocument(bucket);
52
+ if (!registry) return "lost";
53
+ const next = applyAssetWrites(registry, writes);
54
+ if (!next.changed) return "unchanged";
55
+ const body = serializeAssetRegistry(next.registry);
56
+ if (body.byteLength > MAX_ASSET_REGISTRY_BYTES) return "lost";
57
+ // R2 answers a failed precondition with null rather than an error.
58
+ const put = await bucket.put(ASSET_REGISTRY_KEY, body, {
59
+ httpMetadata: { contentType: "application/json", cacheControl: "no-store" },
60
+ onlyIf: etag === null ? { etagDoesNotMatch: "*" } : { etagMatches: etag },
61
+ });
62
+ if (put) return "written";
63
+ }
64
+ return "lost";
65
+ },
66
+ };
67
+ }
@@ -5,10 +5,12 @@
5
5
  * when the sealed revision manifest names the address.
6
6
  */
7
7
  import { sha256Hex } from "@tot/public-runtime";
8
- import { CAPTURABLE_RASTER_MEDIA_TYPES, PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE } from "@tot/public-runtime/hosted-image-media-types";
8
+ import {
9
+ PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE, PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION, sealedMediaTypeAllowed,
10
+ } from "@tot/public-runtime/hosted-image-media-types";
9
11
  import { durableAssetsStatusKey, durableCaptureRevisionId, durableRequiredStores, parseDurableAssetsStatus } from "@tot/public-runtime/durable-assets";
12
+ import { POOL_ASSET_SECURITY_HEADERS } from "@tot/public-runtime/pool-asset-headers";
10
13
 
11
- const CAPTURE_TYPES = new Set<string>(CAPTURABLE_RASTER_MEDIA_TYPES);
12
14
  const ADDRESS_RE = /^[0-9a-f]{64}$/;
13
15
  /** Hostname-like tenant ids (no scheme, no path separators). */
14
16
  const TENANT_ID_RE = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/i;
@@ -27,14 +29,8 @@ function originalImageManifestObjectKey(tenantId: string, revisionId: string): s
27
29
  return `original-images/${tenantId}/revisions/${revisionId}/manifest.json`;
28
30
  }
29
31
 
30
- const EXT_TYPE: Record<string, string> = {
31
- ".png": "image/png",
32
- ".jpg": "image/jpeg",
33
- ".jpeg": "image/jpeg",
34
- ".gif": "image/gif",
35
- ".webp": "image/webp",
36
- ".avif": "image/avif",
37
- };
32
+ /** The shared pinned table, plus the `.jpeg` spelling a hand-written reference may use. */
33
+ const EXT_TYPE: Record<string, string> = { ...PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION, ".jpeg": "image/jpeg" };
38
34
 
39
35
  export type CapturedOriginalBucket = {
40
36
  get(key: string): Promise<unknown>;
@@ -44,7 +40,7 @@ export type ManifestEntry = {
44
40
  address: string;
45
41
  byteLength: number;
46
42
  mediaType: string;
47
- provenance?: { kind: "local" | "remote"; locator: string };
43
+ provenance?: { kind: "local" | "remote" | "upload"; locator: string };
48
44
  };
49
45
 
50
46
  function objectKey(tenantId: string, address: string): string {
@@ -84,7 +80,7 @@ async function readManifestEntries(
84
80
  const entry = raw as Partial<ManifestEntry>;
85
81
  if (typeof entry.address !== "string" || !ADDRESS_RE.test(entry.address)) return null;
86
82
  if (!Number.isSafeInteger(entry.byteLength) || entry.byteLength! < 1 || entry.byteLength! > 8 * 1024 * 1024 ||
87
- typeof entry.mediaType !== "string" || !CAPTURE_TYPES.has(entry.mediaType)) return null;
83
+ !sealedMediaTypeAllowed(entry.provenance?.kind, entry.mediaType)) return null;
88
84
  entries.push({
89
85
  address: entry.address,
90
86
  byteLength: entry.byteLength!,
@@ -184,6 +180,7 @@ export async function tryServeCapturedOriginal(input: {
184
180
  "cache-control": durable ? "no-store" : "public, max-age=31536000, immutable",
185
181
  "x-tot-original-image": durable ? "durable" : "revision-bound",
186
182
  "content-length": String(bytes.byteLength),
183
+ ...POOL_ASSET_SECURITY_HEADERS,
187
184
  };
188
185
  const method = (input.method ?? "GET").toUpperCase();
189
186
  return new Response(method === "HEAD" ? null : bytes, { status: 200, headers });
@@ -8,7 +8,7 @@
8
8
  * bundle-GC sweep holds its lease, then stage and verify every object those references name.
9
9
  */
10
10
  import { sha256Hex } from "@tot/public-runtime";
11
- import { PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION } from "@tot/public-runtime/hosted-image-media-types";
11
+ import { PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION, sealedMediaTypeAllowed } from "@tot/public-runtime/hosted-image-media-types";
12
12
  import {
13
13
  durableAssetsStatusKey,
14
14
  durableCaptureRevisionId,
@@ -19,6 +19,7 @@ import {
19
19
  type DurableAssetRecord,
20
20
  } from "@tot/public-runtime/durable-assets";
21
21
  import { GC_LEASE_KEY, awaitGcQuiescence } from "@tot/public-runtime/retention-holds";
22
+ import { authorizeAssetReference, type AssetRegistryStore, type AssetWrite } from "@tot/public-runtime/asset-registry";
22
23
 
23
24
  const MAX_IMAGE = 8 * 1024 * 1024;
24
25
  const MAX_MANIFEST = 1024 * 1024;
@@ -143,20 +144,45 @@ async function capturedBytes(captureBucket: R2BucketLike, tenant: string, entry:
143
144
  * Stage every captured original a version manifest records (`capturedOriginals`) into the public
144
145
  * pool. The caller has already established the reference (the version manifest in this bucket) and
145
146
  * waited at the fence.
147
+ *
148
+ * The pool is shared by every tenant, so an object being there says nothing about who may reference
149
+ * it (ADR 0038 §11). Each entry must be registered to this tenant in the asset registry, or be in the
150
+ * tenant's own capture store (which registers it); otherwise nothing is staged and the go-live
151
+ * refuses `asset-unauthorized`.
146
152
  */
147
153
  export async function stageCapturedOriginalsR2(input: {
148
154
  tenant: string;
149
155
  entries: readonly string[];
150
156
  captureBucket: R2BucketLike;
151
157
  publicBucket: R2BucketLike;
158
+ registry?: AssetRegistryStore;
152
159
  }): Promise<number> {
160
+ const registry = input.registry ? await input.registry.read().catch(() => null) : null;
161
+ const owned = new Map<string, Uint8Array>();
162
+ const unauthorized: string[] = [];
163
+ for (const entry of input.entries) {
164
+ if (authorizeAssetReference([registry], input.tenant, entry).ok) continue;
165
+ const own = await capturedBytes(input.captureBucket, input.tenant, entry).catch((error: unknown) => {
166
+ if (error instanceof R2StagingError) return null;
167
+ throw error;
168
+ });
169
+ if (own) owned.set(entry, own);
170
+ else unauthorized.push(entry);
171
+ }
172
+ if (input.registry && owned.size > 0) {
173
+ const writes: AssetWrite[] = [...owned].map(([entry, bytes]) => ({
174
+ entry, tenant: input.tenant, mediaType: mediaTypeOf(entry), size: bytes.byteLength,
175
+ }));
176
+ await input.registry.upsert(writes).catch(() => "lost");
177
+ }
178
+ if (unauthorized.length > 0) throw new R2StagingError("asset-unauthorized");
153
179
  let staged = 0;
154
180
  for (const entry of input.entries) {
155
181
  const key = `assets/${entry}`;
156
182
  const present = await readBounded(input.publicBucket, key, MAX_IMAGE);
157
183
  const address = POOL_ENTRY_RE.exec(entry)?.[1];
158
184
  if (present && address && await sha256Hex(present) === address) continue;
159
- await ensurePoolObject(input.publicBucket, key, await capturedBytes(input.captureBucket, input.tenant, entry), mediaTypeOf(entry));
185
+ await ensurePoolObject(input.publicBucket, key, owned.get(entry) ?? await capturedBytes(input.captureBucket, input.tenant, entry), mediaTypeOf(entry));
160
186
  staged++;
161
187
  }
162
188
  return staged;
@@ -203,6 +229,8 @@ export async function designateDurableR2(input: {
203
229
  kv: StatusKvLike;
204
230
  now?: () => Date;
205
231
  awaitQuiescence?: QuiescenceWaiter;
232
+ /** The pool's asset registry: each verified designation is recorded as a transition to `durable`. */
233
+ registry?: AssetRegistryStore;
206
234
  }): Promise<DurableDesignationResult> {
207
235
  if (input.images.length === 0) return { ok: true, designated: [] };
208
236
  const revision = durableCaptureRevisionId(input.revision);
@@ -211,7 +239,7 @@ export async function designateDurableR2(input: {
211
239
  if (manifestBytes) {
212
240
  const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as {
213
241
  version?: unknown; revisionId?: unknown;
214
- entries?: Array<{ address?: unknown; byteLength?: unknown; mediaType?: unknown; provenance?: { locator?: unknown } }>;
242
+ entries?: Array<{ address?: unknown; byteLength?: unknown; mediaType?: unknown; provenance?: { kind?: unknown; locator?: unknown } }>;
215
243
  };
216
244
  if (manifest.version !== 1 || manifest.revisionId !== revision || !Array.isArray(manifest.entries)) {
217
245
  throw new R2StagingError("manifest-invalid");
@@ -219,6 +247,7 @@ export async function designateDurableR2(input: {
219
247
  for (const entry of manifest.entries) {
220
248
  if (typeof entry?.address !== "string" || !/^[0-9a-f]{64}$/.test(entry.address) ||
221
249
  typeof entry.byteLength !== "number" || typeof entry.mediaType !== "string" ||
250
+ !sealedMediaTypeAllowed(entry.provenance?.kind, entry.mediaType) ||
222
251
  typeof entry.provenance?.locator !== "string") throw new R2StagingError("manifest-invalid");
223
252
  byLocator.set(entry.provenance.locator, { address: entry.address, byteLength: entry.byteLength, mediaType: entry.mediaType });
224
253
  }
@@ -249,6 +278,11 @@ export async function designateDurableR2(input: {
249
278
  }
250
279
  await recordDurableStatusKv(input.kv, input.tenant, { revision: input.revision, at: now().toISOString(), declared,
251
280
  designated: prepared.map((item) => ({ entry: item.entry, name: item.image.name, source: item.image.source, verifiedStores: ["r2"] })) });
281
+ // A record, not the retention mechanism: the hold above is what the bundle GC honours.
282
+ await input.registry?.upsert(prepared.map((item) => ({
283
+ entry: item.entry, tenant: input.tenant, mediaType: item.mediaType,
284
+ retention: { to: "durable" as const, at, how: "designation" },
285
+ }))).catch(() => "lost");
252
286
  return { ok: true, designated: declared };
253
287
  }
254
288
 
@@ -263,6 +297,8 @@ export function makeWorkerR2PinnedOriginalStager(deps: {
263
297
  publicBucket: R2BucketLike;
264
298
  captureBucket: R2BucketLike;
265
299
  kv: StatusKvLike;
300
+ /** The pool's asset registry (`STATIC_BUNDLES` `registry/assets.json`). */
301
+ registry?: AssetRegistryStore;
266
302
  now?: () => Date;
267
303
  awaitQuiescence?: QuiescenceWaiter;
268
304
  }) {
@@ -281,12 +317,13 @@ export function makeWorkerR2PinnedOriginalStager(deps: {
281
317
  }
282
318
  await awaitR2Quiescence(deps.publicBucket, deps.awaitQuiescence);
283
319
  await stageCapturedOriginalsR2({ tenant: tenantId, entries: entries as string[],
284
- captureBucket: deps.captureBucket, publicBucket: deps.publicBucket });
320
+ captureBucket: deps.captureBucket, publicBucket: deps.publicBucket, ...(deps.registry ? { registry: deps.registry } : {}) });
285
321
  const images = manifest.durableAssets?.images ?? [];
286
322
  if (images.length > 0) {
287
323
  if (typeof manifest.sourceSha !== "string") throw new R2StagingError("manifest-invalid");
288
324
  const result = await designateDurableR2({ tenant: tenantId, revision: manifest.sourceSha, images,
289
325
  captureBucket: deps.captureBucket, publicBucket: deps.publicBucket, kv: deps.kv,
326
+ ...(deps.registry ? { registry: deps.registry } : {}),
290
327
  ...(deps.now ? { now: deps.now } : {}), ...(deps.awaitQuiescence ? { awaitQuiescence: deps.awaitQuiescence } : {}) });
291
328
  if (!result.ok) throw new R2StagingError(result.reason);
292
329
  }
@@ -6,6 +6,7 @@
6
6
  */
7
7
  import { readKv, readR2 } from "@/lib/publication/shared/env";
8
8
  import { makeWorkerR2PinnedOriginalStager, R2StagingError, type R2BucketLike, type StatusKvLike } from "./durableR2.js";
9
+ import { createR2AssetRegistry, type AssetRegistryBucket } from "./assetRegistryR2.js";
9
10
 
10
11
  export async function workerR2PinnedOriginalStagerFromEnv(): Promise<
11
12
  (args: { tenantId: string; versionId: string; treeDigest?: string | null }) => Promise<void>
@@ -27,5 +28,6 @@ export async function workerR2PinnedOriginalStagerFromEnv(): Promise<
27
28
  publicBucket: publicBucket as unknown as R2BucketLike,
28
29
  captureBucket: captureBucket as unknown as R2BucketLike,
29
30
  kv: kv as unknown as StatusKvLike,
31
+ registry: createR2AssetRegistry(publicBucket as unknown as AssetRegistryBucket),
30
32
  });
31
33
  }
@@ -29,16 +29,15 @@ export function isAvailable(p: CatalogProduct): boolean {
29
29
 
30
30
  /**
31
31
  * "New" is the product's real age. The `new` tag is the merchant's explicit call. Otherwise a
32
- * product is new only when it has a known creation date inside the window: a record with no
33
- * creation date, or whose `created_at` is no earlier than its `migrated_at` (the import stamped
34
- * it, so it says when the record arrived, not when the product was created), is not new.
32
+ * product is new only when its `source_created_at` (the creation date the source system
33
+ * reported) is inside the window. `created_at` and `migrated_at` say when a record arrived, never
34
+ * how old the product is, so they do not age the badge; a record with no readable
35
+ * `source_created_at` is not new.
35
36
  */
36
37
  export function isNew(p: CatalogProduct, days = 60): boolean {
37
38
  if (p.tags.includes("new")) return true;
38
- const created = Date.parse(p.created_at);
39
+ const created = Date.parse(p.source_created_at ?? "");
39
40
  if (Number.isNaN(created)) return false;
40
- const migrated = Date.parse(p.migrated_at);
41
- if (!Number.isNaN(migrated) && created >= migrated) return false;
42
41
  return Date.now() - created < days * 24 * 60 * 60 * 1000;
43
42
  }
44
43
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "3.1.2",
3
+ "version": "3.3.0",
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",
@@ -19,6 +19,9 @@
19
19
  "./extension-contract-values": "./src/extension-contract-values.mjs",
20
20
  "./hosted-image-media-types": "./src/hosted-image-media-types.ts",
21
21
  "./durable-assets": "./src/durable-assets.ts",
22
+ "./asset-registry": "./src/asset-registry.ts",
23
+ "./svg-sanitize": "./src/svg-sanitize.ts",
24
+ "./pool-asset-headers": "./src/pool-asset-headers.ts",
22
25
  "./catalog-files": "./src/catalog-files.ts",
23
26
  "./embed-catalog": "./src/embed-catalog.mjs",
24
27
  "./retention-holds": "./src/retention-holds.ts",
@@ -0,0 +1,403 @@
1
+ /**
2
+ * The asset registry record model (ADR 0038 §11): one address-keyed record per content-addressed
3
+ * pool object (`assets/<sha256><ext>`), held in one document per store (`registry/assets.json`, in
4
+ * the S3 tenant bucket and in R2 `STATIC_BUNDLES`). Every writer — capture seal, upload ingest,
5
+ * durable designation, publish verification — upserts the same record through
6
+ * {@link applyAssetWrite}; every reader parses it through {@link parseAssetRegistry}. The I/O and the
7
+ * byte audit live in `scripts/publish/lib/asset-registry.mjs` (CI) and
8
+ * `apps/storefront/src/lib/assets/assetRegistryR2.ts` (Worker).
9
+ *
10
+ * A record's facets are independent. Each is validated on its own; an invalid facet is dropped, never
11
+ * repaired, so a damaged document can only lose facts. Writers read the document, apply their writes
12
+ * and put it back: a writer that loses a race loses its writes, which only removes facts (a tenant's
13
+ * authorization, a verification, a retention transition). No facet is ever added except by a writer
14
+ * that observed it, so no interleaving can fabricate one.
15
+ *
16
+ * Import-free on purpose: plain node runs it without a workspace install (the retention job).
17
+ */
18
+
19
+ export const ASSET_REGISTRY_KEY = "registry/assets.json";
20
+ export const ASSET_REGISTRY_VERSION = 1;
21
+ /** Bounds the document a reader will parse. */
22
+ export const MAX_ASSET_REGISTRY_BYTES = 32 * 1024 * 1024;
23
+
24
+ export type VerificationMethod = "byte-read" | "create-readback" | "audit";
25
+ export interface VerifiedFacet { size: number; etag: string; at: string; how: VerificationMethod }
26
+
27
+ export type AssetProvenanceKind = "capture" | "upload" | "repo" | "variant";
28
+ export interface AssetProvenance {
29
+ kind: AssetProvenanceKind;
30
+ /** Where the bytes first came from: a query-stripped source URL, a repo path, or the pool URL. */
31
+ locator?: string;
32
+ /** The first tenant revision the address was sealed or built in. */
33
+ firstRevision?: string;
34
+ at: string;
35
+ }
36
+
37
+ export type AssetRecipe =
38
+ | { kind: "original" }
39
+ | { kind: "sanitized"; sanitizer: string; source: string }
40
+ | { kind: "variant"; parent: string; width: number; format: string; encoder: string };
41
+
42
+ export type RetentionClass = "release" | "durable" | "external-use" | "removal-requested";
43
+ export interface RetentionTransition {
44
+ from: RetentionClass;
45
+ to: RetentionClass;
46
+ at: string;
47
+ /** What made the change: a designation, a declaration drop, an owner operation. */
48
+ how: string;
49
+ /** Opaque actor id of the owner who made it, when an owner did. */
50
+ actor?: string;
51
+ }
52
+ export interface RetentionFacet { class: RetentionClass; transitions: RetentionTransition[] }
53
+
54
+ export interface AssetRecord {
55
+ address: string;
56
+ mediaType?: string;
57
+ size?: number;
58
+ provenance?: AssetProvenance;
59
+ /** Tenants authorized to reference this address: each one proved it holds the bytes. */
60
+ tenants?: string[];
61
+ /** Opaque actor id of the first owner who brought the bytes in. */
62
+ owner?: string;
63
+ recipe?: AssetRecipe;
64
+ /** Entries (`<sha256><ext>`) derived from this one. */
65
+ renditions?: string[];
66
+ verified?: VerifiedFacet;
67
+ retention?: RetentionFacet;
68
+ }
69
+
70
+ export interface AssetRegistry {
71
+ version: 1;
72
+ kind: "asset-registry";
73
+ store: string;
74
+ assets: Record<string, AssetRecord>;
75
+ }
76
+
77
+ /** One writer's facts about one entry. Absent fields leave the record as it is. */
78
+ export interface AssetWrite {
79
+ entry: string;
80
+ mediaType?: string;
81
+ size?: number;
82
+ provenance?: AssetProvenance;
83
+ tenant?: string;
84
+ owner?: string;
85
+ recipe?: AssetRecipe;
86
+ rendition?: string;
87
+ verified?: VerifiedFacet;
88
+ retention?: { to: RetentionClass; at: string; how: string; actor?: string };
89
+ }
90
+
91
+ const ENTRY_RE = /^([0-9a-f]{64})(\.[A-Za-z0-9]+)?$/;
92
+ const POOL_KEY_RE = /^assets\/([0-9a-f]{64})(\.[A-Za-z0-9]+)?$/;
93
+ const ETAG_RE = /^[A-Za-z0-9-]{1,128}$/;
94
+ const TENANT_RE = /^[a-z0-9][a-z0-9._-]{0,127}$/;
95
+ const ACTOR_RE = /^[A-Za-z0-9._:-]{1,128}$/;
96
+ const REVISION_RE = /^[A-Za-z0-9._-]{1,128}$/;
97
+ const MEDIA_TYPE_RE = /^[a-z]+\/[a-z0-9.+-]{1,64}$/;
98
+ const TOKEN_RE = /^[A-Za-z0-9._:-]{1,64}$/;
99
+ const HOW = new Set<string>(["byte-read", "create-readback", "audit"]);
100
+ const PROVENANCE_KINDS = new Set<string>(["capture", "upload", "repo", "variant"]);
101
+ const RETENTION_CLASSES = new Set<string>(["release", "durable", "external-use", "removal-requested"]);
102
+ const MAX_TENANTS = 256;
103
+ const MAX_RENDITIONS = 64;
104
+ const MAX_TRANSITIONS = 32;
105
+ const MAX_LOCATOR = 2048;
106
+
107
+ /**
108
+ * The retention transitions an owner or a designation may make. A record with no retention facet is
109
+ * `release`. The pool GCs still decide deletion from the create-only holds and removal records
110
+ * (ADR 0038 §4–§6), so a lost write of this facet never shortens what is kept.
111
+ */
112
+ export const RETENTION_TRANSITIONS: Readonly<Record<RetentionClass, readonly RetentionClass[]>> = Object.freeze({
113
+ release: ["durable", "external-use"],
114
+ "external-use": ["durable", "removal-requested"],
115
+ durable: ["removal-requested"],
116
+ "removal-requested": ["durable", "external-use", "release"],
117
+ });
118
+
119
+ /** The pool key's registry entry name (`<sha256><ext>`) and address, or null for any other key. */
120
+ export function poolEntry(key: string): { entry: string; address: string } | null {
121
+ const match = POOL_KEY_RE.exec(key);
122
+ return match ? { entry: key.slice("assets/".length), address: match[1]! } : null;
123
+ }
124
+
125
+ /** The address of an entry name (`<sha256><ext>`), or null. */
126
+ export function entryAddress(entry: string): string | null {
127
+ return ENTRY_RE.exec(entry)?.[1] ?? null;
128
+ }
129
+
130
+ /** An ETag without its quotes, as both S3 and R2 listings and GETs report it. */
131
+ export function normalizeEtag(etag: unknown): string {
132
+ return typeof etag === "string" ? etag.replace(/"/g, "") : "";
133
+ }
134
+
135
+ export function emptyAssetRegistry(store: string): AssetRegistry {
136
+ return { version: ASSET_REGISTRY_VERSION, kind: "asset-registry", store, assets: {} };
137
+ }
138
+
139
+ function isObject(value: unknown): value is Record<string, unknown> {
140
+ return !!value && typeof value === "object" && !Array.isArray(value);
141
+ }
142
+
143
+ function validTime(value: unknown): value is string {
144
+ return typeof value === "string" && value.length <= 64 && !Number.isNaN(Date.parse(value));
145
+ }
146
+
147
+ function validLocator(value: unknown): value is string {
148
+ return typeof value === "string" && value.length > 0 && value.length <= MAX_LOCATOR && !/[?#\u0000-\u001f]/.test(value);
149
+ }
150
+
151
+ function normalizeVerified(raw: unknown): VerifiedFacet | null {
152
+ if (!isObject(raw) || !Number.isSafeInteger(raw.size) || (raw.size as number) < 1 ||
153
+ typeof raw.etag !== "string" || !ETAG_RE.test(raw.etag) || typeof raw.at !== "string" ||
154
+ typeof raw.how !== "string" || !HOW.has(raw.how)) return null;
155
+ return { size: raw.size as number, etag: raw.etag, at: raw.at, how: raw.how as VerificationMethod };
156
+ }
157
+
158
+ function normalizeProvenance(raw: unknown): AssetProvenance | null {
159
+ if (!isObject(raw) || typeof raw.kind !== "string" || !PROVENANCE_KINDS.has(raw.kind) || !validTime(raw.at)) return null;
160
+ if (raw.locator !== undefined && !validLocator(raw.locator)) return null;
161
+ if (raw.firstRevision !== undefined && (typeof raw.firstRevision !== "string" || !REVISION_RE.test(raw.firstRevision))) return null;
162
+ return {
163
+ kind: raw.kind as AssetProvenanceKind, at: raw.at,
164
+ ...(raw.locator !== undefined ? { locator: raw.locator as string } : {}),
165
+ ...(raw.firstRevision !== undefined ? { firstRevision: raw.firstRevision as string } : {}),
166
+ };
167
+ }
168
+
169
+ function normalizeRecipe(raw: unknown): AssetRecipe | null {
170
+ if (!isObject(raw)) return null;
171
+ if (raw.kind === "original") return { kind: "original" };
172
+ if (raw.kind === "sanitized" && typeof raw.sanitizer === "string" && TOKEN_RE.test(raw.sanitizer) &&
173
+ typeof raw.source === "string" && entryAddress(raw.source) !== null) {
174
+ return { kind: "sanitized", sanitizer: raw.sanitizer, source: raw.source };
175
+ }
176
+ if (raw.kind === "variant" && typeof raw.parent === "string" && entryAddress(raw.parent) !== null &&
177
+ Number.isSafeInteger(raw.width) && (raw.width as number) > 0 && (raw.width as number) <= 16384 &&
178
+ typeof raw.format === "string" && TOKEN_RE.test(raw.format) &&
179
+ typeof raw.encoder === "string" && TOKEN_RE.test(raw.encoder)) {
180
+ return { kind: "variant", parent: raw.parent, width: raw.width as number, format: raw.format, encoder: raw.encoder };
181
+ }
182
+ return null;
183
+ }
184
+
185
+ function normalizeTransition(raw: unknown): RetentionTransition | null {
186
+ if (!isObject(raw) || typeof raw.from !== "string" || typeof raw.to !== "string" ||
187
+ !RETENTION_CLASSES.has(raw.from) || !RETENTION_CLASSES.has(raw.to) || !validTime(raw.at) ||
188
+ typeof raw.how !== "string" || !TOKEN_RE.test(raw.how)) return null;
189
+ if (raw.actor !== undefined && (typeof raw.actor !== "string" || !ACTOR_RE.test(raw.actor))) return null;
190
+ return {
191
+ from: raw.from as RetentionClass, to: raw.to as RetentionClass, at: raw.at, how: raw.how,
192
+ ...(raw.actor !== undefined ? { actor: raw.actor as string } : {}),
193
+ };
194
+ }
195
+
196
+ /**
197
+ * The retention facet, or null. Its class must be where its own transition log ends: a facet whose
198
+ * log does not chain, or whose class contradicts the log, is dropped as a whole.
199
+ */
200
+ function normalizeRetention(raw: unknown): RetentionFacet | null {
201
+ if (!isObject(raw) || typeof raw.class !== "string" || !RETENTION_CLASSES.has(raw.class) ||
202
+ !Array.isArray(raw.transitions) || raw.transitions.length > MAX_TRANSITIONS) return null;
203
+ const transitions: RetentionTransition[] = [];
204
+ for (const item of raw.transitions) {
205
+ const transition = normalizeTransition(item);
206
+ if (!transition || !RETENTION_TRANSITIONS[transition.from].includes(transition.to)) return null;
207
+ const previous = transitions[transitions.length - 1];
208
+ if (previous && previous.to !== transition.from) return null;
209
+ transitions.push(transition);
210
+ }
211
+ const last = transitions[transitions.length - 1];
212
+ if ((last ? last.to : "release") !== raw.class) return null;
213
+ return { class: raw.class as RetentionClass, transitions };
214
+ }
215
+
216
+ function sortedUnique(values: readonly string[], limit: number): string[] {
217
+ return [...new Set(values)].sort().slice(0, limit);
218
+ }
219
+
220
+ /**
221
+ * One stored record, facet by facet. Null when the entry or its address is not a pool address: then
222
+ * nothing about it can be trusted. Any other invalid facet is dropped and the rest kept.
223
+ */
224
+ export function normalizeAssetRecord(entry: string, raw: unknown): AssetRecord | null {
225
+ const address = entryAddress(entry);
226
+ if (!address || !isObject(raw) || raw.address !== address) return null;
227
+ const record: AssetRecord = { address };
228
+ if (typeof raw.mediaType === "string" && MEDIA_TYPE_RE.test(raw.mediaType)) record.mediaType = raw.mediaType;
229
+ if (Number.isSafeInteger(raw.size) && (raw.size as number) > 0) record.size = raw.size as number;
230
+ const provenance = normalizeProvenance(raw.provenance);
231
+ if (provenance) record.provenance = provenance;
232
+ if (Array.isArray(raw.tenants)) {
233
+ const tenants = raw.tenants.filter((tenant): tenant is string => typeof tenant === "string" && TENANT_RE.test(tenant));
234
+ if (tenants.length > 0) record.tenants = sortedUnique(tenants, MAX_TENANTS);
235
+ }
236
+ if (typeof raw.owner === "string" && ACTOR_RE.test(raw.owner)) record.owner = raw.owner;
237
+ const recipe = normalizeRecipe(raw.recipe);
238
+ if (recipe) record.recipe = recipe;
239
+ if (Array.isArray(raw.renditions)) {
240
+ const renditions = raw.renditions.filter((item): item is string => typeof item === "string" && entryAddress(item) !== null);
241
+ if (renditions.length > 0) record.renditions = sortedUnique(renditions, MAX_RENDITIONS);
242
+ }
243
+ const verified = normalizeVerified(raw.verified);
244
+ if (verified) record.verified = verified;
245
+ const retention = normalizeRetention(raw.retention);
246
+ if (retention) record.retention = retention;
247
+ return record;
248
+ }
249
+
250
+ /**
251
+ * Parse a stored document. Null when it is not a registry for `store`: the caller then trusts no
252
+ * record and leaves the document alone.
253
+ */
254
+ export function parseAssetRegistry(raw: Uint8Array | string, store: string): AssetRegistry | null {
255
+ let parsed: unknown;
256
+ try {
257
+ parsed = JSON.parse(typeof raw === "string" ? raw : new TextDecoder("utf-8", { fatal: true }).decode(raw));
258
+ } catch { return null; }
259
+ if (!isObject(parsed) || parsed.version !== ASSET_REGISTRY_VERSION || parsed.kind !== "asset-registry" ||
260
+ parsed.store !== store || !isObject(parsed.assets)) return null;
261
+ const registry = emptyAssetRegistry(store);
262
+ for (const [entry, value] of Object.entries(parsed.assets)) {
263
+ const record = normalizeAssetRecord(entry, value);
264
+ if (record && !isEmptyAssetRecord(record)) registry.assets[entry] = record;
265
+ }
266
+ return registry;
267
+ }
268
+
269
+ export function serializeAssetRegistry(registry: AssetRegistry): Uint8Array {
270
+ return new TextEncoder().encode(JSON.stringify(registry));
271
+ }
272
+
273
+ /** True when the record carries nothing but its address. */
274
+ export function isEmptyAssetRecord(record: AssetRecord): boolean {
275
+ return Object.keys(record).length === 1;
276
+ }
277
+
278
+ /**
279
+ * Apply one writer's facts to a record and return the result (`existing` is not mutated).
280
+ *
281
+ * - `mediaType`, `size`, `provenance`, `owner`, `recipe`: the first writer's value stands. These
282
+ * describe immutable bytes, so a later writer has nothing to add.
283
+ * - `tenants`, `renditions`: a union.
284
+ * - `verified`: the latest verification replaces the earlier one.
285
+ * - `retention`: a transition is applied only when the current class allows it
286
+ * ({@link RETENTION_TRANSITIONS}); a write naming the current class is a no-op. `rejected` reports
287
+ * a transition that was not allowed.
288
+ *
289
+ * An invalid fact is ignored, never coerced.
290
+ */
291
+ export function applyAssetWrite(existing: AssetRecord | undefined, write: AssetWrite): { record: AssetRecord | null; rejected?: string } {
292
+ const address = entryAddress(write.entry);
293
+ if (!address || (existing && existing.address !== address)) return { record: existing ?? null };
294
+ const record: AssetRecord = existing ? structuredCloneRecord(existing) : { address };
295
+ if (record.mediaType === undefined && write.mediaType !== undefined && MEDIA_TYPE_RE.test(write.mediaType)) record.mediaType = write.mediaType;
296
+ if (record.size === undefined && Number.isSafeInteger(write.size) && (write.size as number) > 0) record.size = write.size as number;
297
+ if (record.provenance === undefined && write.provenance) {
298
+ const provenance = normalizeProvenance(write.provenance);
299
+ if (provenance) record.provenance = provenance;
300
+ }
301
+ if (write.tenant !== undefined && TENANT_RE.test(write.tenant)) {
302
+ record.tenants = sortedUnique([...(record.tenants ?? []), write.tenant], MAX_TENANTS);
303
+ }
304
+ if (record.owner === undefined && write.owner !== undefined && ACTOR_RE.test(write.owner)) record.owner = write.owner;
305
+ if (record.recipe === undefined && write.recipe) {
306
+ const recipe = normalizeRecipe(write.recipe);
307
+ if (recipe) record.recipe = recipe;
308
+ }
309
+ if (write.rendition !== undefined && entryAddress(write.rendition) !== null) {
310
+ record.renditions = sortedUnique([...(record.renditions ?? []), write.rendition], MAX_RENDITIONS);
311
+ }
312
+ if (write.verified) {
313
+ const verified = normalizeVerified(write.verified);
314
+ if (verified) record.verified = verified;
315
+ }
316
+ let rejected: string | undefined;
317
+ if (write.retention) {
318
+ const from = record.retention?.class ?? "release";
319
+ const transition = normalizeTransition({ from, ...write.retention });
320
+ if (!transition) rejected = "retention-invalid";
321
+ else if (transition.to !== from) {
322
+ if (!RETENTION_TRANSITIONS[from].includes(transition.to)) rejected = `retention ${from} -> ${transition.to} is not allowed`;
323
+ else {
324
+ record.retention = {
325
+ class: transition.to,
326
+ transitions: [...(record.retention?.transitions ?? []), transition].slice(-MAX_TRANSITIONS),
327
+ };
328
+ }
329
+ }
330
+ }
331
+ return rejected ? { record, rejected } : { record };
332
+ }
333
+
334
+ function structuredCloneRecord(record: AssetRecord): AssetRecord {
335
+ return JSON.parse(JSON.stringify(record)) as AssetRecord;
336
+ }
337
+
338
+ /** Apply a batch of writes to a registry, returning a new registry and whether anything changed. */
339
+ export function applyAssetWrites(registry: AssetRegistry, writes: Iterable<AssetWrite>): { registry: AssetRegistry; changed: boolean; rejected: string[] } {
340
+ const next: AssetRegistry = { ...registry, assets: { ...registry.assets } };
341
+ let changed = false;
342
+ const rejected: string[] = [];
343
+ for (const write of writes) {
344
+ const before = next.assets[write.entry];
345
+ const result = applyAssetWrite(before, write);
346
+ if (result.rejected) rejected.push(`${write.entry}: ${result.rejected}`);
347
+ if (!result.record || isEmptyAssetRecord(result.record)) continue;
348
+ if (!before || JSON.stringify(before) !== JSON.stringify(result.record)) {
349
+ next.assets[write.entry] = result.record;
350
+ changed = true;
351
+ }
352
+ }
353
+ return { registry: next, changed, rejected };
354
+ }
355
+
356
+ /**
357
+ * One store's registry document, for a writer outside CI (the Worker's R2 store). `upsert` applies
358
+ * the writes to the current document and puts it back; `lost` means a concurrent writer won and
359
+ * these facts were dropped, which callers treat as the weakening the model allows.
360
+ */
361
+ export interface AssetRegistryStore {
362
+ read(): Promise<AssetRegistry | null>;
363
+ upsert(writes: readonly AssetWrite[]): Promise<"written" | "unchanged" | "lost">;
364
+ }
365
+
366
+ export type AssetAuthorization =
367
+ | { ok: true }
368
+ | { ok: false; code: "asset-unauthorized"; entry: string; message: string };
369
+
370
+ /**
371
+ * May `tenantId` reference the pool object `entry`? Only when a record for it lists the tenant.
372
+ * Content addressing deduplicates bytes across tenants; it never shares authorization: a tenant is
373
+ * listed only by a writer that saw that tenant hold the bytes (its own upload, its own sealed
374
+ * capture, its own built bundle). Pass every registry the caller can read; any one listing the
375
+ * tenant authorizes.
376
+ */
377
+ export function authorizeAssetReference(
378
+ registries: readonly (AssetRegistry | null | undefined)[], tenantId: string, entry: string,
379
+ ): AssetAuthorization {
380
+ if (TENANT_RE.test(tenantId) && entryAddress(entry) !== null &&
381
+ registries.some((registry) => registry?.assets[entry]?.tenants?.includes(tenantId))) return { ok: true };
382
+ return {
383
+ ok: false, code: "asset-unauthorized", entry,
384
+ message: `/_a/${entry} is not registered to this store: it was not uploaded, captured or built by this tenant. ` +
385
+ "Upload the image with `tot image add <file>` (or reference its original source) and use the URL it prints.",
386
+ };
387
+ }
388
+
389
+ /**
390
+ * The tenants a record lists other than `tenantId`, without naming them: true when the address is
391
+ * registered and this tenant is not among its tenants. Capture uses it to refuse a reference to
392
+ * another tenant's image while leaving unregistered legacy references to the publisher.
393
+ */
394
+ export function registeredToOthers(registries: readonly (AssetRegistry | null | undefined)[], tenantId: string, entry: string): boolean {
395
+ let listed = false;
396
+ for (const registry of registries) {
397
+ const tenants = registry?.assets[entry]?.tenants;
398
+ if (!tenants) continue;
399
+ if (tenants.includes(tenantId)) return false;
400
+ listed = true;
401
+ }
402
+ return listed;
403
+ }
@@ -28,7 +28,7 @@ export type CaptureRewrite = {
28
28
  /** A sealed capture-manifest entry, as `original-image-capture.ts` writes it. */
29
29
  export interface CapturedManifestEntry {
30
30
  /** Remote `locator` is query-stripped for display; remote entries are matched by `locatorDigest`. */
31
- provenance: { kind: "local" | "remote"; locator: string };
31
+ provenance: { kind: "local" | "remote" | "upload"; locator: string };
32
32
  address: string;
33
33
  mediaType: string;
34
34
  /** Remote entries: digest of the exact fetched locator, query included (`captureLocatorDigest`). */
@@ -43,3 +43,18 @@ export type CapturableRasterMediaType = (typeof CAPTURABLE_RASTER_MEDIA_TYPES)[n
43
43
 
44
44
  /** Comma-joined for an HTTP `Accept` header -- derived, never a second literal. */
45
45
  export const CAPTURABLE_RASTER_ACCEPT_HEADER = CAPTURABLE_RASTER_MEDIA_TYPES.join(",");
46
+
47
+ /**
48
+ * An author upload may also be an SVG: the upload path is the one ingest that sanitizes it
49
+ * (`svg-sanitize.ts`) before it enters the pool, so a sealed `upload` entry may carry it while a
50
+ * captured (`local`/`remote`) entry never does.
51
+ */
52
+ export const UPLOADABLE_MEDIA_TYPES = Object.freeze([...CAPTURABLE_RASTER_MEDIA_TYPES, "image/svg+xml"] as const);
53
+
54
+ export type UploadableMediaType = (typeof UPLOADABLE_MEDIA_TYPES)[number];
55
+
56
+ /** May a sealed original of provenance `kind` carry `mediaType`? Every reader of a manifest asks this. */
57
+ export function sealedMediaTypeAllowed(kind: unknown, mediaType: unknown): boolean {
58
+ const types: readonly string[] = kind === "upload" ? UPLOADABLE_MEDIA_TYPES : CAPTURABLE_RASTER_MEDIA_TYPES;
59
+ return typeof mediaType === "string" && types.includes(mediaType);
60
+ }
@@ -141,7 +141,7 @@ export async function digestCaptureManifest(manifest: {
141
141
  version: 1;
142
142
  revisionId: string;
143
143
  entries: ReadonlyArray<{
144
- provenance: { kind: "local" | "remote"; locator: string };
144
+ provenance: { kind: "local" | "remote" | "upload"; locator: string };
145
145
  address: string;
146
146
  byteLength: number;
147
147
  mediaType: string;
@@ -0,0 +1,19 @@
1
+ /**
2
+ * Response headers every content-addressed pool image (`/_a/<sha256><ext>`) is served with, on both
3
+ * serving paths (ADR 0038 §12): the Worker sets them on its R2 responses, and each S3 pool image
4
+ * carries the policy as stamped `content-security-policy` metadata that the CloudFront
5
+ * viewer-response function (`scripts/publish/cloudfront/csp-meta-emit.mjs`) emits as the header.
6
+ *
7
+ * A raster loaded by `<img>` ignores the policy. A document opened directly — an SVG navigated to —
8
+ * runs sandboxed: no script, no plugins, no network, an opaque origin. That holds even for bytes a
9
+ * sanitizer would have missed, which is why it is sent for every pool image, not only sanitized
10
+ * ones. Only images carry it: a script a page runs as a worker takes its policy from its own response.
11
+ * Import-free, so plain node reads it.
12
+ */
13
+ export const POOL_ASSET_CONTENT_SECURITY_POLICY =
14
+ "default-src 'none'; img-src 'self' data:; style-src 'unsafe-inline'; sandbox";
15
+
16
+ export const POOL_ASSET_SECURITY_HEADERS: Readonly<Record<string, string>> = Object.freeze({
17
+ "content-security-policy": POOL_ASSET_CONTENT_SECURITY_POLICY,
18
+ "x-content-type-options": "nosniff",
19
+ });
@@ -275,6 +275,12 @@ export interface CatalogProduct {
275
275
  created_at: string; // ISO 8601
276
276
  updated_at: string; // ISO 8601
277
277
  migrated_at: string; // ISO 8601
278
+ /**
279
+ * When the product was created at its source system (ISO 8601). Written only by an importer
280
+ * that read the date from the source; an import time never goes here. It is the only date the
281
+ * "New" badge ages: absent or unreadable means no age-based "New".
282
+ */
283
+ source_created_at?: string;
278
284
  /** Content hash for incremental sync / change detection. */
279
285
  source_checksum: string;
280
286
  /**
@@ -45,6 +45,7 @@ import { formatHtmlCacheControl } from "./html-cache-policy.js";
45
45
  import { NATIVE_GA4_SANDBOX_ASSET_PATH } from "./integration/native-ga4-sandbox-asset.mjs";
46
46
  import { CONTENT_NAMED_ASSET_PREFIXES } from "./content-named-asset-prefixes.mjs";
47
47
  import { resolveRedirect } from "./publication-redirects.mjs";
48
+ import { POOL_ASSET_SECURITY_HEADERS } from "./pool-asset-headers.js";
48
49
 
49
50
  // ---------------------------------------------------------------------------
50
51
  // R2 seam — same structural subset tenant-assets.ts uses (no workers-types dep)
@@ -366,7 +367,9 @@ export function versionIndependentAssetKey(pathname: string): string | null {
366
367
  * Serve a version-independent asset straight from R2, or return null when `pathname` is not one
367
368
  * (or, for `/_astro/`, when the named store does not hold it — the Worker's own build also serves
368
369
  * that prefix, so a miss belongs to the caller). A missing `/_a/` object is a 404: the address is
369
- * exact, so there is nothing else it could mean.
370
+ * exact, so there is nothing else it could mean. A pool image is sent with the sandboxing pool
371
+ * headers (`pool-asset-headers.ts`): the pool is shared, so an SVG opened directly on any tenant's
372
+ * host runs with no script and no network.
370
373
  */
371
374
  export async function serveVersionIndependentAsset(
372
375
  bucket: StaticBundleR2BucketLike,
@@ -383,10 +386,12 @@ export async function serveVersionIndependentAsset(
383
386
  headers: { "cache-control": "no-store", "x-tot-static-bundle": "pinned-miss" },
384
387
  });
385
388
  }
389
+ const contentType = assetContentType(key);
386
390
  const headers: Record<string, string> = {
387
- "content-type": assetContentType(key),
391
+ "content-type": contentType,
388
392
  "cache-control": IMMUTABLE_CACHE_CONTROL,
389
393
  "x-tot-static-bundle": "pinned",
394
+ ...(pathname.startsWith(PINNED_ASSET_URL_PREFIX) && contentType.startsWith("image/") ? POOL_ASSET_SECURITY_HEADERS : {}),
390
395
  };
391
396
  if (typeof object.size === "number") headers["content-length"] = String(object.size);
392
397
  const body: BodyInit | null = method.toUpperCase() === "HEAD"
@@ -0,0 +1,331 @@
1
+ /**
2
+ * The pool's SVG sanitizer (ADR 0038 §12). An SVG is a document that can run script, so the pool
3
+ * accepts one only after this allowlist pass, and serves every pool object with the sandboxing
4
+ * policy in `pool-asset-headers.ts`, which contains whatever a sanitizer could miss.
5
+ *
6
+ * The pass is a strict XML reader, not an HTML parser: the browser reads `image/svg+xml` as XML, so
7
+ * the sanitizer reads it the same way and refuses anything it cannot read exactly (a custom entity,
8
+ * a DOCTYPE internal subset, a processing instruction, an unbalanced tag). What it reads, it keeps
9
+ * only when allowlisted:
10
+ *
11
+ * - elements: static SVG drawing, text, paint servers, clipping, masking and filters. Any other
12
+ * element — `script`, `foreignObject`, animation (`set`/`animate*` can rewrite an `href`),
13
+ * `iframe`, editor metadata, anything namespaced — is removed with its whole subtree;
14
+ * - attributes: event handlers (`on*`) and every namespaced attribute except `xlink:href`,
15
+ * `xml:space` and `xml:lang` are removed;
16
+ * - references (`href`, `xlink:href`, every `url()`): only a same-document fragment (`#id`), a pool
17
+ * object (`/_a/<sha256><ext>`) or an inline raster `data:` image. Any other reference is removed;
18
+ * - `<style>` text with an escape, `@import`, a non-pool `url()` or a script-capable construct
19
+ * refuses the image rather than being half-cleaned.
20
+ *
21
+ * Output is the input with the removed spans cut out, byte for byte otherwise, so an already-clean
22
+ * SVG keeps its bytes and therefore its address. Import-free.
23
+ */
24
+
25
+ export const SVG_SANITIZER_ID = "svg-allowlist-1";
26
+
27
+ const SVG_NS = "http://www.w3.org/2000/svg";
28
+ const XLINK_NS = "http://www.w3.org/1999/xlink";
29
+
30
+ const ALLOWED_ELEMENTS = new Set([
31
+ "svg", "g", "defs", "symbol", "use", "title", "desc", "switch", "a", "view",
32
+ "path", "rect", "circle", "ellipse", "line", "polyline", "polygon",
33
+ "text", "tspan", "textPath", "image", "style",
34
+ "linearGradient", "radialGradient", "stop", "pattern", "clipPath", "mask", "marker",
35
+ "filter", "feBlend", "feColorMatrix", "feComponentTransfer", "feComposite", "feConvolveMatrix",
36
+ "feDiffuseLighting", "feDisplacementMap", "feDistantLight", "feDropShadow", "feFlood", "feFuncA",
37
+ "feFuncB", "feFuncG", "feFuncR", "feGaussianBlur", "feImage", "feMerge", "feMergeNode",
38
+ "feMorphology", "feOffset", "fePointLight", "feSpecularLighting", "feSpotLight", "feTile",
39
+ "feTurbulence",
40
+ ]);
41
+ const ALLOWED_PREFIXED_ATTRIBUTES = new Set(["xlink:href", "xml:space", "xml:lang"]);
42
+
43
+ export type SvgRefusalReason = "too-large" | "not-utf8" | "not-svg" | "malformed" | "entity" | "unsafe-style" | "too-complex";
44
+
45
+ export type SvgSanitizeResult =
46
+ | { ok: true; bytes: Uint8Array; changed: boolean; removedElements: number; removedAttributes: number }
47
+ | { ok: false; reason: SvgRefusalReason; message: string };
48
+
49
+ export interface SvgSanitizeLimits { maxBytes: number; maxElements: number; maxDepth: number }
50
+ const DEFAULT_LIMITS: SvgSanitizeLimits = { maxBytes: 8 * 1024 * 1024, maxElements: 100_000, maxDepth: 256 };
51
+
52
+ class Refusal extends Error {
53
+ readonly reason: SvgRefusalReason;
54
+ readonly detail: string;
55
+ constructor(reason: SvgRefusalReason, detail: string) {
56
+ super(detail);
57
+ this.reason = reason;
58
+ this.detail = detail;
59
+ }
60
+ }
61
+
62
+ const NAME = /[A-Za-z_][A-Za-z0-9._:-]*/y;
63
+ const SPACE = /[ \t\r\n]*/y;
64
+ const ATTRIBUTE = /[ \t\r\n]+([A-Za-z_][A-Za-z0-9._:-]*)[ \t\r\n]*=[ \t\r\n]*("[^"]*"|'[^']*')/y;
65
+ const TAG_END = /[ \t\r\n]*(\/?)>/y;
66
+ const CLOSE_TAG = /<\/([A-Za-z_][A-Za-z0-9._:-]*)[ \t\r\n]*>/y;
67
+ const XML_DECLARATION = /^<\?xml[ \t\r\n]+version[ \t\r\n]*=[ \t\r\n]*(["'])1\.[0-9]\1(?:[ \t\r\n]+encoding[ \t\r\n]*=[ \t\r\n]*(["'])([A-Za-z][A-Za-z0-9._-]*)\2)?(?:[ \t\r\n]+standalone[ \t\r\n]*=[ \t\r\n]*(["'])(?:yes|no)\4)?[ \t\r\n]*\?>/;
68
+
69
+ const FRAGMENT = /^#[A-Za-z_][A-Za-z0-9._:-]*$/;
70
+ const POOL_URL = /^\/_a\/[0-9a-f]{64}\.(?:png|jpg|gif|webp|avif|svg)$/;
71
+ const RASTER_DATA = /^data:image\/(?:png|jpeg|gif|webp|avif);base64,[A-Za-z0-9+/]+={0,2}$/;
72
+
73
+ /** Is `value` a reference the pool allows: a fragment, a pool object, or an inline raster? */
74
+ export function isPoolSafeReference(value: string): boolean {
75
+ const trimmed = value.trim();
76
+ if (FRAGMENT.test(trimmed) || POOL_URL.test(trimmed)) return true;
77
+ return /^data:/i.test(trimmed) && RASTER_DATA.test(trimmed.replace(/[ \t\r\n]+/g, ""));
78
+ }
79
+
80
+ /** Decode XML's five predefined entities and character references; null on any other `&`. */
81
+ function decodeEntities(text: string): string | null {
82
+ if (/&(?!(?:#x[0-9A-Fa-f]{1,6}|#[0-9]{1,7}|amp|lt|gt|quot|apos);)/.test(text)) return null;
83
+ let invalid = false;
84
+ const decoded = text.replace(/&(?:#x([0-9A-Fa-f]{1,6})|#([0-9]{1,7})|(amp|lt|gt|quot|apos));/g, (_all, hex, dec, named) => {
85
+ if (named) return ({ amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" } as Record<string, string>)[named]!;
86
+ const code = hex ? parseInt(hex, 16) : parseInt(dec, 10);
87
+ if (code === 0 || code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) { invalid = true; return ""; }
88
+ return String.fromCodePoint(code);
89
+ });
90
+ return invalid ? null : decoded;
91
+ }
92
+
93
+ /** Every `url(...)` target in `css`, or null when one cannot be read exactly. */
94
+ function urlTargets(css: string): string[] | null {
95
+ const targets: string[] = [];
96
+ const lower = css.toLowerCase();
97
+ let from = 0;
98
+ for (;;) {
99
+ const at = lower.indexOf("url(", from);
100
+ if (at === -1) return targets;
101
+ let i = at + 4;
102
+ while (i < css.length && /[ \t\r\n\f]/.test(css[i]!)) i++;
103
+ let target: string;
104
+ const quote = css[i];
105
+ if (quote === '"' || quote === "'") {
106
+ const end = css.indexOf(quote, i + 1);
107
+ if (end === -1) return null;
108
+ target = css.slice(i + 1, end);
109
+ i = end + 1;
110
+ } else {
111
+ const end = css.indexOf(")", i);
112
+ if (end === -1) return null;
113
+ target = css.slice(i, end).replace(/[ \t\r\n\f]+$/, "");
114
+ if (/[\s"'(]/.test(target)) return null;
115
+ i = end;
116
+ }
117
+ while (i < css.length && /[ \t\r\n\f]/.test(css[i]!)) i++;
118
+ if (css[i] !== ")") return null;
119
+ targets.push(target);
120
+ from = i + 1;
121
+ }
122
+ }
123
+
124
+ const UNSAFE_CSS = ["@import", "@font-face", "@namespace", "expression(", "javascript:", "vbscript:", "-moz-binding",
125
+ "behavior", "image-set(", "cross-fade(", "element(", "src("];
126
+
127
+ /** Why `css` is unsafe, or null when it is safe to keep. */
128
+ function unsafeCss(css: string): string | null {
129
+ if (css.includes("\\")) return "it uses a CSS escape sequence";
130
+ const stripped = css.replace(/\/\*[\s\S]*?\*\//g, "");
131
+ if (stripped.includes("/*")) return "it has an unterminated CSS comment";
132
+ const lower = stripped.toLowerCase();
133
+ const construct = UNSAFE_CSS.find((item) => lower.includes(item));
134
+ if (construct) return `it uses \`${construct}\``;
135
+ const targets = urlTargets(stripped);
136
+ if (!targets) return "it has a url() that cannot be read exactly";
137
+ if (targets.some((target) => !isPoolSafeReference(target))) return "it references a url() outside this image or the pool";
138
+ return null;
139
+ }
140
+
141
+ interface Open { name: string; dropped: boolean; style: { css: string } | null }
142
+
143
+ /**
144
+ * Sanitize one SVG. Refuses (never rasterizes, never guesses) when the document cannot be read
145
+ * exactly; otherwise returns the allowlisted document and how much was removed.
146
+ */
147
+ export function sanitizeSvg(bytes: Uint8Array, limits: Partial<SvgSanitizeLimits> = {}): SvgSanitizeResult {
148
+ const bound = { ...DEFAULT_LIMITS, ...limits };
149
+ const refuse = (reason: SvgRefusalReason, detail: string): SvgSanitizeResult => ({
150
+ ok: false, reason,
151
+ message: `The SVG was refused: ${detail}. Export it again as plain SVG (no scripts, entities or external references), or as PNG or WebP.`,
152
+ });
153
+ if (bytes.byteLength > bound.maxBytes) return refuse("too-large", `it is larger than ${bound.maxBytes} bytes`);
154
+ let source: string;
155
+ try {
156
+ source = new TextDecoder("utf-8", { fatal: true, ignoreBOM: false }).decode(bytes);
157
+ } catch {
158
+ return refuse("not-utf8", "it is not UTF-8 text");
159
+ }
160
+ try {
161
+ return sanitizeText(source, bound);
162
+ } catch (error) {
163
+ if (error instanceof Refusal) return refuse(error.reason, error.detail);
164
+ throw error;
165
+ }
166
+ }
167
+
168
+ function sanitizeText(source: string, limits: SvgSanitizeLimits): SvgSanitizeResult {
169
+ const out: string[] = [];
170
+ const stack: Open[] = [];
171
+ let i = 0;
172
+ let rootSeen = false;
173
+ let rootClosed = false;
174
+ let elements = 0;
175
+ let removedElements = 0;
176
+ let removedAttributes = 0;
177
+ const malformed = (detail: string) => new Refusal("malformed", detail);
178
+
179
+ const declaration = XML_DECLARATION.exec(source);
180
+ if (declaration) {
181
+ const encoding = declaration[3];
182
+ if (encoding && !/^utf-?8$/i.test(encoding)) throw new Refusal("not-utf8", `it declares the ${encoding} encoding`);
183
+ out.push(declaration[0]);
184
+ i = declaration[0].length;
185
+ }
186
+
187
+ while (i < source.length) {
188
+ const open = stack[stack.length - 1];
189
+ const dropped = open?.dropped ?? false;
190
+ if (source.startsWith("<!--", i)) {
191
+ const end = source.indexOf("-->", i + 4);
192
+ if (end === -1) throw malformed("a comment is not closed");
193
+ const body = source.slice(i + 4, end);
194
+ if (body.includes("--") || body.endsWith("-")) throw malformed("a comment contains `--`");
195
+ i = end + 3;
196
+ continue;
197
+ }
198
+ if (source.startsWith("<![CDATA[", i)) {
199
+ const end = source.indexOf("]]>", i + 9);
200
+ if (end === -1) throw malformed("a CDATA section is not closed");
201
+ if (!dropped) {
202
+ if (!open?.style) throw malformed("a CDATA section appears outside <style>");
203
+ open.style.css += source.slice(i + 9, end);
204
+ out.push(source.slice(i, end + 3));
205
+ }
206
+ i = end + 3;
207
+ continue;
208
+ }
209
+ if (source.startsWith("<!DOCTYPE", i)) {
210
+ if (rootSeen) throw malformed("a DOCTYPE appears inside the document");
211
+ const end = source.indexOf(">", i);
212
+ if (end === -1) throw malformed("the DOCTYPE is not closed");
213
+ if (source.slice(i, end).includes("[")) throw new Refusal("entity", "it declares entities in a DOCTYPE internal subset");
214
+ i = end + 1;
215
+ continue;
216
+ }
217
+ if (source.startsWith("<!", i)) throw malformed("it contains a markup declaration");
218
+ if (source.startsWith("<?", i)) throw malformed("it contains a processing instruction");
219
+ if (source.startsWith("</", i)) {
220
+ CLOSE_TAG.lastIndex = i;
221
+ const match = CLOSE_TAG.exec(source);
222
+ if (!match) throw malformed("a closing tag cannot be read");
223
+ const top = stack.pop();
224
+ if (!top || top.name !== match[1]) throw malformed(`</${match[1]}> does not close the open element`);
225
+ if (top.style) {
226
+ const decoded = decodeEntities(top.style.css);
227
+ if (decoded === null) throw new Refusal("entity", "a <style> uses an undefined entity");
228
+ const unsafe = unsafeCss(decoded);
229
+ if (unsafe) throw new Refusal("unsafe-style", `a <style> element is unsafe: ${unsafe}`);
230
+ }
231
+ if (!top.dropped) out.push(match[0]);
232
+ if (stack.length === 0) rootClosed = true;
233
+ i = CLOSE_TAG.lastIndex;
234
+ continue;
235
+ }
236
+ if (source[i] === "<") {
237
+ NAME.lastIndex = i + 1;
238
+ const nameMatch = NAME.exec(source);
239
+ if (!nameMatch) throw malformed("a tag cannot be read");
240
+ const name = nameMatch[0];
241
+ let cursor = NAME.lastIndex;
242
+ const attributes: { name: string; raw: string; value: string }[] = [];
243
+ const seen = new Set<string>();
244
+ for (;;) {
245
+ ATTRIBUTE.lastIndex = cursor;
246
+ const attribute = ATTRIBUTE.exec(source);
247
+ if (!attribute) break;
248
+ const quoted = attribute[2]!;
249
+ const value = quoted.slice(1, -1);
250
+ if (value.includes("<")) throw malformed(`attribute ${attribute[1]} contains \`<\``);
251
+ if (seen.has(attribute[1]!)) throw malformed(`attribute ${attribute[1]} appears twice`);
252
+ seen.add(attribute[1]!);
253
+ attributes.push({ name: attribute[1]!, raw: attribute[0], value });
254
+ cursor = ATTRIBUTE.lastIndex;
255
+ }
256
+ TAG_END.lastIndex = cursor;
257
+ const end = TAG_END.exec(source);
258
+ if (!end) throw malformed(`<${name}> cannot be read`);
259
+ const selfClosing = end[1] === "/";
260
+ i = TAG_END.lastIndex;
261
+
262
+ if (++elements > limits.maxElements) throw new Refusal("too-complex", `it has more than ${limits.maxElements} elements`);
263
+ if (stack.length >= limits.maxDepth) throw new Refusal("too-complex", `it nests deeper than ${limits.maxDepth} elements`);
264
+ const isRoot = stack.length === 0;
265
+ if (isRoot) {
266
+ if (rootClosed) throw malformed("it has more than one root element");
267
+ rootSeen = true;
268
+ const xmlns = attributes.find((attribute) => attribute.name === "xmlns");
269
+ if (name !== "svg" || !xmlns || decodeEntities(xmlns.value) !== SVG_NS) {
270
+ throw new Refusal("not-svg", "its root element is not an <svg> in the SVG namespace");
271
+ }
272
+ }
273
+ // Every attribute value must read exactly, kept element or not.
274
+ const decoded = attributes.map((attribute) => {
275
+ const value = decodeEntities(attribute.value);
276
+ if (value === null) throw new Refusal("entity", `attribute ${attribute.name} uses an undefined entity`);
277
+ return value;
278
+ });
279
+ const keep = !dropped && ALLOWED_ELEMENTS.has(name);
280
+ if (!dropped && !keep) removedElements++;
281
+ if (keep) {
282
+ let tag = `<${name}`;
283
+ attributes.forEach((attribute, index) => {
284
+ if (keepAttribute(attribute.name, decoded[index]!, isRoot)) tag += attribute.raw;
285
+ else removedAttributes++;
286
+ });
287
+ out.push(tag + end[0]);
288
+ }
289
+ if (!selfClosing) stack.push({ name, dropped: !keep, style: keep && name === "style" ? { css: "" } : null });
290
+ else if (isRoot) rootClosed = true;
291
+ continue;
292
+ }
293
+ const next = source.indexOf("<", i);
294
+ const text = source.slice(i, next === -1 ? source.length : next);
295
+ if (stack.length === 0) {
296
+ if (text.trim() !== "") throw malformed("it has text outside the root element");
297
+ out.push(text);
298
+ } else {
299
+ if (text.includes("]]>")) throw malformed("text contains `]]>`");
300
+ if (decodeEntities(text) === null) throw new Refusal("entity", "its text uses an undefined entity");
301
+ if (!dropped) {
302
+ if (open?.style) open.style.css += text;
303
+ out.push(text);
304
+ }
305
+ }
306
+ i = next === -1 ? source.length : next;
307
+ }
308
+ if (!rootSeen) throw new Refusal("not-svg", "it has no root element");
309
+ if (stack.length > 0) throw malformed(`<${stack[stack.length - 1]!.name}> is not closed`);
310
+ const output = out.join("");
311
+ return {
312
+ ok: true, bytes: new TextEncoder().encode(output), changed: output !== source,
313
+ removedElements, removedAttributes,
314
+ };
315
+ }
316
+
317
+ /** Whether a kept element keeps this attribute (its decoded value). */
318
+ function keepAttribute(name: string, value: string, isRoot: boolean): boolean {
319
+ if (/^on/i.test(name)) return false;
320
+ if (name === "xmlns") return isRoot || value === SVG_NS;
321
+ if (name.startsWith("xmlns:")) return name === "xmlns:xlink" && value === XLINK_NS;
322
+ if (name.includes(":") && !ALLOWED_PREFIXED_ATTRIBUTES.has(name)) return false;
323
+ if (name === "href" || name === "xlink:href") return isPoolSafeReference(value);
324
+ if (/javascript:|vbscript:/i.test(value.replace(/[\s\u0000-\u001f]+/g, ""))) return false;
325
+ if (name === "style") return unsafeCss(value) === null;
326
+ if (/url[ \t\r\n\f]*\(/i.test(value)) {
327
+ const targets = urlTargets(value);
328
+ return !!targets && targets.every(isPoolSafeReference) && !/url[ \t\r\n\f]+\(/i.test(value);
329
+ }
330
+ return true;
331
+ }