@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.
- package/apps/storefront/env.d.ts +21 -0
- package/apps/storefront/migrations-apps/0012_test_destinations.sql +29 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +1 -1
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +365 -49
- package/apps/storefront/src/components/islands/checkoutVerificationFlow.ts +251 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +18 -7
- package/apps/storefront/src/config/devTenantSeed.ts +24 -1
- package/apps/storefront/src/config/evidenceTenantRoster.ts +25 -0
- package/apps/storefront/src/config/publicationSpaceConfig.ts +25 -0
- package/apps/storefront/src/config/resolver.ts +5 -0
- package/apps/storefront/src/config/tenantAnalyticsFiles.ts +43 -0
- package/apps/storefront/src/config/tenants.ts +11 -0
- package/apps/storefront/src/layouts/Layout.astro +5 -1
- package/apps/storefront/src/lib/activity/killSwitch.ts +1 -1
- package/apps/storefront/src/lib/admin/consentSettingsClient.ts +113 -0
- package/apps/storefront/src/lib/apps/consentConfigRead.ts +24 -0
- package/apps/storefront/src/lib/apps/gtmMigrationDraft.ts +103 -0
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +18 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +11 -1
- package/apps/storefront/src/lib/auth/internalContentAuthority.ts +153 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
- package/apps/storefront/src/lib/auth/session.ts +3 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
- package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
- package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
- package/apps/storefront/src/lib/edgeCache.ts +17 -1
- package/apps/storefront/src/lib/env.ts +13 -111
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
- package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
- package/apps/storefront/src/lib/publication/runtimeSource.ts +97 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
- package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
- package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
- package/apps/storefront/src/middleware/candidatePin.ts +35 -0
- package/apps/storefront/src/middleware/controlPlane.ts +291 -0
- package/apps/storefront/src/middleware/index.ts +200 -793
- package/apps/storefront/src/middleware/publicRender.ts +43 -0
- package/apps/storefront/src/middleware/renderChain.ts +730 -0
- package/apps/storefront/src/middleware/routeBranch.ts +192 -0
- package/apps/storefront/src/middleware/staticBundle.ts +109 -0
- package/apps/storefront/src/pages/[...slug].astro +1 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +1 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +1 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
- package/apps/storefront/src/pages/index.astro +1 -0
- package/apps/storefront/vitest.config.ts +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
- package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
- package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
- package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
- package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
- package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
- package/packages/public-runtime/src/activity/catalog.ts +1 -1
- package/packages/public-runtime/src/candidate-index.ts +6 -0
- package/packages/public-runtime/src/customization-reconcile.ts +8 -0
- package/packages/public-runtime/src/html-cache-policy.ts +57 -0
- package/packages/public-runtime/src/index.ts +9 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
- package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
- package/packages/public-runtime/src/integration/render-phases.ts +8 -3
- package/packages/public-runtime/src/publication-artifact.ts +220 -0
- package/packages/public-runtime/src/publication-reader.ts +187 -0
- package/packages/public-runtime/src/publication-routing.ts +246 -0
- package/packages/public-runtime/src/publication-space.ts +853 -0
- package/packages/public-runtime/src/publication-version.ts +602 -0
- package/packages/public-runtime/src/route-tier.ts +257 -0
- package/packages/public-runtime/src/static-bundle.ts +18 -5
- package/packages/public-runtime/src/tenant-assets.ts +85 -17
- package/packages/public-runtime/src/tenant.ts +70 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
- package/scripts/dev/ai-edit.mjs +16 -3
- package/scripts/dev/port-check.mjs +18 -3
- package/scripts/dev/publish.mjs +26 -3
- package/scripts/tenant/validate.mjs +14 -1
|
@@ -0,0 +1,853 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Versioned publication-space contracts shared by tenant configuration,
|
|
3
|
+
* materialization, routing, and editorial control-plane callers.
|
|
4
|
+
*
|
|
5
|
+
* This module owns path normalization. Downstream callers must consume the
|
|
6
|
+
* normalized mounts emitted here rather than decode or canonicalize paths again.
|
|
7
|
+
*/
|
|
8
|
+
import type { TenantConfig } from "./tenant.js";
|
|
9
|
+
import {
|
|
10
|
+
ANNOTATION_KEYS,
|
|
11
|
+
type ContractResult,
|
|
12
|
+
type ContractViolation,
|
|
13
|
+
Violations,
|
|
14
|
+
isPlainObject,
|
|
15
|
+
reqBool,
|
|
16
|
+
reqEnum,
|
|
17
|
+
reqLiteral,
|
|
18
|
+
reqObject,
|
|
19
|
+
reqString,
|
|
20
|
+
} from "./integration/result.js";
|
|
21
|
+
|
|
22
|
+
export const PUBLICATION_SPACE_MANIFEST_VERSION = 1 as const;
|
|
23
|
+
export const PUBLICATION_SPACE_REF_VERSION = 1 as const;
|
|
24
|
+
export const PUBLICATION_MOUNT_ENTRY_VERSION = 1 as const;
|
|
25
|
+
|
|
26
|
+
/** URL paths are case-sensitive; Unicode is normalized to NFC before encoding. */
|
|
27
|
+
export const PUBLICATION_SPACE_PATH_CASE_POLICY = "sensitive" as const;
|
|
28
|
+
export const PUBLICATION_SPACE_UNICODE_POLICY = "nfc" as const;
|
|
29
|
+
|
|
30
|
+
/** Platform-owned path segments that tenant publication mounts may not claim. */
|
|
31
|
+
export const PUBLICATION_SPACE_RESERVED_MOUNTS = [
|
|
32
|
+
"/.well-known",
|
|
33
|
+
"/__evidence",
|
|
34
|
+
"/__preview",
|
|
35
|
+
"/_astro",
|
|
36
|
+
"/_style-guide",
|
|
37
|
+
"/admin",
|
|
38
|
+
"/api",
|
|
39
|
+
"/apps",
|
|
40
|
+
"/assets",
|
|
41
|
+
"/auth",
|
|
42
|
+
"/brand",
|
|
43
|
+
"/capabilities",
|
|
44
|
+
"/cockpit",
|
|
45
|
+
"/dashboard",
|
|
46
|
+
"/dev",
|
|
47
|
+
"/favicon.svg",
|
|
48
|
+
"/health",
|
|
49
|
+
"/js",
|
|
50
|
+
"/llms.txt",
|
|
51
|
+
"/platform",
|
|
52
|
+
"/preview",
|
|
53
|
+
"/products",
|
|
54
|
+
"/collections",
|
|
55
|
+
"/search",
|
|
56
|
+
"/saved",
|
|
57
|
+
"/robots.txt",
|
|
58
|
+
"/shared",
|
|
59
|
+
"/sitemap.xml",
|
|
60
|
+
"/style-guide",
|
|
61
|
+
"/tenants",
|
|
62
|
+
] as const;
|
|
63
|
+
|
|
64
|
+
export type PublicationSpaceForgeV1 = "gitea" | "github" | "fixture";
|
|
65
|
+
export type PublicationSpaceVisibilityV1 = "public" | "internal";
|
|
66
|
+
export type PublicationAliasBehaviorV1 = "render" | "redirect-308";
|
|
67
|
+
|
|
68
|
+
export interface PublicationSpaceSourceV1 {
|
|
69
|
+
forge: PublicationSpaceForgeV1;
|
|
70
|
+
repo: string;
|
|
71
|
+
defaultBranch: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface PublicationSpaceAliasV1 {
|
|
75
|
+
from: string;
|
|
76
|
+
behavior: PublicationAliasBehaviorV1;
|
|
77
|
+
/** Required for redirects; render aliases target the owning canonical mount. */
|
|
78
|
+
to?: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface PublicationSpaceManifestV1 {
|
|
82
|
+
version: typeof PUBLICATION_SPACE_MANIFEST_VERSION;
|
|
83
|
+
id: string;
|
|
84
|
+
tenant: string;
|
|
85
|
+
source: PublicationSpaceSourceV1;
|
|
86
|
+
routes: {
|
|
87
|
+
canonicalMount: string;
|
|
88
|
+
aliases?: PublicationSpaceAliasV1[];
|
|
89
|
+
};
|
|
90
|
+
visibility: PublicationSpaceVisibilityV1;
|
|
91
|
+
authorization:
|
|
92
|
+
| { policy: "public" }
|
|
93
|
+
| { policy: "capability"; capability: "internal-content-reader" };
|
|
94
|
+
discovery: {
|
|
95
|
+
sitemap: boolean;
|
|
96
|
+
feeds: boolean;
|
|
97
|
+
publicSearch: boolean;
|
|
98
|
+
llms: boolean;
|
|
99
|
+
};
|
|
100
|
+
assets: {
|
|
101
|
+
namespace: string;
|
|
102
|
+
access: "public" | "authorized";
|
|
103
|
+
};
|
|
104
|
+
renderer: {
|
|
105
|
+
contractVersion: string;
|
|
106
|
+
themeContractVersion: string;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export interface PublicationSpaceRefV1 {
|
|
111
|
+
version: typeof PUBLICATION_SPACE_REF_VERSION;
|
|
112
|
+
tenant: string;
|
|
113
|
+
spaceId: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A normalized route-registry row. P1 can consume these without re-normalizing. */
|
|
117
|
+
export interface NormalizedPublicationMountEntryV1 {
|
|
118
|
+
version: typeof PUBLICATION_MOUNT_ENTRY_VERSION;
|
|
119
|
+
tenant: string;
|
|
120
|
+
spaceId: string;
|
|
121
|
+
kind: "canonical" | "alias";
|
|
122
|
+
mount: string;
|
|
123
|
+
canonicalMount: string;
|
|
124
|
+
behavior: "render" | "redirect-308";
|
|
125
|
+
redirectTo?: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export interface PublicationPathNormalizationOptions {
|
|
129
|
+
/** Request paths discard query/fragment; manifest mounts reject them. */
|
|
130
|
+
mode?: "request" | "mount";
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const IDENTIFIER_RE = /^[a-z0-9](?:[a-z0-9._-]{0,126}[a-z0-9])?$/;
|
|
134
|
+
const REPO_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._/-]{0,254}[A-Za-z0-9])?$/;
|
|
135
|
+
const BRANCH_RE = /^(?![-/.])(?!HEAD$)(?!.*\/\.)(?!.*(?:\.\.|@\{|\\|\s|~|\^|:|\?|\*|\[|\/\/))(?!.*(?:^|\/)[^/]*\.lock(?:\/|$))(?!.*[\/.]$)[A-Za-z0-9._/-]+$/;
|
|
136
|
+
const CONTROL_RE = /[\u0000-\u001f\u007f]/;
|
|
137
|
+
const ENCODED_SEPARATOR_RE = /%(?:2f|5c)/i;
|
|
138
|
+
|
|
139
|
+
function rejectPublicationUnknownKeys(
|
|
140
|
+
obj: Record<string, unknown>,
|
|
141
|
+
allowed: readonly string[],
|
|
142
|
+
v: Violations,
|
|
143
|
+
path: string,
|
|
144
|
+
): void {
|
|
145
|
+
const allowedKeys = new Set([...allowed, ...ANNOTATION_KEYS]);
|
|
146
|
+
if (Object.keys(obj).some((key) => !allowedKeys.has(key))) {
|
|
147
|
+
v.add(path || "(root)", "unknown_field", "Unexpected field in publication-space contract.");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function oneViolation(code: string, message: string): ContractResult<string> {
|
|
152
|
+
return { ok: false, violations: [{ path: "path", code, message }] };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function hasDangerousSecondDecode(segment: string): boolean {
|
|
156
|
+
if (!/%[0-9a-f]{2}/i.test(segment)) return false;
|
|
157
|
+
try {
|
|
158
|
+
const twice = decodeURIComponent(segment).normalize("NFC");
|
|
159
|
+
return (
|
|
160
|
+
twice === "." ||
|
|
161
|
+
twice === ".." ||
|
|
162
|
+
twice.includes("/") ||
|
|
163
|
+
twice.includes("\\") ||
|
|
164
|
+
CONTROL_RE.test(twice)
|
|
165
|
+
);
|
|
166
|
+
} catch {
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function encodeCanonicalSegment(segment: string): string {
|
|
172
|
+
return encodeURIComponent(segment).replace(/%[0-9a-f]{2}/gi, (value) => value.toUpperCase());
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Normalize a publication path exactly once.
|
|
177
|
+
*
|
|
178
|
+
* Rules: absolute path only; request query/fragment excluded; repeated and
|
|
179
|
+
* trailing slashes collapse; each segment is decoded once, NFC-normalized, and
|
|
180
|
+
* canonically percent-encoded; case is preserved; traversal, separators,
|
|
181
|
+
* controls, malformed encodings, and dangerous double encodings are rejected.
|
|
182
|
+
*/
|
|
183
|
+
export function normalizePublicationPath(
|
|
184
|
+
input: string,
|
|
185
|
+
options: PublicationPathNormalizationOptions = {},
|
|
186
|
+
): ContractResult<string> {
|
|
187
|
+
if (typeof input !== "string" || input.length === 0) {
|
|
188
|
+
return oneViolation("publication_path_required", "Publication path must be a non-empty string.");
|
|
189
|
+
}
|
|
190
|
+
if (!input.startsWith("/")) {
|
|
191
|
+
return oneViolation("publication_path_absolute", "Publication path must start with '/'.");
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const delimiter = input.search(/[?#]/);
|
|
195
|
+
if (options.mode === "mount" && delimiter !== -1) {
|
|
196
|
+
return oneViolation(
|
|
197
|
+
"publication_mount_query_fragment",
|
|
198
|
+
"Publication mounts cannot contain a query string or fragment.",
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
const path = delimiter === -1 ? input : input.slice(0, delimiter);
|
|
202
|
+
if (path.includes("\\")) {
|
|
203
|
+
return oneViolation("publication_path_backslash", "Backslashes are forbidden in publication paths.");
|
|
204
|
+
}
|
|
205
|
+
if (CONTROL_RE.test(path)) {
|
|
206
|
+
return oneViolation("publication_path_control", "Control characters are forbidden in publication paths.");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const normalizedSegments: string[] = [];
|
|
210
|
+
for (const rawSegment of path.split("/")) {
|
|
211
|
+
if (rawSegment.length === 0) continue;
|
|
212
|
+
if (ENCODED_SEPARATOR_RE.test(rawSegment)) {
|
|
213
|
+
return oneViolation(
|
|
214
|
+
"publication_path_encoded_separator",
|
|
215
|
+
"Encoded path separators are forbidden in publication paths.",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
let decoded: string;
|
|
219
|
+
try {
|
|
220
|
+
decoded = decodeURIComponent(rawSegment).normalize("NFC");
|
|
221
|
+
} catch {
|
|
222
|
+
return oneViolation(
|
|
223
|
+
"publication_path_malformed_encoding",
|
|
224
|
+
"Publication path contains malformed percent encoding.",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
if (decoded === "." || decoded === "..") {
|
|
228
|
+
return oneViolation(
|
|
229
|
+
"publication_path_traversal",
|
|
230
|
+
"Dot and dot-dot traversal segments are forbidden in publication paths.",
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
if (decoded.includes("/") || decoded.includes("\\")) {
|
|
234
|
+
return oneViolation(
|
|
235
|
+
"publication_path_encoded_separator",
|
|
236
|
+
"Encoded path separators are forbidden in publication paths.",
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
if (CONTROL_RE.test(decoded)) {
|
|
240
|
+
return oneViolation("publication_path_control", "Control characters are forbidden in publication paths.");
|
|
241
|
+
}
|
|
242
|
+
if (hasDangerousSecondDecode(decoded)) {
|
|
243
|
+
return oneViolation(
|
|
244
|
+
"publication_path_double_encoding",
|
|
245
|
+
"Publication path contains a double-encoded traversal or separator.",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
normalizedSegments.push(encodeCanonicalSegment(decoded));
|
|
250
|
+
} catch {
|
|
251
|
+
return oneViolation(
|
|
252
|
+
"publication_path_malformed_unicode",
|
|
253
|
+
"Publication path contains malformed Unicode.",
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
return { ok: true, value: normalizedSegments.length === 0 ? "/" : `/${normalizedSegments.join("/")}` };
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Segment-boundary mount matching: /blog matches /blog/x but not /blogger. */
|
|
262
|
+
export function publicationMountMatchesPath(mount: string, normalizedPath: string): boolean {
|
|
263
|
+
return mount === "/" || normalizedPath === mount || normalizedPath.startsWith(`${mount}/`);
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function append(v: Violations, path: string, result: ContractResult<string>): string | undefined {
|
|
267
|
+
if (result.ok) return result.value;
|
|
268
|
+
for (const item of result.violations) {
|
|
269
|
+
v.add(path, item.code, item.message);
|
|
270
|
+
}
|
|
271
|
+
return undefined;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function validateIdentifier(value: string | undefined, v: Violations, path: string, label: string): string | undefined {
|
|
275
|
+
if (value !== undefined && !IDENTIFIER_RE.test(value)) {
|
|
276
|
+
v.add(path, "publication_identifier", `${label} must be a lowercase stable identifier.`);
|
|
277
|
+
return undefined;
|
|
278
|
+
}
|
|
279
|
+
return value;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function isReservedMount(mount: string): boolean {
|
|
283
|
+
return PUBLICATION_SPACE_RESERVED_MOUNTS.some((reserved) => publicationMountMatchesPath(reserved, mount));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function validateSource(raw: unknown, v: Violations, path: string): PublicationSpaceSourceV1 | undefined {
|
|
287
|
+
const source = reqObject(raw, v, path);
|
|
288
|
+
if (!source) return undefined;
|
|
289
|
+
rejectPublicationUnknownKeys(source, ["forge", "repo", "defaultBranch"], v, path);
|
|
290
|
+
const forge = reqEnum<PublicationSpaceForgeV1>(source, "forge", ["gitea", "github", "fixture"], v, path);
|
|
291
|
+
const repo = reqString(source, "repo", v, path);
|
|
292
|
+
const defaultBranch = reqString(source, "defaultBranch", v, path);
|
|
293
|
+
if (repo && (!REPO_RE.test(repo) || repo.includes("..") || repo.includes("//"))) {
|
|
294
|
+
v.add(`${path}.repo`, "publication_source_repo", "Source repo must be a stable repository name or owner/name.");
|
|
295
|
+
}
|
|
296
|
+
if (defaultBranch && (defaultBranch.length > 256 || !BRANCH_RE.test(defaultBranch))) {
|
|
297
|
+
v.add(`${path}.defaultBranch`, "publication_source_branch", "Default branch is not a safe git ref name.");
|
|
298
|
+
}
|
|
299
|
+
if (!forge || !repo || !defaultBranch) return undefined;
|
|
300
|
+
return { forge, repo, defaultBranch };
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function validateAliases(
|
|
304
|
+
raw: unknown,
|
|
305
|
+
canonicalMount: string | undefined,
|
|
306
|
+
v: Violations,
|
|
307
|
+
path: string,
|
|
308
|
+
): PublicationSpaceAliasV1[] | undefined {
|
|
309
|
+
if (raw === undefined) return undefined;
|
|
310
|
+
if (!Array.isArray(raw)) {
|
|
311
|
+
v.add(path, "type", "Expected aliases to be an array.");
|
|
312
|
+
return undefined;
|
|
313
|
+
}
|
|
314
|
+
const aliases: PublicationSpaceAliasV1[] = [];
|
|
315
|
+
raw.forEach((entry, index) => {
|
|
316
|
+
const itemPath = `${path}[${index}]`;
|
|
317
|
+
const alias = reqObject(entry, v, itemPath);
|
|
318
|
+
if (!alias) return;
|
|
319
|
+
rejectPublicationUnknownKeys(alias, ["from", "behavior", "to"], v, itemPath);
|
|
320
|
+
const rawFrom = reqString(alias, "from", v, itemPath);
|
|
321
|
+
const behavior = reqEnum<PublicationAliasBehaviorV1>(
|
|
322
|
+
alias,
|
|
323
|
+
"behavior",
|
|
324
|
+
["render", "redirect-308"],
|
|
325
|
+
v,
|
|
326
|
+
itemPath,
|
|
327
|
+
);
|
|
328
|
+
const from = rawFrom
|
|
329
|
+
? append(v, `${itemPath}.from`, normalizePublicationPath(rawFrom, { mode: "mount" }))
|
|
330
|
+
: undefined;
|
|
331
|
+
const rawTo = typeof alias["to"] === "string" ? alias["to"] : undefined;
|
|
332
|
+
if ("to" in alias && rawTo === undefined) {
|
|
333
|
+
v.add(`${itemPath}.to`, "type", "Alias target must be a non-empty string.");
|
|
334
|
+
}
|
|
335
|
+
const to = rawTo
|
|
336
|
+
? append(v, `${itemPath}.to`, normalizePublicationPath(rawTo, { mode: "mount" }))
|
|
337
|
+
: undefined;
|
|
338
|
+
if (from === "/") {
|
|
339
|
+
v.add(`${itemPath}.from`, "publication_root_alias", "The root mount cannot be an alias.");
|
|
340
|
+
}
|
|
341
|
+
if (from && isReservedMount(from)) {
|
|
342
|
+
v.add(`${itemPath}.from`, "publication_reserved_mount", "Alias mount is platform-reserved.");
|
|
343
|
+
}
|
|
344
|
+
if (behavior === "redirect-308" && !to) {
|
|
345
|
+
v.add(`${itemPath}.to`, "publication_alias_target_required", "A redirect-308 alias requires a target.");
|
|
346
|
+
}
|
|
347
|
+
if (behavior === "render" && "to" in alias) {
|
|
348
|
+
v.add(`${itemPath}.to`, "publication_render_alias_target", "A render alias targets its canonical mount and cannot set 'to'.");
|
|
349
|
+
}
|
|
350
|
+
if (from && canonicalMount && from === canonicalMount) {
|
|
351
|
+
v.add(`${itemPath}.from`, "publication_route_conflict", "Alias mount duplicates its canonical mount.");
|
|
352
|
+
}
|
|
353
|
+
if (from && behavior) aliases.push(to ? { from, behavior, to } : { from, behavior });
|
|
354
|
+
});
|
|
355
|
+
return aliases;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function validateAuthorization(
|
|
359
|
+
raw: unknown,
|
|
360
|
+
v: Violations,
|
|
361
|
+
path: string,
|
|
362
|
+
): PublicationSpaceManifestV1["authorization"] | undefined {
|
|
363
|
+
const auth = reqObject(raw, v, path);
|
|
364
|
+
if (!auth) return undefined;
|
|
365
|
+
const policy = reqEnum<"public" | "capability">(auth, "policy", ["public", "capability"], v, path);
|
|
366
|
+
if (policy === "public") {
|
|
367
|
+
rejectPublicationUnknownKeys(auth, ["policy"], v, path);
|
|
368
|
+
return { policy: "public" };
|
|
369
|
+
}
|
|
370
|
+
rejectPublicationUnknownKeys(auth, ["policy", "capability"], v, path);
|
|
371
|
+
const capability = reqEnum<"internal-content-reader">(
|
|
372
|
+
auth,
|
|
373
|
+
"capability",
|
|
374
|
+
["internal-content-reader"],
|
|
375
|
+
v,
|
|
376
|
+
path,
|
|
377
|
+
);
|
|
378
|
+
return capability ? { policy: "capability", capability } : undefined;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function validateDiscovery(
|
|
382
|
+
raw: unknown,
|
|
383
|
+
v: Violations,
|
|
384
|
+
path: string,
|
|
385
|
+
): PublicationSpaceManifestV1["discovery"] | undefined {
|
|
386
|
+
const discovery = reqObject(raw, v, path);
|
|
387
|
+
if (!discovery) return undefined;
|
|
388
|
+
rejectPublicationUnknownKeys(discovery, ["sitemap", "feeds", "publicSearch", "llms"], v, path);
|
|
389
|
+
const sitemap = reqBool(discovery, "sitemap", v, path);
|
|
390
|
+
const feeds = reqBool(discovery, "feeds", v, path);
|
|
391
|
+
const publicSearch = reqBool(discovery, "publicSearch", v, path);
|
|
392
|
+
const llms = reqBool(discovery, "llms", v, path);
|
|
393
|
+
if ([sitemap, feeds, publicSearch, llms].some((value) => value === undefined)) return undefined;
|
|
394
|
+
return { sitemap: sitemap!, feeds: feeds!, publicSearch: publicSearch!, llms: llms! };
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function validateAssets(
|
|
398
|
+
raw: unknown,
|
|
399
|
+
v: Violations,
|
|
400
|
+
path: string,
|
|
401
|
+
): PublicationSpaceManifestV1["assets"] | undefined {
|
|
402
|
+
const assets = reqObject(raw, v, path);
|
|
403
|
+
if (!assets) return undefined;
|
|
404
|
+
rejectPublicationUnknownKeys(assets, ["namespace", "access"], v, path);
|
|
405
|
+
const namespace = validateIdentifier(reqString(assets, "namespace", v, path), v, `${path}.namespace`, "Asset namespace");
|
|
406
|
+
const access = reqEnum<"public" | "authorized">(assets, "access", ["public", "authorized"], v, path);
|
|
407
|
+
return namespace && access ? { namespace, access } : undefined;
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function validateRenderer(
|
|
411
|
+
raw: unknown,
|
|
412
|
+
v: Violations,
|
|
413
|
+
path: string,
|
|
414
|
+
): PublicationSpaceManifestV1["renderer"] | undefined {
|
|
415
|
+
const renderer = reqObject(raw, v, path);
|
|
416
|
+
if (!renderer) return undefined;
|
|
417
|
+
rejectPublicationUnknownKeys(renderer, ["contractVersion", "themeContractVersion"], v, path);
|
|
418
|
+
const contractVersion = reqString(renderer, "contractVersion", v, path);
|
|
419
|
+
const themeContractVersion = reqString(renderer, "themeContractVersion", v, path);
|
|
420
|
+
return contractVersion && themeContractVersion ? { contractVersion, themeContractVersion } : undefined;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/** Validate, close, and normalize one serialized publication-space manifest. */
|
|
424
|
+
export function validatePublicationSpaceManifestV1(raw: unknown): ContractResult<PublicationSpaceManifestV1> {
|
|
425
|
+
const v = new Violations();
|
|
426
|
+
const root = reqObject(raw, v, "");
|
|
427
|
+
if (!root) return { ok: false, violations: v.items };
|
|
428
|
+
rejectPublicationUnknownKeys(
|
|
429
|
+
root,
|
|
430
|
+
["version", "id", "tenant", "source", "routes", "visibility", "authorization", "discovery", "assets", "renderer"],
|
|
431
|
+
v,
|
|
432
|
+
"",
|
|
433
|
+
);
|
|
434
|
+
reqLiteral(root, "version", PUBLICATION_SPACE_MANIFEST_VERSION, v, "");
|
|
435
|
+
const id = validateIdentifier(reqString(root, "id", v, ""), v, "id", "Publication-space id");
|
|
436
|
+
const tenant = validateIdentifier(reqString(root, "tenant", v, ""), v, "tenant", "Tenant");
|
|
437
|
+
const source = validateSource(root["source"], v, "source");
|
|
438
|
+
const routes = reqObject(root["routes"], v, "routes");
|
|
439
|
+
let canonicalMount: string | undefined;
|
|
440
|
+
let aliases: PublicationSpaceAliasV1[] | undefined;
|
|
441
|
+
if (routes) {
|
|
442
|
+
rejectPublicationUnknownKeys(routes, ["canonicalMount", "aliases"], v, "routes");
|
|
443
|
+
const rawMount = reqString(routes, "canonicalMount", v, "routes");
|
|
444
|
+
canonicalMount = rawMount
|
|
445
|
+
? append(v, "routes.canonicalMount", normalizePublicationPath(rawMount, { mode: "mount" }))
|
|
446
|
+
: undefined;
|
|
447
|
+
if (canonicalMount && canonicalMount !== "/" && isReservedMount(canonicalMount)) {
|
|
448
|
+
v.add("routes.canonicalMount", "publication_reserved_mount", "Canonical mount is platform-reserved.");
|
|
449
|
+
}
|
|
450
|
+
aliases = validateAliases(routes["aliases"], canonicalMount, v, "routes.aliases");
|
|
451
|
+
}
|
|
452
|
+
const visibility = reqEnum<PublicationSpaceVisibilityV1>(root, "visibility", ["public", "internal"], v, "");
|
|
453
|
+
const authorization = validateAuthorization(root["authorization"], v, "authorization");
|
|
454
|
+
const discovery = validateDiscovery(root["discovery"], v, "discovery");
|
|
455
|
+
const assets = validateAssets(root["assets"], v, "assets");
|
|
456
|
+
const renderer = validateRenderer(root["renderer"], v, "renderer");
|
|
457
|
+
|
|
458
|
+
if (visibility && authorization) {
|
|
459
|
+
const matches =
|
|
460
|
+
(visibility === "public" && authorization.policy === "public") ||
|
|
461
|
+
(visibility === "internal" && authorization.policy === "capability");
|
|
462
|
+
if (!matches) {
|
|
463
|
+
v.add(
|
|
464
|
+
"authorization.policy",
|
|
465
|
+
"publication_visibility_authorization_mismatch",
|
|
466
|
+
"Publication visibility does not match its authorization policy.",
|
|
467
|
+
);
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (visibility === "internal" && discovery && Object.values(discovery).some(Boolean)) {
|
|
471
|
+
v.add(
|
|
472
|
+
"discovery",
|
|
473
|
+
"publication_internal_discovery",
|
|
474
|
+
"Internal publication spaces must be disabled on every public discovery surface.",
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
if (visibility && assets) {
|
|
478
|
+
const expected = visibility === "public" ? "public" : "authorized";
|
|
479
|
+
if (assets.access !== expected) {
|
|
480
|
+
v.add(
|
|
481
|
+
"assets.access",
|
|
482
|
+
"publication_visibility_asset_mismatch",
|
|
483
|
+
"Publication visibility does not match its asset access policy.",
|
|
484
|
+
);
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
if (!v.ok || !id || !tenant || !source || !routes || !canonicalMount || !visibility || !authorization || !discovery || !assets || !renderer) {
|
|
489
|
+
return { ok: false, violations: v.items };
|
|
490
|
+
}
|
|
491
|
+
return {
|
|
492
|
+
ok: true,
|
|
493
|
+
value: {
|
|
494
|
+
version: PUBLICATION_SPACE_MANIFEST_VERSION,
|
|
495
|
+
id,
|
|
496
|
+
tenant,
|
|
497
|
+
source,
|
|
498
|
+
routes: aliases === undefined ? { canonicalMount } : { canonicalMount, aliases },
|
|
499
|
+
visibility,
|
|
500
|
+
authorization,
|
|
501
|
+
discovery,
|
|
502
|
+
assets,
|
|
503
|
+
renderer,
|
|
504
|
+
},
|
|
505
|
+
};
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/** Validate one closed publication-space reference. */
|
|
509
|
+
export function validatePublicationSpaceRefV1(raw: unknown): ContractResult<PublicationSpaceRefV1> {
|
|
510
|
+
const v = new Violations();
|
|
511
|
+
const root = reqObject(raw, v, "");
|
|
512
|
+
if (!root) return { ok: false, violations: v.items };
|
|
513
|
+
rejectPublicationUnknownKeys(root, ["version", "tenant", "spaceId"], v, "");
|
|
514
|
+
reqLiteral(root, "version", PUBLICATION_SPACE_REF_VERSION, v, "");
|
|
515
|
+
const tenant = validateIdentifier(reqString(root, "tenant", v, ""), v, "tenant", "Tenant");
|
|
516
|
+
const spaceId = validateIdentifier(reqString(root, "spaceId", v, ""), v, "spaceId", "Publication-space id");
|
|
517
|
+
if (!v.ok || !tenant || !spaceId) return { ok: false, violations: v.items };
|
|
518
|
+
return { ok: true, value: { version: PUBLICATION_SPACE_REF_VERSION, tenant, spaceId } };
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
function normalizedMountField(
|
|
522
|
+
obj: Record<string, unknown>,
|
|
523
|
+
key: string,
|
|
524
|
+
v: Violations,
|
|
525
|
+
path: string,
|
|
526
|
+
): string | undefined {
|
|
527
|
+
const raw = reqString(obj, key, v, path);
|
|
528
|
+
if (!raw) return undefined;
|
|
529
|
+
const normalized = normalizePublicationPath(raw, { mode: "mount" });
|
|
530
|
+
const value = append(v, `${path ? `${path}.` : ""}${key}`, normalized);
|
|
531
|
+
if (value && value !== raw) {
|
|
532
|
+
v.add(
|
|
533
|
+
`${path ? `${path}.` : ""}${key}`,
|
|
534
|
+
"publication_mount_not_normalized",
|
|
535
|
+
"Normalized mount entries must store the canonical path form.",
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
return value;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/** Validate a closed, already-normalized mount-registry row. */
|
|
542
|
+
export function validateNormalizedPublicationMountEntryV1(
|
|
543
|
+
raw: unknown,
|
|
544
|
+
): ContractResult<NormalizedPublicationMountEntryV1> {
|
|
545
|
+
const v = new Violations();
|
|
546
|
+
const root = reqObject(raw, v, "");
|
|
547
|
+
if (!root) return { ok: false, violations: v.items };
|
|
548
|
+
rejectPublicationUnknownKeys(
|
|
549
|
+
root,
|
|
550
|
+
["version", "tenant", "spaceId", "kind", "mount", "canonicalMount", "behavior", "redirectTo"],
|
|
551
|
+
v,
|
|
552
|
+
"",
|
|
553
|
+
);
|
|
554
|
+
reqLiteral(root, "version", PUBLICATION_MOUNT_ENTRY_VERSION, v, "");
|
|
555
|
+
const tenant = validateIdentifier(reqString(root, "tenant", v, ""), v, "tenant", "Tenant");
|
|
556
|
+
const spaceId = validateIdentifier(reqString(root, "spaceId", v, ""), v, "spaceId", "Publication-space id");
|
|
557
|
+
const kind = reqEnum<"canonical" | "alias">(root, "kind", ["canonical", "alias"], v, "");
|
|
558
|
+
const mount = normalizedMountField(root, "mount", v, "");
|
|
559
|
+
const canonicalMount = normalizedMountField(root, "canonicalMount", v, "");
|
|
560
|
+
const behavior = reqEnum<"render" | "redirect-308">(
|
|
561
|
+
root,
|
|
562
|
+
"behavior",
|
|
563
|
+
["render", "redirect-308"],
|
|
564
|
+
v,
|
|
565
|
+
"",
|
|
566
|
+
);
|
|
567
|
+
const redirectTo = "redirectTo" in root
|
|
568
|
+
? normalizedMountField(root, "redirectTo", v, "")
|
|
569
|
+
: undefined;
|
|
570
|
+
|
|
571
|
+
if (kind === "canonical") {
|
|
572
|
+
if (mount && canonicalMount && mount !== canonicalMount) {
|
|
573
|
+
v.add("mount", "publication_canonical_mount_mismatch", "A canonical row's mount must equal canonicalMount.");
|
|
574
|
+
}
|
|
575
|
+
if (behavior && behavior !== "render") {
|
|
576
|
+
v.add("behavior", "publication_canonical_behavior", "Canonical rows must use render behavior.");
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
if (kind === "alias" && mount === "/") {
|
|
580
|
+
v.add("mount", "publication_root_alias", "The root mount cannot be an alias.");
|
|
581
|
+
}
|
|
582
|
+
if (kind === "alias" && mount && canonicalMount && mount === canonicalMount) {
|
|
583
|
+
v.add("mount", "publication_route_conflict", "Alias mount duplicates its canonical mount.");
|
|
584
|
+
}
|
|
585
|
+
if (behavior === "redirect-308" && !redirectTo) {
|
|
586
|
+
v.add("redirectTo", "publication_alias_target_required", "A redirect-308 row requires redirectTo.");
|
|
587
|
+
}
|
|
588
|
+
if (behavior === "render" && "redirectTo" in root) {
|
|
589
|
+
v.add("redirectTo", "publication_render_alias_target", "A render row cannot set redirectTo.");
|
|
590
|
+
}
|
|
591
|
+
if (mount && mount !== "/" && isReservedMount(mount)) {
|
|
592
|
+
v.add("mount", "publication_reserved_mount", "Mount is platform-reserved.");
|
|
593
|
+
}
|
|
594
|
+
if (canonicalMount && canonicalMount !== mount && canonicalMount !== "/" && isReservedMount(canonicalMount)) {
|
|
595
|
+
v.add("canonicalMount", "publication_reserved_mount", "Canonical mount is platform-reserved.");
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (!v.ok || !tenant || !spaceId || !kind || !mount || !canonicalMount || !behavior) {
|
|
599
|
+
return { ok: false, violations: v.items };
|
|
600
|
+
}
|
|
601
|
+
return {
|
|
602
|
+
ok: true,
|
|
603
|
+
value: {
|
|
604
|
+
version: PUBLICATION_MOUNT_ENTRY_VERSION,
|
|
605
|
+
tenant,
|
|
606
|
+
spaceId,
|
|
607
|
+
kind,
|
|
608
|
+
mount,
|
|
609
|
+
canonicalMount,
|
|
610
|
+
behavior,
|
|
611
|
+
...(redirectTo ? { redirectTo } : {}),
|
|
612
|
+
},
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function addRegistryViolation(
|
|
617
|
+
violations: ContractViolation[],
|
|
618
|
+
path: string,
|
|
619
|
+
code: string,
|
|
620
|
+
message: string,
|
|
621
|
+
): void {
|
|
622
|
+
violations.push({ path, code, message });
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
function detectAliasLoops(entries: readonly NormalizedPublicationMountEntryV1[], violations: ContractViolation[]): void {
|
|
626
|
+
const byTenant = new Map<string, NormalizedPublicationMountEntryV1[]>();
|
|
627
|
+
for (const entry of entries) {
|
|
628
|
+
const tenantEntries = byTenant.get(entry.tenant) ?? [];
|
|
629
|
+
tenantEntries.push(entry);
|
|
630
|
+
byTenant.set(entry.tenant, tenantEntries);
|
|
631
|
+
}
|
|
632
|
+
for (const tenantEntries of byTenant.values()) {
|
|
633
|
+
tenantEntries.sort((left, right) => right.mount.length - left.mount.length || left.mount.localeCompare(right.mount));
|
|
634
|
+
for (const start of tenantEntries) {
|
|
635
|
+
if (start.behavior !== "redirect-308" || !start.redirectTo) continue;
|
|
636
|
+
const seen = new Set<string>([start.mount]);
|
|
637
|
+
let target: string | undefined = start.redirectTo;
|
|
638
|
+
while (target) {
|
|
639
|
+
const matched = tenantEntries.find((entry) => publicationMountMatchesPath(entry.mount, target!));
|
|
640
|
+
if (!matched || matched.behavior !== "redirect-308" || !matched.redirectTo) break;
|
|
641
|
+
if (seen.has(matched.mount)) {
|
|
642
|
+
addRegistryViolation(
|
|
643
|
+
violations,
|
|
644
|
+
"mounts",
|
|
645
|
+
"publication_alias_loop",
|
|
646
|
+
"Alias redirect loop detected.",
|
|
647
|
+
);
|
|
648
|
+
break;
|
|
649
|
+
}
|
|
650
|
+
seen.add(matched.mount);
|
|
651
|
+
target = matched.redirectTo;
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* Validate a tenant mount registry and emit deterministic longest-prefix rows.
|
|
659
|
+
* Duplicate route ownership, duplicate ids/namespaces, missing roots, and alias
|
|
660
|
+
* loops fail before any registry can be activated.
|
|
661
|
+
*/
|
|
662
|
+
export function validatePublicationSpaceRegistryV1(
|
|
663
|
+
raw: readonly unknown[],
|
|
664
|
+
): ContractResult<NormalizedPublicationMountEntryV1[]> {
|
|
665
|
+
if (!Array.isArray(raw)) {
|
|
666
|
+
return {
|
|
667
|
+
ok: false,
|
|
668
|
+
violations: [{ path: "spaces", code: "type", message: "Publication-space registry must be an array." }],
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
const manifests: PublicationSpaceManifestV1[] = [];
|
|
672
|
+
const violations: ContractViolation[] = [];
|
|
673
|
+
raw.forEach((item, index) => {
|
|
674
|
+
const result = validatePublicationSpaceManifestV1(item);
|
|
675
|
+
if (result.ok) manifests.push(result.value);
|
|
676
|
+
else for (const violation of result.violations) violations.push({ ...violation, path: `spaces[${index}].${violation.path}` });
|
|
677
|
+
});
|
|
678
|
+
if (violations.length > 0) return { ok: false, violations };
|
|
679
|
+
|
|
680
|
+
const ids = new Map<string, number>();
|
|
681
|
+
const namespaces = new Map<string, number>();
|
|
682
|
+
const routes = new Map<string, number>();
|
|
683
|
+
const roots = new Map<string, { count: number; firstIndex: number }>();
|
|
684
|
+
const entries: NormalizedPublicationMountEntryV1[] = [];
|
|
685
|
+
|
|
686
|
+
manifests.forEach((manifest, index) => {
|
|
687
|
+
const idKey = `${manifest.tenant}\u0000${manifest.id}`;
|
|
688
|
+
const previousId = ids.get(idKey);
|
|
689
|
+
if (previousId !== undefined) {
|
|
690
|
+
addRegistryViolation(
|
|
691
|
+
violations,
|
|
692
|
+
`spaces[${index}].id`,
|
|
693
|
+
"publication_duplicate_space_id",
|
|
694
|
+
`Publication-space id duplicates the earlier entry at spaces[${previousId}] for the same tenant.`,
|
|
695
|
+
);
|
|
696
|
+
} else ids.set(idKey, index);
|
|
697
|
+
|
|
698
|
+
const namespaceKey = `${manifest.tenant}\u0000${manifest.assets.namespace}`;
|
|
699
|
+
const previousNamespace = namespaces.get(namespaceKey);
|
|
700
|
+
if (previousNamespace !== undefined) {
|
|
701
|
+
addRegistryViolation(
|
|
702
|
+
violations,
|
|
703
|
+
`spaces[${index}].assets.namespace`,
|
|
704
|
+
"publication_asset_namespace_reuse",
|
|
705
|
+
`Asset namespace is already owned by the earlier entry at spaces[${previousNamespace}] for the same tenant.`,
|
|
706
|
+
);
|
|
707
|
+
} else namespaces.set(namespaceKey, index);
|
|
708
|
+
|
|
709
|
+
const tenantRoot = roots.get(manifest.tenant) ?? { count: 0, firstIndex: index };
|
|
710
|
+
if (manifest.routes.canonicalMount === "/") tenantRoot.count += 1;
|
|
711
|
+
roots.set(manifest.tenant, tenantRoot);
|
|
712
|
+
const addEntry = (entry: NormalizedPublicationMountEntryV1): void => {
|
|
713
|
+
const routeKey = `${entry.tenant}\u0000${entry.mount}`;
|
|
714
|
+
const previousRoute = routes.get(routeKey);
|
|
715
|
+
if (previousRoute !== undefined) {
|
|
716
|
+
addRegistryViolation(
|
|
717
|
+
violations,
|
|
718
|
+
`spaces[${index}].routes`,
|
|
719
|
+
"publication_route_conflict",
|
|
720
|
+
`Normalized route is already owned by the earlier entry at spaces[${previousRoute}] for the same tenant.`,
|
|
721
|
+
);
|
|
722
|
+
} else routes.set(routeKey, index);
|
|
723
|
+
entries.push(entry);
|
|
724
|
+
};
|
|
725
|
+
|
|
726
|
+
addEntry({
|
|
727
|
+
version: PUBLICATION_MOUNT_ENTRY_VERSION,
|
|
728
|
+
tenant: manifest.tenant,
|
|
729
|
+
spaceId: manifest.id,
|
|
730
|
+
kind: "canonical",
|
|
731
|
+
mount: manifest.routes.canonicalMount,
|
|
732
|
+
canonicalMount: manifest.routes.canonicalMount,
|
|
733
|
+
behavior: "render",
|
|
734
|
+
});
|
|
735
|
+
for (const alias of manifest.routes.aliases ?? []) {
|
|
736
|
+
addEntry({
|
|
737
|
+
version: PUBLICATION_MOUNT_ENTRY_VERSION,
|
|
738
|
+
tenant: manifest.tenant,
|
|
739
|
+
spaceId: manifest.id,
|
|
740
|
+
kind: "alias",
|
|
741
|
+
mount: alias.from,
|
|
742
|
+
canonicalMount: manifest.routes.canonicalMount,
|
|
743
|
+
behavior: alias.behavior,
|
|
744
|
+
...(alias.to ? { redirectTo: alias.to } : {}),
|
|
745
|
+
});
|
|
746
|
+
}
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
for (const tenant of new Set(manifests.map((manifest) => manifest.tenant))) {
|
|
750
|
+
const tenantRoot = roots.get(tenant)!;
|
|
751
|
+
if (tenantRoot.count !== 1) {
|
|
752
|
+
addRegistryViolation(
|
|
753
|
+
violations,
|
|
754
|
+
`spaces[${tenantRoot.firstIndex}].routes.canonicalMount`,
|
|
755
|
+
"publication_root_mount_count",
|
|
756
|
+
"Each tenant must have exactly one canonical root mount.",
|
|
757
|
+
);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
detectAliasLoops(entries, violations);
|
|
761
|
+
if (violations.length > 0) return { ok: false, violations };
|
|
762
|
+
|
|
763
|
+
entries.sort(
|
|
764
|
+
(left, right) =>
|
|
765
|
+
left.tenant.localeCompare(right.tenant) ||
|
|
766
|
+
right.mount.length - left.mount.length ||
|
|
767
|
+
left.mount.localeCompare(right.mount) ||
|
|
768
|
+
left.kind.localeCompare(right.kind),
|
|
769
|
+
);
|
|
770
|
+
return { ok: true, value: entries };
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
/** Parse JSON and return the same stable violation model as object validation. */
|
|
774
|
+
export function parsePublicationSpaceManifestV1Json(json: string): ContractResult<PublicationSpaceManifestV1> {
|
|
775
|
+
let raw: unknown;
|
|
776
|
+
try {
|
|
777
|
+
raw = JSON.parse(json);
|
|
778
|
+
} catch {
|
|
779
|
+
return {
|
|
780
|
+
ok: false,
|
|
781
|
+
violations: [{ path: "(root)", code: "publication_invalid_json", message: "Publication-space manifest is not valid JSON." }],
|
|
782
|
+
};
|
|
783
|
+
}
|
|
784
|
+
return validatePublicationSpaceManifestV1(raw);
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
/** Serialize a validated manifest in canonical property order. */
|
|
788
|
+
export function serializePublicationSpaceManifestV1(manifest: PublicationSpaceManifestV1): string {
|
|
789
|
+
const result = validatePublicationSpaceManifestV1(manifest);
|
|
790
|
+
if (!result.ok) {
|
|
791
|
+
throw new Error(`Invalid PublicationSpaceManifestV1: ${result.violations.map((item) => item.code).join(", ")}`);
|
|
792
|
+
}
|
|
793
|
+
return `${JSON.stringify(result.value, null, 2)}\n`;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
export interface ResolvedTenantPublicationSpacesV1 {
|
|
797
|
+
manifests: PublicationSpaceManifestV1[];
|
|
798
|
+
refs: PublicationSpaceRefV1[];
|
|
799
|
+
mounts: NormalizedPublicationMountEntryV1[];
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/** Resolve a tenant's explicit publication spaces through one validated path. */
|
|
803
|
+
export function resolveTenantPublicationSpacesV1(
|
|
804
|
+
tenant: Pick<TenantConfig, "tenant_id" | "publicationSpaces">,
|
|
805
|
+
): ContractResult<ResolvedTenantPublicationSpacesV1> {
|
|
806
|
+
if (!Array.isArray(tenant.publicationSpaces) || tenant.publicationSpaces.length === 0) {
|
|
807
|
+
return {
|
|
808
|
+
ok: false,
|
|
809
|
+
violations: [{
|
|
810
|
+
path: "publicationSpaces",
|
|
811
|
+
code: "publication_spaces_required",
|
|
812
|
+
message: "Publication spaces are required before publication routing can resolve.",
|
|
813
|
+
}],
|
|
814
|
+
};
|
|
815
|
+
}
|
|
816
|
+
const rawManifests = [...tenant.publicationSpaces];
|
|
817
|
+
const registry = validatePublicationSpaceRegistryV1(rawManifests);
|
|
818
|
+
if (!registry.ok) return registry;
|
|
819
|
+
|
|
820
|
+
const manifests: PublicationSpaceManifestV1[] = [];
|
|
821
|
+
const violations: ContractViolation[] = [];
|
|
822
|
+
rawManifests.forEach((raw, index) => {
|
|
823
|
+
const parsed = validatePublicationSpaceManifestV1(raw);
|
|
824
|
+
if (!parsed.ok) {
|
|
825
|
+
for (const violation of parsed.violations) {
|
|
826
|
+
violations.push({ ...violation, path: `spaces[${index}].${violation.path}` });
|
|
827
|
+
}
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (parsed.value.tenant !== tenant.tenant_id) {
|
|
831
|
+
violations.push({
|
|
832
|
+
path: `spaces[${index}].tenant`,
|
|
833
|
+
code: "publication_tenant_mismatch",
|
|
834
|
+
message: "Publication-space tenant does not match the containing tenant.",
|
|
835
|
+
});
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
manifests.push(parsed.value);
|
|
839
|
+
});
|
|
840
|
+
if (violations.length > 0) return { ok: false, violations };
|
|
841
|
+
return {
|
|
842
|
+
ok: true,
|
|
843
|
+
value: {
|
|
844
|
+
manifests,
|
|
845
|
+
refs: manifests.map((manifest) => ({
|
|
846
|
+
version: PUBLICATION_SPACE_REF_VERSION,
|
|
847
|
+
tenant: manifest.tenant,
|
|
848
|
+
spaceId: manifest.id,
|
|
849
|
+
})),
|
|
850
|
+
mounts: registry.value,
|
|
851
|
+
},
|
|
852
|
+
};
|
|
853
|
+
}
|