@tokenoftrust/storefront-runner 2.0.16 → 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.
- package/apps/storefront/env.d.ts +18 -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/apps/routeParams.ts +45 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +25 -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/env.ts +13 -111
- package/apps/storefront/src/lib/fixtures/regulated-commerce-truth/index.ts +540 -0
- package/apps/storefront/src/lib/fixtures/ws3-readonly-clone-preflight/index.ts +495 -0
- 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/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 +270 -0
- package/apps/storefront/src/middleware/index.ts +100 -796
- package/apps/storefront/src/middleware/publicRender.ts +43 -0
- package/apps/storefront/src/middleware/renderChain.ts +685 -0
- package/apps/storefront/src/middleware/routeBranch.ts +160 -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/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 +7 -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 +203 -0
- package/packages/public-runtime/src/publication-space.ts +853 -0
- package/packages/public-runtime/src/publication-version.ts +587 -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/conformance/fake-destination.ts +402 -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,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Immutable publication versions and tenant+space scoped channel pointers.
|
|
3
|
+
*
|
|
4
|
+
* This is a pure contract/state-transition seam. Durable stores must commit the
|
|
5
|
+
* returned pointer and audit transition atomically; materialization and UI live
|
|
6
|
+
* outside this module.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
type PublicationSpaceForgeV1,
|
|
10
|
+
validatePublicationSpaceRefV1,
|
|
11
|
+
} from "./publication-space.js";
|
|
12
|
+
import {
|
|
13
|
+
ANNOTATION_KEYS,
|
|
14
|
+
type ContractResult,
|
|
15
|
+
type ContractViolation,
|
|
16
|
+
Violations,
|
|
17
|
+
reqEnum,
|
|
18
|
+
reqInt,
|
|
19
|
+
reqLiteral,
|
|
20
|
+
reqObject,
|
|
21
|
+
reqString,
|
|
22
|
+
} from "./integration/result.js";
|
|
23
|
+
|
|
24
|
+
export const PUBLICATION_VERSION_SCHEMA_VERSION = 1 as const;
|
|
25
|
+
export const PUBLICATION_POINTER_SCHEMA_VERSION = 1 as const;
|
|
26
|
+
export const PUBLICATION_POINTER_TRANSITION_SCHEMA_VERSION = 1 as const;
|
|
27
|
+
export const PUBLICATION_TRANSITION_REQUEST_SCHEMA_VERSION = 1 as const;
|
|
28
|
+
|
|
29
|
+
export const PUBLICATION_CHANNELS = ["preview", "live"] as const;
|
|
30
|
+
export type PublicationChannelV1 = (typeof PUBLICATION_CHANNELS)[number];
|
|
31
|
+
export type PublicationTransitionOperationV1 = "activate" | "rollback";
|
|
32
|
+
|
|
33
|
+
const SHA40_RE = /^[0-9a-f]{40}$/;
|
|
34
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
35
|
+
const REPO_RE = /^[A-Za-z0-9](?:[A-Za-z0-9._/-]{0,254}[A-Za-z0-9])?$/;
|
|
36
|
+
const OPAQUE_ID_RE = /^[a-z0-9](?:[a-z0-9._:-]{6,126}[a-z0-9])$/;
|
|
37
|
+
const VERSION_ID_RE = /^pv_[a-z0-9](?:[a-z0-9._:-]{4,122}[a-z0-9])$/;
|
|
38
|
+
const CANONICAL_INSTANT_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
39
|
+
|
|
40
|
+
export interface PublicationVersionV1 {
|
|
41
|
+
schemaVersion: typeof PUBLICATION_VERSION_SCHEMA_VERSION;
|
|
42
|
+
/** Opaque immutable version identity; tenant+space are part of the address. */
|
|
43
|
+
versionId: string;
|
|
44
|
+
tenant: string;
|
|
45
|
+
spaceId: string;
|
|
46
|
+
source: {
|
|
47
|
+
forge: PublicationSpaceForgeV1;
|
|
48
|
+
repo: string;
|
|
49
|
+
commitSha: string;
|
|
50
|
+
};
|
|
51
|
+
renderer: {
|
|
52
|
+
contractVersion: string;
|
|
53
|
+
themeContractVersion: string;
|
|
54
|
+
};
|
|
55
|
+
/** Lowercase sha256 hex of the immutable materialized artifact set. */
|
|
56
|
+
artifactDigest: string;
|
|
57
|
+
createdAt: string;
|
|
58
|
+
/** Closed, non-descriptive build metadata; arbitrary user metadata is forbidden. */
|
|
59
|
+
metadata: {
|
|
60
|
+
buildId: string;
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface PublicationChannelPointerV1 {
|
|
65
|
+
schemaVersion: typeof PUBLICATION_POINTER_SCHEMA_VERSION;
|
|
66
|
+
tenant: string;
|
|
67
|
+
spaceId: string;
|
|
68
|
+
channel: PublicationChannelV1;
|
|
69
|
+
revision: number;
|
|
70
|
+
versionId: string;
|
|
71
|
+
sourceSha: string;
|
|
72
|
+
artifactDigest: string;
|
|
73
|
+
updatedAt: string;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export interface PublicationPointerSnapshotV1 {
|
|
77
|
+
revision: number;
|
|
78
|
+
versionId: string;
|
|
79
|
+
sourceSha: string;
|
|
80
|
+
artifactDigest: string;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface PublicationPointerTransitionV1 {
|
|
84
|
+
schemaVersion: typeof PUBLICATION_POINTER_TRANSITION_SCHEMA_VERSION;
|
|
85
|
+
transitionId: string;
|
|
86
|
+
operation: PublicationTransitionOperationV1;
|
|
87
|
+
tenant: string;
|
|
88
|
+
spaceId: string;
|
|
89
|
+
channel: PublicationChannelV1;
|
|
90
|
+
previous: PublicationPointerSnapshotV1 | null;
|
|
91
|
+
next: PublicationPointerSnapshotV1;
|
|
92
|
+
principal: string;
|
|
93
|
+
occurredAt: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface PublicationTransitionRequestV1 {
|
|
97
|
+
schemaVersion: typeof PUBLICATION_TRANSITION_REQUEST_SCHEMA_VERSION;
|
|
98
|
+
transitionId: string;
|
|
99
|
+
operation: PublicationTransitionOperationV1;
|
|
100
|
+
tenant: string;
|
|
101
|
+
spaceId: string;
|
|
102
|
+
channel: PublicationChannelV1;
|
|
103
|
+
targetVersionId: string;
|
|
104
|
+
expectedSourceSha: string;
|
|
105
|
+
expectedArtifactDigest: string;
|
|
106
|
+
expectedRevision: number;
|
|
107
|
+
principal: string;
|
|
108
|
+
occurredAt: string;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface PublicationVersionStateV1 {
|
|
112
|
+
versions: PublicationVersionV1[];
|
|
113
|
+
pointers: PublicationChannelPointerV1[];
|
|
114
|
+
transitions: PublicationPointerTransitionV1[];
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function rejectUnknownKeys(
|
|
118
|
+
obj: Record<string, unknown>,
|
|
119
|
+
allowed: readonly string[],
|
|
120
|
+
v: Violations,
|
|
121
|
+
path: string,
|
|
122
|
+
): void {
|
|
123
|
+
const allowedKeys = new Set([...allowed, ...ANNOTATION_KEYS]);
|
|
124
|
+
if (Object.keys(obj).some((key) => !allowedKeys.has(key))) {
|
|
125
|
+
v.add(path || "(root)", "unknown_field", "Unexpected field in publication version contract.");
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function validateRefFields(
|
|
130
|
+
tenant: string | undefined,
|
|
131
|
+
spaceId: string | undefined,
|
|
132
|
+
v: Violations,
|
|
133
|
+
): void {
|
|
134
|
+
if (!tenant || !spaceId) return;
|
|
135
|
+
const result = validatePublicationSpaceRefV1({ version: 1, tenant, spaceId });
|
|
136
|
+
if (!result.ok) {
|
|
137
|
+
for (const item of result.violations) v.add(item.path, item.code, item.message);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function validateSha(value: string | undefined, v: Violations, path: string, length: 40 | 64): string | undefined {
|
|
142
|
+
const matches = length === 40 ? SHA40_RE.test(value ?? "") : SHA256_RE.test(value ?? "");
|
|
143
|
+
if (value !== undefined && !matches) {
|
|
144
|
+
v.add(path, length === 40 ? "publication_source_sha" : "publication_artifact_digest", "Expected a lowercase hexadecimal digest of the required length.");
|
|
145
|
+
return undefined;
|
|
146
|
+
}
|
|
147
|
+
return value;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function validateOpaqueId(value: string | undefined, v: Violations, path: string): string | undefined {
|
|
151
|
+
if (value !== undefined && !OPAQUE_ID_RE.test(value)) {
|
|
152
|
+
v.add(path, "publication_opaque_id", "Expected a bounded opaque identifier.");
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
return value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function validateVersionId(value: string | undefined, v: Violations, path: string): string | undefined {
|
|
159
|
+
if (value !== undefined && !VERSION_ID_RE.test(value)) {
|
|
160
|
+
v.add(path, "publication_version_id", "Expected a bounded publication-version identifier.");
|
|
161
|
+
return undefined;
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function fieldPath(path: string, key: string): string {
|
|
167
|
+
return path ? `${path}.${key}` : key;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function validateInstant(value: string | undefined, v: Violations, path: string): string | undefined {
|
|
171
|
+
if (value === undefined) return undefined;
|
|
172
|
+
const timestamp = Date.parse(value);
|
|
173
|
+
if (!CANONICAL_INSTANT_RE.test(value) || !Number.isFinite(timestamp) || new Date(timestamp).toISOString() !== value) {
|
|
174
|
+
v.add(path, "publication_timestamp", "Expected a canonical UTC ISO-8601 instant.");
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
return value;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function validatePositiveRevision(value: number | undefined, v: Violations, path: string): number | undefined {
|
|
181
|
+
if (value !== undefined && value < 1) {
|
|
182
|
+
v.add(path, "publication_pointer_revision", "Pointer revision must be a positive integer.");
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
return value;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Validate and normalize a closed immutable publication version. */
|
|
189
|
+
export function validatePublicationVersionV1(raw: unknown): ContractResult<PublicationVersionV1> {
|
|
190
|
+
const v = new Violations();
|
|
191
|
+
const root = reqObject(raw, v, "");
|
|
192
|
+
if (!root) return { ok: false, violations: v.items };
|
|
193
|
+
rejectUnknownKeys(root, ["schemaVersion", "versionId", "tenant", "spaceId", "source", "renderer", "artifactDigest", "createdAt", "metadata"], v, "");
|
|
194
|
+
reqLiteral(root, "schemaVersion", PUBLICATION_VERSION_SCHEMA_VERSION, v, "");
|
|
195
|
+
const versionId = validateVersionId(reqString(root, "versionId", v, ""), v, "versionId");
|
|
196
|
+
const tenant = reqString(root, "tenant", v, "");
|
|
197
|
+
const spaceId = reqString(root, "spaceId", v, "");
|
|
198
|
+
validateRefFields(tenant, spaceId, v);
|
|
199
|
+
|
|
200
|
+
const sourceRaw = reqObject(root["source"], v, "source");
|
|
201
|
+
let source: PublicationVersionV1["source"] | undefined;
|
|
202
|
+
if (sourceRaw) {
|
|
203
|
+
rejectUnknownKeys(sourceRaw, ["forge", "repo", "commitSha"], v, "source");
|
|
204
|
+
const forge = reqEnum<PublicationSpaceForgeV1>(sourceRaw, "forge", ["gitea", "github", "fixture"], v, "source");
|
|
205
|
+
const repo = reqString(sourceRaw, "repo", v, "source");
|
|
206
|
+
const commitSha = validateSha(reqString(sourceRaw, "commitSha", v, "source"), v, "source.commitSha", 40);
|
|
207
|
+
if (repo && (!REPO_RE.test(repo) || repo.includes("..") || repo.includes("//"))) {
|
|
208
|
+
v.add("source.repo", "publication_source_repo", "Source repo must be a stable repository name or owner/name.");
|
|
209
|
+
}
|
|
210
|
+
if (forge && repo && commitSha) source = { forge, repo, commitSha };
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const rendererRaw = reqObject(root["renderer"], v, "renderer");
|
|
214
|
+
let renderer: PublicationVersionV1["renderer"] | undefined;
|
|
215
|
+
if (rendererRaw) {
|
|
216
|
+
rejectUnknownKeys(rendererRaw, ["contractVersion", "themeContractVersion"], v, "renderer");
|
|
217
|
+
const contractVersion = reqString(rendererRaw, "contractVersion", v, "renderer");
|
|
218
|
+
const themeContractVersion = reqString(rendererRaw, "themeContractVersion", v, "renderer");
|
|
219
|
+
if (contractVersion && themeContractVersion) renderer = { contractVersion, themeContractVersion };
|
|
220
|
+
}
|
|
221
|
+
const artifactDigest = validateSha(reqString(root, "artifactDigest", v, ""), v, "artifactDigest", 64);
|
|
222
|
+
const createdAt = validateInstant(reqString(root, "createdAt", v, ""), v, "createdAt");
|
|
223
|
+
|
|
224
|
+
const metadataRaw = reqObject(root["metadata"], v, "metadata");
|
|
225
|
+
let metadata: PublicationVersionV1["metadata"] | undefined;
|
|
226
|
+
if (metadataRaw) {
|
|
227
|
+
rejectUnknownKeys(metadataRaw, ["buildId"], v, "metadata");
|
|
228
|
+
const buildId = validateOpaqueId(reqString(metadataRaw, "buildId", v, "metadata"), v, "metadata.buildId");
|
|
229
|
+
if (buildId) metadata = { buildId };
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!v.ok || !versionId || !tenant || !spaceId || !source || !renderer || !artifactDigest || !createdAt || !metadata) {
|
|
233
|
+
return { ok: false, violations: v.items };
|
|
234
|
+
}
|
|
235
|
+
return {
|
|
236
|
+
ok: true,
|
|
237
|
+
value: {
|
|
238
|
+
schemaVersion: PUBLICATION_VERSION_SCHEMA_VERSION,
|
|
239
|
+
versionId,
|
|
240
|
+
tenant,
|
|
241
|
+
spaceId,
|
|
242
|
+
source,
|
|
243
|
+
renderer,
|
|
244
|
+
artifactDigest,
|
|
245
|
+
createdAt,
|
|
246
|
+
metadata,
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function validateSnapshot(
|
|
252
|
+
raw: unknown,
|
|
253
|
+
v: Violations,
|
|
254
|
+
path: string,
|
|
255
|
+
closed = true,
|
|
256
|
+
): PublicationPointerSnapshotV1 | undefined {
|
|
257
|
+
const snapshot = reqObject(raw, v, path);
|
|
258
|
+
if (!snapshot) return undefined;
|
|
259
|
+
if (closed) rejectUnknownKeys(snapshot, ["revision", "versionId", "sourceSha", "artifactDigest"], v, path);
|
|
260
|
+
const revision = validatePositiveRevision(reqInt(snapshot, "revision", v, path), v, fieldPath(path, "revision"));
|
|
261
|
+
const versionId = validateVersionId(reqString(snapshot, "versionId", v, path), v, fieldPath(path, "versionId"));
|
|
262
|
+
const sourceSha = validateSha(reqString(snapshot, "sourceSha", v, path), v, fieldPath(path, "sourceSha"), 40);
|
|
263
|
+
const artifactDigest = validateSha(reqString(snapshot, "artifactDigest", v, path), v, fieldPath(path, "artifactDigest"), 64);
|
|
264
|
+
return revision && versionId && sourceSha && artifactDigest
|
|
265
|
+
? { revision, versionId, sourceSha, artifactDigest }
|
|
266
|
+
: undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Validate a closed tenant+space+channel pointer record. */
|
|
270
|
+
export function validatePublicationChannelPointerV1(raw: unknown): ContractResult<PublicationChannelPointerV1> {
|
|
271
|
+
const v = new Violations();
|
|
272
|
+
const root = reqObject(raw, v, "");
|
|
273
|
+
if (!root) return { ok: false, violations: v.items };
|
|
274
|
+
rejectUnknownKeys(root, ["schemaVersion", "tenant", "spaceId", "channel", "revision", "versionId", "sourceSha", "artifactDigest", "updatedAt"], v, "");
|
|
275
|
+
reqLiteral(root, "schemaVersion", PUBLICATION_POINTER_SCHEMA_VERSION, v, "");
|
|
276
|
+
const tenant = reqString(root, "tenant", v, "");
|
|
277
|
+
const spaceId = reqString(root, "spaceId", v, "");
|
|
278
|
+
validateRefFields(tenant, spaceId, v);
|
|
279
|
+
const channel = reqEnum<PublicationChannelV1>(root, "channel", PUBLICATION_CHANNELS, v, "");
|
|
280
|
+
const snapshot = validateSnapshot(root, v, "", false);
|
|
281
|
+
const updatedAt = validateInstant(reqString(root, "updatedAt", v, ""), v, "updatedAt");
|
|
282
|
+
if (!v.ok || !tenant || !spaceId || !channel || !snapshot || !updatedAt) return { ok: false, violations: v.items };
|
|
283
|
+
return { ok: true, value: { schemaVersion: PUBLICATION_POINTER_SCHEMA_VERSION, tenant, spaceId, channel, ...snapshot, updatedAt } };
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Validate one immutable audit record for an atomic pointer transition. */
|
|
287
|
+
export function validatePublicationPointerTransitionV1(raw: unknown): ContractResult<PublicationPointerTransitionV1> {
|
|
288
|
+
const v = new Violations();
|
|
289
|
+
const root = reqObject(raw, v, "");
|
|
290
|
+
if (!root) return { ok: false, violations: v.items };
|
|
291
|
+
rejectUnknownKeys(root, ["schemaVersion", "transitionId", "operation", "tenant", "spaceId", "channel", "previous", "next", "principal", "occurredAt"], v, "");
|
|
292
|
+
reqLiteral(root, "schemaVersion", PUBLICATION_POINTER_TRANSITION_SCHEMA_VERSION, v, "");
|
|
293
|
+
const transitionId = validateOpaqueId(reqString(root, "transitionId", v, ""), v, "transitionId");
|
|
294
|
+
const operation = reqEnum<PublicationTransitionOperationV1>(root, "operation", ["activate", "rollback"], v, "");
|
|
295
|
+
const tenant = reqString(root, "tenant", v, "");
|
|
296
|
+
const spaceId = reqString(root, "spaceId", v, "");
|
|
297
|
+
validateRefFields(tenant, spaceId, v);
|
|
298
|
+
const channel = reqEnum<PublicationChannelV1>(root, "channel", PUBLICATION_CHANNELS, v, "");
|
|
299
|
+
const previous = root["previous"] === null ? null : validateSnapshot(root["previous"], v, "previous");
|
|
300
|
+
const next = validateSnapshot(root["next"], v, "next");
|
|
301
|
+
const principal = validateOpaqueId(reqString(root, "principal", v, ""), v, "principal");
|
|
302
|
+
const occurredAt = validateInstant(reqString(root, "occurredAt", v, ""), v, "occurredAt");
|
|
303
|
+
if (operation === "rollback" && previous === null) {
|
|
304
|
+
v.add("previous", "publication_rollback_without_pointer", "Rollback requires an existing channel pointer.");
|
|
305
|
+
}
|
|
306
|
+
if (!v.ok || !transitionId || !operation || !tenant || !spaceId || !channel || previous === undefined || !next || !principal || !occurredAt) {
|
|
307
|
+
return { ok: false, violations: v.items };
|
|
308
|
+
}
|
|
309
|
+
return {
|
|
310
|
+
ok: true,
|
|
311
|
+
value: {
|
|
312
|
+
schemaVersion: PUBLICATION_POINTER_TRANSITION_SCHEMA_VERSION,
|
|
313
|
+
transitionId,
|
|
314
|
+
operation,
|
|
315
|
+
tenant,
|
|
316
|
+
spaceId,
|
|
317
|
+
channel,
|
|
318
|
+
previous,
|
|
319
|
+
next,
|
|
320
|
+
principal,
|
|
321
|
+
occurredAt,
|
|
322
|
+
},
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** Runtime validator for activation/rollback commands at the persistence boundary. */
|
|
327
|
+
export function validatePublicationTransitionRequestV1(raw: unknown): ContractResult<PublicationTransitionRequestV1> {
|
|
328
|
+
const v = new Violations();
|
|
329
|
+
const root = reqObject(raw, v, "");
|
|
330
|
+
if (!root) return { ok: false, violations: v.items };
|
|
331
|
+
rejectUnknownKeys(root, ["schemaVersion", "transitionId", "operation", "tenant", "spaceId", "channel", "targetVersionId", "expectedSourceSha", "expectedArtifactDigest", "expectedRevision", "principal", "occurredAt"], v, "");
|
|
332
|
+
reqLiteral(root, "schemaVersion", PUBLICATION_TRANSITION_REQUEST_SCHEMA_VERSION, v, "");
|
|
333
|
+
const transitionId = validateOpaqueId(reqString(root, "transitionId", v, ""), v, "transitionId");
|
|
334
|
+
const operation = reqEnum<PublicationTransitionOperationV1>(root, "operation", ["activate", "rollback"], v, "");
|
|
335
|
+
const tenant = reqString(root, "tenant", v, "");
|
|
336
|
+
const spaceId = reqString(root, "spaceId", v, "");
|
|
337
|
+
validateRefFields(tenant, spaceId, v);
|
|
338
|
+
const channel = reqEnum<PublicationChannelV1>(root, "channel", PUBLICATION_CHANNELS, v, "");
|
|
339
|
+
const targetVersionId = validateVersionId(reqString(root, "targetVersionId", v, ""), v, "targetVersionId");
|
|
340
|
+
const expectedSourceSha = validateSha(reqString(root, "expectedSourceSha", v, ""), v, "expectedSourceSha", 40);
|
|
341
|
+
const expectedArtifactDigest = validateSha(reqString(root, "expectedArtifactDigest", v, ""), v, "expectedArtifactDigest", 64);
|
|
342
|
+
const expectedRevision = reqInt(root, "expectedRevision", v, "");
|
|
343
|
+
if (expectedRevision !== undefined && expectedRevision < 0) {
|
|
344
|
+
v.add("expectedRevision", "publication_pointer_revision", "Expected pointer revision must be non-negative.");
|
|
345
|
+
}
|
|
346
|
+
const principal = validateOpaqueId(reqString(root, "principal", v, ""), v, "principal");
|
|
347
|
+
const occurredAt = validateInstant(reqString(root, "occurredAt", v, ""), v, "occurredAt");
|
|
348
|
+
if (!v.ok || !transitionId || !operation || !tenant || !spaceId || !channel || !targetVersionId || !expectedSourceSha || !expectedArtifactDigest || expectedRevision === undefined || !principal || !occurredAt) {
|
|
349
|
+
return { ok: false, violations: v.items };
|
|
350
|
+
}
|
|
351
|
+
return { ok: true, value: { schemaVersion: 1, transitionId, operation, tenant, spaceId, channel, targetVersionId, expectedSourceSha, expectedArtifactDigest, expectedRevision, principal, occurredAt } };
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function parseJson<T>(json: string, validate: (raw: unknown) => ContractResult<T>): ContractResult<T> {
|
|
355
|
+
try {
|
|
356
|
+
return validate(JSON.parse(json));
|
|
357
|
+
} catch {
|
|
358
|
+
return { ok: false, violations: [{ path: "(root)", code: "publication_invalid_json", message: "Publication version contract is not valid JSON." }] };
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function serializeValidated<T>(value: T, validate: (raw: unknown) => ContractResult<T>, label: string): string {
|
|
363
|
+
const result = validate(value);
|
|
364
|
+
if (!result.ok) throw new Error(`Invalid ${label}: ${result.violations.map((item) => item.code).join(", ")}`);
|
|
365
|
+
return `${JSON.stringify(result.value, null, 2)}\n`;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
export function parsePublicationVersionV1Json(json: string): ContractResult<PublicationVersionV1> {
|
|
369
|
+
return parseJson(json, validatePublicationVersionV1);
|
|
370
|
+
}
|
|
371
|
+
export function serializePublicationVersionV1(value: PublicationVersionV1): string {
|
|
372
|
+
return serializeValidated(value, validatePublicationVersionV1, "PublicationVersionV1");
|
|
373
|
+
}
|
|
374
|
+
export function parsePublicationChannelPointerV1Json(json: string): ContractResult<PublicationChannelPointerV1> {
|
|
375
|
+
return parseJson(json, validatePublicationChannelPointerV1);
|
|
376
|
+
}
|
|
377
|
+
export function serializePublicationChannelPointerV1(value: PublicationChannelPointerV1): string {
|
|
378
|
+
return serializeValidated(value, validatePublicationChannelPointerV1, "PublicationChannelPointerV1");
|
|
379
|
+
}
|
|
380
|
+
export function parsePublicationPointerTransitionV1Json(json: string): ContractResult<PublicationPointerTransitionV1> {
|
|
381
|
+
return parseJson(json, validatePublicationPointerTransitionV1);
|
|
382
|
+
}
|
|
383
|
+
export function serializePublicationPointerTransitionV1(value: PublicationPointerTransitionV1): string {
|
|
384
|
+
return serializeValidated(value, validatePublicationPointerTransitionV1, "PublicationPointerTransitionV1");
|
|
385
|
+
}
|
|
386
|
+
export function parsePublicationTransitionRequestV1Json(json: string): ContractResult<PublicationTransitionRequestV1> {
|
|
387
|
+
return parseJson(json, validatePublicationTransitionRequestV1);
|
|
388
|
+
}
|
|
389
|
+
export function serializePublicationTransitionRequestV1(value: PublicationTransitionRequestV1): string {
|
|
390
|
+
return serializeValidated(value, validatePublicationTransitionRequestV1, "PublicationTransitionRequestV1");
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
export function publicationVersionKey(tenant: string, spaceId: string, versionId: string): string {
|
|
394
|
+
return `cust:publication-version:${tenant}:${spaceId}:${versionId}`;
|
|
395
|
+
}
|
|
396
|
+
export function publicationPointerKey(tenant: string, spaceId: string, channel: PublicationChannelV1): string {
|
|
397
|
+
return `cust:publication-pointer:${tenant}:${spaceId}:${channel}`;
|
|
398
|
+
}
|
|
399
|
+
export function publicationTransitionKey(tenant: string, spaceId: string, transitionId: string): string {
|
|
400
|
+
return `cust:publication-transition:${tenant}:${spaceId}:${transitionId}`;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
export function emptyPublicationVersionStateV1(): PublicationVersionStateV1 {
|
|
404
|
+
return { versions: [], pointers: [], transitions: [] };
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function versionKey(value: Pick<PublicationVersionV1, "tenant" | "spaceId" | "versionId">): string {
|
|
408
|
+
return `${value.tenant}\u0000${value.spaceId}\u0000${value.versionId}`;
|
|
409
|
+
}
|
|
410
|
+
function pointerKey(value: Pick<PublicationChannelPointerV1, "tenant" | "spaceId" | "channel">): string {
|
|
411
|
+
return `${value.tenant}\u0000${value.spaceId}\u0000${value.channel}`;
|
|
412
|
+
}
|
|
413
|
+
function transitionKey(value: Pick<PublicationPointerTransitionV1, "tenant" | "spaceId" | "transitionId">): string {
|
|
414
|
+
return `${value.tenant}\u0000${value.spaceId}\u0000${value.transitionId}`;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function compareVersion(left: PublicationVersionV1, right: PublicationVersionV1): number {
|
|
418
|
+
return left.tenant.localeCompare(right.tenant) || left.spaceId.localeCompare(right.spaceId) || left.versionId.localeCompare(right.versionId);
|
|
419
|
+
}
|
|
420
|
+
function comparePointer(left: PublicationChannelPointerV1, right: PublicationChannelPointerV1): number {
|
|
421
|
+
return left.tenant.localeCompare(right.tenant) || left.spaceId.localeCompare(right.spaceId) || left.channel.localeCompare(right.channel);
|
|
422
|
+
}
|
|
423
|
+
function compareTransition(left: PublicationPointerTransitionV1, right: PublicationPointerTransitionV1): number {
|
|
424
|
+
return left.tenant.localeCompare(right.tenant) || left.spaceId.localeCompare(right.spaceId) || left.occurredAt.localeCompare(right.occurredAt) || left.transitionId.localeCompare(right.transitionId);
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
function snapshotsEqual(
|
|
428
|
+
left: PublicationPointerSnapshotV1,
|
|
429
|
+
right: PublicationPointerSnapshotV1,
|
|
430
|
+
): boolean {
|
|
431
|
+
return left.revision === right.revision && left.versionId === right.versionId &&
|
|
432
|
+
left.sourceSha === right.sourceSha && left.artifactDigest === right.artifactDigest;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function snapshot(pointer: PublicationChannelPointerV1): PublicationPointerSnapshotV1 {
|
|
436
|
+
return {
|
|
437
|
+
revision: pointer.revision,
|
|
438
|
+
versionId: pointer.versionId,
|
|
439
|
+
sourceSha: pointer.sourceSha,
|
|
440
|
+
artifactDigest: pointer.artifactDigest,
|
|
441
|
+
};
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** Validate duplicate-free state and return deterministic collection ordering. */
|
|
445
|
+
export function validatePublicationVersionStateV1(raw: PublicationVersionStateV1): ContractResult<PublicationVersionStateV1> {
|
|
446
|
+
if (!raw || !Array.isArray(raw.versions) || !Array.isArray(raw.pointers) || !Array.isArray(raw.transitions)) {
|
|
447
|
+
return { ok: false, violations: [{ path: "state", code: "type", message: "Publication version state must contain record arrays." }] };
|
|
448
|
+
}
|
|
449
|
+
const violations: ContractViolation[] = [];
|
|
450
|
+
const versions: PublicationVersionV1[] = [];
|
|
451
|
+
const pointers: PublicationChannelPointerV1[] = [];
|
|
452
|
+
const transitions: PublicationPointerTransitionV1[] = [];
|
|
453
|
+
const collect = <T>(items: readonly unknown[], validate: (item: unknown) => ContractResult<T>, into: T[], path: string): void => {
|
|
454
|
+
items.forEach((item, index) => {
|
|
455
|
+
const result = validate(item);
|
|
456
|
+
if (result.ok) into.push(result.value);
|
|
457
|
+
else for (const violation of result.violations) violations.push({ ...violation, path: `${path}[${index}].${violation.path}` });
|
|
458
|
+
});
|
|
459
|
+
};
|
|
460
|
+
collect(raw.versions, validatePublicationVersionV1, versions, "versions");
|
|
461
|
+
collect(raw.pointers, validatePublicationChannelPointerV1, pointers, "pointers");
|
|
462
|
+
collect(raw.transitions, validatePublicationPointerTransitionV1, transitions, "transitions");
|
|
463
|
+
const duplicates = <T>(items: readonly T[], key: (item: T) => string, path: string): void => {
|
|
464
|
+
const seen = new Set<string>();
|
|
465
|
+
items.forEach((item, index) => {
|
|
466
|
+
const itemKey = key(item);
|
|
467
|
+
if (seen.has(itemKey)) violations.push({ path: `${path}[${index}]`, code: "duplicate", message: "Duplicate publication version state record." });
|
|
468
|
+
seen.add(itemKey);
|
|
469
|
+
});
|
|
470
|
+
};
|
|
471
|
+
duplicates(versions, versionKey, "versions");
|
|
472
|
+
duplicates(pointers, pointerKey, "pointers");
|
|
473
|
+
duplicates(transitions, transitionKey, "transitions");
|
|
474
|
+
|
|
475
|
+
const byVersion = new Map(versions.map((version) => [versionKey(version), version]));
|
|
476
|
+
const snapshotHasVersion = (
|
|
477
|
+
tenant: string,
|
|
478
|
+
spaceId: string,
|
|
479
|
+
value: PublicationPointerSnapshotV1,
|
|
480
|
+
): boolean => {
|
|
481
|
+
const version = byVersion.get(`${tenant}\u0000${spaceId}\u0000${value.versionId}`);
|
|
482
|
+
return !!version && version.source.commitSha === value.sourceSha &&
|
|
483
|
+
version.artifactDigest === value.artifactDigest;
|
|
484
|
+
};
|
|
485
|
+
pointers.forEach((pointer, index) => {
|
|
486
|
+
if (!snapshotHasVersion(pointer.tenant, pointer.spaceId, snapshot(pointer))) {
|
|
487
|
+
violations.push({
|
|
488
|
+
path: `pointers[${index}]`,
|
|
489
|
+
code: "publication_pointer_version_mismatch",
|
|
490
|
+
message: "Publication pointer does not resolve to matching immutable version provenance.",
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
});
|
|
494
|
+
transitions.forEach((transition, index) => {
|
|
495
|
+
if (!snapshotHasVersion(transition.tenant, transition.spaceId, transition.next) ||
|
|
496
|
+
(transition.previous !== null && !snapshotHasVersion(transition.tenant, transition.spaceId, transition.previous))) {
|
|
497
|
+
violations.push({
|
|
498
|
+
path: `transitions[${index}]`,
|
|
499
|
+
code: "publication_transition_version_mismatch",
|
|
500
|
+
message: "Publication transition does not resolve to matching immutable version provenance.",
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
|
|
505
|
+
const pointerByChannel = new Map(pointers.map((pointer) => [pointerKey(pointer), pointer]));
|
|
506
|
+
const historyByChannel = new Map<string, PublicationPointerTransitionV1[]>();
|
|
507
|
+
for (const transition of transitions) {
|
|
508
|
+
const key = pointerKey(transition);
|
|
509
|
+
const history = historyByChannel.get(key) ?? [];
|
|
510
|
+
history.push(transition);
|
|
511
|
+
historyByChannel.set(key, history);
|
|
512
|
+
}
|
|
513
|
+
for (const [key, history] of historyByChannel) {
|
|
514
|
+
history.sort((left, right) => left.next.revision - right.next.revision || left.transitionId.localeCompare(right.transitionId));
|
|
515
|
+
let prior: PublicationPointerTransitionV1 | undefined;
|
|
516
|
+
const activatedVersions = new Set<string>();
|
|
517
|
+
for (const transition of history) {
|
|
518
|
+
const advancesOnce = transition.next.revision === (transition.previous?.revision ?? 0) + 1;
|
|
519
|
+
const chainStarts = !prior && transition.previous === null && advancesOnce;
|
|
520
|
+
const chainContinues = !!prior && transition.previous !== null &&
|
|
521
|
+
advancesOnce && snapshotsEqual(transition.previous, prior.next) &&
|
|
522
|
+
transition.occurredAt >= prior.occurredAt;
|
|
523
|
+
if (!chainStarts && !chainContinues) {
|
|
524
|
+
violations.push({
|
|
525
|
+
path: "transitions",
|
|
526
|
+
code: "publication_transition_chain",
|
|
527
|
+
message: "Publication transition history is not a contiguous monotonic pointer chain.",
|
|
528
|
+
});
|
|
529
|
+
break;
|
|
530
|
+
}
|
|
531
|
+
if (transition.operation === "rollback" &&
|
|
532
|
+
(!activatedVersions.has(transition.next.versionId) ||
|
|
533
|
+
transition.previous?.versionId === transition.next.versionId)) {
|
|
534
|
+
violations.push({
|
|
535
|
+
path: "transitions",
|
|
536
|
+
code: "publication_rollback_target_not_prior",
|
|
537
|
+
message: "Publication rollback target is not a prior activation in this pointer chain.",
|
|
538
|
+
});
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
if (transition.operation === "activate") activatedVersions.add(transition.next.versionId);
|
|
542
|
+
prior = transition;
|
|
543
|
+
}
|
|
544
|
+
const pointer = pointerByChannel.get(key);
|
|
545
|
+
if (!pointer || !prior || !snapshotsEqual(snapshot(pointer), prior.next) || pointer.updatedAt !== prior.occurredAt) {
|
|
546
|
+
violations.push({
|
|
547
|
+
path: "pointers",
|
|
548
|
+
code: "publication_pointer_history_mismatch",
|
|
549
|
+
message: "Publication pointer does not equal the terminal audited transition.",
|
|
550
|
+
});
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
for (const pointer of pointers) {
|
|
554
|
+
if (!historyByChannel.has(pointerKey(pointer))) {
|
|
555
|
+
violations.push({
|
|
556
|
+
path: "pointers",
|
|
557
|
+
code: "publication_pointer_history_missing",
|
|
558
|
+
message: "Publication pointer has no audited transition history.",
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
if (violations.length > 0) return { ok: false, violations };
|
|
563
|
+
return {
|
|
564
|
+
ok: true,
|
|
565
|
+
value: {
|
|
566
|
+
versions: versions.sort(compareVersion),
|
|
567
|
+
pointers: pointers.sort(comparePointer),
|
|
568
|
+
transitions: transitions.sort(compareTransition),
|
|
569
|
+
},
|
|
570
|
+
};
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
/** Resolve one validated channel pointer and its exact immutable version. */
|
|
574
|
+
export function resolvePublicationChannelVersionV1(
|
|
575
|
+
stateRaw: PublicationVersionStateV1,
|
|
576
|
+
tenant: string,
|
|
577
|
+
spaceId: string,
|
|
578
|
+
channel: PublicationChannelV1,
|
|
579
|
+
): { pointer: PublicationChannelPointerV1; version: PublicationVersionV1 } | null {
|
|
580
|
+
const state = validatePublicationVersionStateV1(stateRaw);
|
|
581
|
+
if (!state.ok) return null;
|
|
582
|
+
const pointer = state.value.pointers.find((item) => item.tenant === tenant && item.spaceId === spaceId && item.channel === channel);
|
|
583
|
+
if (!pointer) return null;
|
|
584
|
+
const version = state.value.versions.find((item) => item.tenant === tenant && item.spaceId === spaceId && item.versionId === pointer.versionId);
|
|
585
|
+
if (!version || version.source.commitSha !== pointer.sourceSha || version.artifactDigest !== pointer.artifactDigest) return null;
|
|
586
|
+
return { pointer, version };
|
|
587
|
+
}
|