@tokenoftrust/storefront-runner 4.11.15 → 4.12.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/agency-kit/tools/lib/public-binaries.mjs +3 -3
- package/apps/storefront/src/lib/access/auth/session.ts +12 -0
- package/apps/storefront/src/lib/access/dashboard/staffStoreEntry.ts +29 -14
- package/apps/storefront/src/lib/activity/uiActor.ts +5 -2
- package/apps/storefront/src/lib/developer-experience/http/fetchTransport.ts +2 -0
- package/apps/storefront/src/middleware/index.ts +11 -1
- package/apps/storefront/src/middleware/renderChain.ts +5 -7
- package/package.json +1 -1
- package/packages/public-runtime/src/capture-rewrites.ts +19 -3
- package/packages/public-runtime/src/customization-runtime.ts +10 -6
- package/packages/public-runtime/src/image-dependencies.ts +31 -0
- package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +11 -2
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tenant binaries never live in the tenant repo. The platform's content-addressed `/_a/<sha256>`
|
|
3
|
-
* pool owns them and the store's repo holds references only.
|
|
4
|
-
*
|
|
5
|
-
*
|
|
3
|
+
* pool owns them and the store's repo holds references only. Publication captures source images;
|
|
4
|
+
* go-live requires zero uncaptured images in every type and sampled live owned-image responses
|
|
5
|
+
* before the source store is shut down. This decides,
|
|
6
6
|
* from a listing of the store's `public/` tree, whether the repo is carrying binaries it must not.
|
|
7
7
|
*
|
|
8
8
|
* Findings, each naming its remedy:
|
|
@@ -204,6 +204,18 @@ export async function updateSession(
|
|
|
204
204
|
return next;
|
|
205
205
|
}
|
|
206
206
|
|
|
207
|
+
/** Persist an authenticated record under a fresh id without extending its lifetime. */
|
|
208
|
+
export async function rotateSession(
|
|
209
|
+
store: SessionStore,
|
|
210
|
+
record: SessionRecord,
|
|
211
|
+
nowSeconds: number,
|
|
212
|
+
): Promise<{ id: string; record: SessionRecord } | null> {
|
|
213
|
+
if (record.exp <= nowSeconds) return null;
|
|
214
|
+
const id = newSessionId();
|
|
215
|
+
await store.put(id, JSON.stringify(record), record.exp - nowSeconds);
|
|
216
|
+
return { id, record };
|
|
217
|
+
}
|
|
218
|
+
|
|
207
219
|
/** Revoke a session (logout / admin kill). Idempotent. */
|
|
208
220
|
export async function revokeSession(
|
|
209
221
|
store: SessionStore,
|
|
@@ -10,15 +10,15 @@
|
|
|
10
10
|
* minted from the VERIFIED session email (no plaintext email on the wire);
|
|
11
11
|
* 2. tot20 re-checks staff scope, scopes the capability, and writes the AUDIT row —
|
|
12
12
|
* it is the authority, and nothing is admitted without its ok;
|
|
13
|
-
* 3.
|
|
14
|
-
* entry),
|
|
13
|
+
* 3. persist the short-lived {@link StaffSelection} under a fresh session id (never a
|
|
14
|
+
* `tenants[]` entry), preserving the original expiry; re-entry re-audits;
|
|
15
15
|
* 4. hand every exit's outcome to the caller's `emit`, which records it as
|
|
16
16
|
* `dashboard.vendor_entered` against its opaque admin actor id (the email is
|
|
17
17
|
* never stored).
|
|
18
18
|
*/
|
|
19
19
|
import type { SessionRecord, SessionStore, StaffSelection } from "@/lib/access/auth/session";
|
|
20
|
-
import {
|
|
21
|
-
import { isStaffSession, buildStaffSelection } from "./staffAdmission";
|
|
20
|
+
import { readSession, rotateSession } from "@/lib/access/auth/session";
|
|
21
|
+
import { isStaffSession, buildStaffSelection, STAFF_SELECTION_TTL_SECONDS } from "./staffAdmission";
|
|
22
22
|
import {
|
|
23
23
|
createStaffVendorAccessClient,
|
|
24
24
|
staffAccessFailureMessage,
|
|
@@ -41,18 +41,19 @@ export interface StaffStoreEntryInput {
|
|
|
41
41
|
/** Records each entry outcome; the route binds it to its opaque admin actor. */
|
|
42
42
|
emit: (event: StaffEntryEvent) => void;
|
|
43
43
|
/** The persisted session to stamp the selection on; null when there is no store. */
|
|
44
|
-
|
|
44
|
+
/** A null id is a freshly verified, not-yet-persisted sign-in record. */
|
|
45
|
+
sessionHandle: () => Promise<{ store: SessionStore; id: string | null } | null>;
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
export type StaffStoreEntryResult =
|
|
48
|
-
| { ok: true; selection: StaffSelection; capability: string }
|
|
49
|
+
| { ok: true; selection: StaffSelection; capability: string; sessionId: string; expiresIn: number }
|
|
49
50
|
| {
|
|
50
51
|
ok: false;
|
|
51
52
|
status: number;
|
|
52
53
|
body: { error: string; reason?: string; code?: string };
|
|
53
54
|
};
|
|
54
55
|
|
|
55
|
-
/**
|
|
56
|
+
/** Audit with tot20, then persist the selection under a fresh, expiry-preserving id. */
|
|
56
57
|
export async function enterStoreAsStaff(input: StaffStoreEntryInput): Promise<StaffStoreEntryResult> {
|
|
57
58
|
const { session, emit } = input;
|
|
58
59
|
const vendorAppDomain = input.vendorAppDomain.trim();
|
|
@@ -99,6 +100,7 @@ export async function enterStoreAsStaff(input: StaffStoreEntryInput): Promise<St
|
|
|
99
100
|
resource: vendorAppDomain,
|
|
100
101
|
capability: result.capability,
|
|
101
102
|
nowSeconds,
|
|
103
|
+
ttlSeconds: Math.max(1, Math.min(STAFF_SELECTION_TTL_SECONDS, session.exp - nowSeconds)),
|
|
102
104
|
});
|
|
103
105
|
const handle = await input.sessionHandle();
|
|
104
106
|
if (!handle) {
|
|
@@ -111,12 +113,25 @@ export async function enterStoreAsStaff(input: StaffStoreEntryInput): Promise<St
|
|
|
111
113
|
});
|
|
112
114
|
return { ok: false, status: 503, body: { error: "session store unavailable; cannot persist admission" } };
|
|
113
115
|
}
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
handle.id,
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
116
|
+
let updated;
|
|
117
|
+
try {
|
|
118
|
+
const current = handle.id ? await readSession(handle.store, handle.id, nowSeconds) : session;
|
|
119
|
+
updated = current && current.email === session.email && current.subject === session.subject && isStaffSession(current) && current.exp > nowSeconds
|
|
120
|
+
? await rotateSession(handle.store, {
|
|
121
|
+
...current,
|
|
122
|
+
createdAt: session.createdAt,
|
|
123
|
+
exp: Math.min(current.exp, session.exp),
|
|
124
|
+
staffSelection: { ...selection, exp: Math.min(selection.exp, current.exp, session.exp) },
|
|
125
|
+
}, nowSeconds)
|
|
126
|
+
: null;
|
|
127
|
+
} catch {
|
|
128
|
+
emit({
|
|
129
|
+
outcome: { status: "failed", errorClass: "session_store_unavailable" },
|
|
130
|
+
scope: { tenantId: vendorAppDomain },
|
|
131
|
+
payload: { args: { resource: vendorAppDomain, capability: result.capability } },
|
|
132
|
+
});
|
|
133
|
+
return { ok: false, status: 503, body: { error: "session store unavailable; cannot persist admission" } };
|
|
134
|
+
}
|
|
120
135
|
if (!updated) {
|
|
121
136
|
emit({
|
|
122
137
|
outcome: { status: "failed", errorClass: "session_expired" },
|
|
@@ -131,5 +146,5 @@ export async function enterStoreAsStaff(input: StaffStoreEntryInput): Promise<St
|
|
|
131
146
|
scope: { tenantId: vendorAppDomain },
|
|
132
147
|
payload: { args: { resource: vendorAppDomain, capability: result.capability } },
|
|
133
148
|
});
|
|
134
|
-
return { ok: true, selection, capability: result.capability };
|
|
149
|
+
return { ok: true, selection, capability: result.capability, sessionId: updated.id, expiresIn: updated.record.exp - nowSeconds };
|
|
135
150
|
}
|
|
@@ -49,6 +49,7 @@ import type {
|
|
|
49
49
|
} from "@tot/public-runtime";
|
|
50
50
|
import type { ActionKey } from "@tot/public-runtime";
|
|
51
51
|
import { readViewerSession } from "@/lib/access/auth/route";
|
|
52
|
+
import type { SessionRecord } from "@/lib/access/auth/session";
|
|
52
53
|
import { sessionHostCapability } from "@/lib/access/auth/loginGate";
|
|
53
54
|
import { isStaffSession } from "@/lib/access/dashboard/staffAdmission";
|
|
54
55
|
import { readEnv } from "@/lib/publication/shared/env";
|
|
@@ -85,6 +86,8 @@ export function classifyActorKind(
|
|
|
85
86
|
}
|
|
86
87
|
|
|
87
88
|
export interface ResolveUiActorInput {
|
|
89
|
+
/** An already verified record for sign-in paths that do not yet have a cookie. */
|
|
90
|
+
session?: SessionRecord;
|
|
88
91
|
/**
|
|
89
92
|
* Fallback acting identity for the HEADLESS path (no signed-in viewer) — typically
|
|
90
93
|
* the resolved tenant owner (`auth.owner`). Only used to seed the opaque id when
|
|
@@ -107,12 +110,12 @@ export async function resolveUiActor(
|
|
|
107
110
|
): Promise<ActivityActor> {
|
|
108
111
|
// The host-bound `locals.viewer` chrome omits `staff[]`, so read the full session
|
|
109
112
|
// to tell a ToT-staff (admin) actor from a store owner (merchant). Best-effort.
|
|
110
|
-
const session = await readViewerSession(context).catch(() => null);
|
|
113
|
+
const session = input.session ?? await readViewerSession(context).catch(() => null);
|
|
111
114
|
// The signed-in identity is the host gate's `locals.viewer`, else the raw session (a
|
|
112
115
|
// route that never went through that gate, e.g. the /dev cockpit's rendezvous
|
|
113
116
|
// approval). Ownership is the session's standing on the ROUTED tenant only — a
|
|
114
117
|
// session that owns another store is not this store's merchant.
|
|
115
|
-
const email = context.locals?.viewer?.email ?? session?.email;
|
|
118
|
+
const email = input.session ? input.session.email : context.locals?.viewer?.email ?? session?.email;
|
|
116
119
|
const viewer: ViewerLike | undefined = email
|
|
117
120
|
? { email, capability: sessionHostCapability(session, context.locals?.tenant?.appDomain ?? "") }
|
|
118
121
|
: undefined;
|
|
@@ -52,6 +52,7 @@ export class FetchHttpTransport implements HttpTransport, HttpGetTransport {
|
|
|
52
52
|
// body). A per-call or per-transport header of the same name still wins.
|
|
53
53
|
headers: { "content-type": "application/json", ...this.defaultHeaders, ...headers },
|
|
54
54
|
body: JSON.stringify(body),
|
|
55
|
+
redirect: "manual",
|
|
55
56
|
signal: this.signal(),
|
|
56
57
|
});
|
|
57
58
|
return { status: res.status, json: () => res.json() };
|
|
@@ -60,6 +61,7 @@ export class FetchHttpTransport implements HttpTransport, HttpGetTransport {
|
|
|
60
61
|
async get(url: string, headers: Record<string, string>): Promise<HttpResponseLike> {
|
|
61
62
|
const res = await fetch(url, {
|
|
62
63
|
method: "GET",
|
|
64
|
+
redirect: "manual",
|
|
63
65
|
headers: { ...this.defaultHeaders, ...headers },
|
|
64
66
|
signal: this.signal(),
|
|
65
67
|
});
|
|
@@ -28,6 +28,7 @@ import { crossSiteFormRefusal, verifyCdnForward } from "@/lib/publication/shared
|
|
|
28
28
|
import { SAMPLE_CART_HANDOFF_URL, commerceTenantId, compileRouteTierIndexV1, resolvePublicationRoutePlanV1, type RouteTierIndexV1 , storefrontModeRequiresSignIn } from "@tot/public-runtime";
|
|
29
29
|
import type { PublicationArtifactBucketV1, TenantConfig } from "@tot/public-runtime";
|
|
30
30
|
import { HTML_CACHE_POLICY_CAPTURE_PROBE } from "@tot/public-runtime";
|
|
31
|
+
import { resolveCapturedRequestVersion, resolveCustomizationEnv } from "@tot/public-runtime";
|
|
31
32
|
import type { RenditionRegistryBucket } from "@/lib/assets/registeredRenditions";
|
|
32
33
|
import { DEV_DRAFT_COOKIE } from "@/lib/developer-experience/dev/devDraft";
|
|
33
34
|
import { applySecurityHeaders } from "@/lib/access/shared/securityHeaders";
|
|
@@ -317,7 +318,16 @@ async function serveRequest(
|
|
|
317
318
|
previewTokenCookie: context.cookies.get("tot_preview")?.value,
|
|
318
319
|
secret: await readEnv("CUSTOMIZATION_PREVIEW_SECRET"),
|
|
319
320
|
});
|
|
320
|
-
const
|
|
321
|
+
const captureEnvironment = await resolveCustomizationEnv({
|
|
322
|
+
isPreviewHost: host.toLowerCase().startsWith("preview."), tenantId: tenant.tenant_id,
|
|
323
|
+
token: url.searchParams.get("__preview") ?? context.cookies.get("tot_preview")?.value,
|
|
324
|
+
secret: await readEnv("CUSTOMIZATION_PREVIEW_SECRET"), pathname: url.pathname,
|
|
325
|
+
nowSeconds: Math.floor(Date.now() / 1000),
|
|
326
|
+
});
|
|
327
|
+
const capturedRevisionId = renderPath.startsWith("/_a/") && !captureEnvironment.reject
|
|
328
|
+
? await resolveCapturedRequestVersion({ get: (key) => kv?.get(key) ?? Promise.resolve(null), tenantId: tenant.tenant_id,
|
|
329
|
+
env: captureEnvironment.env, candidateVersionId: candidatePin?.versionId, cookieVersionId: cookieRevision })
|
|
330
|
+
: candidatePin?.versionId ?? cookieRevision;
|
|
321
331
|
const originalsBucket =
|
|
322
332
|
(capturedRevisionId || renderPath.startsWith("/_a/"))
|
|
323
333
|
? ((await readR2("PRODUCT_IMAGES")) ?? (await readR2("TENANT_ASSETS")) ?? null)
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
resolveCapabilities,
|
|
50
50
|
capabilitiesArtifactPath,
|
|
51
51
|
resolveServableChannelVersion,
|
|
52
|
+
resolveCapturedRequestVersion,
|
|
52
53
|
resolveChannelVersion,
|
|
53
54
|
serveTenantAsset,
|
|
54
55
|
parseTenantAssetPath,
|
|
@@ -399,13 +400,10 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
399
400
|
secret: previewSecret,
|
|
400
401
|
nowSeconds,
|
|
401
402
|
});
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
? (await resolveServableChannelVersion(kvGet, tenant.tenant_id, "preview")).versionId
|
|
407
|
-
: null;
|
|
408
|
-
const capturedRevisionId = candidatePin?.versionId ?? cookieRevision ?? sharedPreviewRevision;
|
|
403
|
+
const capturedRevisionId = renderPath.startsWith("/_a/")
|
|
404
|
+
? await resolveCapturedRequestVersion({ get: kvGet, tenantId: tenant.tenant_id, env: customization.env,
|
|
405
|
+
candidateVersionId: candidatePin?.versionId, cookieVersionId: cookieRevision })
|
|
406
|
+
: candidatePin?.versionId ?? cookieRevision;
|
|
409
407
|
const originalsBucket =
|
|
410
408
|
(capturedRevisionId || renderPath.startsWith("/_a/"))
|
|
411
409
|
? ((await readR2("PRODUCT_IMAGES")) ?? (await readR2("TENANT_ASSETS")) ?? null)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.12.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",
|
|
@@ -10,6 +10,17 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { ImageDependency, ImageDependencyArtifact } from "./image-dependencies.js";
|
|
12
12
|
|
|
13
|
+
/** Fingerprint exact source bytes without copying a whole revision into one JSON string. */
|
|
14
|
+
export async function fingerprintCaptureArtifacts(artifacts: readonly ImageDependencyArtifact[]): Promise<string> {
|
|
15
|
+
const hash = async (value: string) => [...new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value)))]
|
|
16
|
+
.map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
17
|
+
const identities: Array<{ path: string; kind: ImageDependencyArtifact["kind"]; digest: string }> = [];
|
|
18
|
+
for (const artifact of [...artifacts].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0)) {
|
|
19
|
+
identities.push({ path: artifact.path, kind: artifact.kind, digest: await hash(artifact.content) });
|
|
20
|
+
}
|
|
21
|
+
return `sha256:${await hash(JSON.stringify(identities))}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
13
24
|
export type CaptureRewrite = {
|
|
14
25
|
/** Effect-only; never log or render. */
|
|
15
26
|
canonicalLocator: string;
|
|
@@ -539,7 +550,12 @@ export function applyCaptureRewrites(
|
|
|
539
550
|
artifacts: readonly ImageDependencyArtifact[],
|
|
540
551
|
rewrites: readonly CaptureRewrite[],
|
|
541
552
|
): ImageDependencyArtifact[] {
|
|
542
|
-
|
|
553
|
+
return artifacts.map(createCaptureArtifactRewriter(rewrites));
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
/** Prepare locator matching once while allowing callers to persist one rewritten artifact at a time. */
|
|
557
|
+
export function createCaptureArtifactRewriter(rewrites: readonly CaptureRewrite[]): (artifact: ImageDependencyArtifact) => ImageDependencyArtifact {
|
|
558
|
+
if (rewrites.length === 0) return (artifact) => ({ ...artifact });
|
|
543
559
|
const prepared = rewrites.map((rewrite) => {
|
|
544
560
|
// Longer needles first so `/path?v=1` wins over slash-stripped `path`.
|
|
545
561
|
// Include `&` spellings so HTML-escaped query joins still match.
|
|
@@ -554,7 +570,7 @@ export function applyCaptureRewrites(
|
|
|
554
570
|
const exact = new Set((rewrite.exactAliases ?? []).flatMap((n) => [n, ...htmlAmpVariants(n)]));
|
|
555
571
|
return { rewrite, needles, key, exact };
|
|
556
572
|
});
|
|
557
|
-
return
|
|
573
|
+
return (artifact) => {
|
|
558
574
|
let content = artifact.content;
|
|
559
575
|
for (const { rewrite, needles, key, exact } of prepared) {
|
|
560
576
|
if (key !== null && !content.includes(key)) continue;
|
|
@@ -565,7 +581,7 @@ export function applyCaptureRewrites(
|
|
|
565
581
|
// Guard: slash-stripped local needles must not leave a protocol-relative URL.
|
|
566
582
|
content = content.replace(/\/{2,}_a\//g, "/_a/");
|
|
567
583
|
return { ...artifact, content };
|
|
568
|
-
}
|
|
584
|
+
};
|
|
569
585
|
}
|
|
570
586
|
|
|
571
587
|
/**
|
|
@@ -366,14 +366,18 @@ export async function resolvePublishedArtifactKey(
|
|
|
366
366
|
if (!selection) return null;
|
|
367
367
|
if (selection.source !== "channel") return customizationArtifactKey(tenantId, selection.versionId);
|
|
368
368
|
|
|
369
|
-
//
|
|
370
|
-
// version's captured images the way a pinned preview of it does; `live` never reads the
|
|
371
|
-
// overlay (its public path is the published bundle, which carries its own rewrites).
|
|
369
|
+
// Both hosted live and preview render the exact channel revision's sealed capture.
|
|
372
370
|
const { versionId } = await resolveServableChannelVersion(get, tenantId, selection.channel);
|
|
373
371
|
if (!versionId) return null;
|
|
374
|
-
return
|
|
375
|
-
|
|
376
|
-
|
|
372
|
+
return resolveCapturedVersionArtifactKey(get, tenantId, versionId, path);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Exact revision whose sealed originals a hosted request may read. */
|
|
376
|
+
export async function resolveCapturedRequestVersion(input: {
|
|
377
|
+
get: KvGet; tenantId: string; env: CustomizationEnv; candidateVersionId?: string | null; cookieVersionId?: string | null;
|
|
378
|
+
}): Promise<string | null> {
|
|
379
|
+
return input.candidateVersionId ?? input.cookieVersionId ??
|
|
380
|
+
(await resolveServableChannelVersion(input.get, input.tenantId, channelFromEnv(input.env))).versionId;
|
|
377
381
|
}
|
|
378
382
|
|
|
379
383
|
/**
|
|
@@ -32,6 +32,37 @@ export interface ImageDependencyArtifact {
|
|
|
32
32
|
kind?: ImageArtifactKind;
|
|
33
33
|
}
|
|
34
34
|
|
|
35
|
+
export const IMAGE_TYPES = ["catalog", "blog", "pages", "other"] as const;
|
|
36
|
+
export type ImageType = typeof IMAGE_TYPES[number];
|
|
37
|
+
export type ImageTypeCounts = Record<ImageType, { files: number; references: number; uncaptured: number }>;
|
|
38
|
+
|
|
39
|
+
export function imageTypeOf(path: string): ImageType {
|
|
40
|
+
if (path.startsWith("content/catalog/")) return "catalog";
|
|
41
|
+
if (path.startsWith("content/blog/")) return "blog";
|
|
42
|
+
if (path.startsWith("content/pages/") || path.startsWith("content/pages-html/")) return "pages";
|
|
43
|
+
return "other";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function imageCensusByType(input: {
|
|
47
|
+
artifacts: readonly ImageDependencyArtifact[]; tenantId: string; tenantOrigins: readonly string[]; skipped?: readonly string[];
|
|
48
|
+
}): ImageTypeCounts {
|
|
49
|
+
if (!Array.isArray(input.tenantOrigins)) throw new TypeError("tenantOrigins is required");
|
|
50
|
+
const out = {} as ImageTypeCounts;
|
|
51
|
+
for (const type of IMAGE_TYPES) {
|
|
52
|
+
const group = input.artifacts.filter((artifact) => imageTypeOf(artifact.path) === type);
|
|
53
|
+
const skipped = (input.skipped ?? []).filter((path) => imageTypeOf(path) === type).length;
|
|
54
|
+
const census = scanImageDependencies(group, { tenantId: input.tenantId, tenantOrigins: input.tenantOrigins }).census;
|
|
55
|
+
out[type] = { files: group.length + skipped, references: census.references + skipped,
|
|
56
|
+
uncaptured: census.captureRequired + census.securityErrors + census.deferred + skipped };
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Separate from mutable dual-run evidence: one immutable source revision's measured image ownership. */
|
|
62
|
+
export function hostedImageCensusKey(tenantId: string, revisionId: string): string {
|
|
63
|
+
return `image-census:${tenantId}:${revisionId}`;
|
|
64
|
+
}
|
|
65
|
+
|
|
35
66
|
export interface PlatformHostedImageConfig {
|
|
36
67
|
/** Exact already-hosted references. Query/fragment spelling is ignored. */
|
|
37
68
|
references?: readonly string[];
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
* Revision-bound overlay of capture-rewritten preview artifacts (read path).
|
|
3
3
|
*
|
|
4
4
|
* Site-version manifests are immutable; successful `/_a/<sha>` rewrites are
|
|
5
|
-
* stored as content-addressed artifact bytes plus this overlay map.
|
|
6
|
-
* reads prefer
|
|
5
|
+
* stored as content-addressed artifact bytes plus this overlay map. Hosted live and preview
|
|
6
|
+
* reads prefer their exact revision's overlay so documents reference platform-owned URLs.
|
|
7
7
|
* The record and its key-level resolution live in `customization-runtime.ts`,
|
|
8
8
|
* which every versioned read goes through.
|
|
9
9
|
*/
|
|
@@ -12,7 +12,9 @@ import {
|
|
|
12
12
|
customizationArtifactKey,
|
|
13
13
|
readImagePreviewRewriteOverlay,
|
|
14
14
|
themeArtifactPath,
|
|
15
|
+
type ImagePreviewRewriteOverlay,
|
|
15
16
|
} from "./customization-runtime.js";
|
|
17
|
+
import { sha256Hex } from "./hash.js";
|
|
16
18
|
|
|
17
19
|
export {
|
|
18
20
|
imagePreviewRewriteOverlayKey,
|
|
@@ -20,6 +22,13 @@ export {
|
|
|
20
22
|
type ImagePreviewRewriteOverlay,
|
|
21
23
|
} from "./customization-runtime.js";
|
|
22
24
|
|
|
25
|
+
/** Bind measured counts to the exact immutable rewrite map used by live reads. */
|
|
26
|
+
export async function digestImagePreviewRewriteOverlay(overlay: ImagePreviewRewriteOverlay | null): Promise<string | null> {
|
|
27
|
+
if (!overlay) return null;
|
|
28
|
+
const artifacts = Object.entries(overlay.artifacts).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
29
|
+
return `sha256:${await sha256Hex(JSON.stringify({ revisionId: overlay.revisionId, captureManifestDigest: overlay.captureManifestDigest, artifacts }))}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
23
32
|
/** Map a repo-relative capture artifact path to the preview manifest artifact path. */
|
|
24
33
|
export function previewManifestPathFor(tenantId: string, repoPath: string): string {
|
|
25
34
|
if (repoPath === "theme.json") return themeArtifactPath(tenantId);
|