@tokenoftrust/storefront-runner 4.9.2 → 4.10.0
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/agency-kit/tools/lib/analytics-declaration.mjs +299 -0
- package/apps/storefront/migrations-apps/0021_contact_vault.sql +162 -0
- package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
- package/apps/storefront/src/lib/access/shared/cspStaticHashes.ts +1 -1
- package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
- package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
- package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
- package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
- package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
- package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
- package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
- package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
- package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
- package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
- package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
- package/apps/storefront/src/lib/measurement/admin/analyticsRegionPolicy.ts +151 -0
- package/apps/storefront/src/lib/measurement/admin/analyticsRegionsClient.ts +84 -0
- package/apps/storefront/src/lib/orders/admin/orderActions.ts +229 -19
- package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
- package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
- package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
- package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
- package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
- package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
- package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
- package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
- package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
- package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
- package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
- package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
- package/apps/storefront/src/pages/api/contact.ts +16 -4
- package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -0
- package/apps/storefront/test/pipeline/publish-kinds.ts +5 -1
- package/apps/storefront/test/pipeline-faults/matrix-lib.ts +16 -2
- package/package.json +1 -1
- package/packages/public-runtime/src/integration/consent-broker.ts +117 -17
- package/packages/public-runtime/src/integration/events.ts +15 -19
- package/packages/public-runtime/src/integration/index.ts +1 -0
- package/packages/public-runtime/src/integration/lifecycle-boot.ts +41 -7
- package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +84 -1
- package/packages/public-runtime/src/integration/managed-analytics.ts +39 -26
- package/packages/public-runtime/src/integration/native-amplitude.ts +11 -10
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +1 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +20 -0
- package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +24 -13
- package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +8 -11
- package/packages/public-runtime/src/integration/native-ga4.ts +21 -8
- package/packages/public-runtime/src/integration/plan.ts +6 -4
- package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +9 -2
- package/packages/public-runtime/src/integration/render-phases.ts +10 -18
- package/packages/public-runtime/src/integration/resolve.ts +3 -3
- package/packages/public-runtime/src/integration/tenant-analytics.ts +250 -0
- package/packages/public-runtime/src/integration/visitor-region.mjs +1 -1
- package/packages/public-runtime/src/tenant.ts +3 -3
- package/packages/public-runtime/tests/fixtures/integration/campaign-token-corpus.ts +75 -0
- package/scripts/dev/ga4-gs2-canary.mjs +36 -10
- package/scripts/tenant/validate.mjs +9 -0
|
@@ -18,7 +18,6 @@ import {
|
|
|
18
18
|
actorMayCommand,
|
|
19
19
|
decideCommerceCommand,
|
|
20
20
|
publicCommerceEvent,
|
|
21
|
-
readOwnerAddress,
|
|
22
21
|
totalsMatch,
|
|
23
22
|
type CommerceActor,
|
|
24
23
|
type CommerceEvidence,
|
|
@@ -32,6 +31,7 @@ import {
|
|
|
32
31
|
type StockState,
|
|
33
32
|
} from "@tot/public-runtime";
|
|
34
33
|
import { readD1 } from "@/lib/publication/shared/env";
|
|
34
|
+
import { terminalTransitionExpiryStatement } from "@/lib/orders/vault/orderContactVault.js";
|
|
35
35
|
import { decideAdminApiAccess } from "@/lib/access/auth/adminApiGuard";
|
|
36
36
|
import { readViewerSession } from "@/lib/access/auth/route";
|
|
37
37
|
import type { SessionRecord } from "@/lib/access/auth/session";
|
|
@@ -80,7 +80,6 @@ export interface SourceOrderSnapshot {
|
|
|
80
80
|
totals: MinorTotals;
|
|
81
81
|
evidence: CommerceEvidence;
|
|
82
82
|
lines: readonly { sku: string; quantity: number }[];
|
|
83
|
-
address: Record<string, unknown> | null;
|
|
84
83
|
}
|
|
85
84
|
|
|
86
85
|
export interface PackingSlip {
|
|
@@ -279,7 +278,6 @@ function observedEvent(input: {
|
|
|
279
278
|
label: LabelState;
|
|
280
279
|
totals: MinorTotals;
|
|
281
280
|
evidence: CommerceEvidence;
|
|
282
|
-
address: unknown;
|
|
283
281
|
}): string {
|
|
284
282
|
return jsonText(
|
|
285
283
|
publicCommerceEvent({
|
|
@@ -294,7 +292,6 @@ function observedEvent(input: {
|
|
|
294
292
|
label: input.label,
|
|
295
293
|
totals: input.totals,
|
|
296
294
|
verificationDetail: input.evidence,
|
|
297
|
-
address: input.address,
|
|
298
295
|
}),
|
|
299
296
|
);
|
|
300
297
|
}
|
|
@@ -586,32 +583,6 @@ export class FulfillmentService {
|
|
|
586
583
|
return ok({ batchId, orders });
|
|
587
584
|
}
|
|
588
585
|
|
|
589
|
-
async readAddress(
|
|
590
|
-
actor: FulfillmentActor,
|
|
591
|
-
orderId: string,
|
|
592
|
-
): Promise<FulfillmentResult<Record<string, unknown>>> {
|
|
593
|
-
const scope = { tenantId: actor.tenantId, stage: actor.stage };
|
|
594
|
-
let opened = false;
|
|
595
|
-
const gate = readOwnerAddress(actor, scope, () => {
|
|
596
|
-
opened = true;
|
|
597
|
-
return true;
|
|
598
|
-
});
|
|
599
|
-
if (!gate.ok || !opened || !isOpaqueId(actor.actorId)) return refuse("denied");
|
|
600
|
-
if (!isOpaqueId(orderId)) return refuse("not-found");
|
|
601
|
-
const row = await this.db.get<{ address_json: string }>(
|
|
602
|
-
`SELECT address_json FROM scrappy_order_addresses WHERE tenant_id = ? AND stage = ? AND order_id = ?`,
|
|
603
|
-
[scope.tenantId, scope.stage, orderId],
|
|
604
|
-
);
|
|
605
|
-
if (!row) return refuse("not-found");
|
|
606
|
-
try {
|
|
607
|
-
const parsed = JSON.parse(row.address_json) as unknown;
|
|
608
|
-
if (!isRecord(parsed)) return refuse("unreadable");
|
|
609
|
-
return ok(parsed);
|
|
610
|
-
} catch {
|
|
611
|
-
return refuse("unreadable");
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
|
|
615
586
|
async applySourceSnapshot(
|
|
616
587
|
actor: FulfillmentActor,
|
|
617
588
|
snapshot: SourceOrderSnapshot,
|
|
@@ -956,6 +927,7 @@ export class FulfillmentService {
|
|
|
956
927
|
],
|
|
957
928
|
},
|
|
958
929
|
auditStatement(state, actor.actorId, "transition", state.order.fulfillment, "handed_off", version, stamp(this.now(), state.auditCount + 1)),
|
|
930
|
+
terminalTransitionExpiryStatement(state.order, version, new Date(this.now())),
|
|
959
931
|
],
|
|
960
932
|
};
|
|
961
933
|
});
|
|
@@ -1177,21 +1149,6 @@ export class FulfillmentService {
|
|
|
1177
1149
|
];
|
|
1178
1150
|
const gate = `tenant_id = ? AND stage = ? AND order_id = ? AND event_version = 1 AND source = ? AND source_order_id = ?`;
|
|
1179
1151
|
const gateParams = [snapshot.tenantId, snapshot.stage, snapshot.orderId, snapshot.source, snapshot.sourceOrderId];
|
|
1180
|
-
if (snapshot.address) {
|
|
1181
|
-
statements.push({
|
|
1182
|
-
sql: `INSERT INTO scrappy_order_addresses (tenant_id, stage, order_id, address_json)
|
|
1183
|
-
SELECT tenant_id, stage, order_id, ?
|
|
1184
|
-
FROM scrappy_commerce_orders
|
|
1185
|
-
WHERE ${gate}
|
|
1186
|
-
AND NOT EXISTS (
|
|
1187
|
-
SELECT 1 FROM scrappy_order_addresses AS a
|
|
1188
|
-
WHERE a.tenant_id = scrappy_commerce_orders.tenant_id
|
|
1189
|
-
AND a.stage = scrappy_commerce_orders.stage
|
|
1190
|
-
AND a.order_id = scrappy_commerce_orders.order_id
|
|
1191
|
-
)`,
|
|
1192
|
-
params: [jsonText(snapshot.address), ...gateParams],
|
|
1193
|
-
});
|
|
1194
|
-
}
|
|
1195
1152
|
const stub = {
|
|
1196
1153
|
order: {
|
|
1197
1154
|
tenantId: snapshot.tenantId,
|
|
@@ -1215,7 +1172,6 @@ export class FulfillmentService {
|
|
|
1215
1172
|
gatedAudit(stub, actor.actorId, "evidence", "none", jsonText(snapshot.evidence), at, gate, gateParams),
|
|
1216
1173
|
gatedAudit(stub, actor.actorId, "lines", "none", jsonText(snapshot.lines), stamp(this.now(), 2), gate, gateParams),
|
|
1217
1174
|
gatedAudit(stub, actor.actorId, "totals", "none", jsonText(snapshot.totals), stamp(this.now(), 3), gate, gateParams),
|
|
1218
|
-
gatedAudit(stub, actor.actorId, "address-present", "none", snapshot.address ? "yes" : "no", stamp(this.now(), 4), gate, gateParams),
|
|
1219
1175
|
gatedAudit(
|
|
1220
1176
|
stub,
|
|
1221
1177
|
actor.actorId,
|
|
@@ -1233,7 +1189,6 @@ export class FulfillmentService {
|
|
|
1233
1189
|
label,
|
|
1234
1190
|
totals: snapshot.totals,
|
|
1235
1191
|
evidence: snapshot.evidence,
|
|
1236
|
-
address: snapshot.address,
|
|
1237
1192
|
}),
|
|
1238
1193
|
stamp(this.now(), 5),
|
|
1239
1194
|
gate,
|
|
@@ -1299,35 +1254,6 @@ export class FulfillmentService {
|
|
|
1299
1254
|
statements.push(auditStatement(state, actor.actorId, "lines", "source", jsonText(snapshot.lines), nextVersion, stamp(this.now(), n)));
|
|
1300
1255
|
n += 1;
|
|
1301
1256
|
statements.push(auditStatement(state, actor.actorId, "totals", "source", jsonText(snapshot.totals), nextVersion, stamp(this.now(), n)));
|
|
1302
|
-
if (snapshot.address) {
|
|
1303
|
-
statements.push({
|
|
1304
|
-
sql: `INSERT INTO scrappy_order_addresses (tenant_id, stage, order_id, address_json)
|
|
1305
|
-
SELECT ?, ?, ?, ?
|
|
1306
|
-
WHERE EXISTS (
|
|
1307
|
-
SELECT 1 FROM scrappy_commerce_orders
|
|
1308
|
-
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
|
|
1309
|
-
)
|
|
1310
|
-
ON CONFLICT(tenant_id, stage, order_id) DO UPDATE SET address_json = excluded.address_json
|
|
1311
|
-
WHERE EXISTS (
|
|
1312
|
-
SELECT 1 FROM scrappy_commerce_orders
|
|
1313
|
-
WHERE tenant_id = ? AND stage = ? AND order_id = ? AND event_version = ?
|
|
1314
|
-
)`,
|
|
1315
|
-
params: [
|
|
1316
|
-
snapshot.tenantId,
|
|
1317
|
-
snapshot.stage,
|
|
1318
|
-
snapshot.orderId,
|
|
1319
|
-
jsonText(snapshot.address),
|
|
1320
|
-
snapshot.tenantId,
|
|
1321
|
-
snapshot.stage,
|
|
1322
|
-
snapshot.orderId,
|
|
1323
|
-
nextVersion,
|
|
1324
|
-
snapshot.tenantId,
|
|
1325
|
-
snapshot.stage,
|
|
1326
|
-
snapshot.orderId,
|
|
1327
|
-
nextVersion,
|
|
1328
|
-
],
|
|
1329
|
-
});
|
|
1330
|
-
}
|
|
1331
1257
|
const storedFulfillment = state.order.fulfillment === "handed_off" ? "handed_off" : fulfillment;
|
|
1332
1258
|
n += 1;
|
|
1333
1259
|
statements.push(
|
|
@@ -1348,7 +1274,6 @@ export class FulfillmentService {
|
|
|
1348
1274
|
label,
|
|
1349
1275
|
totals: snapshot.totals,
|
|
1350
1276
|
evidence: snapshot.evidence,
|
|
1351
|
-
address: snapshot.address,
|
|
1352
1277
|
}),
|
|
1353
1278
|
nextVersion,
|
|
1354
1279
|
stamp(this.now(), n),
|
|
@@ -1357,6 +1282,7 @@ export class FulfillmentService {
|
|
|
1357
1282
|
if (confirmedCancel && state.order.fulfillment !== "handed_off") {
|
|
1358
1283
|
n += 1;
|
|
1359
1284
|
statements.push(auditStatement(state, actor.actorId, "pending-cancel", "open", "confirmed", nextVersion, stamp(this.now(), n)));
|
|
1285
|
+
statements.push(terminalTransitionExpiryStatement(state.order, nextVersion, new Date(this.now())));
|
|
1360
1286
|
}
|
|
1361
1287
|
if (snapshot.sourceVersion !== undefined) statements.push(projectionCheckpoint(snapshot,nextVersion,this.now()));
|
|
1362
1288
|
return { type: "write", statements, minimumChanges: 1 };
|
|
@@ -1657,7 +1583,6 @@ function validateSnapshot(snapshot: SourceOrderSnapshot): string | null {
|
|
|
1657
1583
|
if (!oneOf(snapshot.label, LABEL_STATES) || !oneOf(snapshot.stage, ["preview", "publication"] as const)) return "malformed";
|
|
1658
1584
|
if (!parseEvidence(snapshot.evidence) || !parseLines(snapshot.lines) || !parseTotals(snapshot.totals)) return "malformed";
|
|
1659
1585
|
if (snapshot.amountMinor !== snapshot.totals.totalMinor || !totalsMatch(snapshot.totals)) return "malformed";
|
|
1660
|
-
if (snapshot.address !== null && !isRecord(snapshot.address)) return "malformed";
|
|
1661
1586
|
return null;
|
|
1662
1587
|
}
|
|
1663
1588
|
|
|
@@ -1,228 +1,68 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* Runtime read of a tenant's own `analytics.json` (TIER 1) for the render hot path.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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.
|
|
4
|
+
* The contract and its materializer live in `@tot/public-runtime`
|
|
5
|
+
* ({@link materializeTenantAnalyticsDeclarations}); publish validation refuses an invalid file
|
|
6
|
+
* before it can reach here. This loader is the defensive last line: fail-closed and value-free.
|
|
34
7
|
*/
|
|
35
8
|
import {
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
Violations,
|
|
39
|
-
isEuUkVisitorPolicy,
|
|
40
|
-
isPlainObject,
|
|
41
|
-
rejectUnknownKeys,
|
|
42
|
-
type BrowserEnvironment,
|
|
43
|
-
type ContractViolation,
|
|
9
|
+
describeTenantAnalyticsViolations,
|
|
10
|
+
materializeTenantAnalyticsDeclarations,
|
|
44
11
|
type IntegrationDeclarationInput,
|
|
45
|
-
type
|
|
12
|
+
type TenantAnalyticsMaterialization,
|
|
46
13
|
} from "@tot/public-runtime";
|
|
47
14
|
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
/** One declared measurement destination: a first-class adapter + its per-stage public ids. */
|
|
55
|
-
export interface TenantAnalyticsDestination {
|
|
56
|
-
/** The first-class platform adapter id, e.g. `platform.google-measurement`. */
|
|
57
|
-
adapter: string;
|
|
58
|
-
/** Public per-stage destination ids. `live` is required; non-live stages take SEPARATE test ids. */
|
|
59
|
-
ids: Record<string, unknown>;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** The result of materializing a tenant's `analytics.json` into declarations. */
|
|
63
|
-
export interface TenantAnalyticsMaterialization {
|
|
64
|
-
/** True only when EVERY declared destination validated; false surfaces `violations`. */
|
|
65
|
-
ok: boolean;
|
|
66
|
-
/** The materialized declarations, in declared order. Empty whenever `ok` is false. */
|
|
67
|
-
declarations: readonly IntegrationDeclarationInput[];
|
|
68
|
-
/**
|
|
69
|
-
* The declared `euUkVisitors` policy when it is not the `count-only` default. Absent
|
|
70
|
-
* whenever `ok` is false, so an invalid file never widens EU/UK collection.
|
|
71
|
-
*/
|
|
72
|
-
euUkVisitors?: TenantConfig["euUkVisitors"];
|
|
73
|
-
/** Per-field violations for the publish surface. Empty whenever `ok` is true. */
|
|
74
|
-
violations: readonly ContractViolation[];
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* The fixed environment the declaration is CONSTRUCTED against. A first-class adapter
|
|
79
|
-
* declaration is environment-INDEPENDENT in content — its per-stage ids are all baked
|
|
80
|
-
* into `publicConfig` and the client picks the current stage at render time — so the
|
|
81
|
-
* environment here only satisfies {@link buildPlatformAdapterDeclaration}'s validation
|
|
82
|
-
* and its plan-derived CSP requirements. The caller must supply the tenant's real
|
|
83
|
-
* canonical host so those requirements authorize the same-site adapter frame that the
|
|
84
|
-
* browser runtime will construct.
|
|
85
|
-
*/
|
|
86
|
-
function constructionEnvironment(canonicalHost: string): BrowserEnvironment {
|
|
87
|
-
return {
|
|
88
|
-
schemaVersion: ENVIRONMENT_SCHEMA_VERSION,
|
|
89
|
-
siteSurface: "storefront",
|
|
90
|
-
environment: "live",
|
|
91
|
-
canonicalHost,
|
|
92
|
-
revision: "tenant-analytics-declaration",
|
|
93
|
-
};
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
const EMPTY: readonly IntegrationDeclarationInput[] = Object.freeze([]);
|
|
97
|
-
|
|
98
|
-
/** Re-anchor a builder violation path under the declaring `destinations[i]` element. */
|
|
99
|
-
function reanchor(violation: ContractViolation, index: number): ContractViolation {
|
|
100
|
-
const base = `destinations[${index}]`;
|
|
101
|
-
const path =
|
|
102
|
-
violation.path === "(root)" || violation.path === ""
|
|
103
|
-
? base
|
|
104
|
-
: violation.path === "adapterId"
|
|
105
|
-
? `${base}.adapter`
|
|
106
|
-
: violation.path.startsWith("adapterId.")
|
|
107
|
-
? `${base}.adapter${violation.path.slice("adapterId".length)}`
|
|
108
|
-
: `${base}.${violation.path}`;
|
|
109
|
-
return { path, code: violation.code, message: violation.message };
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
/** Validate the closed shape of one declared destination before it reaches the builder. */
|
|
113
|
-
function readDestination(
|
|
114
|
-
raw: unknown,
|
|
115
|
-
index: number,
|
|
116
|
-
v: Violations,
|
|
117
|
-
): TenantAnalyticsDestination | undefined {
|
|
118
|
-
const base = `destinations[${index}]`;
|
|
119
|
-
if (!isPlainObject(raw)) {
|
|
120
|
-
v.add(base, "type", "A destination must be an object of { adapter, ids }.");
|
|
121
|
-
return undefined;
|
|
122
|
-
}
|
|
123
|
-
rejectUnknownKeys(raw, ["adapter", "ids"], v, base);
|
|
124
|
-
const adapter = raw["adapter"];
|
|
125
|
-
if (typeof adapter !== "string" || adapter.length === 0) {
|
|
126
|
-
v.add(`${base}.adapter`, "type", "A destination adapter must be a non-empty string.");
|
|
127
|
-
return undefined;
|
|
128
|
-
}
|
|
129
|
-
if (!isPlainObject(raw["ids"])) {
|
|
130
|
-
v.add(`${base}.ids`, "type", "ids must be an object of { stage: destinationId[] }.");
|
|
131
|
-
return undefined;
|
|
15
|
+
function warnDeclaration(summary: string, violations: readonly { path: string; code: string }[]): void {
|
|
16
|
+
try {
|
|
17
|
+
console.warn(`[diag:analytics:declaration] ${summary}`, JSON.stringify({ violations }));
|
|
18
|
+
} catch {
|
|
19
|
+
/* diagnostics must never break the caller */
|
|
132
20
|
}
|
|
133
|
-
return { adapter, ids: raw["ids"] };
|
|
134
21
|
}
|
|
135
22
|
|
|
136
23
|
/**
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
if (raw === undefined || raw === null) {
|
|
151
|
-
return { ok: true, declarations: EMPTY, violations: [] };
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const v = new Violations();
|
|
155
|
-
if (!isPlainObject(raw)) {
|
|
156
|
-
v.add("(root)", "type", "analytics.json must be a JSON object with a `destinations` array.");
|
|
157
|
-
return { ok: false, declarations: EMPTY, violations: v.items };
|
|
158
|
-
}
|
|
159
|
-
rejectUnknownKeys(raw, ["schemaVersion", "destinations", "euUkVisitors"], v, "");
|
|
160
|
-
const euUkVisitors = raw["euUkVisitors"];
|
|
161
|
-
if ("euUkVisitors" in raw && !isEuUkVisitorPolicy(euUkVisitors)) {
|
|
162
|
-
v.add(
|
|
163
|
-
"euUkVisitors",
|
|
164
|
-
"unknown_enum",
|
|
165
|
-
'euUkVisitors must be "count-only" (the default), "full", or "none".',
|
|
166
|
-
);
|
|
167
|
-
}
|
|
168
|
-
if ("schemaVersion" in raw && raw["schemaVersion"] !== TENANT_ANALYTICS_SCHEMA_VERSION) {
|
|
169
|
-
v.add(
|
|
170
|
-
"schemaVersion",
|
|
171
|
-
"unsupported_version",
|
|
172
|
-
`schemaVersion must equal ${TENANT_ANALYTICS_SCHEMA_VERSION}.`,
|
|
173
|
-
);
|
|
174
|
-
}
|
|
175
|
-
const destinationsRaw = raw["destinations"];
|
|
176
|
-
if (!Array.isArray(destinationsRaw)) {
|
|
177
|
-
v.add("destinations", "type", "`destinations` must be an array.");
|
|
178
|
-
return { ok: false, declarations: EMPTY, violations: v.items };
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const declarations: IntegrationDeclarationInput[] = [];
|
|
182
|
-
destinationsRaw.forEach((destinationRaw, index) => {
|
|
183
|
-
const destination = readDestination(destinationRaw, index, v);
|
|
184
|
-
if (!destination) return;
|
|
185
|
-
const built = buildPlatformAdapterDeclaration({
|
|
186
|
-
adapterId: destination.adapter,
|
|
187
|
-
ids: destination.ids,
|
|
188
|
-
tenantPublicId,
|
|
189
|
-
environment: constructionEnvironment(canonicalHost),
|
|
190
|
-
});
|
|
191
|
-
if (!built.ok) {
|
|
192
|
-
for (const violation of built.violations) v.items.push(reanchor(violation, index));
|
|
193
|
-
return;
|
|
194
|
-
}
|
|
195
|
-
// Namespace the ref so a tenant-declared adapter can never be mistaken for — or
|
|
196
|
-
// collide with — the same adapter installed through the managed D1 path.
|
|
197
|
-
declarations.push({ ...built.declaration, ref: `tenant-analytics:${destination.adapter}` });
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
if (!v.ok) return { ok: false, declarations: EMPTY, violations: v.items };
|
|
201
|
-
return {
|
|
202
|
-
ok: true,
|
|
203
|
-
declarations,
|
|
204
|
-
violations: [],
|
|
205
|
-
...(euUkVisitors === "full" || euUkVisitors === "none" ? { euUkVisitors } : {}),
|
|
206
|
-
};
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
/**
|
|
210
|
-
* Read a tenant's already-materialized analytics declarations for the render hot path,
|
|
211
|
-
* fail-closed: a missing OR invalid file yields the empty set (never throws), so an
|
|
212
|
-
* undeclared tenant resolves the environment-only plan and a defensively-invalid file
|
|
213
|
-
* darks its own analytics rather than the page. Publish validation
|
|
214
|
-
* ({@link materializeTenantAnalyticsDeclarations}) is what keeps an invalid file from
|
|
215
|
-
* ever reaching here.
|
|
24
|
+
* Read a tenant's already-materialized analytics declarations, fail-closed: a missing OR
|
|
25
|
+
* invalid file yields the empty set (never throws), so an undeclared tenant resolves the
|
|
26
|
+
* environment-only plan and an invalid file darks its own analytics rather than the page.
|
|
27
|
+
* A file that is present but holds `null` is invalid; only an absent file (`undefined`)
|
|
28
|
+
* declares nothing.
|
|
29
|
+
*
|
|
30
|
+
* `installed` is what the tenant already declares in platform code. The plan resolves an app
|
|
31
|
+
* at most once and refuses the whole plan on a repeat, so a destination whose adapter is
|
|
32
|
+
* already installed there is dropped, keeping the platform's install and the rest of the plan.
|
|
33
|
+
*
|
|
34
|
+
* Neither a refusal nor a drop is silent: each emits one `[diag:analytics:declaration]`
|
|
35
|
+
* warning naming the field path and code. The warning carries no value from the file and no
|
|
36
|
+
* tenant or destination identifier.
|
|
216
37
|
*/
|
|
217
38
|
export function loadTenantAnalyticsDeclarations(
|
|
218
39
|
raw: unknown,
|
|
219
40
|
tenantPublicId: string,
|
|
220
41
|
canonicalHost: string,
|
|
42
|
+
installed: readonly IntegrationDeclarationInput[] = [],
|
|
221
43
|
): Pick<TenantAnalyticsMaterialization, "declarations" | "euUkVisitors"> {
|
|
222
|
-
|
|
44
|
+
if (raw === null) {
|
|
45
|
+
warnDeclaration("analytics.json refused; no analytics declared", [{ path: "(root)", code: "type" }]);
|
|
46
|
+
return { declarations: [] };
|
|
47
|
+
}
|
|
48
|
+
const { ok, declarations, euUkVisitors, violations } = materializeTenantAnalyticsDeclarations({
|
|
223
49
|
raw,
|
|
224
50
|
tenantPublicId,
|
|
225
51
|
canonicalHost,
|
|
226
52
|
});
|
|
227
|
-
|
|
53
|
+
if (!ok) {
|
|
54
|
+
warnDeclaration("analytics.json refused; no analytics declared", describeTenantAnalyticsViolations(violations));
|
|
55
|
+
}
|
|
56
|
+
const installedAppIds = new Set(installed.map((declaration) => declaration.manifest.app.id));
|
|
57
|
+
const dropped: { path: string; code: string }[] = [];
|
|
58
|
+
// A valid file materializes one declaration per destination, in order.
|
|
59
|
+
const kept = declarations.filter((declaration, index) => {
|
|
60
|
+
if (!installedAppIds.has(declaration.manifest.app.id)) return true;
|
|
61
|
+
dropped.push({ path: `destinations[${index}].adapter`, code: "platform_installed" });
|
|
62
|
+
return false;
|
|
63
|
+
});
|
|
64
|
+
if (dropped.length > 0) {
|
|
65
|
+
warnDeclaration("analytics.json destination dropped; the platform already installs its adapter", dropped);
|
|
66
|
+
}
|
|
67
|
+
return euUkVisitors ? { declarations: kept, euUkVisitors } : { declarations: kept };
|
|
228
68
|
}
|
|
@@ -291,9 +291,13 @@ const COMMERCE_PRIVATE_KEYS = new Set([
|
|
|
291
291
|
"phone",
|
|
292
292
|
"line1",
|
|
293
293
|
"line2",
|
|
294
|
+
"city",
|
|
295
|
+
"company",
|
|
294
296
|
"postalCode",
|
|
295
297
|
"shippingAddress",
|
|
296
298
|
"billingAddress",
|
|
299
|
+
"shipTo",
|
|
300
|
+
"billTo",
|
|
297
301
|
"address",
|
|
298
302
|
"firstName",
|
|
299
303
|
"lastName",
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The EU/UK visitor policy a tenant declares in its own `analytics.json` (`euUkVisitors`), read and
|
|
3
|
+
* rewritten as TEXT for the admin consent page.
|
|
4
|
+
*
|
|
5
|
+
* The rewrite touches only the top-level `euUkVisitors` member: every other byte of the file —
|
|
6
|
+
* `destinations` above all — is carried through unchanged, so a policy change never reformats or
|
|
7
|
+
* re-serializes the tenant's destination ids. `ask` is the default and is expressed by ABSENCE, so
|
|
8
|
+
* choosing it removes the key.
|
|
9
|
+
*/
|
|
10
|
+
import {
|
|
11
|
+
EU_UK_VISITOR_POLICIES,
|
|
12
|
+
TENANT_ANALYTICS_SCHEMA_VERSION,
|
|
13
|
+
isEuUkVisitorPolicy,
|
|
14
|
+
tenantAnalyticsDeclarationFindings,
|
|
15
|
+
type EuUkVisitorPolicy,
|
|
16
|
+
type TenantAnalyticsFinding,
|
|
17
|
+
} from "@tot/public-runtime";
|
|
18
|
+
|
|
19
|
+
export { EU_UK_VISITOR_POLICIES, isEuUkVisitorPolicy, type EuUkVisitorPolicy };
|
|
20
|
+
|
|
21
|
+
const POLICY_KEY = "euUkVisitors";
|
|
22
|
+
|
|
23
|
+
/** The declared policy, `ask` when the file or the key is absent. `null` when the file is not a JSON object. */
|
|
24
|
+
export function readEuUkVisitorPolicy(text: string | null): EuUkVisitorPolicy | null {
|
|
25
|
+
if (text === null) return "ask";
|
|
26
|
+
let parsed: unknown;
|
|
27
|
+
try {
|
|
28
|
+
parsed = JSON.parse(text);
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (err instanceof SyntaxError) return null;
|
|
31
|
+
throw err;
|
|
32
|
+
}
|
|
33
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) return null;
|
|
34
|
+
const value = (parsed as Record<string, unknown>)[POLICY_KEY];
|
|
35
|
+
if (value === undefined) return "ask";
|
|
36
|
+
return isEuUkVisitorPolicy(value) ? value : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Value-free violations for a requested policy: empty when it is one of the four policies. */
|
|
40
|
+
export function euUkVisitorPolicyViolations(value: unknown): TenantAnalyticsFinding[] {
|
|
41
|
+
if (isEuUkVisitorPolicy(value)) return [];
|
|
42
|
+
return tenantAnalyticsDeclarationFindings(JSON.stringify({ [POLICY_KEY]: value ?? 0, destinations: [] }));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface Member {
|
|
46
|
+
key: string;
|
|
47
|
+
start: number;
|
|
48
|
+
valueStart: number;
|
|
49
|
+
end: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function skipWhitespace(text: string, i: number): number {
|
|
53
|
+
while (i < text.length && /\s/.test(text[i]!)) i += 1;
|
|
54
|
+
return i;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stringEnd(text: string, i: number): number {
|
|
58
|
+
let j = i + 1;
|
|
59
|
+
while (j < text.length && text[j] !== '"') j += text[j] === "\\" ? 2 : 1;
|
|
60
|
+
return j + 1;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function valueEnd(text: string, i: number): number {
|
|
64
|
+
if (text[i] === '"') return stringEnd(text, i);
|
|
65
|
+
if (text[i] === "{" || text[i] === "[") {
|
|
66
|
+
let depth = 0;
|
|
67
|
+
let j = i;
|
|
68
|
+
while (j < text.length) {
|
|
69
|
+
const c = text[j]!;
|
|
70
|
+
if (c === '"') {
|
|
71
|
+
j = stringEnd(text, j);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (c === "{" || c === "[") depth += 1;
|
|
75
|
+
if (c === "}" || c === "]") depth -= 1;
|
|
76
|
+
j += 1;
|
|
77
|
+
if (depth === 0) return j;
|
|
78
|
+
}
|
|
79
|
+
return j;
|
|
80
|
+
}
|
|
81
|
+
let j = i;
|
|
82
|
+
while (j < text.length && !/[\s,}\]]/.test(text[j]!)) j += 1;
|
|
83
|
+
return j;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** The top-level members of a JSON object text already known to parse, with their byte spans. */
|
|
87
|
+
function topLevelMembers(text: string): { open: number; close: number; members: Member[] } {
|
|
88
|
+
const open = skipWhitespace(text, 0);
|
|
89
|
+
const members: Member[] = [];
|
|
90
|
+
let i = skipWhitespace(text, open + 1);
|
|
91
|
+
while (text[i] === '"') {
|
|
92
|
+
const keyEnd = stringEnd(text, i);
|
|
93
|
+
const key = JSON.parse(text.slice(i, keyEnd)) as string;
|
|
94
|
+
const valueStart = skipWhitespace(text, skipWhitespace(text, keyEnd) + 1);
|
|
95
|
+
const end = valueEnd(text, valueStart);
|
|
96
|
+
members.push({ key, start: i, valueStart, end });
|
|
97
|
+
i = skipWhitespace(text, end);
|
|
98
|
+
if (text[i] === ",") i = skipWhitespace(text, i + 1);
|
|
99
|
+
}
|
|
100
|
+
return { open, close: i, members };
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export type PolicyEdit =
|
|
104
|
+
| { ok: true; changed: false }
|
|
105
|
+
| { ok: true; changed: true; text: string }
|
|
106
|
+
| { ok: false; violations: TenantAnalyticsFinding[] };
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Rewrite `analytics.json` so its `euUkVisitors` is `policy`, changing no other byte. An absent file
|
|
110
|
+
* gains a minimal valid declaration only when the policy is not the `ask` default. The result is
|
|
111
|
+
* validated as a whole file; a file the publish validator would refuse is returned as violations.
|
|
112
|
+
*/
|
|
113
|
+
export function editEuUkVisitorPolicy(text: string | null, policy: unknown): PolicyEdit {
|
|
114
|
+
const requested = euUkVisitorPolicyViolations(policy);
|
|
115
|
+
if (requested.length > 0) return { ok: false, violations: requested };
|
|
116
|
+
const next = policy as EuUkVisitorPolicy;
|
|
117
|
+
if (readEuUkVisitorPolicy(text) === next) return { ok: true, changed: false };
|
|
118
|
+
|
|
119
|
+
let updated: string;
|
|
120
|
+
if (text === null) {
|
|
121
|
+
updated = `${JSON.stringify({ schemaVersion: TENANT_ANALYTICS_SCHEMA_VERSION, [POLICY_KEY]: next, destinations: [] }, null, 2)}\n`;
|
|
122
|
+
} else {
|
|
123
|
+
const current = readEuUkVisitorPolicy(text);
|
|
124
|
+
if (current === null && tenantAnalyticsDeclarationFindings(text).some((f) => f.path === "(root)")) {
|
|
125
|
+
return { ok: false, violations: tenantAnalyticsDeclarationFindings(text) };
|
|
126
|
+
}
|
|
127
|
+
updated = rewrite(text, next);
|
|
128
|
+
}
|
|
129
|
+
const findings = tenantAnalyticsDeclarationFindings(updated);
|
|
130
|
+
if (findings.length > 0) return { ok: false, violations: findings };
|
|
131
|
+
return { ok: true, changed: true, text: updated };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function rewrite(text: string, policy: EuUkVisitorPolicy): string {
|
|
135
|
+
const { open, close, members } = topLevelMembers(text);
|
|
136
|
+
const index = members.findIndex((m) => m.key === POLICY_KEY);
|
|
137
|
+
const literal = JSON.stringify(policy);
|
|
138
|
+
if (index >= 0) {
|
|
139
|
+
const member = members[index]!;
|
|
140
|
+
if (policy !== "ask") return text.slice(0, member.valueStart) + literal + text.slice(member.end);
|
|
141
|
+
const following = members[index + 1];
|
|
142
|
+
if (following) return text.slice(0, member.start) + text.slice(following.start);
|
|
143
|
+
const previous = members[index - 1];
|
|
144
|
+
if (previous) return text.slice(0, previous.end) + text.slice(member.end);
|
|
145
|
+
return text.slice(0, open + 1) + text.slice(close);
|
|
146
|
+
}
|
|
147
|
+
const first = members[0];
|
|
148
|
+
if (!first) return `${text.slice(0, open + 1)}"${POLICY_KEY}": ${literal}${text.slice(close)}`;
|
|
149
|
+
const separator = text.slice(open + 1, first.start);
|
|
150
|
+
return `${text.slice(0, first.start)}"${POLICY_KEY}": ${literal},${separator}${text.slice(first.start)}`;
|
|
151
|
+
}
|