@tokenoftrust/storefront-runner 2.0.17 → 2.0.18

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.
Files changed (86) hide show
  1. package/apps/storefront/env.d.ts +18 -0
  2. package/apps/storefront/migrations-apps/0012_test_destinations.sql +29 -0
  3. package/apps/storefront/src/components/blog/ReactionBar.astro +1 -1
  4. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +365 -49
  5. package/apps/storefront/src/components/islands/checkoutVerificationFlow.ts +251 -0
  6. package/apps/storefront/src/config/compliance/rulesets.ts +18 -7
  7. package/apps/storefront/src/config/devTenantSeed.ts +24 -1
  8. package/apps/storefront/src/config/evidenceTenantRoster.ts +25 -0
  9. package/apps/storefront/src/config/publicationSpaceConfig.ts +25 -0
  10. package/apps/storefront/src/config/resolver.ts +5 -0
  11. package/apps/storefront/src/config/tenantAnalyticsFiles.ts +43 -0
  12. package/apps/storefront/src/config/tenants.ts +11 -0
  13. package/apps/storefront/src/layouts/Layout.astro +5 -1
  14. package/apps/storefront/src/lib/activity/killSwitch.ts +1 -1
  15. package/apps/storefront/src/lib/admin/consentSettingsClient.ts +113 -0
  16. package/apps/storefront/src/lib/apps/consentConfigRead.ts +24 -0
  17. package/apps/storefront/src/lib/apps/gtmMigrationDraft.ts +103 -0
  18. package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
  19. package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
  20. package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
  21. package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
  22. package/apps/storefront/src/lib/env.ts +13 -111
  23. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
  24. package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
  25. package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
  26. package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
  27. package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
  28. package/apps/storefront/src/middleware/candidatePin.ts +35 -0
  29. package/apps/storefront/src/middleware/controlPlane.ts +270 -0
  30. package/apps/storefront/src/middleware/index.ts +100 -796
  31. package/apps/storefront/src/middleware/publicRender.ts +43 -0
  32. package/apps/storefront/src/middleware/renderChain.ts +685 -0
  33. package/apps/storefront/src/middleware/routeBranch.ts +160 -0
  34. package/apps/storefront/src/middleware/staticBundle.ts +109 -0
  35. package/apps/storefront/src/pages/[...slug].astro +1 -0
  36. package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
  37. package/apps/storefront/src/pages/index.astro +1 -0
  38. package/apps/storefront/vitest.config.ts +5 -0
  39. package/package.json +1 -1
  40. package/packages/public-runtime/package.json +1 -0
  41. package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
  42. package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
  43. package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
  44. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
  45. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
  46. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
  47. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
  48. package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
  49. package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
  50. package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
  51. package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
  52. package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
  53. package/packages/public-runtime/src/activity/catalog.ts +1 -1
  54. package/packages/public-runtime/src/candidate-index.ts +6 -0
  55. package/packages/public-runtime/src/customization-reconcile.ts +8 -0
  56. package/packages/public-runtime/src/html-cache-policy.ts +57 -0
  57. package/packages/public-runtime/src/index.ts +7 -0
  58. package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
  59. package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
  60. package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
  61. package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
  62. package/packages/public-runtime/src/integration/render-phases.ts +8 -3
  63. package/packages/public-runtime/src/publication-artifact.ts +203 -0
  64. package/packages/public-runtime/src/publication-space.ts +853 -0
  65. package/packages/public-runtime/src/publication-version.ts +587 -0
  66. package/packages/public-runtime/src/route-tier.ts +257 -0
  67. package/packages/public-runtime/src/static-bundle.ts +18 -5
  68. package/packages/public-runtime/src/tenant-assets.ts +85 -17
  69. package/packages/public-runtime/src/tenant.ts +70 -0
  70. package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
  71. package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
  72. package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
  73. package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
  74. package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
  75. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
  76. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
  77. package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
  78. package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
  79. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
  80. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
  81. package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
  82. package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
  83. package/scripts/dev/ai-edit.mjs +16 -3
  84. package/scripts/dev/port-check.mjs +18 -3
  85. package/scripts/dev/publish.mjs +26 -3
  86. package/scripts/tenant/validate.mjs +14 -1
@@ -0,0 +1,257 @@
1
+ /**
2
+ * The single authority for "what is this route, and what may serve it".
3
+ *
4
+ * One question, one answer: a request path resolves through the tenant's mount
5
+ * registry to a publication space, and that space's declared
6
+ * {visibility, authorization, discovery, assets.access} produces one ADR 0013
7
+ * route tier. ADR 0013's tier split and the mounted-publication-spaces
8
+ * public/internal boundary are the same boundary; every consumer reaches it
9
+ * here or not at all.
10
+ *
11
+ * Provider-neutral by construction: the tier is a property of the route and the
12
+ * tenant's declared config, never of which substrate serves it. Pure and
13
+ * synchronous — no request-time D1/KV/network reads.
14
+ *
15
+ * Contract: docs/architecture/decisions/0013-public-serving-contract.md
16
+ */
17
+ import type { TenantConfig } from "./tenant.js";
18
+ import type { ContractViolation } from "./integration/result.js";
19
+ import {
20
+ PUBLICATION_SPACE_RESERVED_MOUNTS,
21
+ type NormalizedPublicationMountEntryV1,
22
+ type PublicationSpaceManifestV1,
23
+ type PublicationSpaceRefV1,
24
+ type PublicationSpaceVisibilityV1,
25
+ PUBLICATION_SPACE_REF_VERSION,
26
+ normalizePublicationPath,
27
+ publicationMountMatchesPath,
28
+ resolveTenantPublicationSpacesV1,
29
+ } from "./publication-space.js";
30
+
31
+ export const ROUTE_TIER_RESOLUTION_VERSION = 1 as const;
32
+
33
+ /**
34
+ * The ADR 0013 tiers a route can resolve to from declared config.
35
+ *
36
+ * Tier 2 (client-hydrated) is deliberately absent: it is a Tier 0 shell plus
37
+ * authenticated client calls, so at the serving layer a Tier 2 page IS Tier 0.
38
+ */
39
+ export type RouteTierV1 =
40
+ /** Identical bytes for every anonymous visitor; CDN -> content-addressed storage, no origin compute. */
41
+ | "tier-0-static"
42
+ /** A decision before delivery at the CDN edge — here, a declared 308 alias. No origin hop. */
43
+ | "tier-1-edge-decision"
44
+ /** Authorization-gated and per-session; a path-scoped behavior pointing at the Worker origin. */
45
+ | "tier-3-origin-compute";
46
+
47
+ /** Where the answer came from — a tenant space, the platform, or nowhere. */
48
+ export type RouteTierOriginV1 =
49
+ | "publication-space"
50
+ | "platform-reserved"
51
+ | "unresolved";
52
+
53
+ export interface ResolvedRouteTierV1 {
54
+ version: typeof ROUTE_TIER_RESOLUTION_VERSION;
55
+ tenant: string;
56
+ /** The normalized path resolved; the raw input when normalization refused it. */
57
+ path: string;
58
+ tier: RouteTierV1;
59
+ origin: RouteTierOriginV1;
60
+ /** The one authorization question. True for every tier other than 0 and 1. */
61
+ requiresAuthorization: boolean;
62
+ /** The owning space; absent unless `origin` is `publication-space`. */
63
+ space?: PublicationSpaceRefV1;
64
+ /** The matched mount row; absent unless `origin` is `publication-space`. */
65
+ mount?: NormalizedPublicationMountEntryV1;
66
+ /** The 308 target, on a redirect alias only. */
67
+ redirectTo?: string;
68
+ visibility: PublicationSpaceVisibilityV1;
69
+ authorization: PublicationSpaceManifestV1["authorization"];
70
+ discovery: PublicationSpaceManifestV1["discovery"];
71
+ assets: { access: "public" | "authorized" };
72
+ /** Why the route resolved to no space; empty when `origin` is `publication-space`. */
73
+ violations: readonly ContractViolation[];
74
+ }
75
+
76
+ /**
77
+ * A tenant's compiled route-tier lookup: validated once, resolved per request.
78
+ *
79
+ * An index carrying violations holds no mounts, so every route resolves to the
80
+ * most restrictive tier — invalid publication-space config cannot open a route.
81
+ */
82
+ export interface RouteTierIndexV1 {
83
+ version: typeof ROUTE_TIER_RESOLUTION_VERSION;
84
+ tenant: string;
85
+ /** Longest-prefix-first, as emitted by the mount registry. */
86
+ mounts: readonly NormalizedPublicationMountEntryV1[];
87
+ spaces: ReadonlyMap<string, PublicationSpaceManifestV1>;
88
+ violations: readonly ContractViolation[];
89
+ }
90
+
91
+ const RESTRICTED_AUTHORIZATION = {
92
+ policy: "capability",
93
+ capability: "internal-content-reader",
94
+ } as const satisfies PublicationSpaceManifestV1["authorization"];
95
+
96
+ const RESTRICTED_DISCOVERY = {
97
+ sitemap: false,
98
+ feeds: false,
99
+ publicSearch: false,
100
+ llms: false,
101
+ } as const satisfies PublicationSpaceManifestV1["discovery"];
102
+
103
+ function failClosed(
104
+ tenant: string,
105
+ path: string,
106
+ origin: RouteTierOriginV1,
107
+ violations: readonly ContractViolation[],
108
+ ): ResolvedRouteTierV1 {
109
+ return {
110
+ version: ROUTE_TIER_RESOLUTION_VERSION,
111
+ tenant,
112
+ path,
113
+ tier: "tier-3-origin-compute",
114
+ origin,
115
+ requiresAuthorization: true,
116
+ visibility: "internal",
117
+ authorization: RESTRICTED_AUTHORIZATION,
118
+ discovery: RESTRICTED_DISCOVERY,
119
+ assets: { access: "authorized" },
120
+ violations,
121
+ };
122
+ }
123
+
124
+ /**
125
+ * Reserved platform paths match case-insensitively, unlike tenant content.
126
+ *
127
+ * Publication paths are case-SENSITIVE by policy, which is correct for tenant
128
+ * content but a bypass here: `/ADMIN` would miss this check, fall through to
129
+ * the root mount (which matches every path) and resolve public Tier 0. Every
130
+ * reserved entry is lowercase ASCII, so folding the path's case for this one
131
+ * comparison closes that without loosening mount matching anywhere else.
132
+ */
133
+ function isPlatformReserved(normalizedPath: string): boolean {
134
+ const caseFolded = normalizedPath.toLowerCase();
135
+ return PUBLICATION_SPACE_RESERVED_MOUNTS.some((reserved) =>
136
+ publicationMountMatchesPath(reserved, caseFolded),
137
+ );
138
+ }
139
+
140
+ /**
141
+ * Compile a tenant's declared publication spaces into a route-tier index.
142
+ *
143
+ * Never throws and never refuses: config that does not validate yields an empty
144
+ * index carrying its violations, which resolves every route fail-closed.
145
+ */
146
+ export function compileRouteTierIndexV1(
147
+ tenant: Pick<TenantConfig, "tenant_id" | "publicationSpaces">,
148
+ ): RouteTierIndexV1 {
149
+ const resolved = resolveTenantPublicationSpacesV1(tenant);
150
+ if (!resolved.ok) {
151
+ return {
152
+ version: ROUTE_TIER_RESOLUTION_VERSION,
153
+ tenant: tenant.tenant_id,
154
+ mounts: [],
155
+ spaces: new Map(),
156
+ violations: resolved.violations,
157
+ };
158
+ }
159
+ return {
160
+ version: ROUTE_TIER_RESOLUTION_VERSION,
161
+ tenant: tenant.tenant_id,
162
+ mounts: resolved.value.mounts,
163
+ spaces: new Map(resolved.value.manifests.map((manifest) => [manifest.id, manifest])),
164
+ violations: [],
165
+ };
166
+ }
167
+
168
+ /**
169
+ * Resolve one route to its tier. THE tier answer — no consumer may derive its
170
+ * own.
171
+ *
172
+ * Fail-closed at every step: a path that will not normalize, a platform-reserved
173
+ * path, an unmounted path, and an index built from invalid config all resolve to
174
+ * `tier-3-origin-compute` with authorization required. A route is public only
175
+ * when a validated space declares it so.
176
+ */
177
+ export function resolveRouteTierV1(
178
+ index: RouteTierIndexV1,
179
+ requestPath: string,
180
+ ): ResolvedRouteTierV1 {
181
+ const normalized = normalizePublicationPath(requestPath, { mode: "request" });
182
+ if (!normalized.ok) {
183
+ return failClosed(index.tenant, requestPath, "unresolved", normalized.violations);
184
+ }
185
+ const path = normalized.value;
186
+
187
+ if (index.violations.length > 0) {
188
+ return failClosed(index.tenant, path, "unresolved", index.violations);
189
+ }
190
+
191
+ // Before any mount lookup: a tenant's root mount matches every path, and
192
+ // platform surfaces (/admin, /api, /cockpit, …) are not tenant publication
193
+ // content. Handing them to the root space would classify the control plane as
194
+ // public static.
195
+ if (isPlatformReserved(path)) {
196
+ return failClosed(index.tenant, path, "platform-reserved", [
197
+ {
198
+ path: "path",
199
+ code: "publication_reserved_mount",
200
+ message: "Path is a platform-reserved mount and is not served from a publication space.",
201
+ },
202
+ ]);
203
+ }
204
+
205
+ const mount = index.mounts.find((entry) => publicationMountMatchesPath(entry.mount, path));
206
+ if (!mount) {
207
+ return failClosed(index.tenant, path, "unresolved", [
208
+ {
209
+ path: "path",
210
+ code: "publication_route_unmounted",
211
+ message: "No publication mount owns this path.",
212
+ },
213
+ ]);
214
+ }
215
+
216
+ const manifest = index.spaces.get(mount.spaceId);
217
+ if (!manifest) {
218
+ return failClosed(index.tenant, path, "unresolved", [
219
+ {
220
+ path: "path",
221
+ code: "publication_space_missing",
222
+ message: "The mount's publication space is absent from the index.",
223
+ },
224
+ ]);
225
+ }
226
+
227
+ // `authorization.policy` is the tier discriminator. The manifest validator
228
+ // binds `visibility` and `assets.access` to it, so one derivation settles all
229
+ // three. A gated space stays Tier 3 whatever its mount behavior.
230
+ const tier: RouteTierV1 =
231
+ manifest.authorization.policy === "capability"
232
+ ? "tier-3-origin-compute"
233
+ : mount.behavior === "redirect-308"
234
+ ? "tier-1-edge-decision"
235
+ : "tier-0-static";
236
+
237
+ return {
238
+ version: ROUTE_TIER_RESOLUTION_VERSION,
239
+ tenant: index.tenant,
240
+ path,
241
+ tier,
242
+ origin: "publication-space",
243
+ requiresAuthorization: tier === "tier-3-origin-compute",
244
+ space: {
245
+ version: PUBLICATION_SPACE_REF_VERSION,
246
+ tenant: manifest.tenant,
247
+ spaceId: manifest.id,
248
+ },
249
+ mount,
250
+ ...(mount.redirectTo ? { redirectTo: mount.redirectTo } : {}),
251
+ visibility: manifest.visibility,
252
+ authorization: manifest.authorization,
253
+ discovery: manifest.discovery,
254
+ assets: { access: manifest.assets.access },
255
+ violations: [],
256
+ };
257
+ }
@@ -41,6 +41,7 @@
41
41
  * either way, never a guess.
42
42
  */
43
43
  import { assetContentType } from "./tenant-assets.js";
44
+ import { formatHtmlCacheControl } from "./html-cache-policy.js";
44
45
 
45
46
  // ---------------------------------------------------------------------------
46
47
  // R2 seam — same structural subset tenant-assets.ts uses (no workers-types dep)
@@ -156,6 +157,14 @@ export interface StaticBundleManifest {
156
157
  treeDigest: string;
157
158
  pinnedNonce: string;
158
159
  csp: { headerName: string; value: string } | null;
160
+ /**
161
+ * The route-document `Cache-Control` this bundle's tenant declared (ADR 0013
162
+ * `serving.htmlCachePolicy`, formatted by `formatHtmlCacheControl` at materialize).
163
+ * Read by BOTH serving planes so the header is the tenant's property, not the
164
+ * provider's. Absent on a bundle materialized before this contract ⇒ the shared
165
+ * `DEFAULT_HTML_CACHE_POLICY` string (see the route branch of `serveStaticBundle`).
166
+ */
167
+ htmlCacheControl?: string;
159
168
  /** T6 classification: relPath -> {hash, kind}. Absent on a pre-T6 manifest — every lookup then falls back to the old versions/<digest>/<rel> key (see `resolveStaticBundleObjectKey`). */
160
169
  files?: Record<string, StaticBundleFileEntry>;
161
170
  }
@@ -184,7 +193,8 @@ function parseManifest(raw: string): StaticBundleManifest | null {
184
193
  }
185
194
  }
186
195
  }
187
- return { treeDigest: m.treeDigest, pinnedNonce: m.pinnedNonce, csp, files };
196
+ const htmlCacheControl = typeof m.htmlCacheControl === "string" ? m.htmlCacheControl : undefined;
197
+ return { treeDigest: m.treeDigest, pinnedNonce: m.pinnedNonce, csp, htmlCacheControl, files };
188
198
  } catch {
189
199
  return null;
190
200
  }
@@ -250,8 +260,6 @@ function failClosed(deps: ServeStaticBundleDeps, detail: string): Response {
250
260
  });
251
261
  }
252
262
 
253
- /** Cache-Control for a `route`-kind file (HTML/xml/txt — rendered fresh per version, stable non-digest url). */
254
- const ROUTE_CACHE_CONTROL = "public, max-age=0, s-maxage=300, stale-while-revalidate=86400";
255
263
  /** Cache-Control for an `asset`-kind file — the pool key IS the content hash, so caching forever is always correct (epic §A). */
256
264
  const ASSET_CACHE_CONTROL = "public, max-age=31536000, immutable";
257
265
 
@@ -320,6 +328,11 @@ export async function serveStaticBundle(
320
328
  }
321
329
 
322
330
  const isHtml = rel.endsWith(".html");
331
+ // The route-document Cache-Control is the TENANT's declared policy (ADR 0013),
332
+ // baked into the manifest at materialize; an old bundle without it falls to the
333
+ // shared DEFAULT_HTML_CACHE_POLICY string — the SAME source the S3 plane reads, so
334
+ // both planes emit a byte-identical header.
335
+ const routeCacheControl = manifest.htmlCacheControl ?? formatHtmlCacheControl();
323
336
  // Cache tier: use the manifest's `kind` classification when present.
324
337
  // A manifest with no `files` at all falls back to the ORIGINAL
325
338
  // isHtml-based heuristic — every non-html path used to be treated as a
@@ -328,9 +341,9 @@ export async function serveStaticBundle(
328
341
  const cacheControl = entry
329
342
  ? entry.kind === "asset"
330
343
  ? ASSET_CACHE_CONTROL
331
- : ROUTE_CACHE_CONTROL
344
+ : routeCacheControl
332
345
  : isHtml
333
- ? ROUTE_CACHE_CONTROL
346
+ ? routeCacheControl
334
347
  : ASSET_CACHE_CONTROL;
335
348
  const headers: Record<string, string> = {
336
349
  "content-type": assetContentType(rel),
@@ -53,6 +53,16 @@ export interface R2ObjectLike {
53
53
  arrayBuffer(): Promise<ArrayBuffer>;
54
54
  /** Byte length, when the store knows it. */
55
55
  size?: number;
56
+ /** Arbitrary key/value metadata attached at `put()` time (real R2 carries this through unchanged). */
57
+ customMetadata?: Record<string, string>;
58
+ }
59
+
60
+ export interface R2PutOptionsLike {
61
+ /** Attached to the object and read back verbatim via `get().customMetadata` — used
62
+ * here to carry the INGEST-TIME-KNOWN byte length + content hash so a later
63
+ * verify can detect a truncated/corrupted object independent of trusting the
64
+ * store's own reported size alone (see `verifyMaterializedAssets`). */
65
+ customMetadata?: Record<string, string>;
56
66
  }
57
67
 
58
68
  export interface R2BucketLike {
@@ -60,9 +70,14 @@ export interface R2BucketLike {
60
70
  put(
61
71
  key: string,
62
72
  value: ArrayBuffer | ArrayBufferView | ReadableStream | string,
73
+ options?: R2PutOptionsLike,
63
74
  ): Promise<unknown>;
64
75
  }
65
76
 
77
+ /** `customMetadata` keys `ingestAssetVersion` stamps on every pool object (see its header). */
78
+ export const ASSET_BYTE_LENGTH_METADATA_KEY = "byteLength";
79
+ export const ASSET_SHA256_METADATA_KEY = "sha256";
80
+
66
81
  // ---------------------------------------------------------------------------
67
82
  // Channel→version pointer (one snapshot versionId per channel)
68
83
  // ---------------------------------------------------------------------------
@@ -303,10 +318,11 @@ async function putAssetWithRetry(
303
318
  bucket: R2BucketLike,
304
319
  key: string,
305
320
  bytes: Uint8Array,
321
+ customMetadata: Record<string, string>,
306
322
  ): Promise<void> {
307
323
  for (let attempt = 1; attempt <= R2_PUT_MAX_ATTEMPTS; attempt++) {
308
324
  try {
309
- await bucket.put(key, bytes);
325
+ await bucket.put(key, bytes, { customMetadata });
310
326
  return;
311
327
  } catch (error) {
312
328
  if (attempt === R2_PUT_MAX_ATTEMPTS || !isTransientR2PutError(error)) throw error;
@@ -322,6 +338,13 @@ async function putAssetWithRetry(
322
338
  * Non-public files in `files` are ignored, so the whole seed/reconcile file set
323
339
  * can be passed. Pass `versionId` to bind assets to the publish snapshot id;
324
340
  * omit it to derive one from the bytes.
341
+ *
342
+ * Stamps each object's `customMetadata` with the byte length + sha256 of the
343
+ * EXACT bytes handed to `bucket.put()` here (the ingest-time-known-correct
344
+ * values) — `verifyMaterializedAssets` reads them back to detect a later
345
+ * truncation/corruption that mere existence + non-empty can't see (the gap
346
+ * that let a 79357-byte file be silently served as its first 65536 bytes,
347
+ * `forge-response-integrity.ts`'s `assertFileOffsetAdvances` doc).
325
348
  */
326
349
  export async function ingestAssetVersion(
327
350
  bucket: R2BucketLike,
@@ -334,9 +357,13 @@ export async function ingestAssetVersion(
334
357
  const keys: string[] = [];
335
358
  for (const a of assets) {
336
359
  const key = tenantAssetKey(tenantId, vid, a.rel);
360
+ const customMetadata = {
361
+ [ASSET_BYTE_LENGTH_METADATA_KEY]: String(a.bytes.length),
362
+ [ASSET_SHA256_METADATA_KEY]: await sha256Hex(a.bytes),
363
+ };
337
364
  // The key and bytes are immutable for this version, so retrying an ambiguous
338
365
  // transient R2 failure is idempotent even if the first write landed server-side.
339
- await putAssetWithRetry(bucket, key, a.bytes);
366
+ await putAssetWithRetry(bucket, key, a.bytes, customMetadata);
340
367
  keys.push(key);
341
368
  }
342
369
  return { versionId: vid, keys, count: keys.length };
@@ -543,10 +570,20 @@ function matchesEtag(ifNoneMatch: string, etag: string): boolean {
543
570
  export interface AssetVerificationResult {
544
571
  /** App-asset objects expected to serve at this version. */
545
572
  expected: number;
546
- /** How many were confirmed present + non-empty in R2. */
573
+ /** How many were confirmed present + non-empty + byte-length-correct in R2. */
547
574
  verified: number;
548
- /** The served relative paths (or artifact paths) that did NOT materialize. */
575
+ /** The served relative paths (or artifact paths) that did NOT materialize
576
+ * correctly — absent, empty, unnormalizable, OR present-but-truncated
577
+ * (see `corrupted`, a labelled subset of this same list). */
549
578
  missing: string[];
579
+ /** The subset of `missing` that WAS present + non-empty in R2 but whose
580
+ * stored byte length doesn't match the length recorded in the object's
581
+ * own `customMetadata` at ingest time (`ingestAssetVersion`) — i.e. a
582
+ * truncated/corrupted object, distinct from one that never landed at all.
583
+ * Empty for an object with no `customMetadata` (ingested before this
584
+ * check existed): that legacy case falls back to presence + non-empty
585
+ * only, same as before this field existed. */
586
+ corrupted: string[];
550
587
  /** Set when the verification itself could not run (fail-closed, not a pass). */
551
588
  error?: string;
552
589
  }
@@ -554,16 +591,38 @@ export interface AssetVerificationResult {
554
591
  /** How many R2 existence probes run at once — matches the runtime's ~6/host reality. */
555
592
  const VERIFY_PROBE_POOL = 6;
556
593
 
594
+ /** Parse the `customMetadata.byteLength` an ingest stamped, or `null` when absent/malformed (legacy object, or a store that drops custom metadata). */
595
+ function expectedByteLength(customMetadata: Record<string, string> | undefined): number | null {
596
+ const raw = customMetadata?.[ASSET_BYTE_LENGTH_METADATA_KEY];
597
+ if (raw == null) return null;
598
+ const n = Number(raw);
599
+ return Number.isFinite(n) && n >= 0 ? n : null;
600
+ }
601
+
557
602
  /**
558
- * Confirm every app-asset artifact path is present + non-empty in R2 at
559
- * `versionId` — the EXACT object `serveTenantAsset` streams for `/tenants/<id>/<rel>`.
560
- * Positive, fail-closed evidence for the version-scoped asset lane: an asset whose
561
- * path can't normalize to a served rel, or is absent/empty in R2, is reported
562
- * `missing`; a systemic fault (the bucket throwing on every probe) reports the whole
563
- * subtree missing with an `error` rather than a vacuous pass. Checking the object at
564
- * the published `versionId` (not an HTTP self-fetch) is channel-independent: the
565
- * served route may resolve a different channel→version pointer per request, but a
566
- * version's bytes are fixed. `bucket` needs only `get`; the body is never read.
603
+ * Confirm every app-asset artifact path is present, non-empty, AND (when the
604
+ * object carries the `customMetadata` `ingestAssetVersion` stamps) the exact
605
+ * byte length recorded at ingest — in R2 at `versionId`, the EXACT object
606
+ * `serveTenantAsset` streams for `/tenants/<id>/<rel>`. Positive, fail-closed
607
+ * evidence for the version-scoped asset lane: an asset whose path can't
608
+ * normalize to a served rel, is absent/empty in R2, OR is present but SHORTER
609
+ * than its ingest-time length (truncated/corrupted — reported in `corrupted`,
610
+ * a labelled subset of `missing`) is reported `missing`; a systemic fault (the
611
+ * bucket throwing on every probe) reports the whole subtree missing with an
612
+ * `error` rather than a vacuous pass. A LONGER-than-expected object is also
613
+ * flagged (any mismatch is untrusted, not just truncation).
614
+ *
615
+ * Comparing against a byte length captured from the SAME bytes handed to
616
+ * `bucket.put()` at ingest catches corruption introduced between ingest and
617
+ * now (a bad/partial R2 write, bit-rot, a future regression in this store) —
618
+ * it deliberately does NOT re-derive "correct" from the upstream Gitea read
619
+ * again (that class of truncation is guarded independently, at the read
620
+ * boundary — see `forge-response-integrity.ts`'s `assertFileOffsetAdvances`).
621
+ * Checking the object at the published `versionId` (not an HTTP self-fetch)
622
+ * is channel-independent: the served route may resolve a different
623
+ * channel→version pointer per request, but a version's bytes are fixed.
624
+ * `bucket` needs only `get`; the body is never read (checking `size` against
625
+ * `customMetadata` is a metadata-only comparison, no download).
567
626
  */
568
627
  export async function verifyMaterializedAssets(
569
628
  bucket: Pick<R2BucketLike, "get">,
@@ -572,10 +631,11 @@ export async function verifyMaterializedAssets(
572
631
  appAssetArtifactPaths: readonly string[],
573
632
  ): Promise<AssetVerificationResult> {
574
633
  const expected = appAssetArtifactPaths.length;
575
- if (expected === 0) return { expected: 0, verified: 0, missing: [] };
634
+ if (expected === 0) return { expected: 0, verified: 0, missing: [], corrupted: [] };
576
635
 
577
636
  try {
578
637
  const missing: string[] = [];
638
+ const corrupted: string[] = [];
579
639
  let verified = 0;
580
640
  let next = 0;
581
641
  const worker = async (): Promise<void> => {
@@ -591,8 +651,14 @@ export async function verifyMaterializedAssets(
591
651
  let present = false;
592
652
  try {
593
653
  const obj = await bucket.get(tenantAssetKey(tenantId, versionId, rel));
594
- // Present when the object exists and (when the store reports size) is non-empty.
595
- present = obj != null && (obj.size == null || obj.size > 0);
654
+ if (obj != null) {
655
+ const nonEmpty = obj.size == null || obj.size > 0;
656
+ const expectedLength = expectedByteLength(obj.customMetadata);
657
+ const lengthMatches =
658
+ expectedLength == null || obj.size == null || obj.size === expectedLength;
659
+ present = nonEmpty && lengthMatches;
660
+ if (nonEmpty && !lengthMatches) corrupted.push(rel);
661
+ }
596
662
  } catch {
597
663
  present = false;
598
664
  }
@@ -604,7 +670,8 @@ export async function verifyMaterializedAssets(
604
670
  await Promise.all(Array.from({ length: width }, worker));
605
671
  // Stable order aids diffing/readability — pool completion order is nondeterministic.
606
672
  missing.sort();
607
- return { expected, verified, missing };
673
+ corrupted.sort();
674
+ return { expected, verified, missing, corrupted };
608
675
  } catch (err) {
609
676
  // Systemic failure (e.g. the bucket seam throwing on every call): fail closed —
610
677
  // report the whole subtree as unverified rather than claiming a pass.
@@ -612,6 +679,7 @@ export async function verifyMaterializedAssets(
612
679
  expected,
613
680
  verified: 0,
614
681
  missing: [...appAssetArtifactPaths],
682
+ corrupted: [],
615
683
  error: err instanceof Error ? err.message : String(err),
616
684
  };
617
685
  }
@@ -9,6 +9,8 @@ import type { ManagedCheckoutConfig } from "./checkout.js";
9
9
  import type { CapabilityConfig } from "./capabilities.js";
10
10
  import type { AutopilotConfig, MembershipProgramConfig } from "./membership.js";
11
11
  import type { IntegrationDeclarationInput } from "./integration/resolve.js";
12
+ import type { PublicationSpaceManifestV1 } from "./publication-space.js";
13
+ import type { HtmlCachePolicy } from "./html-cache-policy.js";
12
14
 
13
15
  /**
14
16
  * The shared semantic COLOR contract — the widget library's public API.
@@ -225,7 +227,28 @@ export interface ExciseTaxConfig {
225
227
  * fabricates or hand-authors mandated legal copy (per decision
226
228
  * compliance-widgets-platform-owned). Additive — a tenant lists only what applies.
227
229
  */
230
+ /**
231
+ * The reviewed compliance rule profiles a tenant may select. Each names a body of
232
+ * counsel-reviewed rule data (age floor, excise handling, PACT Act posture,
233
+ * per-state bans and order caps) that the enforcement engine evaluates.
234
+ *
235
+ * A union rather than a free string so a typo cannot silently resolve to "no
236
+ * profile", which would fail the tenant's checkout closed with no signal that a
237
+ * declaration was intended.
238
+ */
239
+ export type ComplianceRuleProfile = "vape-nicotine-us";
240
+
228
241
  export interface ComplianceConfig {
242
+ /**
243
+ * The reviewed rule PROFILE whose ruleset the enforcement gate evaluates for
244
+ * this tenant (see `config/compliance/rulesets.ts`). This is what makes a
245
+ * regulated tenant enforceable: a tenant that declares no profile resolves to
246
+ * no ruleset and FAILS CLOSED, which is the correct default — the rule data is
247
+ * counsel-reviewed content, so a tenant selects a profile rather than authoring
248
+ * one. Declared per tenant like any other capability; the platform never infers
249
+ * it from which tenant this is.
250
+ */
251
+ ruleProfile?: ComplianceRuleProfile;
229
252
  /** Minimum age to enter. Drives the soft affirmation gate (e.g. 21). */
230
253
  minAge?: number;
231
254
  /** Render the FDA nicotine warning sitewide + reinforced on PDP. */
@@ -282,6 +305,24 @@ export type SiteType = "commerce" | "marketing";
282
305
  */
283
306
  export type StorefrontMode = "enabled" | "disabled";
284
307
 
308
+ /**
309
+ * Which substrate serves a tenant's LIVE public site (ADR 0013). This decides how
310
+ * go-live resolves the `live` pointer — NOT a display or routing hint.
311
+ *
312
+ * - `"worker-r2-pointer"` — Cloudflare Worker + R2 + KV pointer plane. Go-live
313
+ * resolves the pinned version's already-staged, VALIDATED bundle digest from the
314
+ * staged-version index and flips the KV `live` pointer with its
315
+ * `channelDigests.live` companion in one CAS write, FAIL-CLOSED when that digest
316
+ * is unresolvable or unvalidated (never a live pointer over a bundle the read
317
+ * path cannot serve).
318
+ * - `"s3-overwrite"` — AWS S3/CloudFront copy path. Go-live is a bare KV `live`
319
+ * pointer flip plus a `www-promote` dispatch that byte-copies
320
+ * `versions/<digest>/` → `live/` (the digest resolved from the version ref by
321
+ * CI). It has NO aggregate-keyed staged-version index, so the pointer-plane
322
+ * staged-digest gate does not — and must not — apply.
323
+ */
324
+ export type PublicServingProvider = "worker-r2-pointer" | "s3-overwrite";
325
+
285
326
  /** The one storefront mode value that permits go-live. The gate tests strict equality. */
286
327
  export const STOREFRONT_MODE_ENABLED = "enabled" as const;
287
328
 
@@ -384,6 +425,8 @@ export interface TenantConfig {
384
425
  /** Currency for display formatting. */
385
426
  currency: string;
386
427
  locale: string;
428
+ /** Explicit publication spaces for this tenant, including one canonical root. */
429
+ publicationSpaces?: readonly PublicationSpaceManifestV1[];
387
430
  /**
388
431
  * What the site is for: "commerce" (default) or "marketing". Drives feature
389
432
  * defaults (resolveTenantFeatures) and non-commerce chrome. Omitted == commerce.
@@ -491,6 +534,33 @@ export interface TenantConfig {
491
534
  */
492
535
  verifyUrl?: string;
493
536
  };
537
+ /**
538
+ * Public-serving contract (ADR 0013). Declares WHICH substrate serves this
539
+ * tenant's live public site — the single input that decides how go-live resolves
540
+ * the `live` pointer (see {@link PublicServingProvider}).
541
+ *
542
+ * ABSENT/undefined ⇒ NOT pointer-serving: a hosted tenant served by the Worker,
543
+ * or a static tenant whose bytes reach `live/` by an S3 copy rather than a
544
+ * pointer-plane digest flip. Only `provider: "worker-r2-pointer"` arms the
545
+ * fail-closed staged-digest gate. The `PointerBackend` seam extends this with
546
+ * more fields (delivery gate, cache policy) — declare them here; never infer a
547
+ * serving mode from `staticPublish.target`.
548
+ */
549
+ serving?: {
550
+ /** Which substrate serves this tenant's live public site (ADR 0013). */
551
+ provider: PublicServingProvider;
552
+ /**
553
+ * The route-file HTML cache policy (ADR 0013) — the browser and shared TTLs a
554
+ * serving substrate stamps on route documents, read by BOTH backends so the
555
+ * header contract is a property of the TENANT, not an artifact of the provider
556
+ * (today S3 stamps `max-age=300` while the Worker emits `max-age=0`; this is the
557
+ * declared source that unifies them). `browserMaxAge` is the `max-age` a client
558
+ * caches a route document for; `sharedMaxAge` the `s-maxage` the CDN caches it
559
+ * for; `staleWhileRevalidate` the grace window a stale document may still serve
560
+ * while a fresh one is fetched. All in seconds. Absent ⇒ {@link DEFAULT_HTML_CACHE_POLICY}.
561
+ */
562
+ htmlCachePolicy?: HtmlCachePolicy;
563
+ };
494
564
  /**
495
565
  * Storefront product/subscription state — the paywall the platform gates GO-LIVE
496
566
  * on. This is the storefront's
@@ -0,0 +1,32 @@
1
+ {
2
+ "version": 1,
3
+ "id": "knowledge",
4
+ "tenant": "fixture.example",
5
+ "source": {
6
+ "forge": "fixture",
7
+ "repo": "fixture-private-publications",
8
+ "defaultBranch": "main"
9
+ },
10
+ "routes": {
11
+ "canonicalMount": "/knowledge"
12
+ },
13
+ "visibility": "internal",
14
+ "authorization": {
15
+ "policy": "capability",
16
+ "capability": "internal-content-reader"
17
+ },
18
+ "discovery": {
19
+ "sitemap": false,
20
+ "feeds": false,
21
+ "publicSearch": false,
22
+ "llms": false
23
+ },
24
+ "assets": {
25
+ "namespace": "knowledge",
26
+ "access": "authorized"
27
+ },
28
+ "renderer": {
29
+ "contractVersion": "storefront-v1",
30
+ "themeContractVersion": "theme-v1"
31
+ }
32
+ }