@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.
- package/apps/storefront/public/shared/commerce-chrome.css +5 -0
- package/apps/storefront/src/components/content/storefront/chrome/SiteFooter.astro +9 -6
- package/apps/storefront/src/lib/assets/captured-original-serve.ts +6 -2
- package/apps/storefront/src/lib/assets/durableR2.ts +39 -18
- package/apps/storefront/src/lib/content/chrome/examples/tokenoftrust.chrome-assignments.example.json +1 -1
- package/apps/storefront/src/lib/content/shared/rawChrome.ts +18 -16
- package/package.json +1 -1
- package/packages/public-runtime/src/chrome.ts +1 -1
- package/packages/public-runtime/src/hosted-image-media-types.ts +34 -6
- package/scripts/tenant/validate.mjs +1 -1
|
@@ -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
|
|
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
|
|
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="
|
|
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,
|
|
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
|
-
!
|
|
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,
|
|
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.
|
|
208
|
-
*
|
|
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,
|
|
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
|
|
272
|
-
*
|
|
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
|
-
|
|
306
|
-
await
|
|
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
|
-
!
|
|
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
|
}
|
|
@@ -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
|
|
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(
|
|
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
|
|
966
|
-
*
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
*
|
|
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
|
-
/**
|
|
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
|