@tokenoftrust/storefront-runner 2.0.17 → 2.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/apps/storefront/env.d.ts +18 -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/loginGate.ts +25 -0
  19. package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
  20. package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
  21. package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
  22. package/apps/storefront/src/lib/env.ts +13 -111
  23. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
  24. package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
  25. package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
  26. package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
  27. package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
  28. package/apps/storefront/src/middleware/candidatePin.ts +35 -0
  29. package/apps/storefront/src/middleware/controlPlane.ts +270 -0
  30. package/apps/storefront/src/middleware/index.ts +100 -796
  31. package/apps/storefront/src/middleware/publicRender.ts +43 -0
  32. package/apps/storefront/src/middleware/renderChain.ts +685 -0
  33. package/apps/storefront/src/middleware/routeBranch.ts +160 -0
  34. package/apps/storefront/src/middleware/staticBundle.ts +109 -0
  35. package/apps/storefront/src/pages/[...slug].astro +1 -0
  36. package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
  37. package/apps/storefront/src/pages/index.astro +1 -0
  38. package/apps/storefront/vitest.config.ts +5 -0
  39. package/package.json +1 -1
  40. package/packages/public-runtime/package.json +1 -0
  41. package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
  42. package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
  43. package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
  44. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
  45. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
  46. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
  47. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
  48. package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
  49. package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
  50. package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
  51. package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
  52. package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
  53. package/packages/public-runtime/src/activity/catalog.ts +1 -1
  54. package/packages/public-runtime/src/candidate-index.ts +6 -0
  55. package/packages/public-runtime/src/customization-reconcile.ts +8 -0
  56. package/packages/public-runtime/src/html-cache-policy.ts +57 -0
  57. package/packages/public-runtime/src/index.ts +7 -0
  58. package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
  59. package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
  60. package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
  61. package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
  62. package/packages/public-runtime/src/integration/render-phases.ts +8 -3
  63. package/packages/public-runtime/src/publication-artifact.ts +203 -0
  64. package/packages/public-runtime/src/publication-space.ts +853 -0
  65. package/packages/public-runtime/src/publication-version.ts +587 -0
  66. package/packages/public-runtime/src/route-tier.ts +257 -0
  67. package/packages/public-runtime/src/static-bundle.ts +18 -5
  68. package/packages/public-runtime/src/tenant-assets.ts +85 -17
  69. package/packages/public-runtime/src/tenant.ts +70 -0
  70. package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
  71. package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
  72. package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
  73. package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
  74. package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
  75. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
  76. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
  77. package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
  78. package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
  79. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
  80. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
  81. package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
  82. package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
  83. package/scripts/dev/ai-edit.mjs +16 -3
  84. package/scripts/dev/port-check.mjs +18 -3
  85. package/scripts/dev/publish.mjs +26 -3
  86. package/scripts/tenant/validate.mjs +14 -1
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Pure, transport-free bridge from an uploaded GTM export to a REVIEWABLE
3
+ * migration-draft PROPOSAL — the connective tissue that makes the self-serve
4
+ * import→draft→review→install flow usable by a tenant developer.
5
+ *
6
+ * The analyze surface returns only a REDACTED report, so a developer can never
7
+ * reconstruct the `createMigrationDraft` body (proposedAdapterId /
8
+ * proposedConfiguration / findings) from it — those derive from the UNREDACTED
9
+ * container. This helper runs the same safe pipeline analyze uses
10
+ * (`parseGtmExport` → `buildMigrationGraph` → `classifyMigration`) and then the
11
+ * pure `generateDisabledMigrationDraft`, returning exactly the
12
+ * `CreateMigrationDraftBody` the developer submits, plus an inert review summary.
13
+ *
14
+ * SAFE-BY-CONSTRUCTION: the produced draft is `enabled:false, publishes:false`;
15
+ * the proposal carries only validator-safe values (the generator validates its
16
+ * own output against the pinned schema, and throws rather than emit an invalid
17
+ * one). Nothing here writes, enables, or publishes — it returns a plain data
18
+ * object, like the analyze builder.
19
+ */
20
+ import {
21
+ parseGtmExport,
22
+ buildMigrationGraph,
23
+ classifyMigration,
24
+ generateDisabledMigrationDraft,
25
+ NO_ADAPTER_SELECTED_ID,
26
+ type CreateMigrationDraftBody,
27
+ type GtmViolation,
28
+ type MigrationFindingCode,
29
+ } from "@tot/public-runtime";
30
+
31
+ /** The inert, review-friendly summary of what installing the proposal would stage. */
32
+ export interface GtmDraftProposalSummary {
33
+ /** Whether a managed-analytics adapter was matched (false = nothing auto-portable). */
34
+ readonly adapterSelected: boolean;
35
+ /** Count of GTM objects that mapped confidently into the managed adapter. */
36
+ readonly mappedCount: number;
37
+ /** Count of GTM objects that could not be automatically ported (needs review). */
38
+ readonly unresolvedCount: number;
39
+ /** Whether a destination id was carried into the proposed configuration. */
40
+ readonly destinationConfigured: boolean;
41
+ /** Stable redacted finding codes (no source text). */
42
+ readonly findings: MigrationFindingCode[];
43
+ }
44
+
45
+ export interface GtmProposeOk {
46
+ readonly ok: true;
47
+ readonly correlationId: string;
48
+ /** The exact body a caller POSTs to `createMigrationDraft`. */
49
+ readonly proposal: CreateMigrationDraftBody;
50
+ readonly summary: GtmDraftProposalSummary;
51
+ }
52
+
53
+ export interface GtmProposeErr {
54
+ readonly ok: false;
55
+ readonly correlationId: string;
56
+ readonly error: string;
57
+ readonly violations: GtmViolation[];
58
+ }
59
+
60
+ /**
61
+ * Parse + graph + classify an uploaded export into a disabled migration-draft
62
+ * PROPOSAL, or return the structured parse violations with a stable, safe error.
63
+ * NEVER throws on bad input, NEVER executes any part of the export.
64
+ *
65
+ * `draftId` is a placeholder the pure generator requires for its own provenance
66
+ * stamp; the authoritative draft id is assigned server-side by
67
+ * `createMigrationDraft`, so the caller only reads the returned `proposal`.
68
+ */
69
+ export function proposeGtmMigrationDraft(
70
+ input: string | Uint8Array,
71
+ opts: { correlationId: string; generatedAt: string; draftId: string },
72
+ ): GtmProposeOk | GtmProposeErr {
73
+ const parsed = parseGtmExport(input);
74
+ if (!parsed.ok) {
75
+ return {
76
+ ok: false,
77
+ correlationId: opts.correlationId,
78
+ error: "The uploaded file is not a valid GTM container export.",
79
+ violations: parsed.violations,
80
+ };
81
+ }
82
+ const graph = buildMigrationGraph(parsed.container);
83
+ const disposition = classifyMigration(graph);
84
+ const result = generateDisabledMigrationDraft({
85
+ container: parsed.container,
86
+ graph,
87
+ disposition,
88
+ provenance: { draftId: opts.draftId, generatedAt: opts.generatedAt },
89
+ });
90
+ const { proposedAdapterId, proposedConfiguration, findings } = result.draft;
91
+ return {
92
+ ok: true,
93
+ correlationId: opts.correlationId,
94
+ proposal: { proposedAdapterId, proposedConfiguration, findings },
95
+ summary: {
96
+ adapterSelected: proposedAdapterId !== NO_ADAPTER_SELECTED_ID,
97
+ mappedCount: result.mappedConcepts.length,
98
+ unresolvedCount: result.unresolvedInventory.length,
99
+ destinationConfigured: findings.includes("ga4-destination-id-configured"),
100
+ findings,
101
+ },
102
+ };
103
+ }
@@ -244,6 +244,11 @@ export function isAdminPath(pathname: string): boolean {
244
244
  * - every `/api/` route (tenant-state mutation). Genuinely-public API endpoints
245
245
  * are already gate-exempt via {@link isPublicPath}/{@link isAuthPath}, so
246
246
  * excluding all of `/api/` here costs nothing and fails safe.
247
+ *
248
+ * Every entry is matched in BOTH addressing forms — bare (`/api/...`) and
249
+ * tenant-prefixed (`/<tenant>/api/...`) — because the storefront serves the same
250
+ * route under both, so matching only one leaves the other reachable on a token or
251
+ * bypass header alone.
247
252
  */
248
253
  export function isBypassPrivilegedPath(pathname: string): boolean {
249
254
  if (isAdminPath(pathname)) return true;
@@ -252,6 +257,7 @@ export function isBypassPrivilegedPath(pathname: string): boolean {
252
257
  if (pathname === "/dev" || pathname.startsWith("/dev/")) return true;
253
258
  if (/^\/[^/]+\/dev(?:\/|$)/.test(pathname)) return true;
254
259
  if (pathname === "/api" || pathname.startsWith("/api/")) return true;
260
+ if (/^\/[^/]+\/api(?:\/|$)/.test(pathname)) return true;
255
261
  return false;
256
262
  }
257
263
 
@@ -281,6 +287,19 @@ export interface LoginGateInput {
281
287
  * (admin, cockpit, /api) are never bypassed regardless of this flag.
282
288
  */
283
289
  qaBypass?: boolean;
290
+ /**
291
+ * True when the request presented a signed, unexpired customization-preview
292
+ * token (`?__preview=` or the `tot_preview` cookie) whose `tenantId` claim is
293
+ * THIS request's tenant. The token is HMAC-signed with
294
+ * `CUSTOMIZATION_PREVIEW_SECRET` and tenant-bound, so it is not client-forgeable
295
+ * and one tenant's token never admits a viewer to another's content.
296
+ *
297
+ * Available to EVERY tenant: previewing an unpublished storefront is platform
298
+ * functionality, not a property of which tenant is being viewed. Passes the gate
299
+ * for PUBLIC CONTENT paths only; {@link isBypassPrivilegedPath} paths (admin,
300
+ * cockpit, /api) still require a real session.
301
+ */
302
+ previewTokenValid?: boolean;
284
303
  }
285
304
 
286
305
  /**
@@ -305,6 +324,12 @@ export function evaluateLoginGate(input: LoginGateInput): LoginGateDecision {
305
324
  // public content only. NEVER for admin/cockpit/api — that guard lives here (not
306
325
  // just in middleware) so /admin stays gated even if the flag is set upstream.
307
326
  if (input.qaBypass && !isBypassPrivilegedPath(input.pathname)) return { action: "pass" };
327
+ // Customization preview: a signed, tenant-bound preview token admits a viewer to
328
+ // that tenant's public content on any tenant. Same privileged-path exclusion as
329
+ // the QA bypass — admin/cockpit/api are never reachable on a token alone.
330
+ if (input.previewTokenValid && !isBypassPrivilegedPath(input.pathname)) {
331
+ return { action: "pass" };
332
+ }
308
333
  if (input.hasValidSession) return { action: "pass" };
309
334
  const next = input.pathname + (input.search ?? "");
310
335
  return { action: "redirect", location: `/auth/login?next=${encodeURIComponent(next)}` };
@@ -14,7 +14,7 @@
14
14
  */
15
15
  import type { TenantConfig, ResolvedManagedCheckout } from "@tot/public-runtime";
16
16
  import { resolveManagedCheckout } from "@tot/public-runtime";
17
- import { readEnv } from "@/lib/env";
17
+ import { readEnv } from "@/lib/runtimeEnv";
18
18
 
19
19
  /**
20
20
  * Resolve the effective checkout facts for a tenant, or null when the tenant has
@@ -19,7 +19,7 @@ import type {
19
19
  } from "@tot/public-runtime";
20
20
  import { enforcementSignals } from "@tot/public-runtime";
21
21
  import { readEnv } from "@/lib/env";
22
- import { COMPLIANCE_RULESETS } from "@/config/compliance/rulesets";
22
+ import { COMPLIANCE_RULE_PROFILES } from "@/config/compliance/rulesets";
23
23
 
24
24
  /** True when the tenant sells age/nicotine-restricted goods (drives fail-closed). */
25
25
  export function isRegulatedTenant(
@@ -55,6 +55,26 @@ export function isSandboxVerificationProvider(
55
55
  return !!provider && SANDBOX_VERIFICATION_PROVIDERS.has(provider.toLowerCase());
56
56
  }
57
57
 
58
+ /**
59
+ * The LIVE Token of Trust verification provider. When `TOT_VERIFICATION_PROVIDER`
60
+ * resolves to this, `/api/compliance/preflight` calls the real ToT verification
61
+ * API server-side (`@/lib/verification/totVerificationClient`) and derives the
62
+ * verification state from that call ALONE — never from a client-supplied
63
+ * `verification` field and never from the front-door affirmation.
64
+ *
65
+ * Deliberately DISJOINT from {@link SANDBOX_VERIFICATION_PROVIDERS}: the
66
+ * affirmation-simulates-verification shortcut must be unreachable under the live
67
+ * provider (asserted in enforcement.test.ts).
68
+ */
69
+ export const LIVE_TOT_VERIFICATION_PROVIDER = "tot";
70
+
71
+ /** True when the resolved provider is the live ToT verification integration. */
72
+ export function isLiveTotVerificationProvider(
73
+ provider: string | undefined | null,
74
+ ): boolean {
75
+ return !!provider && provider.toLowerCase() === LIVE_TOT_VERIFICATION_PROVIDER;
76
+ }
77
+
58
78
  /**
59
79
  * The PACT reporting CAPTURE sink status. The record BUILDER
60
80
  * (`buildPactReportRecord`) ships in this unit and the capture point is wired at
@@ -73,8 +93,10 @@ const PACT_REPORTING_CAPTURE_WIRED = true;
73
93
  export async function resolveTenantRuleset(
74
94
  tenant: Pick<TenantConfig, "tenant_id" | "compliance">,
75
95
  ): Promise<ComplianceRuleset | null> {
76
- const base = COMPLIANCE_RULESETS[tenant.tenant_id];
77
- if (!base) return null;
96
+ const profile = tenant.compliance?.ruleProfile;
97
+ const buildRuleset = profile ? COMPLIANCE_RULE_PROFILES[profile] : undefined;
98
+ if (!buildRuleset) return null;
99
+ const base = buildRuleset(tenant.tenant_id);
78
100
  const provider = (await readEnv("TOT_VERIFICATION_PROVIDER")) || undefined;
79
101
  return {
80
102
  ...base,
@@ -206,6 +206,50 @@ export const trackingTenantDatabases = sqliteTable(
206
206
  }),
207
207
  );
208
208
 
209
+ // --- Managed-analytics test destinations -------------------------------------
210
+ // A tenant-owned, NON-LIVE-ONLY destination a developer registers to prove
211
+ // exactly-once delivery for a new marketing action before wiring a real vendor
212
+ // adapter (GA4/Meta/Amplitude fire directly to the vendor with no server-side
213
+ // readback). `environment` is never `live` — enforced by the managed-analytics
214
+ // service, not by this table. Deliberately its OWN table, not `app_installs` —
215
+ // this is not an integration install (no adapter, no manifest, no CSP/consent
216
+ // plan); it is a disposable proving fixture for the action registry.
217
+ export const testDestinations = sqliteTable(
218
+ "test_destinations",
219
+ {
220
+ destinationId: text("destination_id").primaryKey(),
221
+ tenantId: text("tenant_id").notNull(),
222
+ environment: text("environment").notNull(),
223
+ createdAt: text("created_at").notNull(),
224
+ },
225
+ (t) => ({
226
+ idxTestDestinationsTenant: index(
227
+ "idx_test_destinations_tenant",
228
+ ).on(t.tenantId),
229
+ }),
230
+ );
231
+
232
+ // The delivered-event ledger a test destination reads back. Keyed by
233
+ // (destination_id, event_id): a retried/duplicate delivery of the same event id
234
+ // is `INSERT OR IGNORE`, so the row a caller reads back always reflects the
235
+ // FIRST delivery only — the proof that delivery landed exactly once.
236
+ export const testDestinationDeliveries = sqliteTable(
237
+ "test_destination_deliveries",
238
+ {
239
+ destinationId: text("destination_id").notNull(),
240
+ eventId: text("event_id").notNull(),
241
+ tenantId: text("tenant_id").notNull(),
242
+ actionName: text("action_name").notNull(),
243
+ deliveredAt: text("delivered_at").notNull(),
244
+ },
245
+ (t) => ({
246
+ pk: primaryKey({ columns: [t.destinationId, t.eventId] }),
247
+ idxTestDestinationDeliveriesTenant: index(
248
+ "idx_test_destination_deliveries_tenant",
249
+ ).on(t.tenantId),
250
+ }),
251
+ );
252
+
209
253
  // --- Webhook event spine ----------------------------------------------------
210
254
  // Immutable event log. `id` is the semantic event id we mint — distinct from
211
255
  // any per-delivery wire envelope id (see `webhookDeliveries.deliveryId` below).
@@ -1,123 +1,25 @@
1
1
  /**
2
- * Runtime env access that works in BOTH contexts:
3
- * - `astro dev` (Node): reads import.meta.env / process.env.
4
- * - Cloudflare Workers (prod): reads the `cloudflare:workers` env module.
2
+ * The FULL runtime binding surface: everything in `./runtimeEnv.js` plus the D1
3
+ * binding. Control-plane code imports this one and gets all of it.
5
4
  *
6
- * Astro v6+/@astrojs/cloudflare v14 REMOVED `Astro.locals.runtime.env`; the
7
- * supported path is `import { env } from "cloudflare:workers"`. That module only
8
- * exists in the worker runtime, so we import it lazily + guarded and fall back
9
- * to build-time env in dev. See DECISIONS.md.
5
+ * The D1 accessor lives HERE rather than in `runtimeEnv.js` because ADR 0013
6
+ * keeps a live control-plane read off a tenant's public path, and a
7
+ * dependency-cruiser fence can only forbid a MODULE — never a symbol. Splitting
8
+ * `readD1` onto its own import graph is what gives
9
+ * `public-render-no-request-time-d1` (.dependency-cruiser.cjs) something to
10
+ * name: the public-render branch reaches `runtimeEnv.js` for the bindings that
11
+ * serve published bytes (KV cache, R2 bundles) and cannot reach this module at
12
+ * all, so a request-time D1 handle is unobtainable there rather than merely
13
+ * unused.
10
14
  */
11
15
  import type { D1Like } from "./d1/catalog.js";
12
16
 
13
- let workerEnv: Record<string, unknown> | null | undefined;
17
+ export * from "./runtimeEnv.js";
14
18
 
15
- async function getWorkerEnv(): Promise<Record<string, unknown> | null> {
16
- if (workerEnv !== undefined) return workerEnv;
17
- try {
18
- // @ts-expect-error — virtual module, only resolvable in the Workers runtime.
19
- const mod = await import("cloudflare:workers");
20
- workerEnv = (mod.env ?? null) as Record<string, unknown> | null;
21
- } catch {
22
- workerEnv = null;
23
- }
24
- return workerEnv;
25
- }
26
-
27
- function fromBuild(key: string): string | undefined {
28
- const meta = import.meta.env as Record<string, string | undefined>;
29
- if (meta[key] != null) return meta[key];
30
- if (typeof process !== "undefined" && process.env && process.env[key] != null) {
31
- return process.env[key];
32
- }
33
- return undefined;
34
- }
35
-
36
- export async function readEnv(key: string): Promise<string | undefined> {
37
- const w = await getWorkerEnv();
38
- const wv = w?.[key];
39
- if (typeof wv === "string" && wv.length) return wv;
40
- return fromBuild(key);
41
- }
42
-
43
- /** The slice of the Workers ExecutionContext runAfterResponse needs — the
44
- * @astrojs/cloudflare adapter exposes it to routes as `locals.cfContext`. */
45
- export interface CfExecutionContextLike {
46
- waitUntil(promise: Promise<unknown>): void;
47
- }
48
-
49
- /**
50
- * Run `work` PAST the response — the request's `ExecutionContext.waitUntil`
51
- * when the caller passes it, else a detached floating promise (dev/Node). For
52
- * webhook RECEIVERS that must ACK fast: the upstream forwarder (tot-mcp
53
- * gitea-webhook proxy) aborts its forward at 15s and Gitea bounds its delivery
54
- * wait too, while a real reconcile takes tens of seconds — running it inside
55
- * the request meant every healthy reconcile was killed mid-flight and recorded
56
- * as `forward_failed` (a layer of Trello 13075, behind the auth fix).
57
- *
58
- * CALLERS MUST PASS `locals.cfContext` (the @astrojs/cloudflare adapter's
59
- * per-request ExecutionContext, `createLocals: { cfContext: ctx }`): in the
60
- * Workers runtime a merely-floating promise is CANCELLED the moment the
61
- * response returns — an earlier revision here imported `waitUntil` from
62
- * `cloudflare:workers` (not exported by this adapter/runtime version), silently
63
- * degraded to the floating promise, and the async reconcile died unobserved
64
- * right after its 202. The floating-promise branch is a DEV-ONLY fallback.
65
- * Errors are the caller's to handle inside `work` (this never throws).
66
- */
67
- export function runAfterResponse(
68
- work: Promise<unknown>,
69
- cfContext?: CfExecutionContextLike | null,
70
- ): void {
71
- const swallowed = work.catch(() => {});
72
- if (cfContext && typeof cfContext.waitUntil === "function") {
73
- cfContext.waitUntil(swallowed);
74
- return;
75
- }
76
- // dev/Node only — in the Workers runtime this promise would be cancelled at
77
- // response end, which is exactly the bug the parameter above exists to avoid.
78
- void swallowed;
79
- }
80
-
81
- /** KV binding for the newsletter stub / tenant cache, if present in the runtime. */
82
- export async function readKv(binding: string): Promise<KVNamespace | undefined> {
83
- const w = await getWorkerEnv();
84
- const b = w?.[binding];
85
- return (b as KVNamespace | undefined) ?? undefined;
86
- }
19
+ import { getWorkerEnv } from "./runtimeEnv.js";
87
20
 
88
21
  /** D1 catalog binding for the edge read replica, if present in the runtime. */
89
22
  export async function readD1(binding: string): Promise<D1Like | undefined> {
90
23
  const w = await getWorkerEnv();
91
24
  return (w?.[binding] as D1Like | undefined) ?? undefined;
92
25
  }
93
-
94
- /** R2 bucket binding (e.g. TENANT_ASSETS, PRODUCT_IMAGES), if present. */
95
- export async function readR2(binding: string): Promise<R2Bucket | undefined> {
96
- const w = await getWorkerEnv();
97
- return (w?.[binding] as R2Bucket | undefined) ?? undefined;
98
- }
99
-
100
- /**
101
- * Cloudflare Analytics Engine dataset binding (e.g. ACTIVITY_ANALYTICS), if
102
- * present. Untyped (`unknown`) deliberately — the caller casts to its own
103
- * minimal `writeDataPoint` shape (see `activity/store.ts`'s
104
- * `AnalyticsEngineLike`) rather than this module taking a types dep. Absent in
105
- * dev/Node and until the binding is provisioned; callers treat undefined as
106
- * "analytics sink unavailable, skip it" (never an error).
107
- */
108
- export async function readAnalytics(binding: string): Promise<unknown | undefined> {
109
- const w = await getWorkerEnv();
110
- return w?.[binding] ?? undefined;
111
- }
112
-
113
- /**
114
- * Cloudflare Queue producer binding (e.g. WEBHOOK_DELIVERY_QUEUE), if present.
115
- * Untyped (`unknown`) deliberately — callers cast to their own minimal
116
- * queue-shape interface (see `webhooks/cloudflareQueueDispatcher.ts`'s
117
- * `CloudflareQueueSender`) rather than this module taking a types dep on a
118
- * specific binding's shape.
119
- */
120
- export async function readQueue(binding: string): Promise<unknown | undefined> {
121
- const w = await getWorkerEnv();
122
- return w?.[binding] ?? undefined;
123
- }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * Tenant-owned analytics declaration surface (TIER 1).
3
+ *
4
+ * Today a tenant cannot declare a measurement destination from its OWN tree: the
5
+ * declaration lives in `tenant.integrations` (apps/storefront/src/config/tenants.ts),
6
+ * which is platform code a tenant may not touch (customization golden rule #1). So a
7
+ * merchant must file a request for what should be a self-serve, versioned, publishable
8
+ * edit — exactly like `theme.json` or `content/*`.
9
+ *
10
+ * This module is the tenant-tree producer that closes that gap. A tenant commits an
11
+ * `analytics.json` in its own tree (the tenant-owned surface, `tenants/<id>/**`), and
12
+ * this materializes it into the SAME already-consumed declaration seam
13
+ * ({@link IntegrationDeclarationInput}[], read by
14
+ * {@link loadTenantIntegrationDeclarations} and merged into the render plan by the
15
+ * interim bridge). It adds NO new runtime: it is a THIRD producer into an existing
16
+ * consumer, alongside the platform-authored `tenant.integrations` and the D1-backed
17
+ * managed installs.
18
+ *
19
+ * The heavy validation is NOT hand-rolled — each declared destination is built by the
20
+ * shipped, reviewed {@link buildPlatformAdapterDeclaration}, which fail-closes on an
21
+ * unknown adapter, a missing/invalid live id, an invalid per-stage id, or a non-live
22
+ * stage that reuses the live id. That is the whole point: a tenant supplies only public
23
+ * per-stage destination ids and gets a working, consent-gated, CSP-clean, non-live-safe
24
+ * measurement integration with no renderer change.
25
+ *
26
+ * Two callers, one core:
27
+ * - PUBLISH validation — {@link materializeTenantAnalyticsDeclarations} returns
28
+ * structured {@link ContractViolation}s so the ship/customization pipeline can
29
+ * REFUSE an invalid `analytics.json` with an actionable, per-field message, exactly
30
+ * as it refuses an invalid `theme.json`.
31
+ * - RUNTIME materialization — {@link loadTenantAnalyticsDeclarations} is fail-closed:
32
+ * a missing or (defensively) invalid file yields the empty set, never a throw, so an
33
+ * undeclared tenant resolves the environment-only plan and a byte-identical page.
34
+ */
35
+ import {
36
+ buildPlatformAdapterDeclaration,
37
+ ENVIRONMENT_SCHEMA_VERSION,
38
+ Violations,
39
+ isPlainObject,
40
+ rejectUnknownKeys,
41
+ type BrowserEnvironment,
42
+ type ContractViolation,
43
+ type IntegrationDeclarationInput,
44
+ } from "@tot/public-runtime";
45
+
46
+ /** The tenant-owned file that declares measurement destinations, in the tenant tree. */
47
+ export const TENANT_ANALYTICS_FILE = "analytics.json";
48
+
49
+ /** The current `analytics.json` contract version. */
50
+ export const TENANT_ANALYTICS_SCHEMA_VERSION = 1 as const;
51
+
52
+ /** One declared measurement destination: a first-class adapter + its per-stage public ids. */
53
+ export interface TenantAnalyticsDestination {
54
+ /** The first-class platform adapter id, e.g. `platform.google-measurement`. */
55
+ adapter: string;
56
+ /** Public per-stage destination ids. `live` is required; non-live stages take SEPARATE test ids. */
57
+ ids: Record<string, unknown>;
58
+ }
59
+
60
+ /** The result of materializing a tenant's `analytics.json` into declarations. */
61
+ export interface TenantAnalyticsMaterialization {
62
+ /** True only when EVERY declared destination validated; false surfaces `violations`. */
63
+ ok: boolean;
64
+ /** The materialized declarations, in declared order. Empty whenever `ok` is false. */
65
+ declarations: readonly IntegrationDeclarationInput[];
66
+ /** Per-field violations for the publish surface. Empty whenever `ok` is true. */
67
+ violations: readonly ContractViolation[];
68
+ }
69
+
70
+ /**
71
+ * The fixed environment the declaration is CONSTRUCTED against. A first-class adapter
72
+ * declaration is environment-INDEPENDENT in content — its per-stage ids are all baked
73
+ * into `publicConfig` and the client picks the current stage at render time — so the
74
+ * environment here only satisfies {@link buildPlatformAdapterDeclaration}'s validation
75
+ * and its (discarded) plan resolution. `canonicalHost` is a fixed placeholder, never the
76
+ * tenant id, so a clone id (which is not a legal hostname) can never fail construction.
77
+ */
78
+ const CONSTRUCTION_ENVIRONMENT: BrowserEnvironment = {
79
+ schemaVersion: ENVIRONMENT_SCHEMA_VERSION,
80
+ siteSurface: "storefront",
81
+ environment: "live",
82
+ canonicalHost: "tenant-analytics.invalid",
83
+ revision: "tenant-analytics-declaration",
84
+ };
85
+
86
+ const EMPTY: readonly IntegrationDeclarationInput[] = Object.freeze([]);
87
+
88
+ /** Re-anchor a builder violation path under the declaring `destinations[i]` element. */
89
+ function reanchor(violation: ContractViolation, index: number): ContractViolation {
90
+ const base = `destinations[${index}]`;
91
+ const path =
92
+ violation.path === "(root)" || violation.path === ""
93
+ ? base
94
+ : violation.path === "adapterId"
95
+ ? `${base}.adapter`
96
+ : violation.path.startsWith("adapterId.")
97
+ ? `${base}.adapter${violation.path.slice("adapterId".length)}`
98
+ : `${base}.${violation.path}`;
99
+ return { path, code: violation.code, message: violation.message };
100
+ }
101
+
102
+ /** Validate the closed shape of one declared destination before it reaches the builder. */
103
+ function readDestination(
104
+ raw: unknown,
105
+ index: number,
106
+ v: Violations,
107
+ ): TenantAnalyticsDestination | undefined {
108
+ const base = `destinations[${index}]`;
109
+ if (!isPlainObject(raw)) {
110
+ v.add(base, "type", "A destination must be an object of { adapter, ids }.");
111
+ return undefined;
112
+ }
113
+ rejectUnknownKeys(raw, ["adapter", "ids"], v, base);
114
+ const adapter = raw["adapter"];
115
+ if (typeof adapter !== "string" || adapter.length === 0) {
116
+ v.add(`${base}.adapter`, "type", "A destination adapter must be a non-empty string.");
117
+ return undefined;
118
+ }
119
+ if (!isPlainObject(raw["ids"])) {
120
+ v.add(`${base}.ids`, "type", "ids must be an object of { stage: destinationId }.");
121
+ return undefined;
122
+ }
123
+ return { adapter, ids: raw["ids"] };
124
+ }
125
+
126
+ /**
127
+ * Parse and materialize a tenant's `analytics.json` content into first-class adapter
128
+ * declarations. `raw` is the already-parsed JSON value (or `undefined`/`null` when the
129
+ * tenant declares none). Fail-closed and whole-file: if ANY declared destination is
130
+ * invalid, NO declaration is produced and the violations name every offending field —
131
+ * a half-valid analytics file never partially materializes.
132
+ */
133
+ export function materializeTenantAnalyticsDeclarations(input: {
134
+ raw: unknown;
135
+ tenantPublicId: string;
136
+ }): TenantAnalyticsMaterialization {
137
+ const { raw, tenantPublicId } = input;
138
+ // An undeclared tenant (no file) is the zero-cost norm, not an error.
139
+ if (raw === undefined || raw === null) {
140
+ return { ok: true, declarations: EMPTY, violations: [] };
141
+ }
142
+
143
+ const v = new Violations();
144
+ if (!isPlainObject(raw)) {
145
+ v.add("(root)", "type", "analytics.json must be a JSON object with a `destinations` array.");
146
+ return { ok: false, declarations: EMPTY, violations: v.items };
147
+ }
148
+ rejectUnknownKeys(raw, ["schemaVersion", "destinations"], v, "");
149
+ if ("schemaVersion" in raw && raw["schemaVersion"] !== TENANT_ANALYTICS_SCHEMA_VERSION) {
150
+ v.add(
151
+ "schemaVersion",
152
+ "unsupported_version",
153
+ `schemaVersion must equal ${TENANT_ANALYTICS_SCHEMA_VERSION}.`,
154
+ );
155
+ }
156
+ const destinationsRaw = raw["destinations"];
157
+ if (!Array.isArray(destinationsRaw)) {
158
+ v.add("destinations", "type", "`destinations` must be an array.");
159
+ return { ok: false, declarations: EMPTY, violations: v.items };
160
+ }
161
+
162
+ const declarations: IntegrationDeclarationInput[] = [];
163
+ destinationsRaw.forEach((destinationRaw, index) => {
164
+ const destination = readDestination(destinationRaw, index, v);
165
+ if (!destination) return;
166
+ const built = buildPlatformAdapterDeclaration({
167
+ adapterId: destination.adapter,
168
+ ids: destination.ids,
169
+ tenantPublicId,
170
+ environment: CONSTRUCTION_ENVIRONMENT,
171
+ });
172
+ if (!built.ok) {
173
+ for (const violation of built.violations) v.items.push(reanchor(violation, index));
174
+ return;
175
+ }
176
+ // Namespace the ref so a tenant-declared adapter can never be mistaken for — or
177
+ // collide with — the same adapter installed through the managed D1 path.
178
+ declarations.push({ ...built.declaration, ref: `tenant-analytics:${destination.adapter}` });
179
+ });
180
+
181
+ if (!v.ok) return { ok: false, declarations: EMPTY, violations: v.items };
182
+ return { ok: true, declarations, violations: [] };
183
+ }
184
+
185
+ /**
186
+ * Read a tenant's already-materialized analytics declarations for the render hot path,
187
+ * fail-closed: a missing OR invalid file yields the empty set (never throws), so an
188
+ * undeclared tenant resolves the environment-only plan and a defensively-invalid file
189
+ * darks its own analytics rather than the page. Publish validation
190
+ * ({@link materializeTenantAnalyticsDeclarations}) is what keeps an invalid file from
191
+ * ever reaching here.
192
+ */
193
+ export function loadTenantAnalyticsDeclarations(
194
+ raw: unknown,
195
+ tenantPublicId: string,
196
+ ): readonly IntegrationDeclarationInput[] {
197
+ return materializeTenantAnalyticsDeclarations({ raw, tenantPublicId }).declarations;
198
+ }
@@ -115,7 +115,7 @@ export const MONITORS: readonly Monitor[] = [
115
115
  title: "Page / Worker errors (5xx + unhandled exceptions)",
116
116
  category: "availability",
117
117
  signal: {
118
- source: "Cloudflare Worker exceptions + non-2xx responses from middleware/index.ts render path",
118
+ source: "Cloudflare Worker exceptions + non-2xx responses from the middleware render path",
119
119
  observe: "Workers 'Errors' metric + Logpush; alert on error-rate over the request baseline",
120
120
  },
121
121
  threshold: "5xx rate > 1% over 5m, or any sustained unhandled-exception stream",
@@ -19,6 +19,7 @@ import {
19
19
  renderIntegrationPhases,
20
20
  INTERACTION_TRACKING_BOOT_JS,
21
21
  type EffectivePlan,
22
+ type ConsentSnapshot,
22
23
  } from "@tot/public-runtime";
23
24
  import { reportTrackingChainHealth } from "./tracking/interactionHealth.js";
24
25
 
@@ -180,6 +181,13 @@ export function rawHtmlResponse(
180
181
  * silently omit it. Nonces are stamped per response, never serialized (ADR 0018).
181
182
  */
182
183
  plan: EffectivePlan;
184
+ /**
185
+ * The tenant's actual initial consent snapshot (see Layout.astro's identical
186
+ * prop). Optional: omitting it falls back to the fail-closed essential-only
187
+ * default, matching every render path that has not resolved a tenant consent
188
+ * config (candidate/preview renders).
189
+ */
190
+ consentSnapshot?: ConsentSnapshot;
183
191
  },
184
192
  ): Response {
185
193
  let composed =
@@ -222,7 +230,7 @@ export function rawHtmlResponse(
222
230
  // per-response nonce placeholder; the middleware substitutes a fresh nonce at serve
223
231
  // time (ADR 0018), so the same engine output feeds both mounts identically.
224
232
  composed = nonceInlineTags(composed, opts.nonce);
225
- const phases = renderIntegrationPhases(opts.plan, opts.nonce);
233
+ const phases = renderIntegrationPhases(opts.plan, opts.nonce, opts.consentSnapshot);
226
234
  composed = injectHeadStart(composed, phases.head);
227
235
  composed = injectBodyStart(composed, phases.bodyStart);
228
236
  composed = injectBodyEnd(composed, phases.bodyDeferred);