@tokenoftrust/storefront-runner 2.0.15 → 2.0.17

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.
@@ -0,0 +1,495 @@
1
+ /**
2
+ * Fixture-only WS3 clone-read preflight.
3
+ *
4
+ * This is a pure conformance boundary over caller-supplied synthetic metadata.
5
+ * It intentionally has no imports and no execution seam: it cannot resolve,
6
+ * create, update, reconcile, or otherwise operate a clone. WS3 remains the owner
7
+ * of clone execution and public contract publication.
8
+ */
9
+
10
+ export const WS3_CLONE_READ_PREFLIGHT_SCHEMA = "ws3-clone-read-preflight.v1" as const;
11
+
12
+ const READ_CAPABILITIES = ["read:metadata", "read:manifest", "read:invariants"] as const;
13
+ type ReadCapability = (typeof READ_CAPABILITIES)[number];
14
+
15
+ export interface SyntheticCloneReadMetadata {
16
+ schemaVersion: typeof WS3_CLONE_READ_PREFLIGHT_SCHEMA;
17
+ fixtureId: string;
18
+ synthetic: true;
19
+ tenant: {
20
+ tenantId: string;
21
+ tenantTier: "regulated-commerce";
22
+ };
23
+ clone: {
24
+ cloneId: string;
25
+ snapshotReference: string;
26
+ sourceRevision: string;
27
+ capturedAt: string;
28
+ };
29
+ requestedCapabilities: ReadCapability[];
30
+ readFacts: {
31
+ manifestPresent: boolean;
32
+ catalogRecordCount: number;
33
+ orderRecordCount: number;
34
+ integrationReferenceCount: number;
35
+ };
36
+ }
37
+
38
+ export type CloneReadPreflightBlockerCode =
39
+ | "invalid-shape"
40
+ | "not-explicitly-synthetic"
41
+ | "live-looking-identity"
42
+ | "unsafe-field"
43
+ | "live-endpoint"
44
+ | "mutation-capability-refused"
45
+ | "unsupported-capability"
46
+ | "manifest-missing";
47
+
48
+ export interface CloneReadPreflightBlocker {
49
+ code: CloneReadPreflightBlockerCode;
50
+ path: string;
51
+ message: string;
52
+ }
53
+
54
+ export interface CloneReadPreflightInvariant {
55
+ id:
56
+ | "synthetic-identities-only"
57
+ | "read-capabilities-only"
58
+ | "sanitized-offline-snapshot"
59
+ | "manifest-observed";
60
+ passed: boolean;
61
+ }
62
+
63
+ export interface CloneReadPreflightResult {
64
+ schemaVersion: typeof WS3_CLONE_READ_PREFLIGHT_SCHEMA;
65
+ ready: boolean;
66
+ blockers: CloneReadPreflightBlocker[];
67
+ invariants: CloneReadPreflightInvariant[];
68
+ observed: {
69
+ fixtureId: string | null;
70
+ tenantId: string | null;
71
+ cloneId: string | null;
72
+ snapshotReference: string | null;
73
+ requestedCapabilities: ReadCapability[];
74
+ readFacts: SyntheticCloneReadMetadata["readFacts"] | null;
75
+ };
76
+ }
77
+
78
+ const MUTATION_CAPABILITY = /(^|:)(create|update|execute|write|delete|mutate|reconcile|promote|deploy)(:|$)/i;
79
+ const UNSAFE_KEY = /(authorization|credential|secret|password|api[-_]?key|access[-_]?token|refresh[-_]?token|private[-_]?key|email|phone|address|(?:^|[-_])name(?:$|[-_]))/i;
80
+ const CREDENTIAL_VALUE = /(-----BEGIN [A-Z ]*PRIVATE KEY-----|\bBearer\s+[A-Za-z0-9._~-]+|\bAKIA[0-9A-Z]{16}\b)/;
81
+ const EMAIL_VALUE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;
82
+ const PHONE_VALUE = /\b(?:\+?\d{1,3}[-. ]?)?(?:\(\d{3}\)|\d{3})[-. ]\d{3}[-. ]\d{4}\b/;
83
+ const PAYMENT_VALUE = /(?:^|[^0-9])(?:\d{4}[- ]?){3}\d{4}(?:$|[^0-9])|(?:^|[^0-9])\d{13,19}(?:$|[^0-9])/;
84
+ const GOVERNMENT_ID_VALUE = /(?:^|[^0-9])(?:\d{3}-\d{2}-\d{4}|\d{2}-\d{7}|\d{9})(?:$|[^0-9])/;
85
+ const ADDRESS_VALUE = /\b\d{1,6}(?:[- ]+[A-Za-z][A-Za-z0-9.']*){1,5}[- ]+(?:street|st|avenue|ave|road|rd|boulevard|blvd|lane|ln|drive|dr|court|ct|way)\b/i;
86
+ const PERSON_NAME_VALUE = /\b(?:[A-Z][a-z]{1,30}\s+[A-Z][a-z]{1,30}|(?:jane|john)[-_ ]doe)\b/i;
87
+ const LIVE_ENDPOINT = /\b(?:https?|wss?):\/\//i;
88
+ const CODE_VALUE = /(?:<\/?script\b|javascript:|\{\{|=>|\bfunction\s*\(|\b(?:window|document)\.|\bdataLayer\b|\bGTM-[A-Z0-9-]+\b)/i;
89
+ const SHA = /^[a-f0-9]{40}$/;
90
+ const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
91
+ // Local fixture identities stay deliberately closed until WS3 publishes a public read contract.
92
+ const FIXTURE_ID = /^synthetic-ws3-regulated-clone-read-v1$/;
93
+ const TENANT_ID = /^synthetic-regulated-source\.test$/;
94
+ const CLONE_LABEL = /^synthetic-risk-read-\d{3}$/;
95
+ const SNAPSHOT_REFERENCE = /^fixture:\/\/synthetic-ws3-clone\/snapshot-\d{3}$/;
96
+ const MAX_SYNTHETIC_READ_COUNT = 999_999;
97
+
98
+ function record(value: unknown): Record<string, unknown> | null {
99
+ if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
100
+ const prototype = Object.getPrototypeOf(value);
101
+ return prototype === Object.prototype || prototype === null
102
+ ? (value as Record<string, unknown>)
103
+ : null;
104
+ }
105
+
106
+ function ownDataValue(value: Record<string, unknown> | null, key: string): unknown {
107
+ if (!value) return undefined;
108
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
109
+ return descriptor && descriptor.enumerable && Object.hasOwn(descriptor, "value")
110
+ ? descriptor.value
111
+ : undefined;
112
+ }
113
+
114
+ function text(value: unknown): string | null {
115
+ return typeof value === "string" && value.length > 0 ? value : null;
116
+ }
117
+
118
+ function isSanitizedText(value: string): boolean {
119
+ return !(
120
+ CREDENTIAL_VALUE.test(value) ||
121
+ EMAIL_VALUE.test(value) ||
122
+ PHONE_VALUE.test(value) ||
123
+ PAYMENT_VALUE.test(value) ||
124
+ GOVERNMENT_ID_VALUE.test(value) ||
125
+ ADDRESS_VALUE.test(value) ||
126
+ PERSON_NAME_VALUE.test(value) ||
127
+ LIVE_ENDPOINT.test(value) ||
128
+ CODE_VALUE.test(value)
129
+ );
130
+ }
131
+
132
+ function exactKeys(
133
+ value: Record<string, unknown>,
134
+ expected: readonly string[],
135
+ path: string,
136
+ block: (code: CloneReadPreflightBlockerCode, path: string, message: string) => void,
137
+ ): void {
138
+ const allowed = new Set(expected);
139
+ for (const key of Reflect.ownKeys(value)) {
140
+ if (typeof key !== "string" || !allowed.has(key)) {
141
+ block("invalid-shape", path, "contains an unexpected field");
142
+ continue;
143
+ }
144
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
145
+ if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, "value")) {
146
+ block("invalid-shape", `${path}.${key}`, "must be an enumerable data field");
147
+ }
148
+ }
149
+ for (const key of expected) {
150
+ if (ownDataValue(value, key) === undefined) {
151
+ block("invalid-shape", `${path}.${key}`, "required data field is missing");
152
+ }
153
+ }
154
+ }
155
+
156
+ function arrayDataValues(
157
+ value: unknown,
158
+ path: string,
159
+ block: (code: CloneReadPreflightBlockerCode, path: string, message: string) => void,
160
+ ): unknown[] | null {
161
+ if (!Array.isArray(value)) return null;
162
+ const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
163
+ const length = lengthDescriptor && Object.hasOwn(lengthDescriptor, "value")
164
+ ? lengthDescriptor.value
165
+ : -1;
166
+ if (!Number.isSafeInteger(length) || (length as number) < 0) {
167
+ block("invalid-shape", path, "array length is invalid");
168
+ return [];
169
+ }
170
+ const result: unknown[] = [];
171
+ for (let index = 0; index < (length as number); index += 1) {
172
+ const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
173
+ if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, "value")) {
174
+ block("invalid-shape", `${path}[${index}]`, "must be an enumerable data item");
175
+ result.push(undefined);
176
+ } else {
177
+ result.push(descriptor.value);
178
+ }
179
+ }
180
+ for (const key of Reflect.ownKeys(value)) {
181
+ if (key === "length") continue;
182
+ if (typeof key !== "string" || !/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= (length as number)) {
183
+ block("invalid-shape", path, "array contains unexpected metadata");
184
+ }
185
+ }
186
+ return result;
187
+ }
188
+
189
+ function findUnsafeData(
190
+ value: unknown,
191
+ block: (code: CloneReadPreflightBlockerCode, path: string, message: string) => void,
192
+ path = "metadata",
193
+ ): void {
194
+ if (typeof value === "string") {
195
+ if (CREDENTIAL_VALUE.test(value)) block("unsafe-field", path, "credential-shaped value is forbidden");
196
+ if (
197
+ EMAIL_VALUE.test(value) ||
198
+ PHONE_VALUE.test(value) ||
199
+ PAYMENT_VALUE.test(value) ||
200
+ GOVERNMENT_ID_VALUE.test(value) ||
201
+ ADDRESS_VALUE.test(value) ||
202
+ PERSON_NAME_VALUE.test(value)
203
+ ) {
204
+ block("unsafe-field", path, "PII-shaped value is forbidden");
205
+ }
206
+ if (LIVE_ENDPOINT.test(value)) block("live-endpoint", path, "network endpoint values are forbidden");
207
+ if (CODE_VALUE.test(value)) block("unsafe-field", path, "code-bearing value is forbidden");
208
+ return;
209
+ }
210
+ if (Array.isArray(value)) {
211
+ const entries = arrayDataValues(value, path, block) ?? [];
212
+ entries.forEach((entry, index) => findUnsafeData(entry, block, `${path}[${index}]`));
213
+ return;
214
+ }
215
+ const object = record(value);
216
+ if (!object) {
217
+ if (value !== null && typeof value === "object") {
218
+ block("invalid-shape", path, "must be a plain own-data object");
219
+ }
220
+ return;
221
+ }
222
+ for (const key of Reflect.ownKeys(object)) {
223
+ if (typeof key !== "string") {
224
+ block("invalid-shape", path, "contains a non-JSON field");
225
+ continue;
226
+ }
227
+ const descriptor = Object.getOwnPropertyDescriptor(object, key);
228
+ if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, "value")) {
229
+ block("invalid-shape", path, "contains a non-data field");
230
+ continue;
231
+ }
232
+ if (UNSAFE_KEY.test(key)) block("unsafe-field", path, "contains a PII/secret-shaped field");
233
+ // Caller-controlled keys never become blocker paths or messages.
234
+ findUnsafeData(descriptor.value, block, path);
235
+ }
236
+ }
237
+
238
+ function syntheticReadCount(value: unknown): value is number {
239
+ return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= MAX_SYNTHETIC_READ_COUNT;
240
+ }
241
+
242
+ function deepFreeze<T>(value: T): T {
243
+ if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
244
+ Object.freeze(value);
245
+ for (const child of Object.values(value)) deepFreeze(child);
246
+ }
247
+ return value;
248
+ }
249
+
250
+ /**
251
+ * Assess synthetic clone-read metadata without invoking any clone or I/O seam.
252
+ * Invalid input is represented as blockers so callers get a complete, stable
253
+ * conformance report rather than a partial execution attempt.
254
+ */
255
+ function assessSyntheticCloneRead(metadata: unknown): Readonly<CloneReadPreflightResult> {
256
+ const blockers: CloneReadPreflightBlocker[] = [];
257
+ const seen = new Set<string>();
258
+ const block = (code: CloneReadPreflightBlockerCode, path: string, message: string): void => {
259
+ const key = `${code}:${path}:${message}`;
260
+ if (!seen.has(key)) {
261
+ seen.add(key);
262
+ blockers.push({ code, path, message });
263
+ }
264
+ };
265
+
266
+ findUnsafeData(metadata, block);
267
+ const root = record(metadata);
268
+ if (!root) {
269
+ block("invalid-shape", "metadata", "expected an object");
270
+ } else {
271
+ exactKeys(
272
+ root,
273
+ ["schemaVersion", "fixtureId", "synthetic", "tenant", "clone", "requestedCapabilities", "readFacts"],
274
+ "metadata",
275
+ block,
276
+ );
277
+ }
278
+
279
+ const schemaVersion = ownDataValue(root, "schemaVersion");
280
+ const synthetic = ownDataValue(root, "synthetic");
281
+ const fixtureIdCandidate = text(ownDataValue(root, "fixtureId"));
282
+ const fixtureId =
283
+ synthetic === true &&
284
+ fixtureIdCandidate &&
285
+ FIXTURE_ID.test(fixtureIdCandidate) &&
286
+ isSanitizedText(fixtureIdCandidate)
287
+ ? fixtureIdCandidate
288
+ : null;
289
+ if (schemaVersion !== WS3_CLONE_READ_PREFLIGHT_SCHEMA) {
290
+ block("invalid-shape", "metadata.schemaVersion", `expected ${WS3_CLONE_READ_PREFLIGHT_SCHEMA}`);
291
+ }
292
+ if (!fixtureId) {
293
+ block("not-explicitly-synthetic", "metadata", "explicit synthetic metadata and a closed fixture-id format are required");
294
+ }
295
+
296
+ const tenant = record(ownDataValue(root, "tenant"));
297
+ if (!tenant) {
298
+ block("invalid-shape", "metadata.tenant", "expected an object");
299
+ } else {
300
+ exactKeys(tenant, ["tenantId", "tenantTier"], "metadata.tenant", block);
301
+ }
302
+ const tenantIdCandidate = text(ownDataValue(tenant, "tenantId"));
303
+ const tenantId =
304
+ tenantIdCandidate && TENANT_ID.test(tenantIdCandidate) && isSanitizedText(tenantIdCandidate)
305
+ ? tenantIdCandidate
306
+ : null;
307
+ if (!tenantId) {
308
+ block("live-looking-identity", "metadata.tenant.tenantId", "tenantId must use the closed synthetic format");
309
+ }
310
+ if (ownDataValue(tenant, "tenantTier") !== "regulated-commerce") {
311
+ block("invalid-shape", "metadata.tenant.tenantTier", "expected regulated-commerce");
312
+ }
313
+
314
+ const clone = record(ownDataValue(root, "clone"));
315
+ if (!clone) {
316
+ block("invalid-shape", "metadata.clone", "expected an object");
317
+ } else {
318
+ exactKeys(clone, ["cloneId", "snapshotReference", "sourceRevision", "capturedAt"], "metadata.clone", block);
319
+ }
320
+ const cloneIdCandidate = text(ownDataValue(clone, "cloneId"));
321
+ const cloneParts = cloneIdCandidate?.split("~clone~") ?? [];
322
+ const cloneLabel = cloneParts.length === 2 ? cloneParts[1] : null;
323
+ const cloneId =
324
+ tenantId &&
325
+ cloneIdCandidate &&
326
+ cloneParts[0] === tenantId &&
327
+ cloneLabel &&
328
+ cloneLabel.length <= 72 &&
329
+ CLONE_LABEL.test(cloneLabel) &&
330
+ isSanitizedText(cloneIdCandidate)
331
+ ? cloneIdCandidate
332
+ : null;
333
+ if (!cloneId) {
334
+ block(
335
+ "live-looking-identity",
336
+ "metadata.clone.cloneId",
337
+ "cloneId must belong to the synthetic tenant and use a synthetic- clone label",
338
+ );
339
+ }
340
+ const snapshotCandidate = text(ownDataValue(clone, "snapshotReference"));
341
+ const snapshotReference =
342
+ snapshotCandidate && SNAPSHOT_REFERENCE.test(snapshotCandidate) && isSanitizedText(snapshotCandidate)
343
+ ? snapshotCandidate
344
+ : null;
345
+ if (!snapshotReference) {
346
+ block("live-endpoint", "metadata.clone.snapshotReference", "only the closed offline fixture-reference format is allowed");
347
+ }
348
+ if (!text(ownDataValue(clone, "sourceRevision"))?.match(SHA)) {
349
+ block("invalid-shape", "metadata.clone.sourceRevision", "expected a fixed 40-character revision");
350
+ }
351
+ const capturedAt = text(ownDataValue(clone, "capturedAt"));
352
+ if (!capturedAt?.match(ISO_UTC) || Number.isNaN(Date.parse(capturedAt))) {
353
+ block("invalid-shape", "metadata.clone.capturedAt", "expected a canonical UTC timestamp");
354
+ }
355
+
356
+ const requestedCapabilityInput = ownDataValue(root, "requestedCapabilities");
357
+ const capabilityData = arrayDataValues(requestedCapabilityInput, "metadata.requestedCapabilities", block);
358
+ const capabilities = capabilityData ?? [];
359
+ if (!capabilityData) {
360
+ block("invalid-shape", "metadata.requestedCapabilities", "expected an array");
361
+ }
362
+ if (capabilities.length === 0) {
363
+ block("invalid-shape", "metadata.requestedCapabilities", "at least one read capability is required");
364
+ }
365
+ const requestedCapabilities: ReadCapability[] = [];
366
+ capabilities.forEach((capability, index) => {
367
+ const path = `metadata.requestedCapabilities[${index}]`;
368
+ if (typeof capability !== "string") {
369
+ block("invalid-shape", path, "expected a string");
370
+ return;
371
+ }
372
+ if (MUTATION_CAPABILITY.test(capability)) {
373
+ block("mutation-capability-refused", path, "mutation capability is not read-only");
374
+ } else if (!READ_CAPABILITIES.includes(capability as ReadCapability)) {
375
+ block("unsupported-capability", path, "capability is outside the local fixture contract");
376
+ } else {
377
+ requestedCapabilities.push(capability as ReadCapability);
378
+ }
379
+ });
380
+
381
+ const facts = record(ownDataValue(root, "readFacts"));
382
+ if (!facts) {
383
+ block("invalid-shape", "metadata.readFacts", "expected an object");
384
+ } else {
385
+ exactKeys(
386
+ facts,
387
+ ["manifestPresent", "catalogRecordCount", "orderRecordCount", "integrationReferenceCount"],
388
+ "metadata.readFacts",
389
+ block,
390
+ );
391
+ }
392
+ const manifestPresent = ownDataValue(facts, "manifestPresent");
393
+ const catalogRecordCount = ownDataValue(facts, "catalogRecordCount");
394
+ const orderRecordCount = ownDataValue(facts, "orderRecordCount");
395
+ const integrationReferenceCount = ownDataValue(facts, "integrationReferenceCount");
396
+ if (typeof manifestPresent !== "boolean") {
397
+ block("invalid-shape", "metadata.readFacts.manifestPresent", "expected a boolean");
398
+ } else if (!manifestPresent) {
399
+ block("manifest-missing", "metadata.readFacts.manifestPresent", "read snapshot has no manifest");
400
+ }
401
+ if (!syntheticReadCount(catalogRecordCount)) {
402
+ block("invalid-shape", "metadata.readFacts.catalogRecordCount", "expected an integer from 0 through 999999");
403
+ }
404
+ if (!syntheticReadCount(orderRecordCount)) {
405
+ block("invalid-shape", "metadata.readFacts.orderRecordCount", "expected an integer from 0 through 999999");
406
+ }
407
+ if (!syntheticReadCount(integrationReferenceCount)) {
408
+ block("invalid-shape", "metadata.readFacts.integrationReferenceCount", "expected an integer from 0 through 999999");
409
+ }
410
+
411
+ const hasBlocker = (...codes: CloneReadPreflightBlockerCode[]): boolean =>
412
+ blockers.some(({ code }) => codes.includes(code));
413
+ // A skipped accessor, sparse array item, or other malformed data surface means
414
+ // none of the cross-field security claims can be proven from the whole input.
415
+ const shapeInspectable = !hasBlocker("invalid-shape");
416
+ const invariants: CloneReadPreflightInvariant[] = [
417
+ {
418
+ id: "synthetic-identities-only",
419
+ passed: shapeInspectable && !hasBlocker("not-explicitly-synthetic", "live-looking-identity"),
420
+ },
421
+ {
422
+ id: "read-capabilities-only",
423
+ passed: shapeInspectable && !hasBlocker("mutation-capability-refused", "unsupported-capability"),
424
+ },
425
+ {
426
+ id: "sanitized-offline-snapshot",
427
+ passed: shapeInspectable && !hasBlocker("unsafe-field", "live-endpoint"),
428
+ },
429
+ {
430
+ id: "manifest-observed",
431
+ passed: shapeInspectable && !hasBlocker("manifest-missing") && manifestPresent === true,
432
+ },
433
+ ];
434
+
435
+ const readFacts =
436
+ facts &&
437
+ typeof manifestPresent === "boolean" &&
438
+ syntheticReadCount(catalogRecordCount) &&
439
+ syntheticReadCount(orderRecordCount) &&
440
+ syntheticReadCount(integrationReferenceCount)
441
+ ? {
442
+ manifestPresent,
443
+ catalogRecordCount,
444
+ orderRecordCount,
445
+ integrationReferenceCount,
446
+ }
447
+ : null;
448
+
449
+ return deepFreeze({
450
+ schemaVersion: WS3_CLONE_READ_PREFLIGHT_SCHEMA,
451
+ ready: blockers.length === 0 && invariants.every(({ passed }) => passed),
452
+ blockers,
453
+ invariants,
454
+ observed: {
455
+ fixtureId,
456
+ tenantId,
457
+ cloneId,
458
+ snapshotReference,
459
+ requestedCapabilities,
460
+ readFacts,
461
+ },
462
+ });
463
+ }
464
+
465
+ function inspectionFailure(): Readonly<CloneReadPreflightResult> {
466
+ return deepFreeze({
467
+ schemaVersion: WS3_CLONE_READ_PREFLIGHT_SCHEMA,
468
+ ready: false,
469
+ blockers: [{ code: "invalid-shape", path: "metadata", message: "input could not be safely inspected" }],
470
+ invariants: [
471
+ { id: "synthetic-identities-only", passed: false },
472
+ { id: "read-capabilities-only", passed: false },
473
+ { id: "sanitized-offline-snapshot", passed: false },
474
+ { id: "manifest-observed", passed: false },
475
+ ],
476
+ observed: {
477
+ fixtureId: null,
478
+ tenantId: null,
479
+ cloneId: null,
480
+ snapshotReference: null,
481
+ requestedCapabilities: [],
482
+ readFacts: null,
483
+ },
484
+ });
485
+ }
486
+
487
+ export function preflightSyntheticCloneRead(metadata: unknown): Readonly<CloneReadPreflightResult> {
488
+ try {
489
+ return assessSyntheticCloneRead(metadata);
490
+ } catch {
491
+ // Proxy traps and other caller-controlled inspection failures are contained;
492
+ // their thrown text is never copied into the stable conformance result.
493
+ return inspectionFailure();
494
+ }
495
+ }
@@ -5,7 +5,16 @@ import { listBlogPosts } from "@/lib/blog/provider.js";
5
5
 
6
6
  const { tenant, basePath, canonicalBase, content } = Astro.locals;
7
7
  const posts = await listBlogPosts(tenant.tenant_id, content);
8
- if (posts.length === 0) return new Response(null, { status: 404 });
8
+ // A tenant with no blog content has no blog index, so this is still a 404 — but
9
+ // it is a DIFFERENT 404 from "no such route", and telling them apart from the
10
+ // outside used to be impossible (both were bare and byte-identical). The header
11
+ // says which one this is; a nonexistent route never carries it.
12
+ if (posts.length === 0) {
13
+ return new Response(null, {
14
+ status: 404,
15
+ headers: { "x-tot-blog-collection": "empty" },
16
+ });
17
+ }
9
18
 
10
19
  const href = (path: string) => `${basePath}${path}` || "/";
11
20
  const tags = [...new Set(posts.flatMap((post) => post.tags))].sort((a, b) => a.localeCompare(b));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.15",
3
+ "version": "2.0.17",
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",