@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,495 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixture-only WS3 clone-read preflight.
|
|
3
|
+
*
|
|
4
|
+
* This is a pure conformance boundary over caller-supplied synthetic metadata.
|
|
5
|
+
* It intentionally has no imports and no execution seam: it cannot resolve,
|
|
6
|
+
* create, update, reconcile, or otherwise operate a clone. WS3 remains the owner
|
|
7
|
+
* of clone execution and public contract publication.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const WS3_CLONE_READ_PREFLIGHT_SCHEMA = "ws3-clone-read-preflight.v1" as const;
|
|
11
|
+
|
|
12
|
+
const READ_CAPABILITIES = ["read:metadata", "read:manifest", "read:invariants"] as const;
|
|
13
|
+
type ReadCapability = (typeof READ_CAPABILITIES)[number];
|
|
14
|
+
|
|
15
|
+
export interface SyntheticCloneReadMetadata {
|
|
16
|
+
schemaVersion: typeof WS3_CLONE_READ_PREFLIGHT_SCHEMA;
|
|
17
|
+
fixtureId: string;
|
|
18
|
+
synthetic: true;
|
|
19
|
+
tenant: {
|
|
20
|
+
tenantId: string;
|
|
21
|
+
tenantTier: "regulated-commerce";
|
|
22
|
+
};
|
|
23
|
+
clone: {
|
|
24
|
+
cloneId: string;
|
|
25
|
+
snapshotReference: string;
|
|
26
|
+
sourceRevision: string;
|
|
27
|
+
capturedAt: string;
|
|
28
|
+
};
|
|
29
|
+
requestedCapabilities: ReadCapability[];
|
|
30
|
+
readFacts: {
|
|
31
|
+
manifestPresent: boolean;
|
|
32
|
+
catalogRecordCount: number;
|
|
33
|
+
orderRecordCount: number;
|
|
34
|
+
integrationReferenceCount: number;
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export type CloneReadPreflightBlockerCode =
|
|
39
|
+
| "invalid-shape"
|
|
40
|
+
| "not-explicitly-synthetic"
|
|
41
|
+
| "live-looking-identity"
|
|
42
|
+
| "unsafe-field"
|
|
43
|
+
| "live-endpoint"
|
|
44
|
+
| "mutation-capability-refused"
|
|
45
|
+
| "unsupported-capability"
|
|
46
|
+
| "manifest-missing";
|
|
47
|
+
|
|
48
|
+
export interface CloneReadPreflightBlocker {
|
|
49
|
+
code: CloneReadPreflightBlockerCode;
|
|
50
|
+
path: string;
|
|
51
|
+
message: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface CloneReadPreflightInvariant {
|
|
55
|
+
id:
|
|
56
|
+
| "synthetic-identities-only"
|
|
57
|
+
| "read-capabilities-only"
|
|
58
|
+
| "sanitized-offline-snapshot"
|
|
59
|
+
| "manifest-observed";
|
|
60
|
+
passed: boolean;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export interface CloneReadPreflightResult {
|
|
64
|
+
schemaVersion: typeof WS3_CLONE_READ_PREFLIGHT_SCHEMA;
|
|
65
|
+
ready: boolean;
|
|
66
|
+
blockers: CloneReadPreflightBlocker[];
|
|
67
|
+
invariants: CloneReadPreflightInvariant[];
|
|
68
|
+
observed: {
|
|
69
|
+
fixtureId: string | null;
|
|
70
|
+
tenantId: string | null;
|
|
71
|
+
cloneId: string | null;
|
|
72
|
+
snapshotReference: string | null;
|
|
73
|
+
requestedCapabilities: ReadCapability[];
|
|
74
|
+
readFacts: SyntheticCloneReadMetadata["readFacts"] | null;
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const MUTATION_CAPABILITY = /(^|:)(create|update|execute|write|delete|mutate|reconcile|promote|deploy)(:|$)/i;
|
|
79
|
+
const UNSAFE_KEY = /(authorization|credential|secret|password|api[-_]?key|access[-_]?token|refresh[-_]?token|private[-_]?key|email|phone|address|(?:^|[-_])name(?:$|[-_]))/i;
|
|
80
|
+
const CREDENTIAL_VALUE = /(-----BEGIN [A-Z ]*PRIVATE KEY-----|\bBearer\s+[A-Za-z0-9._~-]+|\bAKIA[0-9A-Z]{16}\b)/;
|
|
81
|
+
const EMAIL_VALUE = /\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b/i;
|
|
82
|
+
const PHONE_VALUE = /\b(?:\+?\d{1,3}[-. ]?)?(?:\(\d{3}\)|\d{3})[-. ]\d{3}[-. ]\d{4}\b/;
|
|
83
|
+
const PAYMENT_VALUE = /(?:^|[^0-9])(?:\d{4}[- ]?){3}\d{4}(?:$|[^0-9])|(?:^|[^0-9])\d{13,19}(?:$|[^0-9])/;
|
|
84
|
+
const GOVERNMENT_ID_VALUE = /(?:^|[^0-9])(?:\d{3}-\d{2}-\d{4}|\d{2}-\d{7}|\d{9})(?:$|[^0-9])/;
|
|
85
|
+
const ADDRESS_VALUE = /\b\d{1,6}(?:[- ]+[A-Za-z][A-Za-z0-9.']*){1,5}[- ]+(?:street|st|avenue|ave|road|rd|boulevard|blvd|lane|ln|drive|dr|court|ct|way)\b/i;
|
|
86
|
+
const PERSON_NAME_VALUE = /\b(?:[A-Z][a-z]{1,30}\s+[A-Z][a-z]{1,30}|(?:jane|john)[-_ ]doe)\b/i;
|
|
87
|
+
const LIVE_ENDPOINT = /\b(?:https?|wss?):\/\//i;
|
|
88
|
+
const CODE_VALUE = /(?:<\/?script\b|javascript:|\{\{|=>|\bfunction\s*\(|\b(?:window|document)\.|\bdataLayer\b|\bGTM-[A-Z0-9-]+\b)/i;
|
|
89
|
+
const SHA = /^[a-f0-9]{40}$/;
|
|
90
|
+
const ISO_UTC = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
91
|
+
// Local fixture identities stay deliberately closed until WS3 publishes a public read contract.
|
|
92
|
+
const FIXTURE_ID = /^synthetic-ws3-regulated-clone-read-v1$/;
|
|
93
|
+
const TENANT_ID = /^synthetic-regulated-source\.test$/;
|
|
94
|
+
const CLONE_LABEL = /^synthetic-risk-read-\d{3}$/;
|
|
95
|
+
const SNAPSHOT_REFERENCE = /^fixture:\/\/synthetic-ws3-clone\/snapshot-\d{3}$/;
|
|
96
|
+
const MAX_SYNTHETIC_READ_COUNT = 999_999;
|
|
97
|
+
|
|
98
|
+
function record(value: unknown): Record<string, unknown> | null {
|
|
99
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) return null;
|
|
100
|
+
const prototype = Object.getPrototypeOf(value);
|
|
101
|
+
return prototype === Object.prototype || prototype === null
|
|
102
|
+
? (value as Record<string, unknown>)
|
|
103
|
+
: null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function ownDataValue(value: Record<string, unknown> | null, key: string): unknown {
|
|
107
|
+
if (!value) return undefined;
|
|
108
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
109
|
+
return descriptor && descriptor.enumerable && Object.hasOwn(descriptor, "value")
|
|
110
|
+
? descriptor.value
|
|
111
|
+
: undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function text(value: unknown): string | null {
|
|
115
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isSanitizedText(value: string): boolean {
|
|
119
|
+
return !(
|
|
120
|
+
CREDENTIAL_VALUE.test(value) ||
|
|
121
|
+
EMAIL_VALUE.test(value) ||
|
|
122
|
+
PHONE_VALUE.test(value) ||
|
|
123
|
+
PAYMENT_VALUE.test(value) ||
|
|
124
|
+
GOVERNMENT_ID_VALUE.test(value) ||
|
|
125
|
+
ADDRESS_VALUE.test(value) ||
|
|
126
|
+
PERSON_NAME_VALUE.test(value) ||
|
|
127
|
+
LIVE_ENDPOINT.test(value) ||
|
|
128
|
+
CODE_VALUE.test(value)
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function exactKeys(
|
|
133
|
+
value: Record<string, unknown>,
|
|
134
|
+
expected: readonly string[],
|
|
135
|
+
path: string,
|
|
136
|
+
block: (code: CloneReadPreflightBlockerCode, path: string, message: string) => void,
|
|
137
|
+
): void {
|
|
138
|
+
const allowed = new Set(expected);
|
|
139
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
140
|
+
if (typeof key !== "string" || !allowed.has(key)) {
|
|
141
|
+
block("invalid-shape", path, "contains an unexpected field");
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key);
|
|
145
|
+
if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, "value")) {
|
|
146
|
+
block("invalid-shape", `${path}.${key}`, "must be an enumerable data field");
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
for (const key of expected) {
|
|
150
|
+
if (ownDataValue(value, key) === undefined) {
|
|
151
|
+
block("invalid-shape", `${path}.${key}`, "required data field is missing");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function arrayDataValues(
|
|
157
|
+
value: unknown,
|
|
158
|
+
path: string,
|
|
159
|
+
block: (code: CloneReadPreflightBlockerCode, path: string, message: string) => void,
|
|
160
|
+
): unknown[] | null {
|
|
161
|
+
if (!Array.isArray(value)) return null;
|
|
162
|
+
const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length");
|
|
163
|
+
const length = lengthDescriptor && Object.hasOwn(lengthDescriptor, "value")
|
|
164
|
+
? lengthDescriptor.value
|
|
165
|
+
: -1;
|
|
166
|
+
if (!Number.isSafeInteger(length) || (length as number) < 0) {
|
|
167
|
+
block("invalid-shape", path, "array length is invalid");
|
|
168
|
+
return [];
|
|
169
|
+
}
|
|
170
|
+
const result: unknown[] = [];
|
|
171
|
+
for (let index = 0; index < (length as number); index += 1) {
|
|
172
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, String(index));
|
|
173
|
+
if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, "value")) {
|
|
174
|
+
block("invalid-shape", `${path}[${index}]`, "must be an enumerable data item");
|
|
175
|
+
result.push(undefined);
|
|
176
|
+
} else {
|
|
177
|
+
result.push(descriptor.value);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
181
|
+
if (key === "length") continue;
|
|
182
|
+
if (typeof key !== "string" || !/^(0|[1-9][0-9]*)$/.test(key) || Number(key) >= (length as number)) {
|
|
183
|
+
block("invalid-shape", path, "array contains unexpected metadata");
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function findUnsafeData(
|
|
190
|
+
value: unknown,
|
|
191
|
+
block: (code: CloneReadPreflightBlockerCode, path: string, message: string) => void,
|
|
192
|
+
path = "metadata",
|
|
193
|
+
): void {
|
|
194
|
+
if (typeof value === "string") {
|
|
195
|
+
if (CREDENTIAL_VALUE.test(value)) block("unsafe-field", path, "credential-shaped value is forbidden");
|
|
196
|
+
if (
|
|
197
|
+
EMAIL_VALUE.test(value) ||
|
|
198
|
+
PHONE_VALUE.test(value) ||
|
|
199
|
+
PAYMENT_VALUE.test(value) ||
|
|
200
|
+
GOVERNMENT_ID_VALUE.test(value) ||
|
|
201
|
+
ADDRESS_VALUE.test(value) ||
|
|
202
|
+
PERSON_NAME_VALUE.test(value)
|
|
203
|
+
) {
|
|
204
|
+
block("unsafe-field", path, "PII-shaped value is forbidden");
|
|
205
|
+
}
|
|
206
|
+
if (LIVE_ENDPOINT.test(value)) block("live-endpoint", path, "network endpoint values are forbidden");
|
|
207
|
+
if (CODE_VALUE.test(value)) block("unsafe-field", path, "code-bearing value is forbidden");
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (Array.isArray(value)) {
|
|
211
|
+
const entries = arrayDataValues(value, path, block) ?? [];
|
|
212
|
+
entries.forEach((entry, index) => findUnsafeData(entry, block, `${path}[${index}]`));
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const object = record(value);
|
|
216
|
+
if (!object) {
|
|
217
|
+
if (value !== null && typeof value === "object") {
|
|
218
|
+
block("invalid-shape", path, "must be a plain own-data object");
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
for (const key of Reflect.ownKeys(object)) {
|
|
223
|
+
if (typeof key !== "string") {
|
|
224
|
+
block("invalid-shape", path, "contains a non-JSON field");
|
|
225
|
+
continue;
|
|
226
|
+
}
|
|
227
|
+
const descriptor = Object.getOwnPropertyDescriptor(object, key);
|
|
228
|
+
if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, "value")) {
|
|
229
|
+
block("invalid-shape", path, "contains a non-data field");
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
if (UNSAFE_KEY.test(key)) block("unsafe-field", path, "contains a PII/secret-shaped field");
|
|
233
|
+
// Caller-controlled keys never become blocker paths or messages.
|
|
234
|
+
findUnsafeData(descriptor.value, block, path);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function syntheticReadCount(value: unknown): value is number {
|
|
239
|
+
return Number.isSafeInteger(value) && (value as number) >= 0 && (value as number) <= MAX_SYNTHETIC_READ_COUNT;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function deepFreeze<T>(value: T): T {
|
|
243
|
+
if (value !== null && typeof value === "object" && !Object.isFrozen(value)) {
|
|
244
|
+
Object.freeze(value);
|
|
245
|
+
for (const child of Object.values(value)) deepFreeze(child);
|
|
246
|
+
}
|
|
247
|
+
return value;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Assess synthetic clone-read metadata without invoking any clone or I/O seam.
|
|
252
|
+
* Invalid input is represented as blockers so callers get a complete, stable
|
|
253
|
+
* conformance report rather than a partial execution attempt.
|
|
254
|
+
*/
|
|
255
|
+
function assessSyntheticCloneRead(metadata: unknown): Readonly<CloneReadPreflightResult> {
|
|
256
|
+
const blockers: CloneReadPreflightBlocker[] = [];
|
|
257
|
+
const seen = new Set<string>();
|
|
258
|
+
const block = (code: CloneReadPreflightBlockerCode, path: string, message: string): void => {
|
|
259
|
+
const key = `${code}:${path}:${message}`;
|
|
260
|
+
if (!seen.has(key)) {
|
|
261
|
+
seen.add(key);
|
|
262
|
+
blockers.push({ code, path, message });
|
|
263
|
+
}
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
findUnsafeData(metadata, block);
|
|
267
|
+
const root = record(metadata);
|
|
268
|
+
if (!root) {
|
|
269
|
+
block("invalid-shape", "metadata", "expected an object");
|
|
270
|
+
} else {
|
|
271
|
+
exactKeys(
|
|
272
|
+
root,
|
|
273
|
+
["schemaVersion", "fixtureId", "synthetic", "tenant", "clone", "requestedCapabilities", "readFacts"],
|
|
274
|
+
"metadata",
|
|
275
|
+
block,
|
|
276
|
+
);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const schemaVersion = ownDataValue(root, "schemaVersion");
|
|
280
|
+
const synthetic = ownDataValue(root, "synthetic");
|
|
281
|
+
const fixtureIdCandidate = text(ownDataValue(root, "fixtureId"));
|
|
282
|
+
const fixtureId =
|
|
283
|
+
synthetic === true &&
|
|
284
|
+
fixtureIdCandidate &&
|
|
285
|
+
FIXTURE_ID.test(fixtureIdCandidate) &&
|
|
286
|
+
isSanitizedText(fixtureIdCandidate)
|
|
287
|
+
? fixtureIdCandidate
|
|
288
|
+
: null;
|
|
289
|
+
if (schemaVersion !== WS3_CLONE_READ_PREFLIGHT_SCHEMA) {
|
|
290
|
+
block("invalid-shape", "metadata.schemaVersion", `expected ${WS3_CLONE_READ_PREFLIGHT_SCHEMA}`);
|
|
291
|
+
}
|
|
292
|
+
if (!fixtureId) {
|
|
293
|
+
block("not-explicitly-synthetic", "metadata", "explicit synthetic metadata and a closed fixture-id format are required");
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const tenant = record(ownDataValue(root, "tenant"));
|
|
297
|
+
if (!tenant) {
|
|
298
|
+
block("invalid-shape", "metadata.tenant", "expected an object");
|
|
299
|
+
} else {
|
|
300
|
+
exactKeys(tenant, ["tenantId", "tenantTier"], "metadata.tenant", block);
|
|
301
|
+
}
|
|
302
|
+
const tenantIdCandidate = text(ownDataValue(tenant, "tenantId"));
|
|
303
|
+
const tenantId =
|
|
304
|
+
tenantIdCandidate && TENANT_ID.test(tenantIdCandidate) && isSanitizedText(tenantIdCandidate)
|
|
305
|
+
? tenantIdCandidate
|
|
306
|
+
: null;
|
|
307
|
+
if (!tenantId) {
|
|
308
|
+
block("live-looking-identity", "metadata.tenant.tenantId", "tenantId must use the closed synthetic format");
|
|
309
|
+
}
|
|
310
|
+
if (ownDataValue(tenant, "tenantTier") !== "regulated-commerce") {
|
|
311
|
+
block("invalid-shape", "metadata.tenant.tenantTier", "expected regulated-commerce");
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const clone = record(ownDataValue(root, "clone"));
|
|
315
|
+
if (!clone) {
|
|
316
|
+
block("invalid-shape", "metadata.clone", "expected an object");
|
|
317
|
+
} else {
|
|
318
|
+
exactKeys(clone, ["cloneId", "snapshotReference", "sourceRevision", "capturedAt"], "metadata.clone", block);
|
|
319
|
+
}
|
|
320
|
+
const cloneIdCandidate = text(ownDataValue(clone, "cloneId"));
|
|
321
|
+
const cloneParts = cloneIdCandidate?.split("~clone~") ?? [];
|
|
322
|
+
const cloneLabel = cloneParts.length === 2 ? cloneParts[1] : null;
|
|
323
|
+
const cloneId =
|
|
324
|
+
tenantId &&
|
|
325
|
+
cloneIdCandidate &&
|
|
326
|
+
cloneParts[0] === tenantId &&
|
|
327
|
+
cloneLabel &&
|
|
328
|
+
cloneLabel.length <= 72 &&
|
|
329
|
+
CLONE_LABEL.test(cloneLabel) &&
|
|
330
|
+
isSanitizedText(cloneIdCandidate)
|
|
331
|
+
? cloneIdCandidate
|
|
332
|
+
: null;
|
|
333
|
+
if (!cloneId) {
|
|
334
|
+
block(
|
|
335
|
+
"live-looking-identity",
|
|
336
|
+
"metadata.clone.cloneId",
|
|
337
|
+
"cloneId must belong to the synthetic tenant and use a synthetic- clone label",
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
const snapshotCandidate = text(ownDataValue(clone, "snapshotReference"));
|
|
341
|
+
const snapshotReference =
|
|
342
|
+
snapshotCandidate && SNAPSHOT_REFERENCE.test(snapshotCandidate) && isSanitizedText(snapshotCandidate)
|
|
343
|
+
? snapshotCandidate
|
|
344
|
+
: null;
|
|
345
|
+
if (!snapshotReference) {
|
|
346
|
+
block("live-endpoint", "metadata.clone.snapshotReference", "only the closed offline fixture-reference format is allowed");
|
|
347
|
+
}
|
|
348
|
+
if (!text(ownDataValue(clone, "sourceRevision"))?.match(SHA)) {
|
|
349
|
+
block("invalid-shape", "metadata.clone.sourceRevision", "expected a fixed 40-character revision");
|
|
350
|
+
}
|
|
351
|
+
const capturedAt = text(ownDataValue(clone, "capturedAt"));
|
|
352
|
+
if (!capturedAt?.match(ISO_UTC) || Number.isNaN(Date.parse(capturedAt))) {
|
|
353
|
+
block("invalid-shape", "metadata.clone.capturedAt", "expected a canonical UTC timestamp");
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const requestedCapabilityInput = ownDataValue(root, "requestedCapabilities");
|
|
357
|
+
const capabilityData = arrayDataValues(requestedCapabilityInput, "metadata.requestedCapabilities", block);
|
|
358
|
+
const capabilities = capabilityData ?? [];
|
|
359
|
+
if (!capabilityData) {
|
|
360
|
+
block("invalid-shape", "metadata.requestedCapabilities", "expected an array");
|
|
361
|
+
}
|
|
362
|
+
if (capabilities.length === 0) {
|
|
363
|
+
block("invalid-shape", "metadata.requestedCapabilities", "at least one read capability is required");
|
|
364
|
+
}
|
|
365
|
+
const requestedCapabilities: ReadCapability[] = [];
|
|
366
|
+
capabilities.forEach((capability, index) => {
|
|
367
|
+
const path = `metadata.requestedCapabilities[${index}]`;
|
|
368
|
+
if (typeof capability !== "string") {
|
|
369
|
+
block("invalid-shape", path, "expected a string");
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (MUTATION_CAPABILITY.test(capability)) {
|
|
373
|
+
block("mutation-capability-refused", path, "mutation capability is not read-only");
|
|
374
|
+
} else if (!READ_CAPABILITIES.includes(capability as ReadCapability)) {
|
|
375
|
+
block("unsupported-capability", path, "capability is outside the local fixture contract");
|
|
376
|
+
} else {
|
|
377
|
+
requestedCapabilities.push(capability as ReadCapability);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
const facts = record(ownDataValue(root, "readFacts"));
|
|
382
|
+
if (!facts) {
|
|
383
|
+
block("invalid-shape", "metadata.readFacts", "expected an object");
|
|
384
|
+
} else {
|
|
385
|
+
exactKeys(
|
|
386
|
+
facts,
|
|
387
|
+
["manifestPresent", "catalogRecordCount", "orderRecordCount", "integrationReferenceCount"],
|
|
388
|
+
"metadata.readFacts",
|
|
389
|
+
block,
|
|
390
|
+
);
|
|
391
|
+
}
|
|
392
|
+
const manifestPresent = ownDataValue(facts, "manifestPresent");
|
|
393
|
+
const catalogRecordCount = ownDataValue(facts, "catalogRecordCount");
|
|
394
|
+
const orderRecordCount = ownDataValue(facts, "orderRecordCount");
|
|
395
|
+
const integrationReferenceCount = ownDataValue(facts, "integrationReferenceCount");
|
|
396
|
+
if (typeof manifestPresent !== "boolean") {
|
|
397
|
+
block("invalid-shape", "metadata.readFacts.manifestPresent", "expected a boolean");
|
|
398
|
+
} else if (!manifestPresent) {
|
|
399
|
+
block("manifest-missing", "metadata.readFacts.manifestPresent", "read snapshot has no manifest");
|
|
400
|
+
}
|
|
401
|
+
if (!syntheticReadCount(catalogRecordCount)) {
|
|
402
|
+
block("invalid-shape", "metadata.readFacts.catalogRecordCount", "expected an integer from 0 through 999999");
|
|
403
|
+
}
|
|
404
|
+
if (!syntheticReadCount(orderRecordCount)) {
|
|
405
|
+
block("invalid-shape", "metadata.readFacts.orderRecordCount", "expected an integer from 0 through 999999");
|
|
406
|
+
}
|
|
407
|
+
if (!syntheticReadCount(integrationReferenceCount)) {
|
|
408
|
+
block("invalid-shape", "metadata.readFacts.integrationReferenceCount", "expected an integer from 0 through 999999");
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const hasBlocker = (...codes: CloneReadPreflightBlockerCode[]): boolean =>
|
|
412
|
+
blockers.some(({ code }) => codes.includes(code));
|
|
413
|
+
// A skipped accessor, sparse array item, or other malformed data surface means
|
|
414
|
+
// none of the cross-field security claims can be proven from the whole input.
|
|
415
|
+
const shapeInspectable = !hasBlocker("invalid-shape");
|
|
416
|
+
const invariants: CloneReadPreflightInvariant[] = [
|
|
417
|
+
{
|
|
418
|
+
id: "synthetic-identities-only",
|
|
419
|
+
passed: shapeInspectable && !hasBlocker("not-explicitly-synthetic", "live-looking-identity"),
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
id: "read-capabilities-only",
|
|
423
|
+
passed: shapeInspectable && !hasBlocker("mutation-capability-refused", "unsupported-capability"),
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
id: "sanitized-offline-snapshot",
|
|
427
|
+
passed: shapeInspectable && !hasBlocker("unsafe-field", "live-endpoint"),
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
id: "manifest-observed",
|
|
431
|
+
passed: shapeInspectable && !hasBlocker("manifest-missing") && manifestPresent === true,
|
|
432
|
+
},
|
|
433
|
+
];
|
|
434
|
+
|
|
435
|
+
const readFacts =
|
|
436
|
+
facts &&
|
|
437
|
+
typeof manifestPresent === "boolean" &&
|
|
438
|
+
syntheticReadCount(catalogRecordCount) &&
|
|
439
|
+
syntheticReadCount(orderRecordCount) &&
|
|
440
|
+
syntheticReadCount(integrationReferenceCount)
|
|
441
|
+
? {
|
|
442
|
+
manifestPresent,
|
|
443
|
+
catalogRecordCount,
|
|
444
|
+
orderRecordCount,
|
|
445
|
+
integrationReferenceCount,
|
|
446
|
+
}
|
|
447
|
+
: null;
|
|
448
|
+
|
|
449
|
+
return deepFreeze({
|
|
450
|
+
schemaVersion: WS3_CLONE_READ_PREFLIGHT_SCHEMA,
|
|
451
|
+
ready: blockers.length === 0 && invariants.every(({ passed }) => passed),
|
|
452
|
+
blockers,
|
|
453
|
+
invariants,
|
|
454
|
+
observed: {
|
|
455
|
+
fixtureId,
|
|
456
|
+
tenantId,
|
|
457
|
+
cloneId,
|
|
458
|
+
snapshotReference,
|
|
459
|
+
requestedCapabilities,
|
|
460
|
+
readFacts,
|
|
461
|
+
},
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
function inspectionFailure(): Readonly<CloneReadPreflightResult> {
|
|
466
|
+
return deepFreeze({
|
|
467
|
+
schemaVersion: WS3_CLONE_READ_PREFLIGHT_SCHEMA,
|
|
468
|
+
ready: false,
|
|
469
|
+
blockers: [{ code: "invalid-shape", path: "metadata", message: "input could not be safely inspected" }],
|
|
470
|
+
invariants: [
|
|
471
|
+
{ id: "synthetic-identities-only", passed: false },
|
|
472
|
+
{ id: "read-capabilities-only", passed: false },
|
|
473
|
+
{ id: "sanitized-offline-snapshot", passed: false },
|
|
474
|
+
{ id: "manifest-observed", passed: false },
|
|
475
|
+
],
|
|
476
|
+
observed: {
|
|
477
|
+
fixtureId: null,
|
|
478
|
+
tenantId: null,
|
|
479
|
+
cloneId: null,
|
|
480
|
+
snapshotReference: null,
|
|
481
|
+
requestedCapabilities: [],
|
|
482
|
+
readFacts: null,
|
|
483
|
+
},
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function preflightSyntheticCloneRead(metadata: unknown): Readonly<CloneReadPreflightResult> {
|
|
488
|
+
try {
|
|
489
|
+
return assessSyntheticCloneRead(metadata);
|
|
490
|
+
} catch {
|
|
491
|
+
// Proxy traps and other caller-controlled inspection failures are contained;
|
|
492
|
+
// their thrown text is never copied into the stable conformance result.
|
|
493
|
+
return inspectionFailure();
|
|
494
|
+
}
|
|
495
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tenant-owned analytics declaration surface (TIER 1).
|
|
3
|
+
*
|
|
4
|
+
* Today a tenant cannot declare a measurement destination from its OWN tree: the
|
|
5
|
+
* declaration lives in `tenant.integrations` (apps/storefront/src/config/tenants.ts),
|
|
6
|
+
* which is platform code a tenant may not touch (customization golden rule #1). So a
|
|
7
|
+
* merchant must file a request for what should be a self-serve, versioned, publishable
|
|
8
|
+
* edit — exactly like `theme.json` or `content/*`.
|
|
9
|
+
*
|
|
10
|
+
* This module is the tenant-tree producer that closes that gap. A tenant commits an
|
|
11
|
+
* `analytics.json` in its own tree (the tenant-owned surface, `tenants/<id>/**`), and
|
|
12
|
+
* this materializes it into the SAME already-consumed declaration seam
|
|
13
|
+
* ({@link IntegrationDeclarationInput}[], read by
|
|
14
|
+
* {@link loadTenantIntegrationDeclarations} and merged into the render plan by the
|
|
15
|
+
* interim bridge). It adds NO new runtime: it is a THIRD producer into an existing
|
|
16
|
+
* consumer, alongside the platform-authored `tenant.integrations` and the D1-backed
|
|
17
|
+
* managed installs.
|
|
18
|
+
*
|
|
19
|
+
* The heavy validation is NOT hand-rolled — each declared destination is built by the
|
|
20
|
+
* shipped, reviewed {@link buildPlatformAdapterDeclaration}, which fail-closes on an
|
|
21
|
+
* unknown adapter, a missing/invalid live id, an invalid per-stage id, or a non-live
|
|
22
|
+
* stage that reuses the live id. That is the whole point: a tenant supplies only public
|
|
23
|
+
* per-stage destination ids and gets a working, consent-gated, CSP-clean, non-live-safe
|
|
24
|
+
* measurement integration with no renderer change.
|
|
25
|
+
*
|
|
26
|
+
* Two callers, one core:
|
|
27
|
+
* - PUBLISH validation — {@link materializeTenantAnalyticsDeclarations} returns
|
|
28
|
+
* structured {@link ContractViolation}s so the ship/customization pipeline can
|
|
29
|
+
* REFUSE an invalid `analytics.json` with an actionable, per-field message, exactly
|
|
30
|
+
* as it refuses an invalid `theme.json`.
|
|
31
|
+
* - RUNTIME materialization — {@link loadTenantAnalyticsDeclarations} is fail-closed:
|
|
32
|
+
* a missing or (defensively) invalid file yields the empty set, never a throw, so an
|
|
33
|
+
* undeclared tenant resolves the environment-only plan and a byte-identical page.
|
|
34
|
+
*/
|
|
35
|
+
import {
|
|
36
|
+
buildPlatformAdapterDeclaration,
|
|
37
|
+
ENVIRONMENT_SCHEMA_VERSION,
|
|
38
|
+
Violations,
|
|
39
|
+
isPlainObject,
|
|
40
|
+
rejectUnknownKeys,
|
|
41
|
+
type BrowserEnvironment,
|
|
42
|
+
type ContractViolation,
|
|
43
|
+
type IntegrationDeclarationInput,
|
|
44
|
+
} from "@tot/public-runtime";
|
|
45
|
+
|
|
46
|
+
/** The tenant-owned file that declares measurement destinations, in the tenant tree. */
|
|
47
|
+
export const TENANT_ANALYTICS_FILE = "analytics.json";
|
|
48
|
+
|
|
49
|
+
/** The current `analytics.json` contract version. */
|
|
50
|
+
export const TENANT_ANALYTICS_SCHEMA_VERSION = 1 as const;
|
|
51
|
+
|
|
52
|
+
/** One declared measurement destination: a first-class adapter + its per-stage public ids. */
|
|
53
|
+
export interface TenantAnalyticsDestination {
|
|
54
|
+
/** The first-class platform adapter id, e.g. `platform.google-measurement`. */
|
|
55
|
+
adapter: string;
|
|
56
|
+
/** Public per-stage destination ids. `live` is required; non-live stages take SEPARATE test ids. */
|
|
57
|
+
ids: Record<string, unknown>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** The result of materializing a tenant's `analytics.json` into declarations. */
|
|
61
|
+
export interface TenantAnalyticsMaterialization {
|
|
62
|
+
/** True only when EVERY declared destination validated; false surfaces `violations`. */
|
|
63
|
+
ok: boolean;
|
|
64
|
+
/** The materialized declarations, in declared order. Empty whenever `ok` is false. */
|
|
65
|
+
declarations: readonly IntegrationDeclarationInput[];
|
|
66
|
+
/** Per-field violations for the publish surface. Empty whenever `ok` is true. */
|
|
67
|
+
violations: readonly ContractViolation[];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The fixed environment the declaration is CONSTRUCTED against. A first-class adapter
|
|
72
|
+
* declaration is environment-INDEPENDENT in content — its per-stage ids are all baked
|
|
73
|
+
* into `publicConfig` and the client picks the current stage at render time — so the
|
|
74
|
+
* environment here only satisfies {@link buildPlatformAdapterDeclaration}'s validation
|
|
75
|
+
* and its (discarded) plan resolution. `canonicalHost` is a fixed placeholder, never the
|
|
76
|
+
* tenant id, so a clone id (which is not a legal hostname) can never fail construction.
|
|
77
|
+
*/
|
|
78
|
+
const CONSTRUCTION_ENVIRONMENT: BrowserEnvironment = {
|
|
79
|
+
schemaVersion: ENVIRONMENT_SCHEMA_VERSION,
|
|
80
|
+
siteSurface: "storefront",
|
|
81
|
+
environment: "live",
|
|
82
|
+
canonicalHost: "tenant-analytics.invalid",
|
|
83
|
+
revision: "tenant-analytics-declaration",
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const EMPTY: readonly IntegrationDeclarationInput[] = Object.freeze([]);
|
|
87
|
+
|
|
88
|
+
/** Re-anchor a builder violation path under the declaring `destinations[i]` element. */
|
|
89
|
+
function reanchor(violation: ContractViolation, index: number): ContractViolation {
|
|
90
|
+
const base = `destinations[${index}]`;
|
|
91
|
+
const path =
|
|
92
|
+
violation.path === "(root)" || violation.path === ""
|
|
93
|
+
? base
|
|
94
|
+
: violation.path === "adapterId"
|
|
95
|
+
? `${base}.adapter`
|
|
96
|
+
: violation.path.startsWith("adapterId.")
|
|
97
|
+
? `${base}.adapter${violation.path.slice("adapterId".length)}`
|
|
98
|
+
: `${base}.${violation.path}`;
|
|
99
|
+
return { path, code: violation.code, message: violation.message };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Validate the closed shape of one declared destination before it reaches the builder. */
|
|
103
|
+
function readDestination(
|
|
104
|
+
raw: unknown,
|
|
105
|
+
index: number,
|
|
106
|
+
v: Violations,
|
|
107
|
+
): TenantAnalyticsDestination | undefined {
|
|
108
|
+
const base = `destinations[${index}]`;
|
|
109
|
+
if (!isPlainObject(raw)) {
|
|
110
|
+
v.add(base, "type", "A destination must be an object of { adapter, ids }.");
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
rejectUnknownKeys(raw, ["adapter", "ids"], v, base);
|
|
114
|
+
const adapter = raw["adapter"];
|
|
115
|
+
if (typeof adapter !== "string" || adapter.length === 0) {
|
|
116
|
+
v.add(`${base}.adapter`, "type", "A destination adapter must be a non-empty string.");
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
if (!isPlainObject(raw["ids"])) {
|
|
120
|
+
v.add(`${base}.ids`, "type", "ids must be an object of { stage: destinationId }.");
|
|
121
|
+
return undefined;
|
|
122
|
+
}
|
|
123
|
+
return { adapter, ids: raw["ids"] };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Parse and materialize a tenant's `analytics.json` content into first-class adapter
|
|
128
|
+
* declarations. `raw` is the already-parsed JSON value (or `undefined`/`null` when the
|
|
129
|
+
* tenant declares none). Fail-closed and whole-file: if ANY declared destination is
|
|
130
|
+
* invalid, NO declaration is produced and the violations name every offending field —
|
|
131
|
+
* a half-valid analytics file never partially materializes.
|
|
132
|
+
*/
|
|
133
|
+
export function materializeTenantAnalyticsDeclarations(input: {
|
|
134
|
+
raw: unknown;
|
|
135
|
+
tenantPublicId: string;
|
|
136
|
+
}): TenantAnalyticsMaterialization {
|
|
137
|
+
const { raw, tenantPublicId } = input;
|
|
138
|
+
// An undeclared tenant (no file) is the zero-cost norm, not an error.
|
|
139
|
+
if (raw === undefined || raw === null) {
|
|
140
|
+
return { ok: true, declarations: EMPTY, violations: [] };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const v = new Violations();
|
|
144
|
+
if (!isPlainObject(raw)) {
|
|
145
|
+
v.add("(root)", "type", "analytics.json must be a JSON object with a `destinations` array.");
|
|
146
|
+
return { ok: false, declarations: EMPTY, violations: v.items };
|
|
147
|
+
}
|
|
148
|
+
rejectUnknownKeys(raw, ["schemaVersion", "destinations"], v, "");
|
|
149
|
+
if ("schemaVersion" in raw && raw["schemaVersion"] !== TENANT_ANALYTICS_SCHEMA_VERSION) {
|
|
150
|
+
v.add(
|
|
151
|
+
"schemaVersion",
|
|
152
|
+
"unsupported_version",
|
|
153
|
+
`schemaVersion must equal ${TENANT_ANALYTICS_SCHEMA_VERSION}.`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
const destinationsRaw = raw["destinations"];
|
|
157
|
+
if (!Array.isArray(destinationsRaw)) {
|
|
158
|
+
v.add("destinations", "type", "`destinations` must be an array.");
|
|
159
|
+
return { ok: false, declarations: EMPTY, violations: v.items };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const declarations: IntegrationDeclarationInput[] = [];
|
|
163
|
+
destinationsRaw.forEach((destinationRaw, index) => {
|
|
164
|
+
const destination = readDestination(destinationRaw, index, v);
|
|
165
|
+
if (!destination) return;
|
|
166
|
+
const built = buildPlatformAdapterDeclaration({
|
|
167
|
+
adapterId: destination.adapter,
|
|
168
|
+
ids: destination.ids,
|
|
169
|
+
tenantPublicId,
|
|
170
|
+
environment: CONSTRUCTION_ENVIRONMENT,
|
|
171
|
+
});
|
|
172
|
+
if (!built.ok) {
|
|
173
|
+
for (const violation of built.violations) v.items.push(reanchor(violation, index));
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
// Namespace the ref so a tenant-declared adapter can never be mistaken for — or
|
|
177
|
+
// collide with — the same adapter installed through the managed D1 path.
|
|
178
|
+
declarations.push({ ...built.declaration, ref: `tenant-analytics:${destination.adapter}` });
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
if (!v.ok) return { ok: false, declarations: EMPTY, violations: v.items };
|
|
182
|
+
return { ok: true, declarations, violations: [] };
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Read a tenant's already-materialized analytics declarations for the render hot path,
|
|
187
|
+
* fail-closed: a missing OR invalid file yields the empty set (never throws), so an
|
|
188
|
+
* undeclared tenant resolves the environment-only plan and a defensively-invalid file
|
|
189
|
+
* darks its own analytics rather than the page. Publish validation
|
|
190
|
+
* ({@link materializeTenantAnalyticsDeclarations}) is what keeps an invalid file from
|
|
191
|
+
* ever reaching here.
|
|
192
|
+
*/
|
|
193
|
+
export function loadTenantAnalyticsDeclarations(
|
|
194
|
+
raw: unknown,
|
|
195
|
+
tenantPublicId: string,
|
|
196
|
+
): readonly IntegrationDeclarationInput[] {
|
|
197
|
+
return materializeTenantAnalyticsDeclarations({ raw, tenantPublicId }).declarations;
|
|
198
|
+
}
|