@tokenoftrust/storefront-runner 2.0.17 → 2.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/apps/storefront/env.d.ts +21 -0
  2. package/apps/storefront/migrations-apps/0012_test_destinations.sql +29 -0
  3. package/apps/storefront/src/components/blog/ReactionBar.astro +1 -1
  4. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +365 -49
  5. package/apps/storefront/src/components/islands/checkoutVerificationFlow.ts +251 -0
  6. package/apps/storefront/src/config/compliance/rulesets.ts +18 -7
  7. package/apps/storefront/src/config/devTenantSeed.ts +24 -1
  8. package/apps/storefront/src/config/evidenceTenantRoster.ts +25 -0
  9. package/apps/storefront/src/config/publicationSpaceConfig.ts +25 -0
  10. package/apps/storefront/src/config/resolver.ts +5 -0
  11. package/apps/storefront/src/config/tenantAnalyticsFiles.ts +43 -0
  12. package/apps/storefront/src/config/tenants.ts +11 -0
  13. package/apps/storefront/src/layouts/Layout.astro +5 -1
  14. package/apps/storefront/src/lib/activity/killSwitch.ts +1 -1
  15. package/apps/storefront/src/lib/admin/consentSettingsClient.ts +113 -0
  16. package/apps/storefront/src/lib/apps/consentConfigRead.ts +24 -0
  17. package/apps/storefront/src/lib/apps/gtmMigrationDraft.ts +103 -0
  18. package/apps/storefront/src/lib/auth/brokerAssertion.ts +18 -0
  19. package/apps/storefront/src/lib/auth/identityToken.ts +11 -1
  20. package/apps/storefront/src/lib/auth/internalContentAuthority.ts +153 -0
  21. package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
  22. package/apps/storefront/src/lib/auth/session.ts +3 -0
  23. package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
  24. package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
  25. package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
  26. package/apps/storefront/src/lib/edgeCache.ts +17 -1
  27. package/apps/storefront/src/lib/env.ts +13 -111
  28. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
  29. package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
  30. package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
  31. package/apps/storefront/src/lib/publication/runtimeSource.ts +97 -0
  32. package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
  33. package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
  34. package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
  35. package/apps/storefront/src/middleware/candidatePin.ts +35 -0
  36. package/apps/storefront/src/middleware/controlPlane.ts +291 -0
  37. package/apps/storefront/src/middleware/index.ts +200 -793
  38. package/apps/storefront/src/middleware/publicRender.ts +43 -0
  39. package/apps/storefront/src/middleware/renderChain.ts +730 -0
  40. package/apps/storefront/src/middleware/routeBranch.ts +192 -0
  41. package/apps/storefront/src/middleware/staticBundle.ts +109 -0
  42. package/apps/storefront/src/pages/[...slug].astro +1 -0
  43. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -0
  44. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +1 -0
  45. package/apps/storefront/src/pages/api/auth/verify.ts +1 -0
  46. package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
  47. package/apps/storefront/src/pages/index.astro +1 -0
  48. package/apps/storefront/vitest.config.ts +5 -0
  49. package/package.json +1 -1
  50. package/packages/public-runtime/package.json +1 -0
  51. package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
  52. package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
  53. package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
  54. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
  55. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
  56. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
  57. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
  58. package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
  59. package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
  60. package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
  61. package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
  62. package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
  63. package/packages/public-runtime/src/activity/catalog.ts +1 -1
  64. package/packages/public-runtime/src/candidate-index.ts +6 -0
  65. package/packages/public-runtime/src/customization-reconcile.ts +8 -0
  66. package/packages/public-runtime/src/html-cache-policy.ts +57 -0
  67. package/packages/public-runtime/src/index.ts +9 -0
  68. package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
  69. package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
  70. package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
  71. package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
  72. package/packages/public-runtime/src/integration/render-phases.ts +8 -3
  73. package/packages/public-runtime/src/publication-artifact.ts +220 -0
  74. package/packages/public-runtime/src/publication-reader.ts +187 -0
  75. package/packages/public-runtime/src/publication-routing.ts +246 -0
  76. package/packages/public-runtime/src/publication-space.ts +853 -0
  77. package/packages/public-runtime/src/publication-version.ts +602 -0
  78. package/packages/public-runtime/src/route-tier.ts +257 -0
  79. package/packages/public-runtime/src/static-bundle.ts +18 -5
  80. package/packages/public-runtime/src/tenant-assets.ts +85 -17
  81. package/packages/public-runtime/src/tenant.ts +70 -0
  82. package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
  83. package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
  84. package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
  85. package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
  86. package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
  87. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
  88. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
  89. package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
  90. package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
  91. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
  92. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
  93. package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
  94. package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
  95. package/scripts/dev/ai-edit.mjs +16 -3
  96. package/scripts/dev/port-check.mjs +18 -3
  97. package/scripts/dev/publish.mjs +26 -3
  98. package/scripts/tenant/validate.mjs +14 -1
@@ -140,6 +140,33 @@ export function defaultConsentSnapshot(): ConsentSnapshot {
140
140
  return makeSnapshot([ESSENTIAL]);
141
141
  }
142
142
 
143
+ /**
144
+ * The MATERIALIZE-ONLY request header carrying a tenant's resolved consent snapshot
145
+ * (ADR 0013). The go-live dispatch resolves the tenant's REAL snapshot from D1 (the
146
+ * Worker holds that binding) and the bake pipeline sets this header on each render, so
147
+ * the static render inlines the real snapshot WITHOUT a consent D1 read on the
148
+ * public/materialize path. Never present on a real public request.
149
+ */
150
+ export const CONSENT_SNAPSHOT_INPUT_HEADER = "x-tot-consent-snapshot";
151
+
152
+ /**
153
+ * The HMAC-SHA256 signature (hex) over the {@link CONSENT_SNAPSHOT_INPUT_HEADER}
154
+ * value, carried alongside it. The go-live Worker (which holds both the tenant's real
155
+ * consent D1 and the signing secret) signs the snapshot; the materialize render
156
+ * VERIFIES it before honoring the input header. Absent/invalid ⇒ the snapshot input is
157
+ * rejected and consent falls back to the fail-closed default. Together with the
158
+ * materialize-mode binding gate this makes the input header un-forgeable: the deployed
159
+ * Worker never reads it at all, and even in materialize mode a snapshot without a valid
160
+ * signature from the go-live secret is refused.
161
+ */
162
+ export const CONSENT_SNAPSHOT_SIG_HEADER = "x-tot-consent-snapshot-sig";
163
+
164
+ /** The Worker secret binding whose value signs/verifies the baked consent snapshot. */
165
+ export const CONSENT_SNAPSHOT_HMAC_SECRET_BINDING = "CONSENT_SNAPSHOT_HMAC_SECRET";
166
+
167
+ /** The Worker binding that marks a MATERIALIZE-mode render — absent on the deployed Worker. */
168
+ export const MATERIALIZE_MODE_BINDING = "TOT_MATERIALIZE_MODE";
169
+
143
170
  /**
144
171
  * Whether a purpose is currently eligible. `essential` is always eligible; an
145
172
  * unknown or unapproved-and-ungranted purpose is not — so this both permits
@@ -208,6 +235,94 @@ export function parseConsentSnapshot(raw: unknown): ConsentSnapshot | null {
208
235
  return canonical;
209
236
  }
210
237
 
238
+ // ── Baked-snapshot signature (materialize bake wire) ────────────────────────────
239
+ // The go-live Worker holds the tenant's real consent D1 AND the signing secret; it
240
+ // signs the resolved snapshot, and the materialize render verifies before honoring the
241
+ // input header. Web Crypto only (crypto.subtle) so it runs unchanged on workerd, and
242
+ // verification is constant-time. The signature is TENANT-BOUND — it covers a
243
+ // length-prefixed framing of `tenantId` and `serializedSnapshot`, so neither field's
244
+ // content can shift a byte across the field boundary: a signed snapshot for one tenant
245
+ // can never be replayed as another tenant's baked input, or as a different snapshot for
246
+ // the same tenant.
247
+
248
+ const consentSigTextEncoder = new TextEncoder();
249
+
250
+ /**
251
+ * The exact string the consent snapshot signature covers: each field prefixed by its
252
+ * own UTF-8 byte length, so no value in either field can be interpreted as a field
253
+ * separator.
254
+ */
255
+ function consentSignaturePayload(tenantId: string, serializedSnapshot: string): string {
256
+ const tenantBytes = consentSigTextEncoder.encode(tenantId).length;
257
+ const snapshotBytes = consentSigTextEncoder.encode(serializedSnapshot).length;
258
+ return `${tenantBytes}:${tenantId}${snapshotBytes}:${serializedSnapshot}`;
259
+ }
260
+
261
+ async function consentHmacKey(secret: string): Promise<CryptoKey> {
262
+ return crypto.subtle.importKey(
263
+ "raw",
264
+ consentSigTextEncoder.encode(secret) as unknown as BufferSource,
265
+ { name: "HMAC", hash: "SHA-256" },
266
+ false,
267
+ ["sign", "verify"],
268
+ );
269
+ }
270
+
271
+ function consentSigHexToBytes(hex: string): Uint8Array | null {
272
+ if (hex.length === 0 || hex.length % 2 !== 0 || /[^0-9a-fA-F]/.test(hex)) return null;
273
+ const out = new Uint8Array(hex.length / 2);
274
+ for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
275
+ return out;
276
+ }
277
+
278
+ /**
279
+ * Sign a tenant's serialized consent snapshot: hex HMAC-SHA256 over the length-prefixed
280
+ * {@link consentSignaturePayload}. The go-live Worker calls this; the materialize render
281
+ * verifies with the same secret via {@link verifyConsentSnapshotSig}.
282
+ */
283
+ export async function signConsentSnapshot(
284
+ secret: string,
285
+ tenantId: string,
286
+ serializedSnapshot: string,
287
+ ): Promise<string> {
288
+ const key = await consentHmacKey(secret);
289
+ const sig = await crypto.subtle.sign(
290
+ "HMAC",
291
+ key,
292
+ consentSigTextEncoder.encode(
293
+ consentSignaturePayload(tenantId, serializedSnapshot),
294
+ ) as unknown as BufferSource,
295
+ );
296
+ let hex = "";
297
+ for (const b of new Uint8Array(sig)) hex += b.toString(16).padStart(2, "0");
298
+ return hex;
299
+ }
300
+
301
+ /**
302
+ * Verify a consent snapshot signature (constant-time via `crypto.subtle.verify`). A
303
+ * malformed / short / absent signature returns false rather than throwing — fail-closed,
304
+ * so a caller can never smuggle in an unsigned or forged snapshot.
305
+ */
306
+ export async function verifyConsentSnapshotSig(
307
+ secret: string,
308
+ tenantId: string,
309
+ serializedSnapshot: string,
310
+ signatureHex: string | null | undefined,
311
+ ): Promise<boolean> {
312
+ if (!signatureHex) return false;
313
+ const sig = consentSigHexToBytes(signatureHex.trim());
314
+ if (!sig) return false;
315
+ const key = await consentHmacKey(secret);
316
+ return crypto.subtle.verify(
317
+ "HMAC",
318
+ key,
319
+ sig as unknown as BufferSource,
320
+ consentSigTextEncoder.encode(
321
+ consentSignaturePayload(tenantId, serializedSnapshot),
322
+ ) as unknown as BufferSource,
323
+ );
324
+ }
325
+
211
326
  /** Listener invoked with the new snapshot whenever eligibility changes. */
212
327
  export type ConsentListener = (snapshot: ConsentSnapshot) => void;
213
328
 
@@ -12,13 +12,15 @@
12
12
  * with it (the plan carries no vendor runtime, and `planRevision` moves), so a
13
13
  * rollback leaves no stale effect.
14
14
  *
15
- * It is deliberately narrow (ADR 0016/0023 Q6): the bridge is enabled ONLY on
16
- * eligible unregulated-marketing tenants. A regulated tenant is REFUSED with an
17
- * actionable explanation until a capability-specific certification exists — the
18
- * regulated shared-runtime floor is non-waivable, and this bridge never opens an
19
- * escape hatch around it. Promote-time enforcement still runs through the full
20
- * fail-closed gate (`verifyIntegrationPromotable`); this module is the render-time
21
- * tenant-eligibility decision that precedes it.
15
+ * It is deliberately narrow (ADR 0016/0023 Q6): on a regulated tenant the bridge
16
+ * enables only the declarations the surface × tier matrix permits on the regulated
17
+ * surface without a certification — reviewed platform-adapter/sandbox/server tiers —
18
+ * and REFUSES full parent authority (`elevated-parent`) until a capability-specific
19
+ * certification clears every clause. The regulated shared-runtime floor is
20
+ * non-waivable, and this bridge never opens an escape hatch around it. Promote-time
21
+ * enforcement still runs through the full fail-closed gate
22
+ * (`verifyIntegrationPromotable`); this module is the render-time tenant-eligibility
23
+ * decision that precedes it.
22
24
  */
23
25
  import type { BrowserEnvironment } from "./environment.js";
24
26
  import type { EffectivePlan } from "./plan.js";
@@ -34,6 +36,13 @@ import {
34
36
  isRegulatedParentCertified,
35
37
  type RegulatedParentCertification,
36
38
  } from "./regulated-parent-cert.js";
39
+ import {
40
+ allowedTiersForCell,
41
+ resolveCellAuthority,
42
+ type CellAuthority,
43
+ type TenantTier,
44
+ } from "../integration-capabilities.js";
45
+ import type { ExecutionTier } from "./phases.js";
37
46
 
38
47
  /**
39
48
  * Whether an integration's declared consent dependency permits loading its entry
@@ -57,6 +66,11 @@ export interface InterimBridgeRefusal {
57
66
  surface: AssuranceSurface;
58
67
  /** An operator-facing explanation of the refusal and what unblocks it. */
59
68
  message: string;
69
+ /**
70
+ * The registered feedback item tracking this gap, so a consumer can route the reader
71
+ * to live work without parsing the message. See {@link REGULATED_REFUSAL_FEEDBACK_REF}.
72
+ */
73
+ feedbackRef: string;
60
74
  }
61
75
 
62
76
  /**
@@ -80,10 +94,11 @@ export interface InterimBridgeInput {
80
94
  * G2: the regulated elevated-parent certification artifact for a regulated tenant.
81
95
  * The bridge NEVER accepts a raw "certified" boolean — it runs
82
96
  * {@link decideRegulatedParentCert} itself (via {@link isRegulatedParentCertified}),
83
- * so `certifiedImmutable` has a SINGLE ORIGIN: a regulated tenant is enabled ONLY
84
- * when the cert clears every clause (pinned, undrifted from `servedDigest`, every
85
- * server-derived obligation proven). Absent/failed ⇒ the decision fails closed and
86
- * the tenant stays refused. This is the WS2 forward seam, not a per-tenant bypass.
97
+ * so `certifiedImmutable` has a SINGLE ORIGIN: a regulated tenant's full-parent
98
+ * authority (`elevated-parent`) declaration is enabled ONLY when the cert clears
99
+ * every clause (pinned, undrifted from `servedDigest`, every server-derived
100
+ * obligation proven). Absent/failed ⇒ the decision fails closed and that declaration
101
+ * stays refused. This is the WS2 forward seam, not a per-tenant bypass.
87
102
  */
88
103
  regulatedCertification?: RegulatedParentCertification | null;
89
104
  /**
@@ -101,31 +116,71 @@ export interface InterimBridgeInput {
101
116
  * set), so an eligible tenant's plan is exactly the kernel plan every mount serves —
102
117
  * the bridge adds only the tenant-eligibility gate, not a second plan shape.
103
118
  */
119
+ /**
120
+ * Feedback tripwire for the regulated refusal below.
121
+ *
122
+ * A tripwire is three coupled parts: a registered feedback item describing an accepted
123
+ * gap, a reference to it from the message emitted where execution trips over that gap,
124
+ * and a workstream that owns closing it. Carrying the reference in the refusal itself is
125
+ * what routes a reader to live work instead of a fresh diagnosis.
126
+ *
127
+ * This gate refuses a regulated tenant whose declaration requests full parent
128
+ * authority (`elevated-parent`) without a {@link RegulatedParentCertification} that
129
+ * clears every clause — an absent or failed one fails closed. Owned by the
130
+ * `tot-regulated-integration-certification` workstream.
131
+ *
132
+ * See docs/playbooks/feedback-tripwire.md for the generalized recipe — this is the
133
+ * reference implementation, not a one-off.
134
+ */
135
+ export const REGULATED_REFUSAL_FEEDBACK_REF = "fb-1788612281954-3q8f1n" as const;
136
+
104
137
  export function resolveInterimBridgePlan(input: InterimBridgeInput): InterimBridgeResult {
105
138
  const surface = resolveObligationSurface(input.tenant);
106
139
 
107
140
  // The certification is the SINGLE ORIGIN of `certifiedImmutable`: derive it from the
108
- // decision (never a raw external flag), so a regulated tenant is enabled only when the
109
- // cert actually clears every clause. Absent/failed ⇒ false ⇒ refused.
141
+ // decision (never a raw external flag), so a regulated tenant's full-parent authority
142
+ // declaration is enabled only when the cert actually clears every clause. Absent/failed
143
+ // ⇒ false ⇒ that declaration is refused.
110
144
  const regulatedCertified = isRegulatedParentCertified(input.regulatedCertification, {
111
145
  tenant: input.tenant,
112
146
  servedDigest: input.servedDigest,
113
147
  });
114
148
 
115
- if (isRegulated(input.tenant) && !regulatedCertified) {
116
- return {
117
- ok: false,
118
- reason: "regulated-refused",
119
- refusal: {
120
- code: "regulated-tenant-not-certified",
121
- surface,
122
- message:
123
- `The interim marketing bridge is not enabled for regulated tenants (resolved surface ` +
124
- `"${surface}"). A regulated parent tier requires an immutable, version-pinned, ` +
125
- `capability-specific certification — a mere acknowledgement cannot waive the ` +
126
- `non-waivable regulated shared-runtime floor. Complete WS2 certification to enable it.`,
127
- },
128
- };
149
+ // Drive the render-time refusal off the same surface × tier matrix the policy layer
150
+ // enforces (SURFACE_TENANT_TIER_MATRIX): a regulated tenant is refused only for a
151
+ // declaration the matrix does not permit on the regulated surface — full parent
152
+ // authority (`elevated-parent`) without a certification that clears every clause, never
153
+ // a reviewed platform-adapter/sandbox/server tier the cell already allows. A cert whose
154
+ // codeForm is `mutable-container`, or that has drifted/is incomplete, never clears the
155
+ // decision, so mutable parent code on a regulated surface stays refused.
156
+ // `verifyIntegrationPromotable` remains the authoritative fail-closed backstop at
157
+ // promote time; this only stops the bridge PRE-refusing a class the policy permits.
158
+ if (isRegulated(input.tenant)) {
159
+ const cell = resolveCellAuthority(regulatedTenantTier(input.tenant), "regulated");
160
+ const refused =
161
+ !cell ||
162
+ input.declarations.some((decl) =>
163
+ isRegulatedDeclarationRefused(decl.manifest.browser.executionTier, cell, regulatedCertified),
164
+ );
165
+ if (refused) {
166
+ return {
167
+ ok: false,
168
+ reason: "regulated-refused",
169
+ refusal: {
170
+ code: "regulated-tenant-not-certified",
171
+ surface,
172
+ message:
173
+ `This integration needs full-page access to run on your storefront. Because your ` +
174
+ `store is regulated, that level of access is only available once a compliance ` +
175
+ `certification confirms it can't reach age-verification or customer data — a ` +
176
+ `safeguard, not a limitation of your store. Standard analytics and platform ` +
177
+ `integrations, including Google Analytics 4 (GA4), work normally on a regulated ` +
178
+ `storefront and can be added today. To proceed, choose a standard, already-supported ` +
179
+ `integration, or contact support to pursue certification for this one.`,
180
+ feedbackRef: REGULATED_REFUSAL_FEEDBACK_REF,
181
+ },
182
+ };
183
+ }
129
184
  }
130
185
 
131
186
  const result = resolveEffectivePlan({
@@ -136,3 +191,32 @@ export function resolveInterimBridgePlan(input: InterimBridgeInput): InterimBrid
136
191
  if (!result.ok) return { ok: false, reason: "invalid-plan", violations: result.violations };
137
192
  return { ok: true, plan: result.value };
138
193
  }
194
+
195
+ /**
196
+ * The regulated tenant tier for the matrix lookup. Both regulated tiers carry the
197
+ * identical `regulated` cell (`maxTier: "server"`, `elevatedParent: "cert-required"`),
198
+ * so the split only tracks the tenant's site type; an unset site type resolves to
199
+ * `regulated-commerce`, mirroring {@link resolveObligationSurface}'s commerce floor.
200
+ */
201
+ function regulatedTenantTier(tenant: ObligationTenant): TenantTier {
202
+ return tenant.siteType === "marketing" ? "regulated-marketing" : "regulated-commerce";
203
+ }
204
+
205
+ /**
206
+ * Whether one declaration is refused on the regulated surface, per the cell's authority.
207
+ * Full parent authority (`elevated-parent`) is gated by the cell's elevated-parent
208
+ * disposition — `cert-required` enables it only behind a certification that clears every
209
+ * clause, and any other disposition (`prohibited`/`acknowledge`) never enables it here
210
+ * (fail closed). Every other tier is permitted when it is within the cell's allowed
211
+ * non-elevated tiers (`platform-adapter` ≤ `sandbox` ≤ `server`).
212
+ */
213
+ function isRegulatedDeclarationRefused(
214
+ tier: ExecutionTier,
215
+ cell: CellAuthority,
216
+ regulatedCertified: boolean,
217
+ ): boolean {
218
+ if (tier === "elevated-parent") {
219
+ return cell.elevatedParent === "cert-required" ? !regulatedCertified : true;
220
+ }
221
+ return !allowedTiersForCell(cell).includes(tier);
222
+ }
@@ -286,6 +286,32 @@ const SET_INTEGRATION_INSTALL_STATUS_BODY_SCHEMA = {
286
286
  const DELETE_INTEGRATION_INSTALL_BODY_SCHEMA = {
287
287
  type: "object", additionalProperties: false, required: ["installId"], properties: { installId: PUBLIC_IDENTIFIER },
288
288
  } as const;
289
+ const TEST_DESTINATION_ENVIRONMENT = { type: "string", enum: ["local", "candidate", "staged"] } as const;
290
+ const APPROVED_MARKETING_ACTION_NAME = {
291
+ type: "string",
292
+ enum: [
293
+ "user_clicked_contact_sales_mktg",
294
+ "user_clicked_create_account_mktg",
295
+ "user_clicked_login_mktg",
296
+ "user_clicked_contact_support_consumer_mktg",
297
+ "user_clicked_contact_support_partner_mktg",
298
+ ],
299
+ } as const;
300
+ const REGISTER_TEST_DESTINATION_BODY_SCHEMA = {
301
+ type: "object", additionalProperties: false,
302
+ required: ["destinationId", "environment"],
303
+ properties: { destinationId: PUBLIC_IDENTIFIER, environment: TEST_DESTINATION_ENVIRONMENT },
304
+ } as const;
305
+ const DELIVER_TEST_EVENT_BODY_SCHEMA = {
306
+ type: "object", additionalProperties: false,
307
+ required: ["destinationId", "eventId", "actionName"],
308
+ properties: { destinationId: PUBLIC_IDENTIFIER, eventId: PUBLIC_IDENTIFIER, actionName: APPROVED_MARKETING_ACTION_NAME },
309
+ } as const;
310
+ const TEST_DESTINATION_DELIVERY_QUERY_SCHEMA = {
311
+ type: "object", additionalProperties: false,
312
+ required: ["destinationId", "eventId"],
313
+ properties: { destinationId: PUBLIC_IDENTIFIER, eventId: PUBLIC_IDENTIFIER },
314
+ } as const;
289
315
 
290
316
  export const CREATE_MIGRATION_DRAFT_REQUEST_V1_JSON_SCHEMA = mutationOperationSchema("CreateMigrationDraftRequest", "create-migration-draft-request.schema.json", CREATE_MIGRATION_DRAFT_BODY_SCHEMA);
291
317
  export const UPDATE_MIGRATION_DRAFT_REQUEST_V1_JSON_SCHEMA = mutationOperationSchema("UpdateMigrationDraftRequest", "update-migration-draft-request.schema.json", UPDATE_MIGRATION_DRAFT_BODY_SCHEMA);
@@ -294,6 +320,40 @@ export const CREATE_INTEGRATION_INSTALL_REQUEST_V1_JSON_SCHEMA = mutationOperati
294
320
  export const REVISE_INTEGRATION_INSTALL_REQUEST_V1_JSON_SCHEMA = mutationOperationSchema("ReviseIntegrationInstallRequest", "revise-integration-install-request.schema.json", REVISE_INTEGRATION_INSTALL_BODY_SCHEMA);
295
321
  export const SET_INTEGRATION_INSTALL_STATUS_REQUEST_V1_JSON_SCHEMA = mutationOperationSchema("SetIntegrationInstallStatusRequest", "set-integration-install-status-request.schema.json", SET_INTEGRATION_INSTALL_STATUS_BODY_SCHEMA);
296
322
  export const DELETE_INTEGRATION_INSTALL_REQUEST_V1_JSON_SCHEMA = mutationOperationSchema("DeleteIntegrationInstallRequest", "delete-integration-install-request.schema.json", DELETE_INTEGRATION_INSTALL_BODY_SCHEMA);
323
+ export const REGISTER_TEST_DESTINATION_REQUEST_V1_JSON_SCHEMA = mutationOperationSchema("RegisterTestDestinationRequest", "register-test-destination-request.schema.json", REGISTER_TEST_DESTINATION_BODY_SCHEMA);
324
+ export const DELIVER_TEST_EVENT_REQUEST_V1_JSON_SCHEMA = mutationOperationSchema("DeliverTestEventRequest", "deliver-test-event-request.schema.json", DELIVER_TEST_EVENT_BODY_SCHEMA);
325
+ export const GET_TEST_DESTINATION_DELIVERY_REQUEST_V1_JSON_SCHEMA = readOperationSchema("GetTestDestinationDeliveryRequest", "get-test-destination-delivery-request.schema.json", TEST_DESTINATION_DELIVERY_QUERY_SCHEMA);
326
+
327
+ export const TEST_DESTINATION_V1_JSON_SCHEMA = {
328
+ $schema: DRAFT,
329
+ $id: `${BASE}/test-destination.schema.json`,
330
+ title: "TestDestinationV1",
331
+ type: "object",
332
+ additionalProperties: false,
333
+ required: ["schemaVersion", "destinationId", "environment", "createdAt"],
334
+ properties: {
335
+ schemaVersion: { type: "integer", const: 1 },
336
+ destinationId: PUBLIC_IDENTIFIER,
337
+ environment: TEST_DESTINATION_ENVIRONMENT,
338
+ createdAt: { ...SAFE_CONFIG_STRING, format: "date-time" },
339
+ },
340
+ } as const;
341
+
342
+ export const TEST_DESTINATION_DELIVERY_V1_JSON_SCHEMA = {
343
+ $schema: DRAFT,
344
+ $id: `${BASE}/test-destination-delivery.schema.json`,
345
+ title: "TestDestinationDeliveryV1",
346
+ type: "object",
347
+ additionalProperties: false,
348
+ required: ["schemaVersion", "destinationId", "eventId", "actionName", "deliveredAt"],
349
+ properties: {
350
+ schemaVersion: { type: "integer", const: 1 },
351
+ destinationId: PUBLIC_IDENTIFIER,
352
+ eventId: PUBLIC_IDENTIFIER,
353
+ actionName: APPROVED_MARKETING_ACTION_NAME,
354
+ deliveredAt: { ...SAFE_CONFIG_STRING, format: "date-time" },
355
+ },
356
+ } as const;
297
357
 
298
358
  export const READ_SUCCESS_ENVELOPE_V1_JSON_SCHEMA = {
299
359
  $schema: DRAFT,
@@ -376,6 +436,11 @@ export const MANAGED_ANALYTICS_API_V1_JSON_SCHEMAS = Object.freeze({
376
436
  reviseIntegrationInstallRequest: REVISE_INTEGRATION_INSTALL_REQUEST_V1_JSON_SCHEMA,
377
437
  setIntegrationInstallStatusRequest: SET_INTEGRATION_INSTALL_STATUS_REQUEST_V1_JSON_SCHEMA,
378
438
  deleteIntegrationInstallRequest: DELETE_INTEGRATION_INSTALL_REQUEST_V1_JSON_SCHEMA,
439
+ registerTestDestinationRequest: REGISTER_TEST_DESTINATION_REQUEST_V1_JSON_SCHEMA,
440
+ deliverTestEventRequest: DELIVER_TEST_EVENT_REQUEST_V1_JSON_SCHEMA,
441
+ getTestDestinationDeliveryRequest: GET_TEST_DESTINATION_DELIVERY_REQUEST_V1_JSON_SCHEMA,
442
+ testDestination: TEST_DESTINATION_V1_JSON_SCHEMA,
443
+ testDestinationDelivery: TEST_DESTINATION_DELIVERY_V1_JSON_SCHEMA,
379
444
  readSuccessEnvelope: READ_SUCCESS_ENVELOPE_V1_JSON_SCHEMA,
380
445
  mutationSuccessEnvelope: MUTATION_SUCCESS_ENVELOPE_V1_JSON_SCHEMA,
381
446
  errorEnvelope: API_ERROR_ENVELOPE_V1_JSON_SCHEMA,
@@ -10,6 +10,7 @@
10
10
  import type { ContractResult } from "./result.js";
11
11
  import { Violations, isPlainObject } from "./result.js";
12
12
  import { isForbiddenKey } from "./secrets.js";
13
+ import { APPROVED_MARKETING_ACTION_NAMES } from "./events.js";
13
14
  import {
14
15
  MANAGED_ANALYTICS_PII_VALUE_PATTERN,
15
16
  MANAGED_ANALYTICS_SECRET_VALUE_PATTERN,
@@ -112,6 +113,37 @@ export interface MigrationDraftV1 {
112
113
  updatedAt: string;
113
114
  }
114
115
 
116
+ /**
117
+ * A test destination is intentionally never `live`: it exists so a developer can
118
+ * prove exactly-once delivery for a new marketing action without touching a real
119
+ * vendor property. Real first-class adapters (GA4/Meta/Amplitude) fire directly to
120
+ * their vendor with no server-side readback; this destination is Storefront-owned,
121
+ * so a delivered event can be read back.
122
+ */
123
+ export const TEST_DESTINATION_ENVIRONMENTS = ["local", "candidate", "staged"] as const;
124
+ export type TestDestinationEnvironment = (typeof TEST_DESTINATION_ENVIRONMENTS)[number];
125
+
126
+ export interface TestDestinationV1 {
127
+ schemaVersion: typeof MANAGED_ANALYTICS_SCHEMA_VERSION;
128
+ destinationId: OpaquePublicId;
129
+ environment: TestDestinationEnvironment;
130
+ createdAt: string;
131
+ }
132
+
133
+ /**
134
+ * One delivered event, read back by (destinationId, eventId). `actionName` is
135
+ * restricted to the program-approved v1 marketing action allowlist — the same
136
+ * set a tenant's action registry may enable — so this proves delivery for a real
137
+ * action, never an arbitrary caller-invented event shape.
138
+ */
139
+ export interface TestDestinationDeliveryV1 {
140
+ schemaVersion: typeof MANAGED_ANALYTICS_SCHEMA_VERSION;
141
+ destinationId: OpaquePublicId;
142
+ eventId: OpaquePublicId;
143
+ actionName: OpaquePublicId;
144
+ deliveredAt: string;
145
+ }
146
+
115
147
  export interface PageRequest {
116
148
  cursor?: OpaquePublicId;
117
149
  limit?: number;
@@ -261,6 +293,25 @@ export interface DeleteIntegrationInstallBody { installId: OpaquePublicId }
261
293
  export type DeleteIntegrationInstallRequest = MutationRequest<DeleteIntegrationInstallBody>;
262
294
  export type DeleteIntegrationInstallResponse = MutationResult<{ deleted: true }>;
263
295
 
296
+ export interface RegisterTestDestinationBody {
297
+ destinationId: OpaquePublicId;
298
+ environment: TestDestinationEnvironment;
299
+ }
300
+ export type RegisterTestDestinationRequest = MutationRequest<RegisterTestDestinationBody>;
301
+ export type RegisterTestDestinationResponse = MutationResult<{ destination: TestDestinationV1 }>;
302
+
303
+ export interface DeliverTestEventBody {
304
+ destinationId: OpaquePublicId;
305
+ eventId: OpaquePublicId;
306
+ actionName: OpaquePublicId;
307
+ }
308
+ export type DeliverTestEventRequest = MutationRequest<DeliverTestEventBody>;
309
+ /** `duplicate: true` when this eventId had already been delivered — the exactly-once proof. */
310
+ export type DeliverTestEventResponse = MutationResult<{ delivery: TestDestinationDeliveryV1; duplicate: boolean }>;
311
+
312
+ export type GetTestDestinationDeliveryRequest = ReadRequest<{ destinationId: OpaquePublicId; eventId: OpaquePublicId }>;
313
+ export type GetTestDestinationDeliveryResponse = ApiResult<TestDestinationDeliveryV1>;
314
+
264
315
  /** The only backend seam WS2 needs; tests can mock this interface without HTTP. */
265
316
  export interface ManagedAnalyticsApiV1Client {
266
317
  listAdapterDefinitions(request: ListAdapterDefinitionsRequest): Promise<ListAdapterDefinitionsResponse>;
@@ -557,6 +608,26 @@ export function validateIntegrationRevisionV1(input: unknown): ContractResult<In
557
608
  return v.ok ? prepared : { ok: false, violations: v.items };
558
609
  }
559
610
 
611
+ export function validateTestDestinationV1(input: unknown): ContractResult<TestDestinationV1> {
612
+ const prepared = snapshotManagedAnalyticsDeclarativeInput<TestDestinationV1>(input); if (!prepared.ok) return prepared;
613
+ const v = new Violations(); const obj = object(prepared.value, v, ""); if (!obj) return { ok: false, violations: v.items };
614
+ rejectKeys(obj, ["schemaVersion", "destinationId", "environment", "createdAt"], v, "");
615
+ literal(obj, "schemaVersion", 1, v, ""); opaqueId(obj, "destinationId", v, ""); enumeration(obj, "environment", TEST_DESTINATION_ENVIRONMENTS, v, ""); timestamp(obj, "createdAt", v, "");
616
+ const safe = validateManagedAnalyticsSafePayload(obj); if (!safe.ok) v.items.push(...safe.violations);
617
+ return v.ok ? prepared : { ok: false, violations: v.items };
618
+ }
619
+
620
+ export function validateTestDestinationDeliveryV1(input: unknown): ContractResult<TestDestinationDeliveryV1> {
621
+ const prepared = snapshotManagedAnalyticsDeclarativeInput<TestDestinationDeliveryV1>(input); if (!prepared.ok) return prepared;
622
+ const v = new Violations(); const obj = object(prepared.value, v, ""); if (!obj) return { ok: false, violations: v.items };
623
+ rejectKeys(obj, ["schemaVersion", "destinationId", "eventId", "actionName", "deliveredAt"], v, "");
624
+ literal(obj, "schemaVersion", 1, v, ""); opaqueId(obj, "destinationId", v, ""); opaqueId(obj, "eventId", v, "");
625
+ enumeration(obj, "actionName", APPROVED_MARKETING_ACTION_NAMES, v, "");
626
+ timestamp(obj, "deliveredAt", v, "");
627
+ const safe = validateManagedAnalyticsSafePayload(obj); if (!safe.ok) v.items.push(...safe.violations);
628
+ return v.ok ? prepared : { ok: false, violations: v.items };
629
+ }
630
+
560
631
  export function validateMigrationDraftV1(input: unknown): ContractResult<MigrationDraftV1> {
561
632
  const prepared = snapshotManagedAnalyticsDeclarativeInput<MigrationDraftV1>(input); if (!prepared.ok) return prepared;
562
633
  const v = new Violations(); const obj = object(prepared.value, v, ""); if (!obj) return { ok: false, violations: v.items };
@@ -649,6 +720,13 @@ const validateSetIntegrationInstallStatusBody: ObjectShapeValidator = (obj, v, p
649
720
  rejectKeys(obj, ["installId", "status"], v, path); opaqueId(obj, "installId", v, path); enumeration(obj, "status", INTEGRATION_INSTALL_STATUSES, v, path);
650
721
  };
651
722
  const validateDeleteIntegrationInstallBody: ObjectShapeValidator = (obj, v, path) => { rejectKeys(obj, ["installId"], v, path); opaqueId(obj, "installId", v, path); };
723
+ const validateRegisterTestDestinationBody: ObjectShapeValidator = (obj, v, path) => {
724
+ rejectKeys(obj, ["destinationId", "environment"], v, path); opaqueId(obj, "destinationId", v, path); enumeration(obj, "environment", TEST_DESTINATION_ENVIRONMENTS, v, path);
725
+ };
726
+ const validateDeliverTestEventBody: ObjectShapeValidator = (obj, v, path) => {
727
+ rejectKeys(obj, ["destinationId", "eventId", "actionName"], v, path); opaqueId(obj, "destinationId", v, path); opaqueId(obj, "eventId", v, path); enumeration(obj, "actionName", APPROVED_MARKETING_ACTION_NAMES, v, path);
728
+ };
729
+ const validateTestDestinationDeliveryQuery: ObjectShapeValidator = (obj, v, path) => { rejectKeys(obj, ["destinationId", "eventId"], v, path); opaqueId(obj, "destinationId", v, path); opaqueId(obj, "eventId", v, path); };
652
730
 
653
731
  export function validateCreateMigrationDraftRequest(input: unknown): ContractResult<CreateMigrationDraftRequest> { return validateMutationOperation(input, validateCreateMigrationDraftBody); }
654
732
  export function validateUpdateMigrationDraftRequest(input: unknown): ContractResult<UpdateMigrationDraftRequest> { return validateMutationOperation(input, validateUpdateMigrationDraftBody); }
@@ -657,6 +735,9 @@ export function validateCreateIntegrationInstallRequest(input: unknown): Contrac
657
735
  export function validateReviseIntegrationInstallRequest(input: unknown): ContractResult<ReviseIntegrationInstallRequest> { return validateMutationOperation(input, validateReviseIntegrationInstallBody); }
658
736
  export function validateSetIntegrationInstallStatusRequest(input: unknown): ContractResult<SetIntegrationInstallStatusRequest> { return validateMutationOperation(input, validateSetIntegrationInstallStatusBody); }
659
737
  export function validateDeleteIntegrationInstallRequest(input: unknown): ContractResult<DeleteIntegrationInstallRequest> { return validateMutationOperation(input, validateDeleteIntegrationInstallBody); }
738
+ export function validateRegisterTestDestinationRequest(input: unknown): ContractResult<RegisterTestDestinationRequest> { return validateMutationOperation(input, validateRegisterTestDestinationBody); }
739
+ export function validateDeliverTestEventRequest(input: unknown): ContractResult<DeliverTestEventRequest> { return validateMutationOperation(input, validateDeliverTestEventBody); }
740
+ export function validateGetTestDestinationDeliveryRequest(input: unknown): ContractResult<GetTestDestinationDeliveryRequest> { return validateReadOperation(input, validateTestDestinationDeliveryQuery); }
660
741
 
661
742
  /** Validate the closed, public-safe error shape before it crosses the API boundary. */
662
743
  export function validateApiErrorEnvelope(input: unknown): ContractResult<ApiErrorEnvelope> {
@@ -36,7 +36,7 @@ import {
36
36
  renderBrowserEnvironmentScript,
37
37
  serializeForInlineScript,
38
38
  } from "./environment.js";
39
- import { defaultConsentSnapshot } from "./consent-broker.js";
39
+ import { defaultConsentSnapshot, type ConsentSnapshot } from "./consent-broker.js";
40
40
 
41
41
  /**
42
42
  * The sentinel a renderer stamps in place of a live CSP nonce. It is a valid
@@ -138,11 +138,16 @@ export function phaseBoundary(phase: ReservedPhase): string {
138
138
  * splice regions both document mounts consume. `nonce` is the value stamped onto
139
139
  * the emitted inline scripts — pass {@link INTEGRATION_NONCE_PLACEHOLDER} so the
140
140
  * renderer can stamp a fresh nonce per response; an empty string omits the
141
- * attribute (for tests / mounts that stamp uniformly downstream).
141
+ * attribute (for tests / mounts that stamp uniformly downstream). `consentSnapshot`
142
+ * is the tenant's actual initial consent state — the snapshot the ext-policy-03
143
+ * broker computes from its {@link TenantConsentConfig}, seeded before any CMP/banner
144
+ * interaction; it defaults to {@link defaultConsentSnapshot} (essential-only, fail
145
+ * closed) for callers with no tenant consent config to resolve.
142
146
  */
143
147
  export function renderIntegrationPhases(
144
148
  plan: EffectivePlan,
145
149
  nonce: string = INTEGRATION_NONCE_PLACEHOLDER,
150
+ consentSnapshot: ConsentSnapshot = defaultConsentSnapshot(),
146
151
  ): IntegrationPhaseMarkup {
147
152
  assertRenderablePlan(plan);
148
153
 
@@ -150,7 +155,7 @@ export function renderIntegrationPhases(
150
155
  phaseBoundary("platform.environment") +
151
156
  renderBrowserEnvironmentScript(plan.environment, nonce) +
152
157
  phaseBoundary("consent.defaults") +
153
- inlineFrozenGlobal(INTEGRATION_CONSENT_GLOBAL, defaultConsentSnapshot(), nonce) +
158
+ inlineFrozenGlobal(INTEGRATION_CONSENT_GLOBAL, consentSnapshot, nonce) +
154
159
  phaseBoundary("head.early") +
155
160
  inlineFrozenGlobal(INTEGRATION_PLAN_GLOBAL, bootDescriptor(plan), nonce);
156
161