@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.
Files changed (57) hide show
  1. package/agency-kit/tools/lib/analytics-declaration.mjs +299 -0
  2. package/apps/storefront/migrations-apps/0021_contact_vault.sql +162 -0
  3. package/apps/storefront/src/lib/access/auth/contactReaderAuthority.ts +64 -0
  4. package/apps/storefront/src/lib/access/shared/cspStaticHashes.ts +1 -1
  5. package/apps/storefront/src/lib/access/shared/viewerGrant.ts +30 -0
  6. package/apps/storefront/src/lib/compliance/shared/visitorRegionCookie.ts +1 -1
  7. package/apps/storefront/src/lib/customers/contactVault/contactAudit.ts +80 -0
  8. package/apps/storefront/src/lib/customers/contactVault/envelope.ts +166 -0
  9. package/apps/storefront/src/lib/customers/contactVault/retention.ts +44 -0
  10. package/apps/storefront/src/lib/engagement/contact/contactMessages.ts +242 -65
  11. package/apps/storefront/src/lib/engagement/contact/contract.ts +2 -0
  12. package/apps/storefront/src/lib/fulfillment/commerceProjection.ts +0 -1
  13. package/apps/storefront/src/lib/fulfillment/fulfillmentService.ts +3 -78
  14. package/apps/storefront/src/lib/integrations/integration/tenantAnalyticsDeclarations.ts +46 -206
  15. package/apps/storefront/src/lib/integrations/webhooks/deliveryEngine.ts +4 -0
  16. package/apps/storefront/src/lib/measurement/admin/analyticsRegionPolicy.ts +151 -0
  17. package/apps/storefront/src/lib/measurement/admin/analyticsRegionsClient.ts +84 -0
  18. package/apps/storefront/src/lib/orders/admin/orderActions.ts +229 -19
  19. package/apps/storefront/src/lib/orders/data/commerceCallerConfig.ts +37 -0
  20. package/apps/storefront/src/lib/orders/data/orderForwardReceiver.ts +10 -3
  21. package/apps/storefront/src/lib/orders/data/orderMapper.ts +9 -0
  22. package/apps/storefront/src/lib/orders/data/sourceAuthorityContract.ts +2 -1
  23. package/apps/storefront/src/lib/orders/vault/contactIntake.ts +61 -0
  24. package/apps/storefront/src/lib/orders/vault/contactRoutes.ts +99 -0
  25. package/apps/storefront/src/lib/orders/vault/keyringEnv.ts +7 -0
  26. package/apps/storefront/src/lib/orders/vault/orderContact.ts +96 -0
  27. package/apps/storefront/src/lib/orders/vault/orderContactVault.ts +369 -0
  28. package/apps/storefront/src/lib/publication/d1/schema-apps.ts +22 -11
  29. package/apps/storefront/src/lib/publication/shared/routedApiParam.ts +3 -0
  30. package/apps/storefront/src/pages/api/admin/fulfillment/orders/[id]/address.ts +26 -23
  31. package/apps/storefront/src/pages/api/contact.ts +16 -4
  32. package/apps/storefront/src/scheduled/contactVaultSweep.ts +80 -0
  33. package/apps/storefront/test/pipeline/publish-kinds.ts +5 -1
  34. package/apps/storefront/test/pipeline-faults/matrix-lib.ts +16 -2
  35. package/package.json +1 -1
  36. package/packages/public-runtime/src/integration/consent-broker.ts +117 -17
  37. package/packages/public-runtime/src/integration/events.ts +15 -19
  38. package/packages/public-runtime/src/integration/index.ts +1 -0
  39. package/packages/public-runtime/src/integration/lifecycle-boot.ts +41 -7
  40. package/packages/public-runtime/src/integration/managed-analytics-value-safety.ts +84 -1
  41. package/packages/public-runtime/src/integration/managed-analytics.ts +39 -26
  42. package/packages/public-runtime/src/integration/native-amplitude.ts +11 -10
  43. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.d.mts +1 -0
  44. package/packages/public-runtime/src/integration/native-ga4-sandbox-asset.mjs +20 -0
  45. package/packages/public-runtime/src/integration/native-ga4-sandbox-entry.ts +24 -13
  46. package/packages/public-runtime/src/integration/native-ga4-sandbox.ts +8 -11
  47. package/packages/public-runtime/src/integration/native-ga4.ts +21 -8
  48. package/packages/public-runtime/src/integration/plan.ts +6 -4
  49. package/packages/public-runtime/src/integration/platform-adapter-runtime.ts +9 -2
  50. package/packages/public-runtime/src/integration/render-phases.ts +10 -18
  51. package/packages/public-runtime/src/integration/resolve.ts +3 -3
  52. package/packages/public-runtime/src/integration/tenant-analytics.ts +250 -0
  53. package/packages/public-runtime/src/integration/visitor-region.mjs +1 -1
  54. package/packages/public-runtime/src/tenant.ts +3 -3
  55. package/packages/public-runtime/tests/fixtures/integration/campaign-token-corpus.ts +75 -0
  56. package/scripts/dev/ga4-gs2-canary.mjs +36 -10
  57. 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
- * Tenant-owned analytics declaration surface (TIER 1).
2
+ * Runtime read of a tenant's own `analytics.json` (TIER 1) for the render hot path.
3
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.
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
- buildPlatformAdapterDeclaration,
37
- ENVIRONMENT_SCHEMA_VERSION,
38
- Violations,
39
- isEuUkVisitorPolicy,
40
- isPlainObject,
41
- rejectUnknownKeys,
42
- type BrowserEnvironment,
43
- type ContractViolation,
9
+ describeTenantAnalyticsViolations,
10
+ materializeTenantAnalyticsDeclarations,
44
11
  type IntegrationDeclarationInput,
45
- type TenantConfig,
12
+ type TenantAnalyticsMaterialization,
46
13
  } from "@tot/public-runtime";
47
14
 
48
- /** The tenant-owned file that declares measurement destinations, in the tenant tree. */
49
- export const TENANT_ANALYTICS_FILE = "analytics.json";
50
-
51
- /** The current `analytics.json` contract version. */
52
- export const TENANT_ANALYTICS_SCHEMA_VERSION = 1 as const;
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
- * Parse and materialize a tenant's `analytics.json` content into first-class adapter
138
- * declarations. `raw` is the already-parsed JSON value (or `undefined`/`null` when the
139
- * tenant declares none). Fail-closed and whole-file: if ANY declared destination is
140
- * invalid, NO declaration is produced and the violations name every offending field —
141
- * a half-valid analytics file never partially materializes.
142
- */
143
- export function materializeTenantAnalyticsDeclarations(input: {
144
- raw: unknown;
145
- tenantPublicId: string;
146
- canonicalHost: string;
147
- }): TenantAnalyticsMaterialization {
148
- const { raw, tenantPublicId, canonicalHost } = input;
149
- // An undeclared tenant (no file) is the zero-cost norm, not an error.
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
- const { declarations, euUkVisitors } = materializeTenantAnalyticsDeclarations({
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
- return euUkVisitors ? { declarations, euUkVisitors } : { declarations };
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
+ }