@tokenoftrust/storefront-runner 2.0.17 → 2.0.19

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 (98) hide show
  1. package/apps/storefront/env.d.ts +21 -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/brokerAssertion.ts +18 -0
  19. package/apps/storefront/src/lib/auth/identityToken.ts +11 -1
  20. package/apps/storefront/src/lib/auth/internalContentAuthority.ts +153 -0
  21. package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
  22. package/apps/storefront/src/lib/auth/session.ts +3 -0
  23. package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
  24. package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
  25. package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
  26. package/apps/storefront/src/lib/edgeCache.ts +17 -1
  27. package/apps/storefront/src/lib/env.ts +13 -111
  28. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
  29. package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
  30. package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
  31. package/apps/storefront/src/lib/publication/runtimeSource.ts +97 -0
  32. package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
  33. package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
  34. package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
  35. package/apps/storefront/src/middleware/candidatePin.ts +35 -0
  36. package/apps/storefront/src/middleware/controlPlane.ts +291 -0
  37. package/apps/storefront/src/middleware/index.ts +200 -793
  38. package/apps/storefront/src/middleware/publicRender.ts +43 -0
  39. package/apps/storefront/src/middleware/renderChain.ts +730 -0
  40. package/apps/storefront/src/middleware/routeBranch.ts +192 -0
  41. package/apps/storefront/src/middleware/staticBundle.ts +109 -0
  42. package/apps/storefront/src/pages/[...slug].astro +1 -0
  43. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -0
  44. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +1 -0
  45. package/apps/storefront/src/pages/api/auth/verify.ts +1 -0
  46. package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
  47. package/apps/storefront/src/pages/index.astro +1 -0
  48. package/apps/storefront/vitest.config.ts +5 -0
  49. package/package.json +1 -1
  50. package/packages/public-runtime/package.json +1 -0
  51. package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
  52. package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
  53. package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
  54. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
  55. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
  56. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
  57. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
  58. package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
  59. package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
  60. package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
  61. package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
  62. package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
  63. package/packages/public-runtime/src/activity/catalog.ts +1 -1
  64. package/packages/public-runtime/src/candidate-index.ts +6 -0
  65. package/packages/public-runtime/src/customization-reconcile.ts +8 -0
  66. package/packages/public-runtime/src/html-cache-policy.ts +57 -0
  67. package/packages/public-runtime/src/index.ts +9 -0
  68. package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
  69. package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
  70. package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
  71. package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
  72. package/packages/public-runtime/src/integration/render-phases.ts +8 -3
  73. package/packages/public-runtime/src/publication-artifact.ts +220 -0
  74. package/packages/public-runtime/src/publication-reader.ts +187 -0
  75. package/packages/public-runtime/src/publication-routing.ts +246 -0
  76. package/packages/public-runtime/src/publication-space.ts +853 -0
  77. package/packages/public-runtime/src/publication-version.ts +602 -0
  78. package/packages/public-runtime/src/route-tier.ts +257 -0
  79. package/packages/public-runtime/src/static-bundle.ts +18 -5
  80. package/packages/public-runtime/src/tenant-assets.ts +85 -17
  81. package/packages/public-runtime/src/tenant.ts +70 -0
  82. package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
  83. package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
  84. package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
  85. package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
  86. package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
  87. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
  88. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
  89. package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
  90. package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
  91. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
  92. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
  93. package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
  94. package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
  95. package/scripts/dev/ai-edit.mjs +16 -3
  96. package/scripts/dev/port-check.mjs +18 -3
  97. package/scripts/dev/publish.mjs +26 -3
  98. package/scripts/tenant/validate.mjs +14 -1
@@ -0,0 +1,220 @@
1
+ import type { PublicationSpaceManifestV1 } from "./publication-space.js";
2
+ import { sha256Hex } from "./hash.js";
3
+
4
+ const PUBLICATION_ARTIFACT_IDENTIFIER_RE = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
5
+ const PUBLICATION_ARTIFACT_VERSION_RE = /^pv_[a-z0-9](?:[a-z0-9._:-]{4,122}[a-z0-9])$/;
6
+
7
+ /** Renderer-safe description of one immutable publication artifact file. */
8
+ export interface PublicationArtifactFileV1 {
9
+ lane: "content" | "public-asset" | "authorized-asset";
10
+ path: string;
11
+ encoding: "utf8" | "base64";
12
+ content: string;
13
+ byteSize: number;
14
+ }
15
+
16
+ /** Renderer-safe marker for one complete tenant+space+version artifact set. */
17
+ export interface PublicationArtifactBundleV1 {
18
+ tenant: string;
19
+ spaceId: string;
20
+ versionId: string;
21
+ sourceSha: string;
22
+ artifactDigest: string;
23
+ sourceLock: string;
24
+ visibility: PublicationSpaceManifestV1["visibility"];
25
+ assetNamespace: string;
26
+ assetAccess: PublicationSpaceManifestV1["assets"]["access"];
27
+ files: PublicationArtifactFileV1[];
28
+ }
29
+
30
+ export interface PublicationArtifactBundleExpectationV1 {
31
+ tenant: string;
32
+ spaceId: string;
33
+ versionId: string;
34
+ sourceSha: string;
35
+ artifactDigest: string;
36
+ forge: string;
37
+ repo: string;
38
+ visibility?: PublicationArtifactBundleV1["visibility"];
39
+ assetNamespace?: string;
40
+ assetAccess?: PublicationArtifactBundleV1["assetAccess"];
41
+ }
42
+
43
+ /** Locale-independent canonical order for immutable publication files. */
44
+ export function comparePublicationArtifactFilesV1(
45
+ left: PublicationArtifactFileV1,
46
+ right: PublicationArtifactFileV1,
47
+ ): number {
48
+ const leftIdentity = `${left.lane}\0${left.path}`;
49
+ const rightIdentity = `${right.lane}\0${right.path}`;
50
+ return leftIdentity < rightIdentity ? -1 : leftIdentity > rightIdentity ? 1 : 0;
51
+ }
52
+
53
+ function safeArtifactPath(path: string): boolean {
54
+ return !!path && !path.startsWith("/") && !path.includes("\\") && !path.includes("\0") &&
55
+ path.split("/").every((part) => !!part && part !== "." && part !== "..");
56
+ }
57
+
58
+ function hasExactKeys(value: object, keys: readonly string[]): boolean {
59
+ const actual = Object.keys(value).sort();
60
+ const expected = [...keys].sort();
61
+ return actual.length === expected.length && actual.every((key, index) => key === expected[index]);
62
+ }
63
+
64
+ function decodePublicationArtifactFileV1(file: PublicationArtifactFileV1): Uint8Array | null {
65
+ try {
66
+ if (file.encoding === "utf8") return new TextEncoder().encode(file.content);
67
+ if (file.encoding !== "base64" ||
68
+ !/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(file.content)) return null;
69
+ const binary = atob(file.content);
70
+ if (btoa(binary) !== file.content) return null;
71
+ return Uint8Array.from(binary, (character) => character.charCodeAt(0));
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+
77
+ function validPublicationArtifactFileV1(value: unknown): value is PublicationArtifactFileV1 {
78
+ if (!value || typeof value !== "object" || !hasExactKeys(value,
79
+ ["lane", "path", "encoding", "content", "byteSize"])) return false;
80
+ const file = value as PublicationArtifactFileV1;
81
+ return ["content", "public-asset", "authorized-asset"].includes(file.lane) &&
82
+ ["utf8", "base64"].includes(file.encoding) && typeof file.path === "string" &&
83
+ safeArtifactPath(file.path) && typeof file.content === "string" &&
84
+ Number.isSafeInteger(file.byteSize) && file.byteSize >= 0;
85
+ }
86
+
87
+ /** Decode one canonical portable artifact file while enforcing its declared byte length. */
88
+ export function publicationArtifactFileBytesV1(file: PublicationArtifactFileV1): Uint8Array | null {
89
+ if (!validPublicationArtifactFileV1(file)) return null;
90
+ try {
91
+ const decoded = decodePublicationArtifactFileV1(file);
92
+ if (!decoded) return null;
93
+ return decoded.byteLength === file.byteSize ? decoded : null;
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Canonical marker admission shared by the writer-side activation gate and the
101
+ * renderer. It validates shape, identity, source provenance/lock, and recomputes
102
+ * the complete file-list digest; a marker cannot define its own admission facts.
103
+ */
104
+ export async function validatePublicationArtifactBundleV1(
105
+ value: unknown,
106
+ expected: PublicationArtifactBundleExpectationV1,
107
+ ): Promise<{ ok: true; value: PublicationArtifactBundleV1 } | { ok: false }> {
108
+ if (!value || typeof value !== "object") return { ok: false };
109
+ const bundle = value as PublicationArtifactBundleV1;
110
+ if (!hasExactKeys(bundle, ["tenant", "spaceId", "versionId", "sourceSha", "artifactDigest",
111
+ "sourceLock", "visibility", "assetNamespace", "assetAccess", "files"]) ||
112
+ bundle.tenant !== expected.tenant || bundle.spaceId !== expected.spaceId ||
113
+ bundle.versionId !== expected.versionId || bundle.sourceSha !== expected.sourceSha ||
114
+ !/^[0-9a-f]{40}$/.test(bundle.sourceSha) || bundle.artifactDigest !== expected.artifactDigest ||
115
+ !/^[0-9a-f]{64}$/.test(bundle.artifactDigest) || typeof bundle.sourceLock !== "string" ||
116
+ !/^[0-9a-f]{64}$/.test(bundle.sourceLock) ||
117
+ !["public", "internal"].includes(bundle.visibility) || typeof bundle.assetNamespace !== "string" ||
118
+ !bundle.assetNamespace ||
119
+ !["public", "authorized"].includes(bundle.assetAccess) || !Array.isArray(bundle.files) ||
120
+ (expected.visibility !== undefined && bundle.visibility !== expected.visibility) ||
121
+ (expected.assetNamespace !== undefined && bundle.assetNamespace !== expected.assetNamespace) ||
122
+ (expected.assetAccess !== undefined && bundle.assetAccess !== expected.assetAccess)) return { ok: false };
123
+ const paths = new Set<string>();
124
+ for (const file of bundle.files) {
125
+ if (!validPublicationArtifactFileV1(file) || !publicationArtifactFileBytesV1(file)) {
126
+ return { ok: false };
127
+ }
128
+ if ((file.lane === "public-asset" && bundle.assetAccess !== "public") ||
129
+ (file.lane === "authorized-asset" && bundle.assetAccess !== "authorized")) return { ok: false };
130
+ if (paths.has(file.path)) return { ok: false };
131
+ paths.add(file.path);
132
+ }
133
+ try {
134
+ const digest = await publicationArtifactDigestV1(bundle.files);
135
+ if (digest !== expected.artifactDigest) return { ok: false };
136
+ const sourceLock = await publicationArtifactSourceLockV1({
137
+ tenant: expected.tenant,
138
+ spaceId: expected.spaceId,
139
+ forge: expected.forge,
140
+ repo: expected.repo,
141
+ sourceSha: expected.sourceSha,
142
+ versionId: expected.versionId,
143
+ artifactDigest: digest,
144
+ });
145
+ return sourceLock === bundle.sourceLock ? { ok: true, value: bundle } : { ok: false };
146
+ } catch {
147
+ return { ok: false };
148
+ }
149
+ }
150
+
151
+ /** Canonical content digest shared by the trusted writer and renderer readback. */
152
+ export async function publicationArtifactDigestV1(
153
+ files: readonly PublicationArtifactFileV1[],
154
+ ): Promise<string> {
155
+ if (!Array.isArray(files)) throw new Error("publication artifact file list is malformed");
156
+ const ordered = [...files].sort(comparePublicationArtifactFilesV1);
157
+ const paths = new Set<string>();
158
+ const rows = await Promise.all(ordered.map(async (file) => {
159
+ if (!validPublicationArtifactFileV1(file) || paths.has(file.path)) {
160
+ throw new Error("publication artifact file is malformed or duplicated");
161
+ }
162
+ paths.add(file.path);
163
+ const bytes = publicationArtifactFileBytesV1(file);
164
+ if (!bytes) throw new Error("publication artifact file is malformed");
165
+ return `${file.lane}\0${file.path}\0${file.encoding}\0${file.byteSize}\0${await sha256Hex(bytes)}`;
166
+ }));
167
+ return sha256Hex(rows.join("\n"));
168
+ }
169
+
170
+ /** Canonical source lock binds one immutable artifact to its configured source. */
171
+ export function publicationArtifactSourceLockV1(input: {
172
+ tenant: string;
173
+ spaceId: string;
174
+ forge: string;
175
+ repo: string;
176
+ sourceSha: string;
177
+ versionId: string;
178
+ artifactDigest: string;
179
+ }): Promise<string> {
180
+ return sha256Hex([
181
+ input.tenant, input.spaceId, input.forge, input.repo,
182
+ input.sourceSha, input.versionId, input.artifactDigest,
183
+ ].join("\0"));
184
+ }
185
+
186
+ /** Physical immutable namespace. Logical artifact paths are always relative. */
187
+ export function publicationArtifactKeyV1(
188
+ tenant: string,
189
+ spaceId: string,
190
+ versionId: string,
191
+ lane: PublicationArtifactFileV1["lane"],
192
+ relativePath: string,
193
+ ): string {
194
+ if (!PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(tenant) ||
195
+ !PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(spaceId) ||
196
+ !PUBLICATION_ARTIFACT_VERSION_RE.test(versionId)) {
197
+ throw new Error("publication artifact identity is unsafe");
198
+ }
199
+ if (!["content", "public-asset", "authorized-asset"].includes(lane) ||
200
+ !relativePath || relativePath.startsWith("/") || relativePath.includes("\\") ||
201
+ relativePath.includes("\0") ||
202
+ relativePath.split("/").some((part) => !part || part === "." || part === "..")) {
203
+ throw new Error("publication artifact path is unsafe");
204
+ }
205
+ return `publications/${tenant}/${spaceId}/${versionId}/${lane}/${relativePath}`;
206
+ }
207
+
208
+ /** Marker address for one immutable tenant+space+version bundle. */
209
+ export function publicationArtifactBundleKeyV1(
210
+ tenant: string,
211
+ spaceId: string,
212
+ versionId: string,
213
+ ): string {
214
+ if (!PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(tenant) ||
215
+ !PUBLICATION_ARTIFACT_IDENTIFIER_RE.test(spaceId) ||
216
+ !PUBLICATION_ARTIFACT_VERSION_RE.test(versionId)) {
217
+ throw new Error("publication artifact identity is unsafe");
218
+ }
219
+ return `publications/${tenant}/${spaceId}/${versionId}/bundle.json`;
220
+ }
@@ -0,0 +1,187 @@
1
+ import {
2
+ publicationArtifactBundleKeyV1,
3
+ publicationArtifactFileBytesV1,
4
+ publicationArtifactKeyV1,
5
+ validatePublicationArtifactBundleV1,
6
+ type PublicationArtifactBundleV1,
7
+ type PublicationArtifactFileV1,
8
+ } from "./publication-artifact.js";
9
+ import {
10
+ selectPublicationRouteVersionV1,
11
+ type PublicationRouteFailureCodeV1,
12
+ type PublicationRoutePlanV1,
13
+ type SelectedPublicationRouteV1,
14
+ } from "./publication-routing.js";
15
+ import type {
16
+ PublicationChannelV1,
17
+ PublicationVersionStateV1,
18
+ } from "./publication-version.js";
19
+ import type { RouteTierIndexV1 } from "./route-tier.js";
20
+
21
+ export interface PublicationStoredObjectV1 {
22
+ text(): Promise<string>;
23
+ arrayBuffer(): Promise<ArrayBuffer>;
24
+ }
25
+
26
+ /** Provider-neutral object-store read surface; R2 and test stores both satisfy it. */
27
+ export interface PublicationArtifactBucketV1 {
28
+ get(key: string): Promise<PublicationStoredObjectV1 | null>;
29
+ }
30
+
31
+ export type PublicationArtifactOpenFailureCodeV1 =
32
+ | PublicationRouteFailureCodeV1
33
+ | "publication_artifact_marker_missing"
34
+ | "publication_artifact_marker_malformed"
35
+ | "publication_artifact_file_missing"
36
+ | "publication_artifact_file_mismatch"
37
+ | "publication_artifact_source_unavailable";
38
+
39
+ export interface OpenPublicationArtifactV1 {
40
+ selection: SelectedPublicationRouteV1;
41
+ bundle: PublicationArtifactBundleV1;
42
+ /** Read a UTF-8 artifact relative to the selected space's content/ root. */
43
+ readContent(relativePath: string): string | null;
44
+ /** List paths relative to the selected space's content/ root. */
45
+ listContentPaths(prefix?: string): string[];
46
+ /** Read bytes relative to this space's declared asset namespace. */
47
+ readAsset(relativePath: string): Uint8Array | null;
48
+ }
49
+
50
+ export type OpenPublicationArtifactResultV1 =
51
+ | { ok: true; value: OpenPublicationArtifactV1 }
52
+ | { ok: false; code: PublicationArtifactOpenFailureCodeV1 };
53
+
54
+ function safeRelativePath(path: string, allowEmpty = false): boolean {
55
+ if (path === "") return allowEmpty;
56
+ return !path.startsWith("/") && !path.includes("\\") && !path.includes("\0") &&
57
+ path.split("/").every((part) => !!part && part !== "." && part !== "..");
58
+ }
59
+
60
+ function equalBytes(left: Uint8Array, right: Uint8Array): boolean {
61
+ return left.byteLength === right.byteLength &&
62
+ left.every((value, index) => value === right[index]);
63
+ }
64
+
65
+ /**
66
+ * Open exactly one selected publication version and validate every physical
67
+ * object before rendering from it. A route/state/provenance/object failure is
68
+ * terminal for this source; callers must not fall through to another space.
69
+ */
70
+ export async function openPublicationArtifactV1(input: {
71
+ index: RouteTierIndexV1;
72
+ requestPath: string;
73
+ plan: PublicationRoutePlanV1;
74
+ state: PublicationVersionStateV1;
75
+ channel: PublicationChannelV1;
76
+ bucket: PublicationArtifactBucketV1;
77
+ }): Promise<OpenPublicationArtifactResultV1> {
78
+ const selected = selectPublicationRouteVersionV1(
79
+ input.index,
80
+ input.requestPath,
81
+ input.plan,
82
+ input.state,
83
+ input.channel,
84
+ );
85
+ if (!selected.ok) return { ok: false, code: selected.code };
86
+ return openSelectedPublicationArtifactV1(selected.value, input.bucket);
87
+ }
88
+
89
+ async function openSelectedPublicationArtifactV1(
90
+ selection: SelectedPublicationRouteV1,
91
+ bucket: PublicationArtifactBucketV1,
92
+ ): Promise<OpenPublicationArtifactResultV1> {
93
+ const { manifest, publicationVersion } = selection;
94
+ let marker: PublicationStoredObjectV1 | null;
95
+ try {
96
+ marker = await bucket.get(publicationArtifactBundleKeyV1(
97
+ manifest.tenant,
98
+ manifest.id,
99
+ publicationVersion.versionId,
100
+ ));
101
+ } catch {
102
+ return { ok: false, code: "publication_artifact_source_unavailable" };
103
+ }
104
+ if (!marker) return { ok: false, code: "publication_artifact_marker_missing" };
105
+
106
+ let markerValue: unknown;
107
+ try {
108
+ markerValue = JSON.parse(await marker.text());
109
+ } catch {
110
+ return { ok: false, code: "publication_artifact_marker_malformed" };
111
+ }
112
+ const checked = await validatePublicationArtifactBundleV1(markerValue, {
113
+ tenant: manifest.tenant,
114
+ spaceId: manifest.id,
115
+ versionId: publicationVersion.versionId,
116
+ sourceSha: publicationVersion.source.commitSha,
117
+ artifactDigest: publicationVersion.artifactDigest,
118
+ forge: manifest.source.forge,
119
+ repo: manifest.source.repo,
120
+ visibility: manifest.visibility,
121
+ assetNamespace: manifest.assets.namespace,
122
+ assetAccess: manifest.assets.access,
123
+ });
124
+ if (!checked.ok) return { ok: false, code: "publication_artifact_marker_malformed" };
125
+
126
+ const physical = new Map<string, Uint8Array>();
127
+ for (const file of checked.value.files) {
128
+ const expected = publicationArtifactFileBytesV1(file);
129
+ if (!expected) return { ok: false, code: "publication_artifact_marker_malformed" };
130
+ let stored: PublicationStoredObjectV1 | null;
131
+ try {
132
+ stored = await bucket.get(publicationArtifactKeyV1(
133
+ manifest.tenant,
134
+ manifest.id,
135
+ publicationVersion.versionId,
136
+ file.lane,
137
+ file.path,
138
+ ));
139
+ } catch {
140
+ return { ok: false, code: "publication_artifact_source_unavailable" };
141
+ }
142
+ if (!stored) return { ok: false, code: "publication_artifact_file_missing" };
143
+ let actual: Uint8Array;
144
+ try {
145
+ actual = new Uint8Array(await stored.arrayBuffer());
146
+ } catch {
147
+ return { ok: false, code: "publication_artifact_file_missing" };
148
+ }
149
+ if (!equalBytes(actual, expected)) {
150
+ return { ok: false, code: "publication_artifact_file_mismatch" };
151
+ }
152
+ physical.set(`${file.lane}\0${file.path}`, actual);
153
+ }
154
+
155
+ const contentFiles = checked.value.files.filter(
156
+ (file): file is PublicationArtifactFileV1 & { encoding: "utf8" } =>
157
+ file.lane === "content" && file.encoding === "utf8" && file.path.startsWith("content/"),
158
+ );
159
+ const assetLane = manifest.assets.access === "public" ? "public-asset" : "authorized-asset";
160
+ const assetPrefix = `${manifest.assets.namespace}/`;
161
+ return {
162
+ ok: true,
163
+ value: {
164
+ selection,
165
+ bundle: checked.value,
166
+ readContent(relativePath) {
167
+ if (!safeRelativePath(relativePath)) return null;
168
+ const path = `content/${relativePath}`;
169
+ const file = contentFiles.find((candidate) => candidate.path === path);
170
+ if (!file) return null;
171
+ const bytes = physical.get(`content\0${path}`);
172
+ return bytes ? new TextDecoder().decode(bytes) : null;
173
+ },
174
+ listContentPaths(prefix = "") {
175
+ if (!safeRelativePath(prefix.replace(/\/$/, ""), true)) return [];
176
+ return contentFiles
177
+ .map((file) => file.path.slice("content/".length))
178
+ .filter((path) => path.startsWith(prefix))
179
+ .sort();
180
+ },
181
+ readAsset(relativePath) {
182
+ if (!safeRelativePath(relativePath)) return null;
183
+ return physical.get(`${assetLane}\0${assetPrefix}${relativePath}`) ?? null;
184
+ },
185
+ },
186
+ };
187
+ }
@@ -0,0 +1,246 @@
1
+ /**
2
+ * One publication request plan: normalized mount resolution, canonical route,
3
+ * content lookup path, and independently selected immutable version.
4
+ *
5
+ * The route-tier resolver owns normalization and longest-prefix matching. This
6
+ * module consumes that answer without decoding or canonicalizing the request a
7
+ * second time, so request routing, preview, static materialization, and canonical
8
+ * URL generation cannot disagree about which publication space owns a path.
9
+ */
10
+ import type { ContractViolation } from "./integration/result.js";
11
+ import {
12
+ resolvePublicationChannelVersionV1,
13
+ validatePublicationVersionStateV1,
14
+ type PublicationChannelPointerV1,
15
+ type PublicationChannelV1,
16
+ type PublicationVersionStateV1,
17
+ type PublicationVersionV1,
18
+ } from "./publication-version.js";
19
+ import {
20
+ resolveRouteTierV1,
21
+ type ResolvedRouteTierV1,
22
+ type RouteTierIndexV1,
23
+ } from "./route-tier.js";
24
+ import type { PublicationSpaceManifestV1 } from "./publication-space.js";
25
+
26
+ export const PUBLICATION_ROUTE_PLAN_VERSION = 1 as const;
27
+
28
+ export type PublicationRouteFailureCodeV1 =
29
+ | "publication_route_unresolved"
30
+ | "publication_route_authority_mismatch"
31
+ | "publication_state_malformed"
32
+ | "publication_channel_unselected"
33
+ | "publication_version_provenance_mismatch"
34
+ | "publication_renderer_contract_mismatch";
35
+
36
+ export interface PublicationRouteRenderPlanV1 {
37
+ version: typeof PUBLICATION_ROUTE_PLAN_VERSION;
38
+ action: "render";
39
+ route: ResolvedRouteTierV1;
40
+ manifest: PublicationSpaceManifestV1;
41
+ /** Normalized route as requested, with the matched mount still attached. */
42
+ requestPath: string;
43
+ /** Route relative to the selected publication source. Always absolute. */
44
+ contentPath: string;
45
+ /** Canonical tenant-relative route used by links and Astro dispatch. */
46
+ canonicalPath: string;
47
+ assetNamespace: string;
48
+ }
49
+
50
+ export interface PublicationRouteRedirectPlanV1 {
51
+ version: typeof PUBLICATION_ROUTE_PLAN_VERSION;
52
+ action: "redirect-308";
53
+ route: ResolvedRouteTierV1;
54
+ manifest: PublicationSpaceManifestV1;
55
+ requestPath: string;
56
+ /** Canonical tenant-relative redirect target, including the unmatched suffix. */
57
+ locationPath: string;
58
+ }
59
+
60
+ export interface PublicationRouteFailedPlanV1 {
61
+ version: typeof PUBLICATION_ROUTE_PLAN_VERSION;
62
+ action: "fail-closed";
63
+ route: ResolvedRouteTierV1;
64
+ code: "publication_route_unresolved";
65
+ violations: readonly ContractViolation[];
66
+ }
67
+
68
+ export type PublicationRoutePlanV1 =
69
+ | PublicationRouteRenderPlanV1
70
+ | PublicationRouteRedirectPlanV1
71
+ | PublicationRouteFailedPlanV1;
72
+
73
+ export interface SelectedPublicationRouteV1 extends PublicationRouteRenderPlanV1 {
74
+ channel: PublicationChannelV1;
75
+ pointer: PublicationChannelPointerV1;
76
+ publicationVersion: PublicationVersionV1;
77
+ }
78
+
79
+ export type PublicationRouteSelectionV1 =
80
+ | { ok: true; value: SelectedPublicationRouteV1 }
81
+ | { ok: false; code: PublicationRouteFailureCodeV1; plan: PublicationRoutePlanV1 };
82
+
83
+ function suffixAfterMount(path: string, mount: string): string {
84
+ if (mount === "/") return path === "/" ? "" : path;
85
+ return path === mount ? "" : path.slice(mount.length);
86
+ }
87
+
88
+ function appendSuffix(mount: string, suffix: string): string {
89
+ if (!suffix) return mount;
90
+ if (mount === "/") return suffix;
91
+ return `${mount}${suffix}`;
92
+ }
93
+
94
+ /** Resolve one request through the already-compiled tenant mount authority. */
95
+ export function resolvePublicationRoutePlanV1(
96
+ index: RouteTierIndexV1,
97
+ requestPath: string,
98
+ ): PublicationRoutePlanV1 {
99
+ const route = resolveRouteTierV1(index, requestPath);
100
+ const mount = route.mount;
101
+ const spaceId = route.space?.spaceId;
102
+ const manifest = spaceId ? index.spaces.get(spaceId) : undefined;
103
+ if (route.origin !== "publication-space" || !mount || !manifest) {
104
+ return {
105
+ version: PUBLICATION_ROUTE_PLAN_VERSION,
106
+ action: "fail-closed",
107
+ route,
108
+ code: "publication_route_unresolved",
109
+ violations: route.violations,
110
+ };
111
+ }
112
+
113
+ const suffix = suffixAfterMount(route.path, mount.mount);
114
+ if (mount.behavior === "redirect-308") {
115
+ if (!mount.redirectTo) {
116
+ return {
117
+ version: PUBLICATION_ROUTE_PLAN_VERSION,
118
+ action: "fail-closed",
119
+ route,
120
+ code: "publication_route_unresolved",
121
+ violations: [{
122
+ path: "mount.redirectTo",
123
+ code: "publication_redirect_target_required",
124
+ message: "A redirect publication mount must have a canonical target.",
125
+ }],
126
+ };
127
+ }
128
+ return {
129
+ version: PUBLICATION_ROUTE_PLAN_VERSION,
130
+ action: "redirect-308",
131
+ route,
132
+ manifest,
133
+ requestPath: route.path,
134
+ locationPath: appendSuffix(mount.redirectTo, suffix),
135
+ };
136
+ }
137
+
138
+ return {
139
+ version: PUBLICATION_ROUTE_PLAN_VERSION,
140
+ action: "render",
141
+ route,
142
+ manifest,
143
+ requestPath: route.path,
144
+ contentPath: suffix || "/",
145
+ canonicalPath: appendSuffix(mount.canonicalMount, suffix),
146
+ assetNamespace: manifest.assets.namespace,
147
+ };
148
+ }
149
+
150
+ /**
151
+ * Prove a carried plan still describes the current registry and request. This
152
+ * check must run before pointer or artifact I/O whenever a plan crosses a seam.
153
+ */
154
+ export function publicationRoutePlanIsCurrentV1(
155
+ index: RouteTierIndexV1,
156
+ requestPath: string,
157
+ plan: PublicationRoutePlanV1,
158
+ ): boolean {
159
+ return JSON.stringify(resolvePublicationRoutePlanV1(index, requestPath)) === JSON.stringify(plan);
160
+ }
161
+
162
+ /**
163
+ * Bind a render plan to exactly one channel pointer and immutable version.
164
+ * Any malformed/missing/mismatched state fails closed; there is no root or
165
+ * tenant-wide fallback after a publication space has been selected.
166
+ */
167
+ export function selectPublicationRouteVersionV1(
168
+ index: RouteTierIndexV1,
169
+ requestPath: string,
170
+ plan: PublicationRoutePlanV1,
171
+ stateRaw: PublicationVersionStateV1,
172
+ channel: PublicationChannelV1,
173
+ ): PublicationRouteSelectionV1 {
174
+ if (!publicationRoutePlanIsCurrentV1(index, requestPath, plan)) {
175
+ return { ok: false, code: "publication_route_authority_mismatch", plan };
176
+ }
177
+ if (plan.action !== "render") {
178
+ return { ok: false, code: "publication_route_unresolved", plan };
179
+ }
180
+ const checked = validatePublicationVersionStateV1(stateRaw);
181
+ if (!checked.ok) return { ok: false, code: "publication_state_malformed", plan };
182
+ const selected = resolvePublicationChannelVersionV1(
183
+ checked.value,
184
+ plan.manifest.tenant,
185
+ plan.manifest.id,
186
+ channel,
187
+ );
188
+ if (!selected) return { ok: false, code: "publication_channel_unselected", plan };
189
+ const { pointer, version } = selected;
190
+ if (
191
+ version.source.forge !== plan.manifest.source.forge ||
192
+ version.source.repo !== plan.manifest.source.repo ||
193
+ version.source.commitSha !== pointer.sourceSha
194
+ ) {
195
+ return { ok: false, code: "publication_version_provenance_mismatch", plan };
196
+ }
197
+ if (
198
+ version.renderer.contractVersion !== plan.manifest.renderer.contractVersion ||
199
+ version.renderer.themeContractVersion !== plan.manifest.renderer.themeContractVersion
200
+ ) {
201
+ return { ok: false, code: "publication_renderer_contract_mismatch", plan };
202
+ }
203
+ return {
204
+ ok: true,
205
+ value: {
206
+ ...plan,
207
+ channel,
208
+ pointer,
209
+ publicationVersion: version,
210
+ },
211
+ };
212
+ }
213
+
214
+ /** Build an absolute canonical URL without carrying query or fragment input. */
215
+ export function publicationCanonicalUrlV1(
216
+ canonicalOrigin: string,
217
+ plan: Pick<PublicationRouteRenderPlanV1, "canonicalPath">,
218
+ ): string {
219
+ const origin = new URL(canonicalOrigin);
220
+ return new URL(plan.canonicalPath, `${origin.protocol}//${origin.host}`).toString();
221
+ }
222
+
223
+ /**
224
+ * Canonicalize a tenant-relative link through the same mount authority as a
225
+ * request. Queries/fragments are opaque suffixes; only the path is resolved.
226
+ * External, protocol-relative, reserved, and malformed routes fail closed.
227
+ */
228
+ export function canonicalizePublicationLinkV1(
229
+ index: RouteTierIndexV1,
230
+ href: string,
231
+ ): string | null {
232
+ if (!href.startsWith("/") || href.startsWith("//")) return null;
233
+ const query = href.indexOf("?");
234
+ const fragment = href.indexOf("#");
235
+ const delimiter = query === -1
236
+ ? fragment
237
+ : fragment === -1
238
+ ? query
239
+ : Math.min(query, fragment);
240
+ const path = delimiter === -1 ? href : href.slice(0, delimiter);
241
+ const suffix = delimiter === -1 ? "" : href.slice(delimiter);
242
+ const plan = resolvePublicationRoutePlanV1(index, path);
243
+ if (plan.action === "fail-closed") return null;
244
+ const canonicalPath = plan.action === "render" ? plan.canonicalPath : plan.locationPath;
245
+ return `${canonicalPath}${suffix}`;
246
+ }