@tokenoftrust/storefront-runner 3.2.0 → 3.3.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.
- package/agency-kit/tools/lib/catalog-model/model.mjs +1 -0
- package/apps/storefront/src/config/tenants.ts +1 -1
- package/apps/storefront/src/lib/assets/assetRegistryR2.ts +67 -0
- package/apps/storefront/src/lib/assets/captured-original-serve.ts +8 -11
- package/apps/storefront/src/lib/assets/durableR2.ts +41 -4
- package/apps/storefront/src/lib/assets/durableR2Bindings.ts +2 -0
- package/apps/storefront/src/lib/catalog/tot/query.ts +5 -6
- package/package.json +1 -1
- package/packages/public-runtime/package.json +3 -0
- package/packages/public-runtime/src/asset-registry.ts +403 -0
- package/packages/public-runtime/src/hosted-image-media-types.ts +15 -0
- package/packages/public-runtime/src/page-csp.d.mts +5 -0
- package/packages/public-runtime/src/page-csp.mjs +27 -0
- package/packages/public-runtime/src/pool-asset-headers.ts +19 -0
- package/packages/public-runtime/src/product.ts +6 -0
- package/packages/public-runtime/src/static-bundle.ts +23 -7
- package/packages/public-runtime/src/svg-sanitize.ts +331 -0
|
@@ -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 {
|
|
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
|
-
|
|
31
|
-
|
|
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>;
|
|
@@ -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
|
-
|
|
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
|
|
33
|
-
*
|
|
34
|
-
*
|
|
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.
|
|
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.
|
|
3
|
+
"version": "3.3.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",
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
/** Types for the shared (S3 publish + Cloudflare serve) per-page CSP resolution. */
|
|
2
|
+
export declare function pageCspFor(
|
|
3
|
+
manifest: { csp?: unknown; perRouteCsp?: Record<string, unknown> | null } | null | undefined,
|
|
4
|
+
relPath: string,
|
|
5
|
+
): { headerName: string; value: string } | null;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { NATIVE_GA4_SANDBOX_ASSET_PATH } from "./integration/native-ga4-sandbox-asset.mjs";
|
|
2
|
+
|
|
3
|
+
/** @param {unknown} value */
|
|
4
|
+
function captureOrNull(value) {
|
|
5
|
+
const capture = /** @type {{headerName?: unknown, value?: unknown} | null | undefined} */ (value);
|
|
6
|
+
return capture && typeof capture.headerName === "string" && typeof capture.value === "string"
|
|
7
|
+
? /** @type {{headerName: string, value: string}} */ (capture)
|
|
8
|
+
: null;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The tenant CSP one HTML document carries, shared by every plane that applies it: the S3 version
|
|
13
|
+
* upload stamps it as object metadata (`planVersionUpload`) and the Cloudflare static-bundle serve
|
|
14
|
+
* emits it as a response header (`serveStaticBundle`), so the two cannot answer differently.
|
|
15
|
+
*
|
|
16
|
+
* A page's own captured policy (`manifest.perRouteCsp[<bundlePath>]`) wins; the home-route
|
|
17
|
+
* `manifest.csp` is only the fallback for a document with no entry of its own. The platform-owned
|
|
18
|
+
* GA4 sandbox document enforces its own hash-pinned meta CSP and takes none.
|
|
19
|
+
* @param {{csp?: unknown, perRouteCsp?: Record<string, unknown> | null} | null | undefined} manifest
|
|
20
|
+
* @param {string} relPath the bundle-relative path of the document
|
|
21
|
+
* @returns {{headerName: string, value: string} | null}
|
|
22
|
+
*/
|
|
23
|
+
export function pageCspFor(manifest, relPath) {
|
|
24
|
+
if (relPath === NATIVE_GA4_SANDBOX_ASSET_PATH) return null;
|
|
25
|
+
const own = manifest?.perRouteCsp ? captureOrNull(manifest.perRouteCsp[relPath]) : null;
|
|
26
|
+
return own ?? captureOrNull(manifest?.csp);
|
|
27
|
+
}
|
|
@@ -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
|
/**
|
|
@@ -42,9 +42,10 @@
|
|
|
42
42
|
*/
|
|
43
43
|
import { assetContentType } from "./tenant-assets.js";
|
|
44
44
|
import { formatHtmlCacheControl } from "./html-cache-policy.js";
|
|
45
|
-
import { NATIVE_GA4_SANDBOX_ASSET_PATH } from "./integration/native-ga4-sandbox-asset.mjs";
|
|
46
45
|
import { CONTENT_NAMED_ASSET_PREFIXES } from "./content-named-asset-prefixes.mjs";
|
|
47
46
|
import { resolveRedirect } from "./publication-redirects.mjs";
|
|
47
|
+
import { pageCspFor } from "./page-csp.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)
|
|
@@ -160,6 +161,8 @@ export interface StaticBundleManifest {
|
|
|
160
161
|
treeDigest: string;
|
|
161
162
|
pinnedNonce: string;
|
|
162
163
|
csp: { headerName: string; value: string } | null;
|
|
164
|
+
/** Each HTML document's OWN captured CSP, keyed by bundle path; `csp` is the fallback (see `pageCspFor`). */
|
|
165
|
+
perRouteCsp?: Record<string, { headerName: string; value: string }>;
|
|
163
166
|
/**
|
|
164
167
|
* The route-document `Cache-Control` this bundle's tenant declared (ADR 0013
|
|
165
168
|
* `serving.htmlCachePolicy`, formatted by `formatHtmlCacheControl` at materialize).
|
|
@@ -221,6 +224,16 @@ function parseManifest(raw: string): StaticBundleManifest | null {
|
|
|
221
224
|
typeof (m.csp as { value?: unknown }).value === "string"
|
|
222
225
|
? (m.csp as { headerName: string; value: string })
|
|
223
226
|
: null;
|
|
227
|
+
let perRouteCsp: StaticBundleManifest["perRouteCsp"];
|
|
228
|
+
if (m.perRouteCsp && typeof m.perRouteCsp === "object") {
|
|
229
|
+
perRouteCsp = {};
|
|
230
|
+
for (const [rel, value] of Object.entries(m.perRouteCsp as Record<string, unknown>)) {
|
|
231
|
+
const capture = value as { headerName?: unknown; value?: unknown } | null;
|
|
232
|
+
if (capture && typeof capture.headerName === "string" && typeof capture.value === "string") {
|
|
233
|
+
perRouteCsp[rel] = { headerName: capture.headerName, value: capture.value };
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
224
237
|
let files: StaticBundleManifest["files"];
|
|
225
238
|
if (m.files && typeof m.files === "object") {
|
|
226
239
|
files = {};
|
|
@@ -252,7 +265,7 @@ function parseManifest(raw: string): StaticBundleManifest | null {
|
|
|
252
265
|
}
|
|
253
266
|
}
|
|
254
267
|
const htmlCacheControl = typeof m.htmlCacheControl === "string" ? m.htmlCacheControl : undefined;
|
|
255
|
-
return { treeDigest: m.treeDigest, pinnedNonce: m.pinnedNonce, csp, htmlCacheControl, files, redirects };
|
|
268
|
+
return { treeDigest: m.treeDigest, pinnedNonce: m.pinnedNonce, csp, perRouteCsp, htmlCacheControl, files, redirects };
|
|
256
269
|
} catch {
|
|
257
270
|
return null;
|
|
258
271
|
}
|
|
@@ -366,7 +379,9 @@ export function versionIndependentAssetKey(pathname: string): string | null {
|
|
|
366
379
|
* Serve a version-independent asset straight from R2, or return null when `pathname` is not one
|
|
367
380
|
* (or, for `/_astro/`, when the named store does not hold it — the Worker's own build also serves
|
|
368
381
|
* 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.
|
|
382
|
+
* exact, so there is nothing else it could mean. A pool image is sent with the sandboxing pool
|
|
383
|
+
* headers (`pool-asset-headers.ts`): the pool is shared, so an SVG opened directly on any tenant's
|
|
384
|
+
* host runs with no script and no network.
|
|
370
385
|
*/
|
|
371
386
|
export async function serveVersionIndependentAsset(
|
|
372
387
|
bucket: StaticBundleR2BucketLike,
|
|
@@ -383,10 +398,12 @@ export async function serveVersionIndependentAsset(
|
|
|
383
398
|
headers: { "cache-control": "no-store", "x-tot-static-bundle": "pinned-miss" },
|
|
384
399
|
});
|
|
385
400
|
}
|
|
401
|
+
const contentType = assetContentType(key);
|
|
386
402
|
const headers: Record<string, string> = {
|
|
387
|
-
"content-type":
|
|
403
|
+
"content-type": contentType,
|
|
388
404
|
"cache-control": IMMUTABLE_CACHE_CONTROL,
|
|
389
405
|
"x-tot-static-bundle": "pinned",
|
|
406
|
+
...(pathname.startsWith(PINNED_ASSET_URL_PREFIX) && contentType.startsWith("image/") ? POOL_ASSET_SECURITY_HEADERS : {}),
|
|
390
407
|
};
|
|
391
408
|
if (typeof object.size === "number") headers["content-length"] = String(object.size);
|
|
392
409
|
const body: BodyInit | null = method.toUpperCase() === "HEAD"
|
|
@@ -499,9 +516,8 @@ export async function serveStaticBundle(
|
|
|
499
516
|
// The reviewed GA4 transport frame is a platform-owned security boundary with
|
|
500
517
|
// its own generated, hash-pinned meta CSP. Applying the tenant page CSP as a
|
|
501
518
|
// second policy would intersect the two and block the reviewed gtag.js origin.
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
}
|
|
519
|
+
const pageCsp = isHtml ? pageCspFor(manifest, rel) : null;
|
|
520
|
+
if (pageCsp) headers[pageCsp.headerName] = pageCsp.value;
|
|
505
521
|
|
|
506
522
|
if (typeof object.size === "number") {
|
|
507
523
|
headers["content-length"] = String(object.size);
|
|
@@ -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
|
+
}
|