@tokenoftrust/storefront-runner 6.1.4 → 6.2.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.
@@ -421,6 +421,11 @@ a.article-card__tag:hover { text-decoration: underline; }
421
421
  .footer-bottom nav ul { display: flex; flex-wrap: wrap; gap: 6px 20px; list-style: none; margin: 0; padding: 0; }
422
422
  .footer-bottom nav a:hover { color: var(--color-primary-contrast); text-decoration: underline; }
423
423
 
424
+ .site-footer[data-variant="minimal"] { padding-block: clamp(1.5rem, 3vw, 2rem); }
425
+ .site-footer[data-variant="minimal"] .footer-bottom { margin-top: 0; padding-top: 0; border-top: 0; }
426
+ .site-footer[data-variant="minimal"] .footer-disclaimer,
427
+ .site-footer[data-variant="minimal"] .footer-warning { margin-top: 0; margin-bottom: 1.5rem; }
428
+
424
429
  /* Source-parity light footer. Native details provide real zero-JS disclosure
425
430
  semantics on mobile; desktop CSS presents the same content as open columns. */
426
431
  .site-footer[data-variant="light-collapsible"] {
@@ -73,10 +73,12 @@ const bottomLinkStamp = (l: ChromeLink, index: number) =>
73
73
  });
74
74
  const newsletterAgeAttestation = resolveNewsletterAgeAttestation(newsletter?.ageAttestation);
75
75
  const collapsible = variant === "light-collapsible";
76
+ const minimal = variant === "minimal";
76
77
  ---
77
78
 
78
79
  <footer class:list={["border-t border-border bg-surface text-text", collapsible && "footer-collapsible pb-[62px] md:pb-0", className]} data-variant={variant}>
79
- <div class="container-prose py-12">
80
+ <div class:list={["container-prose", minimal ? "py-6 md:py-8" : "py-12"]}>
81
+ {!minimal && (
80
82
  <div class="grid gap-10 md:grid-cols-[1.4fr_repeat(3,1fr)]">
81
83
  <!-- Brand -->
82
84
  <div>
@@ -176,10 +178,11 @@ const collapsible = variant === "light-collapsible";
176
178
  })
177
179
  }
178
180
  </div>
181
+ )}
179
182
 
180
183
  <!-- Newsletter -->
181
184
  {
182
- newsletter && (
185
+ !minimal && newsletter && (
183
186
  <div class="mt-10 border-t border-border pt-8">
184
187
  {newsletter.title && <h3 class="font-display text-base font-semibold">{newsletter.title}</h3>}
185
188
  {newsletter.body && <p class="mt-1 text-sm text-muted">{newsletter.body}</p>}
@@ -225,7 +228,7 @@ const collapsible = variant === "light-collapsible";
225
228
 
226
229
  <!-- Payment badges -->
227
230
  {
228
- payments.length > 0 && (
231
+ !minimal && payments.length > 0 && (
229
232
  <div class="mt-8 flex flex-wrap items-center gap-2" aria-label="Accepted payment methods">
230
233
  {payments.map((p) => (
231
234
  <span class="rounded border border-border px-2 py-1 font-mono text-[0.62rem] uppercase tracking-[0.08em] text-muted">
@@ -239,7 +242,7 @@ const collapsible = variant === "light-collapsible";
239
242
  <!-- Merchant legal copy (verbatim) -->
240
243
  {
241
244
  disclaimer?.trim() && (
242
- <p class="mt-8 border-t border-border pt-6 text-xs text-muted" data-footer-disclaimer>
245
+ <p class:list={["text-xs text-muted", !minimal && "mt-8 border-t border-border pt-6"]} data-footer-disclaimer>
243
246
  {disclaimer.trim().split(/\r?\n/).map((line, i) => (
244
247
  <>
245
248
  {i > 0 && <br />}
@@ -253,14 +256,14 @@ const collapsible = variant === "light-collapsible";
253
256
  <!-- Compliance / trust line -->
254
257
  {
255
258
  compliance && (
256
- <p class="mt-8 border-t border-border pt-6 text-xs text-muted">{compliance}</p>
259
+ <p class:list={["text-xs text-muted", minimal ? disclaimer?.trim() && "mt-4" : "mt-8 border-t border-border pt-6"]}>{compliance}</p>
257
260
  )
258
261
  }
259
262
 
260
263
  <!-- Legal bottom bar: text line + optional policy links -->
261
264
  {
262
265
  (legal || bottomLinks.length > 0) && (
263
- <div class="mt-4 flex flex-col gap-3 border-t border-border pt-4 text-xs text-muted sm:flex-row sm:items-center sm:justify-between">
266
+ <div class:list={["flex flex-col gap-3 text-xs text-muted sm:flex-row sm:items-center sm:justify-between", (!minimal || disclaimer?.trim() || compliance) && "mt-4 border-t border-border pt-4"]}>
264
267
  {legal && <p class="font-mono text-[0.68rem] uppercase tracking-[0.08em]">{legal}</p>}
265
268
  {
266
269
  bottomLinks.length > 0 && (
@@ -14,7 +14,8 @@
14
14
  import { sha256Hex } from "@tot/public-runtime";
15
15
  import { IMAGE_LIMITS_CEILING } from "@tot/public-runtime/image-limits";
16
16
  import {
17
- PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE, PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION, sealedMediaTypeAllowed,
17
+ PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE, PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION,
18
+ sealedOriginalEntryAllowed, validSvgSanitizeReceipt, type SvgSanitizeReceipt,
18
19
  } from "@tot/public-runtime/hosted-image-media-types";
19
20
  import { durableAssetsStatusKey, durableCaptureRevisionId, durableRequiredStores, parseDurableAssetsStatus } from "@tot/public-runtime/durable-assets";
20
21
  import { POOL_ASSET_SECURITY_HEADERS } from "@tot/public-runtime/pool-asset-headers";
@@ -49,6 +50,7 @@ export type ManifestEntry = {
49
50
  byteLength: number;
50
51
  mediaType: string;
51
52
  provenance?: { kind: "local" | "remote" | "upload"; locator: string };
53
+ sanitized?: SvgSanitizeReceipt;
52
54
  };
53
55
 
54
56
  function objectKey(tenantId: string, address: string): string {
@@ -89,12 +91,14 @@ async function readManifestEntries(
89
91
  const entry = raw as Partial<ManifestEntry>;
90
92
  if (typeof entry.address !== "string" || !ADDRESS_RE.test(entry.address)) return null;
91
93
  if (!Number.isSafeInteger(entry.byteLength) || entry.byteLength! < 1 || entry.byteLength! > maxEntryBytes ||
92
- !sealedMediaTypeAllowed(entry.provenance?.kind, entry.mediaType)) return null;
94
+ !sealedOriginalEntryAllowed(entry.provenance?.kind, entry.mediaType, entry.sanitized)) return null;
95
+ const sanitized = validSvgSanitizeReceipt(entry.sanitized);
93
96
  entries.push({
94
97
  address: entry.address,
95
98
  byteLength: entry.byteLength!,
96
99
  mediaType: entry.mediaType!,
97
100
  provenance: entry.provenance,
101
+ ...(sanitized ? { sanitized } : {}),
98
102
  });
99
103
  }
100
104
  return entries;
@@ -7,7 +7,7 @@
7
7
  * Order, always: write the references (version manifest already in R2, or the holds), wait while a
8
8
  * bundle-GC sweep holds its lease, then stage and verify every object those references name.
9
9
  */
10
- import { PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION, sealedMediaTypeAllowed } from "@tot/public-runtime/hosted-image-media-types";
10
+ import { PINNED_ASSET_MEDIA_TYPE_BY_EXTENSION, sealedOriginalEntryAllowed } from "@tot/public-runtime/hosted-image-media-types";
11
11
  import {
12
12
  durableAssetsStatusKey,
13
13
  durableCaptureRevisionId,
@@ -19,8 +19,9 @@ import {
19
19
  } from "@tot/public-runtime/durable-assets";
20
20
  import { GC_LEASE_KEY, awaitGcQuiescence } from "@tot/public-runtime/retention-holds";
21
21
  import { authorizeAssetReference, type AssetRegistryStore, type AssetWrite } from "@tot/public-runtime/asset-registry";
22
- import { IMAGE_LIMITS_CEILING } from "@tot/public-runtime/image-limits";
22
+ import { IMAGE_LIMITS_CEILING, IMAGE_TIERS } from "@tot/public-runtime/image-limits";
23
23
  import { incrementalSha256 } from "@tot/public-runtime/incremental-sha256";
24
+ import { sanitizeSvg } from "@tot/public-runtime/svg-sanitize";
24
25
 
25
26
  const MAX_MANIFEST = 1024 * 1024;
26
27
  const POOL_ENTRY_RE = /^([0-9a-f]{64})(\.[A-Za-z0-9]+)$/;
@@ -204,19 +205,35 @@ export function awaitR2Quiescence(bucket: R2BucketLike, waiter: QuiescenceWaiter
204
205
 
205
206
  /**
206
207
  * Present with exactly the source's bytes, writing it create-only when absent; refuses a mismatched
207
- * object. A {@link StreamSource} is streamed into R2 with its sha256, so R2 refuses (writes nothing)
208
- * on any other body; the existing object and the read-back are hashed as streams, never held whole.
208
+ * object. Raster streams are put with their sha256; SVG sources are bounded, hashed and checked
209
+ * against the sanitizer before any existing-object shortcut, then those exact bytes are put.
210
+ * The existing object and the read-back are hashed as streams.
209
211
  */
210
212
  export async function ensurePoolObject(bucket: R2BucketLike, key: string, source: Uint8Array | StreamSource, mediaType: string): Promise<void> {
211
213
  const address = POOL_ENTRY_RE.exec(key.replace(/^assets\//, ""))?.[1];
212
214
  if (!address) throw new R2StagingError("pool-key");
213
215
  const size = source instanceof Uint8Array ? source.byteLength : source.size;
216
+ let verifiedSource = source;
217
+ if (mediaType === "image/svg+xml") {
218
+ // SVG bytes must already be sanitizer output, even when the shared pool has this address.
219
+ const limit = IMAGE_TIERS.standard.maxEncodedBytes;
220
+ if (!Number.isSafeInteger(size) || size < 1 || size > limit) throw new R2StagingError("unsupported-format");
221
+ const bytes = source instanceof Uint8Array ? source.slice()
222
+ : await readBounded({ get: async () => ({ size, body: await source.open() }) }, key, limit);
223
+ if (!bytes || bytes.byteLength !== size) throw new R2StagingError("original-missing");
224
+ const hash = incrementalSha256();
225
+ await hash.update(bytes);
226
+ if (await hash.hex() !== address) throw new R2StagingError("original-missing");
227
+ const inspected = sanitizeSvg(bytes, { maxBytes: limit });
228
+ if (!inspected.ok || inspected.changed) throw new R2StagingError("unsupported-format");
229
+ verifiedSource = bytes;
230
+ }
214
231
  // Bounded by the size being staged: an object of any other size cannot hold this address.
215
232
  const existing = await digestBounded(bucket, key, size);
216
233
  if (existing && (existing.sha256 !== address || existing.size !== size)) throw new R2StagingError("store-mismatch");
217
234
  if (!existing) {
218
235
  if (!bucket.put) throw new R2StagingError("store-unwritable");
219
- await bucket.put(key, source instanceof Uint8Array ? source : exactLength(await source.open(), size), {
236
+ await bucket.put(key, verifiedSource instanceof Uint8Array ? verifiedSource : exactLength(await verifiedSource.open(), size), {
220
237
  httpMetadata: { contentType: mediaType, cacheControl: IMMUTABLE },
221
238
  onlyIf: new Headers({ "If-None-Match": "*" }),
222
239
  sha256: address,
@@ -268,8 +285,8 @@ async function capturedSource(captureBucket: R2BucketLike, tenant: string, entry
268
285
  * tenant's own capture store (which registers it); otherwise nothing is staged and the go-live
269
286
  * refuses `asset-unauthorized`.
270
287
  *
271
- * Originals are hashed and copied as streams, one at a time, within the image ceiling: none is ever
272
- * held whole.
288
+ * Originals are staged one at a time within the image ceiling. Rasters stream; SVGs are held
289
+ * within the sanitizer's bound for fixed-point validation before staging.
273
290
  */
274
291
  export async function stageCapturedOriginalsR2(input: {
275
292
  tenant: string;
@@ -290,22 +307,26 @@ export async function stageCapturedOriginalsR2(input: {
290
307
  if (own) owned.set(entry, own.size);
291
308
  else unauthorized.push(entry);
292
309
  }
293
- if (input.registry && owned.size > 0) {
294
- const writes: AssetWrite[] = [...owned].map(([entry, size]) => ({
295
- entry, tenant: input.tenant, mediaType: mediaTypeOf(entry), size,
296
- }));
297
- await input.registry.upsert(writes).catch(() => "lost");
298
- }
299
310
  if (unauthorized.length > 0) throw new R2StagingError("asset-unauthorized");
300
311
  let staged = 0;
301
312
  for (const entry of input.entries) {
302
313
  const key = `assets/${entry}`;
303
- const present = await digestBounded(input.publicBucket, key, IMAGE_LIMITS_CEILING.maxEncodedBytes);
304
314
  const address = POOL_ENTRY_RE.exec(entry)?.[1];
305
- if (present && address && present.sha256 === address) continue;
306
- await ensurePoolObject(input.publicBucket, key, await capturedSource(input.captureBucket, input.tenant, entry), mediaTypeOf(entry));
315
+ const mediaType = mediaTypeOf(entry);
316
+ const present = address ? await verifiedObjectSource(input.publicBucket, key, address, IMAGE_LIMITS_CEILING.maxEncodedBytes) : null;
317
+ if (present) {
318
+ if (mediaType === "image/svg+xml") await ensurePoolObject(input.publicBucket, key, present, mediaType);
319
+ continue;
320
+ }
321
+ await ensurePoolObject(input.publicBucket, key, await capturedSource(input.captureBucket, input.tenant, entry), mediaType);
307
322
  staged++;
308
323
  }
324
+ if (input.registry && owned.size > 0) {
325
+ const writes: AssetWrite[] = [...owned].map(([entry, size]) => ({
326
+ entry, tenant: input.tenant, mediaType: mediaTypeOf(entry), size,
327
+ }));
328
+ await input.registry.upsert(writes).catch(() => "lost");
329
+ }
309
330
  return staged;
310
331
  }
311
332
 
@@ -360,7 +381,7 @@ export async function designateDurableR2(input: {
360
381
  if (manifestBytes) {
361
382
  const manifest = JSON.parse(new TextDecoder().decode(manifestBytes)) as {
362
383
  version?: unknown; revisionId?: unknown;
363
- entries?: Array<{ address?: unknown; byteLength?: unknown; mediaType?: unknown; provenance?: { kind?: unknown; locator?: unknown } }>;
384
+ entries?: Array<{ address?: unknown; byteLength?: unknown; mediaType?: unknown; sanitized?: unknown; provenance?: { kind?: unknown; locator?: unknown } }>;
364
385
  };
365
386
  if (manifest.version !== 1 || manifest.revisionId !== revision || !Array.isArray(manifest.entries)) {
366
387
  throw new R2StagingError("manifest-invalid");
@@ -368,7 +389,7 @@ export async function designateDurableR2(input: {
368
389
  for (const entry of manifest.entries) {
369
390
  if (typeof entry?.address !== "string" || !/^[0-9a-f]{64}$/.test(entry.address) ||
370
391
  typeof entry.byteLength !== "number" || typeof entry.mediaType !== "string" ||
371
- !sealedMediaTypeAllowed(entry.provenance?.kind, entry.mediaType) ||
392
+ !sealedOriginalEntryAllowed(entry.provenance?.kind, entry.mediaType, entry.sanitized) ||
372
393
  typeof entry.provenance?.locator !== "string") throw new R2StagingError("manifest-invalid");
373
394
  byLocator.set(entry.provenance.locator, { address: entry.address, byteLength: entry.byteLength, mediaType: entry.mediaType });
374
395
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "assignments": {
3
3
  "/contact/contact-sales": { "headerVariant": "minimal" },
4
- "/contact/contact-sales/thank-you": { "headerVariant": "minimal" }
4
+ "/contact/contact-sales/thank-you": { "headerVariant": "minimal", "footerVariant": "minimal" }
5
5
  }
6
6
  }
@@ -49,11 +49,12 @@ import type {
49
49
  ChromeNavItem,
50
50
  ChromeSearch,
51
51
  ChromeUtilityNav,
52
+ FooterVariant,
52
53
  HeaderVariant,
53
54
  SocialLink,
54
55
  } from "../chrome/model.js";
55
56
  import { isSafeAgeGateHref, resolveAgeGateCopy, resolveChromeAssignment } from "../chrome/model.js";
56
- import { resolveHeaderVariant } from "../chrome/variant.js";
57
+ import { resolveFooterVariant, resolveHeaderVariant } from "../chrome/variant.js";
57
58
 
58
59
  /** HTML-escape text/attribute content. Covers the five significant characters. */
59
60
  function esc(value: string): string {
@@ -534,11 +535,12 @@ function renderFooterNewsletter(n: NonNullable<ChromeFooter["newsletter"]>): str
534
535
  );
535
536
  }
536
537
 
537
- function renderFooter(footer: ChromeFooter): string {
538
- const collapsible = footer.variant === "light-collapsible";
538
+ function renderFooter(footer: ChromeFooter, variant: FooterVariant): string {
539
+ const minimal = variant === "minimal";
540
+ const collapsible = variant === "light-collapsible";
539
541
  const brand = footer.brand;
540
542
  const brandBlock =
541
- brand || footer.newsletter
543
+ !minimal && (brand || footer.newsletter)
542
544
  ? (() => {
543
545
  const logo = brand?.logo && /^(https?:|\/)/.test(brand.logo.src)
544
546
  ? `<img src="${esc(brand.logo.src)}" alt="${esc(brand.logo.alt)}"${
@@ -556,7 +558,7 @@ function renderFooter(footer: ChromeFooter): string {
556
558
  // — scope indices count RENDERED grid columns, so they describe where a link
557
559
  // actually is rather than where it sat in the authored array.
558
560
  const { gridColumns, legalLinks } = splitFooterLegal(footer.columns);
559
- const columns = gridColumns
561
+ const columns = (minimal ? [] : gridColumns)
560
562
  .map((c, colIndex) => {
561
563
  const links = c.links
562
564
  .map((l, i) => {
@@ -576,15 +578,15 @@ function renderFooter(footer: ChromeFooter): string {
576
578
  : ` <div class="footer-col">\n <p class="footer-col-title">${esc(c.title)}</p>\n <ul>${links}</ul>\n </div>`;
577
579
  })
578
580
  .join("\n");
579
- const trust = footer.trustMarks?.length
581
+ const trust = !minimal && footer.trustMarks?.length
580
582
  ? `\n <div class="trust-marks" aria-label="Store commitments">${footer.trustMarks
581
583
  .map((t) => `<span><svg viewBox="0 0 24 24">${t.icon}</svg>${esc(t.label)}</span>`)
582
584
  .join("")}</div>`
583
585
  : "";
584
- const social = footer.social?.length
586
+ const social = !minimal && footer.social?.length
585
587
  ? `\n <div class="footer-social">${renderSocial(footer.social)}</div>`
586
588
  : "";
587
- const payments = footer.payments?.length
589
+ const payments = !minimal && footer.payments?.length
588
590
  ? `\n <div class="pay-badges" aria-label="Accepted payments">${footer.payments
589
591
  .map((p) => `<span>${esc(p)}</span>`)
590
592
  .join("")}</div>`
@@ -620,9 +622,9 @@ function renderFooter(footer: ChromeFooter): string {
620
622
  const bottom =
621
623
  bottomText || legalNav ? `\n <div class="footer-bottom">${bottomText}${legalNav}</div>` : "";
622
624
  return (
623
- `<footer class="site-footer" data-variant="${esc(footer.variant)}">\n` +
625
+ `<footer class="site-footer" data-variant="${esc(variant)}">\n` +
624
626
  ` <div class="container">\n` +
625
- ` <div class="footer-grid">\n${brandBlock}${columns}\n </div>` +
627
+ (minimal ? "" : ` <div class="footer-grid">\n${brandBlock}${columns}\n </div>`) +
626
628
  `${trust}${social}${payments}${disclaimer}${warning}${bottom}\n` +
627
629
  ` </div>\n` +
628
630
  `</footer>`
@@ -962,10 +964,8 @@ function faviconMime(href: string): string {
962
964
  * `aria-current="page"` on the primary-nav link that exactly matches it.
963
965
  *
964
966
  * `assignments`, when given, is the tenant's page-assignment manifest: the
965
- * entry matching `currentPath` overrides `chrome.header.variant` for THIS page
966
- * only. Resolution happens once, here, through the same fail-closed
967
- * resolveHeaderVariant the Layout.astro prop path uses, and the resolved value
968
- * is what reaches the markup.
967
+ * entry matching `currentPath` overrides the header/footer variants for this page
968
+ * through the same fail-closed resolvers the Layout.astro prop path uses.
969
969
  */
970
970
  export function renderRawChrome(
971
971
  chrome: ChromeConfig,
@@ -987,10 +987,12 @@ export function renderRawChrome(
987
987
  .filter(Boolean)
988
988
  .join("\n");
989
989
 
990
+ const assignment = resolveChromeAssignment(assignments, currentPath);
990
991
  const headerVariant = resolveHeaderVariant(
991
- resolveChromeAssignment(assignments, currentPath)?.headerVariant,
992
+ assignment?.headerVariant,
992
993
  chrome.header.variant,
993
994
  );
995
+ const footerVariant = resolveFooterVariant(assignment?.footerVariant, chrome.footer.variant);
994
996
 
995
997
  const skipHref = esc(chrome.skipLink?.href ?? "#main");
996
998
  const skipLabel = esc(chrome.skipLink?.label ?? "Skip to main content");
@@ -1006,7 +1008,7 @@ export function renderRawChrome(
1006
1008
  chrome.complianceStrip
1007
1009
  ? `<div class="compliance-strip"><div class="container"><p>${esc(chrome.complianceStrip)}</p></div></div>`
1008
1010
  : "",
1009
- renderFooter(chrome.footer),
1011
+ renderFooter(chrome.footer, footerVariant),
1010
1012
  ].filter(Boolean);
1011
1013
 
1012
1014
  return (
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "6.1.4",
3
+ "version": "6.2.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",
@@ -289,7 +289,7 @@ export const HEADER_VARIANTS = ["primary", "minimal", "compact-centered"] as con
289
289
  export type HeaderVariant = (typeof HEADER_VARIANTS)[number];
290
290
 
291
291
  /** Footer content shapes. The string union keeps presentation explicit and validated. */
292
- export const FOOTER_VARIANTS = ["default", "light-collapsible"] as const;
292
+ export const FOOTER_VARIANTS = ["default", "light-collapsible", "minimal"] as const;
293
293
  export type FooterVariant = (typeof FOOTER_VARIANTS)[number];
294
294
 
295
295
  export function isHeaderVariant(v: unknown): v is HeaderVariant {
@@ -17,8 +17,9 @@
17
17
  * - CAPTURABLE_RASTER_MEDIA_TYPES: the raster formats capture reads by their magic
18
18
  * bytes. An SVG is never stored as fetched: capture and upload pass it through the
19
19
  * one sanitizer (`svg-sanitize.ts`) and store only its output, which is what lets a
20
- * sealed `remote` or `upload` entry carry `image/svg+xml` (`sealedMediaTypeAllowed`).
20
+ * sealed local, remote or upload entry carry `image/svg+xml`.
21
21
  */
22
+ import { SVG_SANITIZER_ID } from "@tot/public-runtime/svg-sanitize";
22
23
 
23
24
  export const PINNED_ASSET_EXTENSION_BY_MEDIA_TYPE: Readonly<Record<string, string>> = Object.freeze({
24
25
  "image/png": ".png",
@@ -42,9 +43,7 @@ export const CAPTURABLE_RASTER_MEDIA_TYPES = Object.freeze([
42
43
  export type CapturableRasterMediaType = (typeof CAPTURABLE_RASTER_MEDIA_TYPES)[number];
43
44
 
44
45
  /**
45
- * An author upload or a remote capture may also be an SVG: both pass it through the sanitizer
46
- * (`svg-sanitize.ts`) and store only its output, so a sealed `upload` or `remote` entry may carry
47
- * it. A `local` entry is a repo file capture reads by raster magic, never an SVG.
46
+ * Capture and author uploads may carry SVG only as the existing sanitizer's output.
48
47
  */
49
48
  export const UPLOADABLE_MEDIA_TYPES = Object.freeze([...CAPTURABLE_RASTER_MEDIA_TYPES, "image/svg+xml"] as const);
50
49
 
@@ -53,8 +52,37 @@ export type UploadableMediaType = (typeof UPLOADABLE_MEDIA_TYPES)[number];
53
52
  /** Comma-joined for a remote capture's HTTP `Accept` header -- derived, never a second literal. */
54
53
  export const CAPTURE_ACCEPT_HEADER = UPLOADABLE_MEDIA_TYPES.join(",");
55
54
 
56
- /** May a sealed original of provenance `kind` carry `mediaType`? Every reader of a manifest asks this. */
55
+ /** Media-type lookup only; manifest readers must use sealedOriginalEntryAllowed for receipt policy. */
57
56
  export function sealedMediaTypeAllowed(kind: unknown, mediaType: unknown): boolean {
58
- const types: readonly string[] = kind === "upload" || kind === "remote" ? UPLOADABLE_MEDIA_TYPES : CAPTURABLE_RASTER_MEDIA_TYPES;
57
+ const types: readonly string[] = kind === "upload" || kind === "remote" || kind === "local" ? UPLOADABLE_MEDIA_TYPES : CAPTURABLE_RASTER_MEDIA_TYPES;
59
58
  return typeof mediaType === "string" && types.includes(mediaType);
60
59
  }
60
+
61
+ /** Provenance of the sanitize pass; it does not authorize or verify the stored object's bytes. */
62
+ export interface SvgSanitizeReceipt {
63
+ sanitizer: string;
64
+ removedElements: number;
65
+ removedAttributes: number;
66
+ source: string;
67
+ }
68
+
69
+ /** Normalize the supported sanitizer's receipt, discarding untrusted extra fields. */
70
+ export function validSvgSanitizeReceipt(raw: unknown): SvgSanitizeReceipt | null {
71
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
72
+ const receipt = raw as Partial<SvgSanitizeReceipt>;
73
+ if (receipt.sanitizer !== SVG_SANITIZER_ID || typeof receipt.source !== "string" || !/^[0-9a-f]{64}$/.test(receipt.source)) return null;
74
+ if (!Number.isSafeInteger(receipt.removedElements) || (receipt.removedElements as number) < 0) return null;
75
+ if (!Number.isSafeInteger(receipt.removedAttributes) || (receipt.removedAttributes as number) < 0) return null;
76
+ return {
77
+ sanitizer: receipt.sanitizer, removedElements: receipt.removedElements as number,
78
+ removedAttributes: receipt.removedAttributes as number, source: receipt.source,
79
+ };
80
+ }
81
+
82
+ /** A sealed capture SVG requires its receipt; upload provenance resides in the upload registry. */
83
+ export function sealedOriginalEntryAllowed(kind: unknown, mediaType: unknown, sanitized: unknown): boolean {
84
+ if (kind !== "local" && kind !== "remote" && kind !== "upload") return false;
85
+ if (!sealedMediaTypeAllowed(kind, mediaType)) return false;
86
+ const needsReceipt = kind !== "upload" && mediaType === "image/svg+xml";
87
+ return needsReceipt ? validSvgSanitizeReceipt(sanitized) !== null : sanitized === undefined;
88
+ }
@@ -203,7 +203,7 @@ function validateRawHtmlBody(html) {
203
203
  // packages/public-runtime/tests/chrome-config-shape-parity.test.ts. Kept in
204
204
  // step with packages/cli/src/validate.mjs's identical copy.
205
205
  const CHROME_HEADER_VARIANTS = new Set(["primary", "minimal", "compact-centered"]);
206
- const CHROME_FOOTER_VARIANTS = new Set(["default", "light-collapsible"]);
206
+ const CHROME_FOOTER_VARIANTS = new Set(["default", "light-collapsible", "minimal"]);
207
207
  /**
208
208
  * The declarative-interaction-tracking rule, mirrored: every actionable item —
209
209
  * an object carrying both `id` and `href` — declares EXACTLY ONE of a non-empty