@tokenoftrust/storefront-runner 2.0.46 → 2.0.48
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/dev-plugins/tenant-hot-reload.mjs +46 -21
- package/apps/storefront/integrations/materialize-guard.mjs +4 -4
- package/apps/storefront/src/config/publicationSpaceRegistry.ts +1 -1
- package/apps/storefront/src/lib/assets/captured-original-serve.ts +146 -0
- package/apps/storefront/src/lib/assets/preview-version-cookie.ts +31 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +1 -1
- package/apps/storefront/src/lib/storyblok/provider.ts +5 -2
- package/apps/storefront/src/middleware/index.ts +22 -1
- package/apps/storefront/src/middleware/renderChain.ts +22 -1
- package/apps/storefront/src/middleware/staticBundle.ts +33 -2
- package/apps/storefront/src/pages/tenants/[id]/[...path].ts +53 -24
- package/package.json +1 -1
- package/packages/public-runtime/package.json +3 -0
- package/packages/public-runtime/src/image-dependency-identity.ts +11 -0
- package/packages/public-runtime/src/image-finding-acceptance.ts +168 -0
- package/packages/public-runtime/src/image-finding-dependencies.ts +31 -0
- package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +69 -0
- package/packages/public-runtime/src/tenant-assets.ts +6 -0
- package/scripts/tenant/materialize-tenant-content.mjs +48 -29
|
@@ -15,7 +15,6 @@ import { readFile } from "node:fs/promises";
|
|
|
15
15
|
// normalized to match Vite's ctx.file (Vite always reports posix-style paths).
|
|
16
16
|
const REPO_ROOT = fileURLToPath(new URL("../../..", import.meta.url));
|
|
17
17
|
const TENANTS_DIR = join(REPO_ROOT, "tenants").replace(/\\/g, "/");
|
|
18
|
-
const PUBLIC_TENANTS_DEST = fileURLToPath(new URL("../public/tenants", import.meta.url));
|
|
19
18
|
|
|
20
19
|
/** Per-file finding signatures, so an unchanged warning is not reprinted on every
|
|
21
20
|
* keystroke-save. Lives for the dev server's lifetime. */
|
|
@@ -58,12 +57,13 @@ async function loadTsModule(server, url) {
|
|
|
58
57
|
* @param {string} file
|
|
59
58
|
* @returns {{ id: string, rest: string } | null}
|
|
60
59
|
*/
|
|
61
|
-
export function classifyTenantFile(file) {
|
|
60
|
+
export function classifyTenantFile(file, tenantsDir = TENANTS_DIR) {
|
|
62
61
|
const f = String(file).replace(/\\/g, "/");
|
|
63
|
-
|
|
62
|
+
const normalizedTenantsDir = String(tenantsDir).replace(/\\/g, "/");
|
|
63
|
+
if (!f.startsWith(normalizedTenantsDir + "/")) return null;
|
|
64
64
|
// "<id>/<...>" for a real tenant, "e2e/<id>/<...>" for a platform test tenant —
|
|
65
65
|
// consume the nesting segment so `id` is always the tenant's own id.
|
|
66
|
-
let rel = f.slice(
|
|
66
|
+
let rel = f.slice(normalizedTenantsDir.length + 1);
|
|
67
67
|
if (rel.startsWith(E2E_TENANT_DIR + "/")) rel = rel.slice(E2E_TENANT_DIR.length + 1);
|
|
68
68
|
const slash = rel.indexOf("/");
|
|
69
69
|
if (slash < 0) return null;
|
|
@@ -102,23 +102,31 @@ export function stripThemeMeta(parsed) {
|
|
|
102
102
|
* save mirroring into public/tenants/<id>/.
|
|
103
103
|
* @param {string} id
|
|
104
104
|
*/
|
|
105
|
-
export function isVersionedTenant(id) {
|
|
105
|
+
export function isVersionedTenant(id, root = REPO_ROOT) {
|
|
106
106
|
const envList = (process.env.TENANT_ASSETS_VERSIONED ?? "")
|
|
107
107
|
.split(",")
|
|
108
108
|
.map((s) => s.trim())
|
|
109
109
|
.filter(Boolean);
|
|
110
110
|
if (envList.includes(id)) return true;
|
|
111
|
-
return existsSync(join(
|
|
111
|
+
return existsSync(join(root, ...tenantDirSegments(id), ".tot", "assets-versioned"));
|
|
112
112
|
}
|
|
113
113
|
|
|
114
114
|
/**
|
|
115
115
|
* Re-mirror one changed/added public asset into public/tenants/<id>/ (skip R2-versioned).
|
|
116
116
|
* @param {string} id @param {string} rest @param {string} sourceFile
|
|
117
117
|
*/
|
|
118
|
-
async function mirrorPublicAsset(
|
|
119
|
-
|
|
118
|
+
async function mirrorPublicAsset(
|
|
119
|
+
id,
|
|
120
|
+
rest,
|
|
121
|
+
sourceFile,
|
|
122
|
+
{
|
|
123
|
+
root = REPO_ROOT,
|
|
124
|
+
publicTenantsDest = join(root, "apps", "storefront", "public", "tenants"),
|
|
125
|
+
} = {},
|
|
126
|
+
) {
|
|
127
|
+
if (isVersionedTenant(id, root)) return;
|
|
120
128
|
const { cp, mkdir } = await import("node:fs/promises");
|
|
121
|
-
const target = join(
|
|
129
|
+
const target = join(publicTenantsDest, id, rest.slice("public/".length));
|
|
122
130
|
try {
|
|
123
131
|
await mkdir(dirname(target), { recursive: true });
|
|
124
132
|
await cp(sourceFile, target);
|
|
@@ -131,10 +139,17 @@ async function mirrorPublicAsset(id, rest, sourceFile) {
|
|
|
131
139
|
* Remove the stale mirrored copy under public/tenants/<id>/ when a public asset is deleted.
|
|
132
140
|
* @param {string} id @param {string} rest
|
|
133
141
|
*/
|
|
134
|
-
async function unmirrorPublicAsset(
|
|
135
|
-
|
|
142
|
+
async function unmirrorPublicAsset(
|
|
143
|
+
id,
|
|
144
|
+
rest,
|
|
145
|
+
{
|
|
146
|
+
root = REPO_ROOT,
|
|
147
|
+
publicTenantsDest = join(root, "apps", "storefront", "public", "tenants"),
|
|
148
|
+
} = {},
|
|
149
|
+
) {
|
|
150
|
+
if (isVersionedTenant(id, root)) return;
|
|
136
151
|
const { rm } = await import("node:fs/promises");
|
|
137
|
-
const target = join(
|
|
152
|
+
const target = join(publicTenantsDest, id, rest.slice("public/".length));
|
|
138
153
|
try {
|
|
139
154
|
await rm(target, { force: true });
|
|
140
155
|
} catch {
|
|
@@ -194,7 +209,11 @@ export function seedKnown(root, known) {
|
|
|
194
209
|
// the worker teardown, same as the change path). Public assets are NOT in any
|
|
195
210
|
// graph, so Vite ignores their add/unlink entirely — we mirror (add) / un-mirror
|
|
196
211
|
// (unlink) and reload ourselves. loopState.lastEvent then reflects add/unlink.
|
|
197
|
-
export function tenantHotReload(
|
|
212
|
+
export function tenantHotReload({
|
|
213
|
+
root = REPO_ROOT,
|
|
214
|
+
publicTenantsDest = join(root, "apps", "storefront", "public", "tenants"),
|
|
215
|
+
} = {}) {
|
|
216
|
+
const tenantsDir = join(root, "tenants").replace(/\\/g, "/");
|
|
198
217
|
// Typed `any`: `vite` isn't a direct dependency of this package (it's pulled in
|
|
199
218
|
// transitively via astro), so `import('vite').Plugin` can't be resolved here and
|
|
200
219
|
// importing from "vite" would even fail at runtime. `any` keeps the hook params
|
|
@@ -205,7 +224,7 @@ export function tenantHotReload() {
|
|
|
205
224
|
apply: "serve",
|
|
206
225
|
/** @param {any} server */
|
|
207
226
|
configureServer(server) {
|
|
208
|
-
server.watcher.add(
|
|
227
|
+
server.watcher.add(tenantsDir);
|
|
209
228
|
|
|
210
229
|
// Tell the hosted /dev panel the store is UP the moment the dev server is
|
|
211
230
|
// listening — completes Step 1 ("Get running") before any edit. Best-effort,
|
|
@@ -218,12 +237,12 @@ export function tenantHotReload() {
|
|
|
218
237
|
// really added/removed so the guard stays accurate for the session.
|
|
219
238
|
/** @type {Set<string>} */
|
|
220
239
|
const known = new Set();
|
|
221
|
-
seedKnown(
|
|
240
|
+
seedKnown(tenantsDir, known);
|
|
222
241
|
|
|
223
242
|
/** @param {"add"|"unlink"} event @param {string} rawPath */
|
|
224
243
|
const onAddUnlink = async (event, rawPath) => {
|
|
225
244
|
const file = String(rawPath).replace(/\\/g, "/");
|
|
226
|
-
const c = classifyTenantFile(file);
|
|
245
|
+
const c = classifyTenantFile(file, tenantsDir);
|
|
227
246
|
if (!c) return;
|
|
228
247
|
const { id, rest } = c;
|
|
229
248
|
|
|
@@ -244,8 +263,11 @@ export function tenantHotReload() {
|
|
|
244
263
|
// Public assets: Vite doesn't react (not in the module graph). Maintain the
|
|
245
264
|
// mirror ourselves, then reload (server.ws.send is timestamped by the Monitor).
|
|
246
265
|
if (rest.startsWith("public/")) {
|
|
247
|
-
if (event === "add")
|
|
248
|
-
|
|
266
|
+
if (event === "add") {
|
|
267
|
+
await mirrorPublicAsset(id, rest, file, { root, publicTenantsDest });
|
|
268
|
+
} else {
|
|
269
|
+
await unmirrorPublicAsset(id, rest, { root, publicTenantsDest });
|
|
270
|
+
}
|
|
249
271
|
server.ws.send({ type: "full-reload" });
|
|
250
272
|
return;
|
|
251
273
|
}
|
|
@@ -259,7 +281,7 @@ export function tenantHotReload() {
|
|
|
259
281
|
},
|
|
260
282
|
/** @param {any} ctx */
|
|
261
283
|
async handleHotUpdate(ctx) {
|
|
262
|
-
const c = classifyTenantFile(ctx.file);
|
|
284
|
+
const c = classifyTenantFile(ctx.file, tenantsDir);
|
|
263
285
|
if (!c) return undefined;
|
|
264
286
|
const { id, rest } = c;
|
|
265
287
|
|
|
@@ -276,7 +298,7 @@ export function tenantHotReload() {
|
|
|
276
298
|
// terminal a beat later. Putting this in the reload path would tax every
|
|
277
299
|
// save to report something that is almost always empty.
|
|
278
300
|
void validateSavedFile({
|
|
279
|
-
tenantDir: join(
|
|
301
|
+
tenantDir: join(root, tenantDirRelative(id)),
|
|
280
302
|
tenantId: id,
|
|
281
303
|
rest,
|
|
282
304
|
memo: savedFileFindings,
|
|
@@ -301,7 +323,10 @@ export function tenantHotReload() {
|
|
|
301
323
|
// Unlike content HTML this reloads no worker, so there's no teardown race to
|
|
302
324
|
// fire ahead of. Skip R2-versioned tenants, like copy-tenant-assets.mjs.
|
|
303
325
|
if (rest.startsWith("public/")) {
|
|
304
|
-
await mirrorPublicAsset(id, rest, ctx.file.replace(/\\/g, "/")
|
|
326
|
+
await mirrorPublicAsset(id, rest, ctx.file.replace(/\\/g, "/"), {
|
|
327
|
+
root,
|
|
328
|
+
publicTenantsDest,
|
|
329
|
+
});
|
|
305
330
|
ctx.server.ws.send({ type: "full-reload" });
|
|
306
331
|
return [];
|
|
307
332
|
}
|
|
@@ -19,7 +19,7 @@ import {
|
|
|
19
19
|
* STATIC publish (materialize-tenant.mjs) sets this to the single tenant it renders,
|
|
20
20
|
* so a sibling tenant can never block it. Unset ⇒ framework-only Worker build.
|
|
21
21
|
*/
|
|
22
|
-
export function materializeGuard() {
|
|
22
|
+
export function materializeGuard({ root } = {}) {
|
|
23
23
|
return {
|
|
24
24
|
name: "materialize-guard",
|
|
25
25
|
hooks: {
|
|
@@ -40,7 +40,7 @@ export function materializeGuard() {
|
|
|
40
40
|
// set (not "all tenants", which `null` would mean), so a sibling
|
|
41
41
|
// tenant that is present-but-stale can never block this build.
|
|
42
42
|
const frameworkOnlyTenants = [];
|
|
43
|
-
const { problems } = checkPinned(
|
|
43
|
+
const { problems } = checkPinned(root, { expectRef, allowUnpinned, onlyTenants: frameworkOnlyTenants });
|
|
44
44
|
const absent = problems.filter((p) => p.reason === "absent");
|
|
45
45
|
const real = problems.filter((p) => p.reason !== "absent");
|
|
46
46
|
if (absent.length) {
|
|
@@ -49,11 +49,11 @@ export function materializeGuard() {
|
|
|
49
49
|
else console.log(msg);
|
|
50
50
|
}
|
|
51
51
|
if (real.length) {
|
|
52
|
-
assertPinnedMaterialized({ expectRef, allowUnpinned, onlyTenants: frameworkOnlyTenants });
|
|
52
|
+
assertPinnedMaterialized({ root, expectRef, allowUnpinned, onlyTenants: frameworkOnlyTenants });
|
|
53
53
|
}
|
|
54
54
|
return;
|
|
55
55
|
}
|
|
56
|
-
assertPinnedMaterialized({ expectRef, allowUnpinned, onlyTenants });
|
|
56
|
+
assertPinnedMaterialized({ root, expectRef, allowUnpinned, onlyTenants });
|
|
57
57
|
},
|
|
58
58
|
},
|
|
59
59
|
};
|
|
@@ -10,7 +10,7 @@ const ROOT_SOURCES: Readonly<Record<string, PublicationSpaceSourceV1>> = {
|
|
|
10
10
|
defaultBranch: "main",
|
|
11
11
|
},
|
|
12
12
|
"sample-store.com": { forge: "gitea", repo: "sample-store.com", defaultBranch: "main" },
|
|
13
|
-
"sample-store.e2e.test": { forge: "
|
|
13
|
+
"sample-store.e2e.test": { forge: "gitea", repo: "sample-store.e2e.test", defaultBranch: "main" },
|
|
14
14
|
"verify.e2e.test": { forge: "fixture", repo: "verify.e2e.test", defaultBranch: "main" },
|
|
15
15
|
"fixture.e2e.test": { forge: "fixture", repo: "fixture.e2e.test", defaultBranch: "main" },
|
|
16
16
|
"chrome.e2e.test": { forge: "fixture", repo: "chrome.e2e.test", defaultBranch: "main" },
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Worker-safe serve of revision-bound captured originals.
|
|
3
|
+
*
|
|
4
|
+
* Does not import Node capture — R2 + Web Crypto only. Bytes are served only
|
|
5
|
+
* when the sealed revision manifest names the address.
|
|
6
|
+
*/
|
|
7
|
+
import { sha256Hex } from "@tot/public-runtime";
|
|
8
|
+
|
|
9
|
+
const ADDRESS_RE = /^[0-9a-f]{64}$/;
|
|
10
|
+
/** Hostname-like tenant ids (no scheme, no path separators). */
|
|
11
|
+
const TENANT_ID_RE = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/i;
|
|
12
|
+
/** Hostname-like revision ids, or production `sha256:<64 hex>`. */
|
|
13
|
+
const REVISION_ID_RE =
|
|
14
|
+
/^(?:[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?|sha256:[0-9a-f]{64})$/i;
|
|
15
|
+
|
|
16
|
+
function isSafeTenantId(tenantId: string): boolean {
|
|
17
|
+
return TENANT_ID_RE.test(tenantId);
|
|
18
|
+
}
|
|
19
|
+
function isSafeRevisionId(revisionId: string): boolean {
|
|
20
|
+
return REVISION_ID_RE.test(revisionId);
|
|
21
|
+
}
|
|
22
|
+
/** Same key layout as private-controlplane original-image-capture manifestKey. */
|
|
23
|
+
function originalImageManifestObjectKey(tenantId: string, revisionId: string): string {
|
|
24
|
+
return `original-images/${tenantId}/revisions/${revisionId}/manifest.json`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const EXT_TYPE: Record<string, string> = {
|
|
28
|
+
".png": "image/png",
|
|
29
|
+
".jpg": "image/jpeg",
|
|
30
|
+
".jpeg": "image/jpeg",
|
|
31
|
+
".gif": "image/gif",
|
|
32
|
+
".webp": "image/webp",
|
|
33
|
+
".avif": "image/avif",
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
export type CapturedOriginalBucket = {
|
|
37
|
+
get(key: string): Promise<unknown>;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
type ManifestEntry = {
|
|
41
|
+
address: string;
|
|
42
|
+
byteLength: number;
|
|
43
|
+
mediaType: string;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
function objectKey(tenantId: string, address: string): string {
|
|
47
|
+
return `original-images/${tenantId}/objects/${address}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Parse `/_a/<sha256>[.ext]` — null when the path is not a pinned asset URL. */
|
|
51
|
+
export function parsePinnedImagePath(pathname: string): { address: string; ext: string } | null {
|
|
52
|
+
const match = pathname.match(/^\/_a\/([0-9a-f]{64})(\.[A-Za-z0-9]+)?$/);
|
|
53
|
+
if (!match) return null;
|
|
54
|
+
return { address: match[1]!, ext: match[2] ?? "" };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function readManifestEntries(
|
|
58
|
+
bucket: CapturedOriginalBucket,
|
|
59
|
+
tenantId: string,
|
|
60
|
+
revisionId: string,
|
|
61
|
+
): Promise<ManifestEntry[] | null> {
|
|
62
|
+
if (!isSafeTenantId(tenantId) || !isSafeRevisionId(revisionId)) return null;
|
|
63
|
+
try {
|
|
64
|
+
const object = await bucket.get(originalImageManifestObjectKey(tenantId, revisionId));
|
|
65
|
+
if (!object || typeof object !== "object") return null;
|
|
66
|
+
const body = object as { arrayBuffer?: () => Promise<ArrayBuffer> };
|
|
67
|
+
if (typeof body.arrayBuffer !== "function") return null;
|
|
68
|
+
const parsed = JSON.parse(new TextDecoder().decode(new Uint8Array(await body.arrayBuffer()))) as {
|
|
69
|
+
version?: unknown;
|
|
70
|
+
revisionId?: unknown;
|
|
71
|
+
entries?: unknown;
|
|
72
|
+
};
|
|
73
|
+
if (parsed.version !== 1 || parsed.revisionId !== revisionId || !Array.isArray(parsed.entries)) {
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
const entries: ManifestEntry[] = [];
|
|
77
|
+
for (const raw of parsed.entries) {
|
|
78
|
+
if (!raw || typeof raw !== "object") return null;
|
|
79
|
+
const entry = raw as Partial<ManifestEntry>;
|
|
80
|
+
if (typeof entry.address !== "string" || !ADDRESS_RE.test(entry.address)) return null;
|
|
81
|
+
if (typeof entry.byteLength !== "number" || typeof entry.mediaType !== "string") return null;
|
|
82
|
+
entries.push({
|
|
83
|
+
address: entry.address,
|
|
84
|
+
byteLength: entry.byteLength,
|
|
85
|
+
mediaType: entry.mediaType,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
return entries;
|
|
89
|
+
} catch {
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Serve a captured original for the exact tenant+revision, or null to fall through.
|
|
96
|
+
* Missing/unbound/tampered addresses are 404 with no locator or digest in the body.
|
|
97
|
+
*/
|
|
98
|
+
export async function tryServeCapturedOriginal(input: {
|
|
99
|
+
bucket: CapturedOriginalBucket;
|
|
100
|
+
tenantId: string;
|
|
101
|
+
revisionId: string;
|
|
102
|
+
pathname: string;
|
|
103
|
+
method?: string;
|
|
104
|
+
}): Promise<Response | null> {
|
|
105
|
+
const parsed = parsePinnedImagePath(input.pathname);
|
|
106
|
+
if (!parsed) return null;
|
|
107
|
+
if (!isSafeTenantId(input.tenantId) || !isSafeRevisionId(input.revisionId)) {
|
|
108
|
+
return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const entries = await readManifestEntries(input.bucket, input.tenantId, input.revisionId);
|
|
112
|
+
const entry = entries?.find((item) => item.address === parsed.address);
|
|
113
|
+
if (!entry) {
|
|
114
|
+
return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
const object = await input.bucket.get(objectKey(input.tenantId, parsed.address));
|
|
119
|
+
if (!object || typeof object !== "object") {
|
|
120
|
+
return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
|
|
121
|
+
}
|
|
122
|
+
const body = object as { arrayBuffer?: () => Promise<ArrayBuffer> };
|
|
123
|
+
if (typeof body.arrayBuffer !== "function") {
|
|
124
|
+
return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
|
|
125
|
+
}
|
|
126
|
+
const bytes = new Uint8Array(await body.arrayBuffer());
|
|
127
|
+
if (bytes.byteLength !== entry.byteLength) {
|
|
128
|
+
return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
|
|
129
|
+
}
|
|
130
|
+
const digest = await sha256Hex(bytes);
|
|
131
|
+
if (digest !== parsed.address) {
|
|
132
|
+
return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
|
|
133
|
+
}
|
|
134
|
+
const contentType = EXT_TYPE[parsed.ext.toLowerCase()] ?? entry.mediaType;
|
|
135
|
+
const headers: Record<string, string> = {
|
|
136
|
+
"content-type": contentType,
|
|
137
|
+
"cache-control": "public, max-age=31536000, immutable",
|
|
138
|
+
"x-tot-original-image": "revision-bound",
|
|
139
|
+
"content-length": String(bytes.byteLength),
|
|
140
|
+
};
|
|
141
|
+
const method = (input.method ?? "GET").toUpperCase();
|
|
142
|
+
return new Response(method === "HEAD" ? null : bytes, { status: 200, headers });
|
|
143
|
+
} catch {
|
|
144
|
+
return new Response("Not found", { status: 404, headers: { "cache-control": "no-store" } });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolve the candidate/revision asset pin from preview cookies.
|
|
3
|
+
*
|
|
4
|
+
* Candidate and revision renderers set `tot_preview_version` (the exact sha) and
|
|
5
|
+
* `tot_preview` (capability token). Subresource GETs (img `/_a/…`, `/tenants/…`)
|
|
6
|
+
* carry those cookies but not `__tot_candidate_pin`, so this is the revision
|
|
7
|
+
* authority for revision-bound captured originals and preview assets.
|
|
8
|
+
*/
|
|
9
|
+
import { verifyPreviewToken } from "@tot/public-runtime";
|
|
10
|
+
|
|
11
|
+
const SAFE_VERSION_ID = /^[A-Za-z0-9._-]{1,128}$/;
|
|
12
|
+
|
|
13
|
+
export async function resolvePreviewVersionFromCookies(input: {
|
|
14
|
+
tenantId: string;
|
|
15
|
+
previewVersionCookie: string | null | undefined;
|
|
16
|
+
previewTokenCookie: string | null | undefined;
|
|
17
|
+
secret: string | null | undefined;
|
|
18
|
+
nowSeconds?: number;
|
|
19
|
+
}): Promise<string | null> {
|
|
20
|
+
const versionId = input.previewVersionCookie?.trim() || null;
|
|
21
|
+
const token = input.previewTokenCookie?.trim() || null;
|
|
22
|
+
if (!versionId || !token || !input.tenantId || !input.secret) return null;
|
|
23
|
+
if (!SAFE_VERSION_ID.test(versionId) || versionId.includes("..")) return null;
|
|
24
|
+
const verified = await verifyPreviewToken(
|
|
25
|
+
input.secret,
|
|
26
|
+
token,
|
|
27
|
+
input.nowSeconds ?? Math.floor(Date.now() / 1000),
|
|
28
|
+
);
|
|
29
|
+
if (!verified.ok || verified.claims.tenantId !== input.tenantId) return null;
|
|
30
|
+
return versionId;
|
|
31
|
+
}
|
|
@@ -196,7 +196,7 @@ export function isDevCliSessionPath(pathname: string): boolean {
|
|
|
196
196
|
* it: an unauthenticated request still gets a 401 from the route's own check.
|
|
197
197
|
*/
|
|
198
198
|
export function isStaticBundleUploadPath(pathname: string): boolean {
|
|
199
|
-
return pathname === "/api/publish/stage-bundle";
|
|
199
|
+
return pathname === "/api/publish/stage-bundle" || pathname === "/api/publish/captured-original";
|
|
200
200
|
}
|
|
201
201
|
|
|
202
202
|
/**
|
|
@@ -33,6 +33,7 @@ import { renderRawChrome } from "../rawChrome.js";
|
|
|
33
33
|
import { parseChromeConfig } from "../chrome/parseConfig.js";
|
|
34
34
|
import { parseChromeAssignments } from "../chrome/model.js";
|
|
35
35
|
import { stampTrackingIdsAtPublish } from "../tracking/publishSafetyNet.js";
|
|
36
|
+
import { readWithImagePreviewRewriteOverlay } from "@tot/public-runtime/image-preview-rewrite-overlay";
|
|
36
37
|
|
|
37
38
|
/** Safe, always-valid fallback when neither the tenant's own chrome.json nor
|
|
38
39
|
* the default scope's validates — the storefront must always render. */
|
|
@@ -702,11 +703,13 @@ export class VersionPinnedContentProvider implements ContentProvider {
|
|
|
702
703
|
) {}
|
|
703
704
|
|
|
704
705
|
private pinnedRaw(relative: string): Promise<string | null> {
|
|
705
|
-
|
|
706
|
+
const path = contentArtifactPath(this.tenantId, relative);
|
|
707
|
+
return readWithImagePreviewRewriteOverlay(
|
|
706
708
|
this.get,
|
|
707
709
|
this.tenantId,
|
|
708
710
|
this.versionId,
|
|
709
|
-
|
|
711
|
+
path,
|
|
712
|
+
() => readVersionArtifact(this.get, this.tenantId, this.versionId, path),
|
|
710
713
|
);
|
|
711
714
|
}
|
|
712
715
|
|
|
@@ -231,6 +231,19 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
231
231
|
// keep off the public path. Managed installs are not consulted: they inject
|
|
232
232
|
// document markup, never asset bytes, so this stays a D1-free path.
|
|
233
233
|
if (branch === "static-passthrough") {
|
|
234
|
+
const candidatePin = await resolveCandidateContentPin(url, tenant.tenant_id);
|
|
235
|
+
const { resolvePreviewVersionFromCookies } = await import("@/lib/assets/preview-version-cookie");
|
|
236
|
+
const cookieRevision = await resolvePreviewVersionFromCookies({
|
|
237
|
+
tenantId: tenant.tenant_id,
|
|
238
|
+
previewVersionCookie: context.cookies.get("tot_preview_version")?.value,
|
|
239
|
+
previewTokenCookie: context.cookies.get("tot_preview")?.value,
|
|
240
|
+
secret: await readEnv("CUSTOMIZATION_PREVIEW_SECRET"),
|
|
241
|
+
});
|
|
242
|
+
const capturedRevisionId = candidatePin?.versionId ?? cookieRevision;
|
|
243
|
+
const originalsBucket =
|
|
244
|
+
capturedRevisionId
|
|
245
|
+
? ((await readR2("PRODUCT_IMAGES")) ?? (await readR2("TENANT_ASSETS")) ?? null)
|
|
246
|
+
: null;
|
|
234
247
|
const bundled = await tryServeStaticBundle({
|
|
235
248
|
request,
|
|
236
249
|
url,
|
|
@@ -241,9 +254,17 @@ export const onRequest = defineMiddleware(async (context, next) => {
|
|
|
241
254
|
isPreviewHost: host.toLowerCase().startsWith("preview."),
|
|
242
255
|
hasPreviewToken:
|
|
243
256
|
url.searchParams.has("__preview") || !!context.cookies.get("tot_preview")?.value,
|
|
244
|
-
hasCandidatePin: !!
|
|
257
|
+
hasCandidatePin: !!candidatePin,
|
|
245
258
|
hasDraftCookie: !!context.cookies.get(DEV_DRAFT_COOKIE)?.value,
|
|
246
259
|
hasManagedInstalls: false,
|
|
260
|
+
...(capturedRevisionId && originalsBucket
|
|
261
|
+
? {
|
|
262
|
+
capturedOriginal: {
|
|
263
|
+
bucket: originalsBucket as { get(key: string): Promise<unknown> },
|
|
264
|
+
revisionId: capturedRevisionId,
|
|
265
|
+
},
|
|
266
|
+
}
|
|
267
|
+
: {}),
|
|
247
268
|
});
|
|
248
269
|
if (bundled) return bundled;
|
|
249
270
|
const rootLocation = platformStaticRootLocationV1({
|
|
@@ -12,7 +12,7 @@ import type { APIContext, MiddlewareNext } from "astro";
|
|
|
12
12
|
import { referenceTheme } from "@/themes/reference";
|
|
13
13
|
import { mergeTheme, themeToCssVars } from "@/themes/schema";
|
|
14
14
|
import { createToTClient } from "@/lib/tot/ToTClient";
|
|
15
|
-
import { readEnv } from "@/lib/runtimeEnv";
|
|
15
|
+
import { readEnv, readR2 } from "@/lib/runtimeEnv";
|
|
16
16
|
import { tryServeStaticBundle } from "./staticBundle.js";
|
|
17
17
|
import { resolveCandidateContentPin } from "./candidatePin.js";
|
|
18
18
|
import { createContentProvider, DevDraftContentProvider } from "@/lib/storyblok/provider";
|
|
@@ -248,6 +248,19 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
248
248
|
// Digest-addressed static-bundle serving. Deliberately before the edge page
|
|
249
249
|
// cache/catalog/theme machinery. A selected publication bypasses this legacy
|
|
250
250
|
// whole-site bundle until B1 can compose mounted spaces into it reproducibly.
|
|
251
|
+
const { resolvePreviewVersionFromCookies } = await import("@/lib/assets/preview-version-cookie");
|
|
252
|
+
const cookieRevision = await resolvePreviewVersionFromCookies({
|
|
253
|
+
tenantId: tenant.tenant_id,
|
|
254
|
+
previewVersionCookie: context.cookies.get("tot_preview_version")?.value,
|
|
255
|
+
previewTokenCookie: cookieToken,
|
|
256
|
+
secret: previewSecret,
|
|
257
|
+
nowSeconds,
|
|
258
|
+
});
|
|
259
|
+
const capturedRevisionId = candidatePin?.versionId ?? cookieRevision;
|
|
260
|
+
const originalsBucket =
|
|
261
|
+
capturedRevisionId
|
|
262
|
+
? ((await readR2("PRODUCT_IMAGES")) ?? (await readR2("TENANT_ASSETS")) ?? null)
|
|
263
|
+
: null;
|
|
251
264
|
const bundled = publicationSource.status === "legacy-root" ? await tryServeStaticBundle({
|
|
252
265
|
request,
|
|
253
266
|
url,
|
|
@@ -260,6 +273,14 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
|
|
|
260
273
|
hasCandidatePin: !!candidatePin,
|
|
261
274
|
hasDraftCookie: !!draftCookie,
|
|
262
275
|
hasManagedInstalls: managedIntegrationDeclarations.length > 0,
|
|
276
|
+
...(capturedRevisionId && originalsBucket
|
|
277
|
+
? {
|
|
278
|
+
capturedOriginal: {
|
|
279
|
+
bucket: originalsBucket as { get(key: string): Promise<unknown> },
|
|
280
|
+
revisionId: capturedRevisionId,
|
|
281
|
+
},
|
|
282
|
+
}
|
|
283
|
+
: {}),
|
|
263
284
|
}) : null;
|
|
264
285
|
if (bundled) return bundled;
|
|
265
286
|
|
|
@@ -31,6 +31,14 @@ export interface StaticBundleAttemptInput {
|
|
|
31
31
|
hasDraftCookie: boolean;
|
|
32
32
|
/** Managed installs inject document markup, so a bundled DOCUMENT would be stale. */
|
|
33
33
|
hasManagedInstalls: boolean;
|
|
34
|
+
/**
|
|
35
|
+
* When set, a `/_a/<sha>` miss falls through to revision-bound captured
|
|
36
|
+
* originals (hosted preview image capture) before returning 404.
|
|
37
|
+
*/
|
|
38
|
+
capturedOriginal?: {
|
|
39
|
+
bucket: { get(key: string): Promise<unknown> };
|
|
40
|
+
revisionId: string;
|
|
41
|
+
};
|
|
34
42
|
}
|
|
35
43
|
|
|
36
44
|
/**
|
|
@@ -71,10 +79,33 @@ export async function tryServeStaticBundle(
|
|
|
71
79
|
url.pathname,
|
|
72
80
|
method,
|
|
73
81
|
);
|
|
74
|
-
if (pinned) {
|
|
75
|
-
if (pinnedKey && cache
|
|
82
|
+
if (pinned && pinned.ok) {
|
|
83
|
+
if (pinnedKey && cache) await putCachedPage(cache, pinnedKey, pinned);
|
|
76
84
|
return pinned;
|
|
77
85
|
}
|
|
86
|
+
// `/_a/` miss: try revision-bound captured originals before the pinned 404.
|
|
87
|
+
if (input.capturedOriginal && url.pathname.startsWith("/_a/")) {
|
|
88
|
+
const { tryServeCapturedOriginal } = await import("@/lib/assets/captured-original-serve");
|
|
89
|
+
const captured = await tryServeCapturedOriginal({
|
|
90
|
+
bucket: input.capturedOriginal.bucket,
|
|
91
|
+
tenantId: tenant.tenant_id,
|
|
92
|
+
revisionId: input.capturedOriginal.revisionId,
|
|
93
|
+
pathname: url.pathname,
|
|
94
|
+
method,
|
|
95
|
+
});
|
|
96
|
+
if (captured) return captured;
|
|
97
|
+
}
|
|
98
|
+
if (pinned) return pinned;
|
|
99
|
+
} else if (input.capturedOriginal && url.pathname.startsWith("/_a/")) {
|
|
100
|
+
const { tryServeCapturedOriginal } = await import("@/lib/assets/captured-original-serve");
|
|
101
|
+
const captured = await tryServeCapturedOriginal({
|
|
102
|
+
bucket: input.capturedOriginal.bucket,
|
|
103
|
+
tenantId: tenant.tenant_id,
|
|
104
|
+
revisionId: input.capturedOriginal.revisionId,
|
|
105
|
+
pathname: url.pathname,
|
|
106
|
+
method,
|
|
107
|
+
});
|
|
108
|
+
if (captured) return captured;
|
|
78
109
|
}
|
|
79
110
|
}
|
|
80
111
|
|
|
@@ -13,38 +13,50 @@
|
|
|
13
13
|
* and a versioned tenant (skipped from baking via a `.tot/assets-versioned`
|
|
14
14
|
* marker) falls through to R2. When the tenant has no published asset version,
|
|
15
15
|
* `serveTenantAsset` returns null → 404 (nothing published yet).
|
|
16
|
+
*
|
|
17
|
+
* Preview sessions also consult the image-capture rewrite overlay so rewritten
|
|
18
|
+
* `public/*.css` (and similar text assets) serve `/_a/<sha>` references.
|
|
16
19
|
*/
|
|
17
20
|
import type { APIContext } from "astro";
|
|
18
21
|
import {
|
|
22
|
+
assetContentType,
|
|
23
|
+
parseTenantAssetPath,
|
|
19
24
|
serveTenantAsset,
|
|
20
|
-
verifyPreviewToken,
|
|
21
25
|
type R2BucketLike,
|
|
22
26
|
} from "@tot/public-runtime";
|
|
27
|
+
import { readWithImagePreviewRewriteOverlay } from "@tot/public-runtime/image-preview-rewrite-overlay";
|
|
23
28
|
import { readR2, readKv, readEnv } from "@/lib/env";
|
|
29
|
+
import { resolvePreviewVersionFromCookies } from "@/lib/assets/preview-version-cookie";
|
|
24
30
|
|
|
25
31
|
export const prerender = false;
|
|
26
32
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
33
|
+
async function tryServePreviewRewriteOverlay(input: {
|
|
34
|
+
kvGet: (key: string) => Promise<string | null>;
|
|
35
|
+
tenantId: string;
|
|
36
|
+
revisionId: string;
|
|
37
|
+
pathname: string;
|
|
38
|
+
}): Promise<Response | null> {
|
|
39
|
+
const parsed = parseTenantAssetPath(input.pathname);
|
|
40
|
+
if (!parsed || parsed.tenantId !== input.tenantId) return null;
|
|
41
|
+
// Capture overlays store repo paths as tenants/<id>/public/<rel>.
|
|
42
|
+
const overlayPath = `tenants/${input.tenantId}/public/${parsed.rel}`;
|
|
43
|
+
const text = await readWithImagePreviewRewriteOverlay(
|
|
44
|
+
input.kvGet,
|
|
45
|
+
input.tenantId,
|
|
46
|
+
input.revisionId,
|
|
47
|
+
overlayPath,
|
|
48
|
+
async () => null,
|
|
49
|
+
);
|
|
50
|
+
if (text == null) return null;
|
|
51
|
+
return new Response(text, {
|
|
52
|
+
status: 200,
|
|
53
|
+
headers: {
|
|
54
|
+
"content-type": assetContentType(parsed.rel),
|
|
55
|
+
"cache-control": "no-store",
|
|
56
|
+
"x-robots-tag": "noindex, nofollow",
|
|
57
|
+
"x-tot-image-preview-rewrite": "1",
|
|
58
|
+
},
|
|
59
|
+
});
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
export async function GET(context: APIContext): Promise<Response> {
|
|
@@ -60,7 +72,24 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
60
72
|
});
|
|
61
73
|
}
|
|
62
74
|
|
|
63
|
-
const
|
|
75
|
+
const urlTenant = context.params.id ?? "";
|
|
76
|
+
const previewVersionId = await resolvePreviewVersionFromCookies({
|
|
77
|
+
tenantId: urlTenant,
|
|
78
|
+
previewVersionCookie: context.cookies.get("tot_preview_version")?.value,
|
|
79
|
+
previewTokenCookie: context.cookies.get("tot_preview")?.value,
|
|
80
|
+
secret: await readEnv("CUSTOMIZATION_PREVIEW_SECRET"),
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const pathname = new URL(context.request.url).pathname;
|
|
84
|
+
if (previewVersionId) {
|
|
85
|
+
const overlay = await tryServePreviewRewriteOverlay({
|
|
86
|
+
kvGet: (key) => kv.get(key),
|
|
87
|
+
tenantId: urlTenant,
|
|
88
|
+
revisionId: previewVersionId,
|
|
89
|
+
pathname,
|
|
90
|
+
});
|
|
91
|
+
if (overlay) return overlay;
|
|
92
|
+
}
|
|
64
93
|
|
|
65
94
|
const res = await serveTenantAsset(
|
|
66
95
|
{
|
|
@@ -71,7 +100,7 @@ export async function GET(context: APIContext): Promise<Response> {
|
|
|
71
100
|
...(previewVersionId ? { versionId: previewVersionId } : {}),
|
|
72
101
|
ifNoneMatch: context.request.headers.get("if-none-match"),
|
|
73
102
|
},
|
|
74
|
-
|
|
103
|
+
pathname,
|
|
75
104
|
);
|
|
76
105
|
|
|
77
106
|
// A candidate-preview asset must NEVER be edge/CDN-cached under its tokenless URL
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.48",
|
|
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",
|
|
@@ -12,6 +12,9 @@
|
|
|
12
12
|
"./blog-visibility": "./src/blog-visibility.mjs",
|
|
13
13
|
"./publication-redirects": "./src/publication-redirects.mjs",
|
|
14
14
|
"./image-dependencies": "./src/image-dependencies.ts",
|
|
15
|
+
"./image-finding-dependencies": "./src/image-finding-dependencies.ts",
|
|
16
|
+
"./image-finding-acceptance": "./src/image-finding-acceptance.ts",
|
|
17
|
+
"./image-preview-rewrite-overlay": "./src/image-preview-rewrite-overlay.ts",
|
|
15
18
|
"./tenant-embed-defaults": "./src/tenant-embed-defaults.mjs",
|
|
16
19
|
"./integration/lifecycle-host-asset": "./src/integration/lifecycle-host-asset.mjs",
|
|
17
20
|
"./integration/native-ga4-sandbox-asset": "./src/integration/native-ga4-sandbox-asset.mjs"
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ImageDependency } from "./image-dependencies.js";
|
|
2
|
+
|
|
3
|
+
/** Private locator-free identity, stable when artifact order changes. */
|
|
4
|
+
export async function digestImageDependencyIdentity(
|
|
5
|
+
dependency: Pick<ImageDependency, "surface" | "reason" | "canonicalLocator">,
|
|
6
|
+
): Promise<string> {
|
|
7
|
+
if (!dependency.reason) throw new Error("image dependency has no unresolved reason");
|
|
8
|
+
const input = `${dependency.surface}\0${dependency.reason}\0${dependency.canonicalLocator}`;
|
|
9
|
+
const bytes = new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(input)));
|
|
10
|
+
return `sha256:${[...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("")}`;
|
|
11
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Exact image-finding acceptance helpers: canonical digests and the application /
|
|
3
|
+
* publication overridability classifiers. Findings themselves never carry a locator,
|
|
4
|
+
* tenant id, path, or content digest — only stable ids, codes, and reasons.
|
|
5
|
+
*/
|
|
6
|
+
import { sha256Hex } from "./hash.js";
|
|
7
|
+
import type {
|
|
8
|
+
ImageDependencyFinding,
|
|
9
|
+
ImageDependencyReason,
|
|
10
|
+
} from "./image-dependencies.js";
|
|
11
|
+
|
|
12
|
+
export type ImageApplicationClass = ImageDependencyFinding["authoring"];
|
|
13
|
+
export type ImagePublicationClass = ImageDependencyFinding["hostedPreview"];
|
|
14
|
+
|
|
15
|
+
/** Capture findings are never overridable; every reason is a negative control. */
|
|
16
|
+
export const NON_OVERRIDABLE_CAPTURE_REASONS = [
|
|
17
|
+
"symlink",
|
|
18
|
+
"fifo",
|
|
19
|
+
"device",
|
|
20
|
+
"escape",
|
|
21
|
+
"changed-file",
|
|
22
|
+
"unsafe-scheme",
|
|
23
|
+
"userinfo",
|
|
24
|
+
"nonstandard-port",
|
|
25
|
+
"blocked-address",
|
|
26
|
+
"redirect-limit",
|
|
27
|
+
"timeout",
|
|
28
|
+
"too-large",
|
|
29
|
+
"mime",
|
|
30
|
+
"magic",
|
|
31
|
+
"active-content",
|
|
32
|
+
"pixels",
|
|
33
|
+
"tampered",
|
|
34
|
+
"fetch-failed",
|
|
35
|
+
"unsupported",
|
|
36
|
+
"store-failed",
|
|
37
|
+
"manifest-mismatch",
|
|
38
|
+
"unbound",
|
|
39
|
+
] as const;
|
|
40
|
+
|
|
41
|
+
export type NonOverridableCaptureReason = (typeof NON_OVERRIDABLE_CAPTURE_REASONS)[number];
|
|
42
|
+
|
|
43
|
+
/** Dependency reasons that are security/isolation errors in both classifiers. */
|
|
44
|
+
export const NON_OVERRIDABLE_DEPENDENCY_REASONS = [
|
|
45
|
+
"cross-tenant",
|
|
46
|
+
"tenant-escape",
|
|
47
|
+
"unsafe-scheme",
|
|
48
|
+
"malformed",
|
|
49
|
+
] as const;
|
|
50
|
+
|
|
51
|
+
export type NonOverridableDependencyReason = (typeof NON_OVERRIDABLE_DEPENDENCY_REASONS)[number];
|
|
52
|
+
|
|
53
|
+
/** Safe unresolved / deferred image reasons an owner may accept. */
|
|
54
|
+
export const OVERRIDABLE_DEPENDENCY_REASONS = ["third-party", "local", "unsupported"] as const;
|
|
55
|
+
|
|
56
|
+
export type ImageFindingIdentity = {
|
|
57
|
+
id: string;
|
|
58
|
+
code: string;
|
|
59
|
+
reason: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** Stable publish/operation finding id for an overridable image finding. */
|
|
63
|
+
export function formatOverridableImageFindingId(finding: Pick<ImageDependencyFinding, "id">): string {
|
|
64
|
+
return `IMAGE: ${finding.id}`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Stable publish/operation finding id for a non-overridable image finding. */
|
|
68
|
+
export function formatBlockingImageFindingId(
|
|
69
|
+
finding: Pick<ImageDependencyFinding, "id" | "code">,
|
|
70
|
+
): string {
|
|
71
|
+
return `${finding.code}: ${finding.id}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function formatImageFindingId(finding: Pick<ImageDependencyFinding, "id" | "code" | "overridable">): string {
|
|
75
|
+
return finding.overridable
|
|
76
|
+
? formatOverridableImageFindingId(finding)
|
|
77
|
+
: formatBlockingImageFindingId(finding);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Application (authoring) classifier — warning vs non-overridable security. */
|
|
81
|
+
export function classifyImageFindingApplication(
|
|
82
|
+
reason: ImageDependencyReason,
|
|
83
|
+
): ImageApplicationClass {
|
|
84
|
+
return (NON_OVERRIDABLE_DEPENDENCY_REASONS as readonly string[]).includes(reason)
|
|
85
|
+
? "non-overridable-security-error"
|
|
86
|
+
: "warning";
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Publication / hosted-preview classifier — overridable quality vs security. */
|
|
90
|
+
export function classifyImageFindingPublication(
|
|
91
|
+
reason: ImageDependencyReason,
|
|
92
|
+
): ImagePublicationClass {
|
|
93
|
+
return (NON_OVERRIDABLE_DEPENDENCY_REASONS as readonly string[]).includes(reason)
|
|
94
|
+
? "non-overridable-security-error"
|
|
95
|
+
: "overridable-quality-error";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function isOverridableImageDependencyReason(reason: ImageDependencyReason): boolean {
|
|
99
|
+
return classifyImageFindingPublication(reason) === "overridable-quality-error";
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Capture-time findings are never overridable in either classifier. */
|
|
103
|
+
export function classifyCaptureFindingApplication(
|
|
104
|
+
_reason: NonOverridableCaptureReason,
|
|
105
|
+
): "non-overridable-security-error" {
|
|
106
|
+
return "non-overridable-security-error";
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function classifyCaptureFindingPublication(
|
|
110
|
+
_reason: NonOverridableCaptureReason,
|
|
111
|
+
): "non-overridable-security-error" {
|
|
112
|
+
return "non-overridable-security-error";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Canonical sorted digest of an image finding set. Identity is id+code+reason only —
|
|
117
|
+
* never a locator, tenant, path, or byte digest.
|
|
118
|
+
*/
|
|
119
|
+
export async function digestImageFindingSet(
|
|
120
|
+
findings: readonly ImageFindingIdentity[],
|
|
121
|
+
): Promise<string> {
|
|
122
|
+
const lines = findings
|
|
123
|
+
.map((f) => `${f.id}\0${f.code}\0${f.reason}`)
|
|
124
|
+
.sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
125
|
+
return `sha256:${await sha256Hex(lines.join("\n"))}`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Digest of the exact finding id strings recorded on an acceptance / problem. */
|
|
129
|
+
export async function digestFindingIdSet(findingIds: readonly string[]): Promise<string> {
|
|
130
|
+
const lines = [...findingIds].map(String).sort((a, b) => (a < b ? -1 : a > b ? 1 : 0));
|
|
131
|
+
return `sha256:${await sha256Hex(lines.join("\n"))}`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export { digestImageDependencyIdentity } from "./image-dependency-identity.js";
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Capture-manifest digest: SHA-256 over the same sorted JSON encoding the capture
|
|
138
|
+
* store seals. Locators stay inside the sealed bytes; callers persist only this digest.
|
|
139
|
+
*/
|
|
140
|
+
export async function digestCaptureManifest(manifest: {
|
|
141
|
+
version: 1;
|
|
142
|
+
revisionId: string;
|
|
143
|
+
entries: ReadonlyArray<{
|
|
144
|
+
provenance: { kind: "local" | "remote"; locator: string };
|
|
145
|
+
address: string;
|
|
146
|
+
byteLength: number;
|
|
147
|
+
mediaType: string;
|
|
148
|
+
}>;
|
|
149
|
+
}): Promise<string> {
|
|
150
|
+
const entries = [...manifest.entries].sort((left, right) => {
|
|
151
|
+
if (left.provenance.locator < right.provenance.locator) return -1;
|
|
152
|
+
if (left.provenance.locator > right.provenance.locator) return 1;
|
|
153
|
+
return left.address < right.address ? -1 : left.address > right.address ? 1 : 0;
|
|
154
|
+
});
|
|
155
|
+
const encoded = new TextEncoder().encode(
|
|
156
|
+
JSON.stringify({ version: 1, revisionId: manifest.revisionId, entries }),
|
|
157
|
+
);
|
|
158
|
+
return `sha256:${await sha256Hex(encoded)}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function isImageFindingId(findingId: string): boolean {
|
|
162
|
+
const prefix = String(findingId).split(":", 1)[0]!.trim();
|
|
163
|
+
return prefix === "IMAGE" || prefix.startsWith("image-");
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
export function imageFindingIdsIn(findingIds: readonly string[]): string[] {
|
|
167
|
+
return findingIds.filter(isImageFindingId);
|
|
168
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { ImageDependency, ImageDependencyFinding, ImageDependencyScan } from "./image-dependencies.js";
|
|
2
|
+
|
|
3
|
+
/** Recover one unresolved finding's exact scanner group without a locator in its ID. */
|
|
4
|
+
export function dependenciesForScanFinding(
|
|
5
|
+
finding: ImageDependencyFinding,
|
|
6
|
+
scan: ImageDependencyScan,
|
|
7
|
+
): ImageDependency[] {
|
|
8
|
+
const match = /^image-dependency-(\d+)-(\d+)$/.exec(finding.id);
|
|
9
|
+
if (!match) return [];
|
|
10
|
+
const artifactIndex = Number(match[1]) - 1;
|
|
11
|
+
const groupOrdinal = Number(match[2]);
|
|
12
|
+
const seen = new Map<string, { dep: ImageDependency; ordinal: number }>();
|
|
13
|
+
const counts = new Map<number, number>();
|
|
14
|
+
for (const dep of scan.dependencies) {
|
|
15
|
+
if (dep.disposition !== "capture-required" && dep.disposition !== "security-error" &&
|
|
16
|
+
dep.disposition !== "deferred") continue;
|
|
17
|
+
const key = `${dep.artifactIndex}\0${dep.surface}\0${dep.disposition}\0${dep.reason}\0${dep.canonicalLocator}`;
|
|
18
|
+
if (seen.has(key)) continue;
|
|
19
|
+
const ordinal = (counts.get(dep.artifactIndex) ?? 0) + 1;
|
|
20
|
+
counts.set(dep.artifactIndex, ordinal);
|
|
21
|
+
seen.set(key, { dep, ordinal });
|
|
22
|
+
}
|
|
23
|
+
const group = [...seen.values()].find(
|
|
24
|
+
(entry) => entry.dep.artifactIndex === artifactIndex && entry.ordinal === groupOrdinal,
|
|
25
|
+
);
|
|
26
|
+
if (!group) return [];
|
|
27
|
+
return scan.dependencies.filter((dep) =>
|
|
28
|
+
dep.artifactIndex === group.dep.artifactIndex && dep.surface === group.dep.surface &&
|
|
29
|
+
dep.disposition === group.dep.disposition && dep.reason === group.dep.reason &&
|
|
30
|
+
dep.canonicalLocator === group.dep.canonicalLocator);
|
|
31
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Revision-bound overlay of capture-rewritten preview artifacts (read path).
|
|
3
|
+
*
|
|
4
|
+
* Site-version manifests are immutable; successful `/_a/<sha>` rewrites are
|
|
5
|
+
* stored as content-addressed artifact bytes plus this overlay map. Preview
|
|
6
|
+
* reads prefer the overlay so candidate documents reference platform-owned URLs.
|
|
7
|
+
*/
|
|
8
|
+
import { contentArtifactPath, customizationArtifactKey, themeArtifactPath } from "./customization-runtime.js";
|
|
9
|
+
|
|
10
|
+
export type ImagePreviewRewriteOverlay = {
|
|
11
|
+
version: 1;
|
|
12
|
+
revisionId: string;
|
|
13
|
+
/** Capture-manifest digest bound at write time; mismatched rewrite loses CAS. */
|
|
14
|
+
captureManifestDigest: string;
|
|
15
|
+
/** Full artifact path → content hash (same keys as SiteVersionManifest.artifacts). */
|
|
16
|
+
artifacts: Record<string, string>;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function imagePreviewRewriteOverlayKey(tenantId: string, revisionId: string): string {
|
|
20
|
+
return `cust:image-preview-rewrite:${tenantId}:${revisionId}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Map a repo-relative capture artifact path to the preview manifest artifact path. */
|
|
24
|
+
export function previewManifestPathFor(tenantId: string, repoPath: string): string {
|
|
25
|
+
if (repoPath === "theme.json") return themeArtifactPath(tenantId);
|
|
26
|
+
if (repoPath.startsWith("content/")) {
|
|
27
|
+
return contentArtifactPath(tenantId, repoPath.slice("content/".length));
|
|
28
|
+
}
|
|
29
|
+
return `tenants/${tenantId}/${repoPath}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function readImagePreviewRewriteOverlay(
|
|
33
|
+
get: (key: string) => Promise<string | null>,
|
|
34
|
+
tenantId: string,
|
|
35
|
+
revisionId: string,
|
|
36
|
+
): Promise<ImagePreviewRewriteOverlay | null> {
|
|
37
|
+
try {
|
|
38
|
+
const raw = await get(imagePreviewRewriteOverlayKey(tenantId, revisionId));
|
|
39
|
+
if (!raw) return null;
|
|
40
|
+
const parsed = JSON.parse(raw) as Partial<ImagePreviewRewriteOverlay>;
|
|
41
|
+
if (parsed.version !== 1 || parsed.revisionId !== revisionId) return null;
|
|
42
|
+
if (typeof parsed.captureManifestDigest !== "string") return null;
|
|
43
|
+
if (!/^sha256:[0-9a-f]{64}$/.test(parsed.captureManifestDigest)) return null;
|
|
44
|
+
if (!parsed.artifacts || typeof parsed.artifacts !== "object") return null;
|
|
45
|
+
return {
|
|
46
|
+
version: 1,
|
|
47
|
+
revisionId,
|
|
48
|
+
captureManifestDigest: parsed.captureManifestDigest,
|
|
49
|
+
artifacts: { ...parsed.artifacts },
|
|
50
|
+
};
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Prefer overlay bytes for a full artifact path when present. */
|
|
57
|
+
export async function readWithImagePreviewRewriteOverlay(
|
|
58
|
+
get: (key: string) => Promise<string | null>,
|
|
59
|
+
tenantId: string,
|
|
60
|
+
revisionId: string,
|
|
61
|
+
manifestPath: string,
|
|
62
|
+
fallback: () => Promise<string | null>,
|
|
63
|
+
): Promise<string | null> {
|
|
64
|
+
const overlay = await readImagePreviewRewriteOverlay(get, tenantId, revisionId);
|
|
65
|
+
const hash = overlay?.artifacts?.[manifestPath];
|
|
66
|
+
if (!hash) return fallback();
|
|
67
|
+
const bytes = await get(customizationArtifactKey(tenantId, hash));
|
|
68
|
+
return bytes ?? fallback();
|
|
69
|
+
}
|
|
@@ -63,6 +63,12 @@ export interface R2PutOptionsLike {
|
|
|
63
63
|
* verify can detect a truncated/corrupted object independent of trusting the
|
|
64
64
|
* store's own reported size alone (see `verifyMaterializedAssets`). */
|
|
65
65
|
customMetadata?: Record<string, string>;
|
|
66
|
+
/**
|
|
67
|
+
* Conditional write. `etagDoesNotMatch: "*"` creates the object only when the
|
|
68
|
+
* key is absent (HTTP If-None-Match: *). Capture uses it so an immutable
|
|
69
|
+
* content address is never overwritten.
|
|
70
|
+
*/
|
|
71
|
+
onlyIf?: { etagDoesNotMatch?: string };
|
|
66
72
|
}
|
|
67
73
|
|
|
68
74
|
export interface R2BucketLike {
|
|
@@ -2,14 +2,13 @@
|
|
|
2
2
|
/**
|
|
3
3
|
* Materialize a Gitea-hosted tenant's content into the monorepo working tree.
|
|
4
4
|
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* — they're tracked in git and this script refuses to touch them.
|
|
5
|
+
* Gitea repos (git.tokenoftrust.com/storefront/<repo>) are the source of truth
|
|
6
|
+
* for tenants declared in the manifest. Materialized content uses the shared
|
|
7
|
+
* tenant directory resolver: `tenants/<id>/` for real tenants and
|
|
8
|
+
* `tenants/e2e/<id>/` for synthetic tenants. The shared Storefront Worker reads
|
|
9
|
+
* published tenant revisions from KV/R2; local development and per-tenant
|
|
10
|
+
* static publication materialize their exact source. Tenants absent from the
|
|
11
|
+
* manifest are committed fixtures and are refused.
|
|
13
12
|
*
|
|
14
13
|
* Which tenants are Gitea-classified lives in `tenants/.gitea-tenants.json`
|
|
15
14
|
* (the same manifest the CI reintroduction guard reads —
|
|
@@ -24,9 +23,9 @@
|
|
|
24
23
|
* the canonical shape (theme.json, content/, public/) when absent.
|
|
25
24
|
* 3. Copy each mapped workspace path into the monorepo target, REPLACING the
|
|
26
25
|
* target tree (stale files from a previous materialize are removed). Every
|
|
27
|
-
* target must resolve under
|
|
26
|
+
* target must resolve under that tenant's directory — anything else is refused.
|
|
28
27
|
* 4. Copy `.tot/config.json` itself (the CLI + validate flows read it) and
|
|
29
|
-
* stamp
|
|
28
|
+
* stamp the tenant's `.materialized.json` with { repo, ref, sha, at } so
|
|
30
29
|
* `--check` and humans can see what's populated.
|
|
31
30
|
*
|
|
32
31
|
* Modes:
|
|
@@ -71,6 +70,7 @@ import { execFileSync } from "node:child_process";
|
|
|
71
70
|
import { tmpdir } from "node:os";
|
|
72
71
|
import { dirname, join, resolve, sep } from "node:path";
|
|
73
72
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
73
|
+
import { tenantDirRelative } from "../lib/tenant-dirs.mjs";
|
|
74
74
|
|
|
75
75
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
76
76
|
const repoRoot = resolve(here, "..", "..");
|
|
@@ -119,9 +119,14 @@ export function buildCloneUrl({ base, repo, user, token }) {
|
|
|
119
119
|
* write elsewhere in the tree (compare reconcile's manifest deny-by-default).
|
|
120
120
|
*/
|
|
121
121
|
export function resolveTargetPath(root, tenant, repoPath) {
|
|
122
|
-
const
|
|
123
|
-
const
|
|
124
|
-
|
|
122
|
+
const declared = `tenants/${tenant}`;
|
|
123
|
+
const physical = tenantDirRelative(tenant);
|
|
124
|
+
const translated = repoPath === declared ? physical
|
|
125
|
+
: repoPath.startsWith(`${declared}/`) ? `${physical}${repoPath.slice(declared.length)}` : repoPath;
|
|
126
|
+
const target = resolve(root, translated);
|
|
127
|
+
const tenantRoot = resolve(root, physical);
|
|
128
|
+
const bound = tenantRoot + sep;
|
|
129
|
+
if (!target.startsWith(bound) && target !== tenantRoot) {
|
|
125
130
|
throw new Error(
|
|
126
131
|
`mapping target "${repoPath}" escapes tenants/${tenant}/ — refusing`,
|
|
127
132
|
);
|
|
@@ -142,15 +147,19 @@ function canonicalMappings(tenant) {
|
|
|
142
147
|
/**
|
|
143
148
|
* Copy one mapping from the checked-out tenant repo into the monorepo tree.
|
|
144
149
|
* `tree` targets are replaced wholesale so a removed file doesn't linger from a
|
|
145
|
-
* previous materialize. Missing OPTIONAL workspace paths
|
|
146
|
-
* without public/ is fine); the reconcile-side required-artifact rules (theme +
|
|
150
|
+
* previous materialize. Missing OPTIONAL workspace paths remove any old target
|
|
151
|
+
* (a tenant without public/ is fine); the reconcile-side required-artifact rules (theme +
|
|
147
152
|
* home present) are enforced by validate/reconcile, not here.
|
|
148
153
|
*/
|
|
149
154
|
export function copyMapping({ checkoutDir, root, tenant, mapping, log = (/** @type {string} */ _m) => {} }) {
|
|
150
155
|
const src = join(checkoutDir, mapping.workspace.replace(/\/+$/, ""));
|
|
151
156
|
const target = resolveTargetPath(root, tenant, mapping.repo.replace(/\/+$/, ""));
|
|
157
|
+
if (target === resolve(root, tenantDirRelative(tenant))) {
|
|
158
|
+
throw new Error("mapping target cannot replace the tenant root");
|
|
159
|
+
}
|
|
152
160
|
if (!existsSync(src)) {
|
|
153
|
-
|
|
161
|
+
rmSync(target, { recursive: true, force: true });
|
|
162
|
+
log(` ~ ${mapping.workspace} absent in repo — old target removed`);
|
|
154
163
|
return { copied: false, target };
|
|
155
164
|
}
|
|
156
165
|
if (mapping.kind === "tree" && existsSync(target)) {
|
|
@@ -229,7 +238,7 @@ export function materializeTenant({
|
|
|
229
238
|
// local iteration against the default branch is legitimate; publishes are
|
|
230
239
|
// separately guarded by ci-www-publish.sh's PUBLISH_REF requirement.
|
|
231
240
|
console.warn(
|
|
232
|
-
`[materialize-tenant-content] WARNING: no --ref given — materializing
|
|
241
|
+
`[materialize-tenant-content] WARNING: no --ref given — materializing from the ` +
|
|
233
242
|
`repo's DEFAULT branch, which may be stale. Pin a commit (--ref <sha|branch>) for anything ` +
|
|
234
243
|
`feeding a build or publish.`,
|
|
235
244
|
);
|
|
@@ -262,6 +271,8 @@ export function materializeTenant({
|
|
|
262
271
|
if (existsSync(cfgPath)) {
|
|
263
272
|
const cfgTarget = resolveTargetPath(root, tenant, `tenants/${tenant}/.tot/config.json`);
|
|
264
273
|
cpSync(cfgPath, cfgTarget);
|
|
274
|
+
} else {
|
|
275
|
+
rmSync(resolveTargetPath(root, tenant, `tenants/${tenant}/.tot/config.json`), { force: true });
|
|
265
276
|
}
|
|
266
277
|
// The assets-r2-versioned opt-in marker:
|
|
267
278
|
// copy-tenant-assets.mjs reads this from the MATERIALIZED tree, not the repo
|
|
@@ -276,6 +287,8 @@ export function materializeTenant({
|
|
|
276
287
|
);
|
|
277
288
|
cpSync(versionedMarkerSrc, versionedMarkerTarget);
|
|
278
289
|
log(` + .tot/assets-versioned → tenants/${tenant}/.tot/assets-versioned`);
|
|
290
|
+
} else {
|
|
291
|
+
rmSync(resolveTargetPath(root, tenant, `tenants/${tenant}/.tot/assets-versioned`), { force: true });
|
|
279
292
|
}
|
|
280
293
|
const stamp = resolveTargetPath(root, tenant, `tenants/${tenant}/.materialized.json`);
|
|
281
294
|
writeFileSync(
|
|
@@ -302,7 +315,7 @@ export function checkMaterialized(root = repoRoot) {
|
|
|
302
315
|
const manifest = loadManifest(root);
|
|
303
316
|
const missing = [];
|
|
304
317
|
for (const tenant of Object.keys(manifest.tenants)) {
|
|
305
|
-
const dir = join(root,
|
|
318
|
+
const dir = join(root, tenantDirRelative(tenant));
|
|
306
319
|
const populated =
|
|
307
320
|
existsSync(join(dir, "theme.json")) ||
|
|
308
321
|
existsSync(join(dir, "content")) ||
|
|
@@ -345,7 +358,7 @@ export function checkPinned(
|
|
|
345
358
|
const scope = onlyTenants ? new Set(onlyTenants) : null;
|
|
346
359
|
for (const tenant of Object.keys(manifest.tenants)) {
|
|
347
360
|
if (scope && !scope.has(tenant)) continue;
|
|
348
|
-
const dir = join(root,
|
|
361
|
+
const dir = join(root, tenantDirRelative(tenant));
|
|
349
362
|
const populated =
|
|
350
363
|
existsSync(join(dir, "theme.json")) ||
|
|
351
364
|
existsSync(join(dir, "content")) ||
|
|
@@ -432,7 +445,7 @@ export function checkFresh(
|
|
|
432
445
|
const scope = onlyTenants ? new Set(onlyTenants) : null;
|
|
433
446
|
for (const tenant of Object.keys(manifest.tenants)) {
|
|
434
447
|
if (scope && !scope.has(tenant)) continue;
|
|
435
|
-
const stampPath = join(root,
|
|
448
|
+
const stampPath = join(root, tenantDirRelative(tenant), ".materialized.json");
|
|
436
449
|
if (!existsSync(stampPath)) continue; // checkPinned already reports absent/missing
|
|
437
450
|
let stamp;
|
|
438
451
|
try {
|
|
@@ -610,15 +623,21 @@ function main() {
|
|
|
610
623
|
}
|
|
611
624
|
for (const tenant of targets) {
|
|
612
625
|
const repo = manifest.tenants[tenant]?.repo ?? tenant;
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
626
|
+
const synthetic = manifest.tenants[tenant]?.kind === "synthetic-qa";
|
|
627
|
+
console.log(synthetic ? "Materializing synthetic QA source…" : `Materializing ${tenant} (repo ${repo})…`);
|
|
628
|
+
try {
|
|
629
|
+
materializeTenant({
|
|
630
|
+
tenant,
|
|
631
|
+
repo,
|
|
632
|
+
ref: args.ref,
|
|
633
|
+
remote: args.remote,
|
|
634
|
+
gitBase,
|
|
635
|
+
log: synthetic ? () => {} : (s) => console.log(s),
|
|
636
|
+
});
|
|
637
|
+
} catch (error) {
|
|
638
|
+
if (synthetic) throw new Error("synthetic QA materialization refused");
|
|
639
|
+
throw error;
|
|
640
|
+
}
|
|
622
641
|
}
|
|
623
642
|
}
|
|
624
643
|
|