@tokenoftrust/storefront-runner 2.0.16 → 2.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) 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/apps/routeParams.ts +45 -0
  19. package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
  20. package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
  21. package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
  22. package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
  23. package/apps/storefront/src/lib/env.ts +13 -111
  24. package/apps/storefront/src/lib/fixtures/regulated-commerce-truth/index.ts +540 -0
  25. package/apps/storefront/src/lib/fixtures/ws3-readonly-clone-preflight/index.ts +495 -0
  26. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
  27. package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
  28. package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
  29. package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
  30. package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
  31. package/apps/storefront/src/middleware/candidatePin.ts +35 -0
  32. package/apps/storefront/src/middleware/controlPlane.ts +270 -0
  33. package/apps/storefront/src/middleware/index.ts +100 -796
  34. package/apps/storefront/src/middleware/publicRender.ts +43 -0
  35. package/apps/storefront/src/middleware/renderChain.ts +685 -0
  36. package/apps/storefront/src/middleware/routeBranch.ts +160 -0
  37. package/apps/storefront/src/middleware/staticBundle.ts +109 -0
  38. package/apps/storefront/src/pages/[...slug].astro +1 -0
  39. package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
  40. package/apps/storefront/src/pages/index.astro +1 -0
  41. package/apps/storefront/vitest.config.ts +5 -0
  42. package/package.json +1 -1
  43. package/packages/public-runtime/package.json +1 -0
  44. package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
  45. package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
  46. package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
  47. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
  48. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
  49. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
  50. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
  51. package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
  52. package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
  53. package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
  54. package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
  55. package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
  56. package/packages/public-runtime/src/activity/catalog.ts +1 -1
  57. package/packages/public-runtime/src/candidate-index.ts +6 -0
  58. package/packages/public-runtime/src/customization-reconcile.ts +8 -0
  59. package/packages/public-runtime/src/html-cache-policy.ts +57 -0
  60. package/packages/public-runtime/src/index.ts +7 -0
  61. package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
  62. package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
  63. package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
  64. package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
  65. package/packages/public-runtime/src/integration/render-phases.ts +8 -3
  66. package/packages/public-runtime/src/publication-artifact.ts +203 -0
  67. package/packages/public-runtime/src/publication-space.ts +853 -0
  68. package/packages/public-runtime/src/publication-version.ts +587 -0
  69. package/packages/public-runtime/src/route-tier.ts +257 -0
  70. package/packages/public-runtime/src/static-bundle.ts +18 -5
  71. package/packages/public-runtime/src/tenant-assets.ts +85 -17
  72. package/packages/public-runtime/src/tenant.ts +70 -0
  73. package/packages/public-runtime/tests/conformance/fake-destination.ts +402 -0
  74. package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
  75. package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
  76. package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
  77. package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
  78. package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
  79. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
  80. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
  81. package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
  82. package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
  83. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
  84. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
  85. package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
  86. package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
  87. package/scripts/dev/ai-edit.mjs +16 -3
  88. package/scripts/dev/port-check.mjs +18 -3
  89. package/scripts/dev/publish.mjs +26 -3
  90. package/scripts/tenant/validate.mjs +14 -1
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Live-provider checkout verification flow — the CLIENT half of the
3
+ * `/api/compliance/preflight` contract.
4
+ *
5
+ * On a tenant wired to the real Token of Trust verification provider, preflight
6
+ * echoes a `verification` meta block alongside the decision: the correlation ref
7
+ * for this checkout attempt, the provider's status, and (for an interactive step)
8
+ * the continuation URL the shopper completes verification at. Sandbox and
9
+ * no-provider tenants never get that block, so its PRESENCE is the live-provider
10
+ * tell and the only thing that branches the checkout dialog into the live flow.
11
+ *
12
+ * Everything here is pure or dependency-injected so the state machine is unit
13
+ * tested directly (the storefront's vitest run is node-only by design — see
14
+ * `vitest.config.ts`; render logic lives in components, decisions live here).
15
+ *
16
+ * Fail-closed by construction:
17
+ * - an unreadable/malformed preflight body parses to `null`, never to a pass;
18
+ * - the ONLY thing that releases checkout is the server's own
19
+ * `decision.status === "clear"` — this module never infers a pass from a
20
+ * provider status, an affirmation, or a client-side timer;
21
+ * - a transport error, a malformed poll response, or the poll deadline stops
22
+ * the flow and surfaces a paused state.
23
+ */
24
+ import type { CartComplianceDecision } from "@tot/public-runtime";
25
+
26
+ /** Provider status vocabulary echoed by `/api/compliance/preflight`. */
27
+ export type LiveVerificationStatus =
28
+ | "cleared"
29
+ | "rejected"
30
+ | "pending"
31
+ | "not_started"
32
+ | "conditions_met"
33
+ | "interactive";
34
+
35
+ const LIVE_STATUSES: ReadonlySet<string> = new Set<LiveVerificationStatus>([
36
+ "cleared",
37
+ "rejected",
38
+ "pending",
39
+ "not_started",
40
+ "conditions_met",
41
+ "interactive",
42
+ ]);
43
+
44
+ /** The live-provider meta block. Informational — the DECISION is `decision`. */
45
+ export interface LiveVerificationMeta {
46
+ ref?: string;
47
+ status?: LiveVerificationStatus;
48
+ continuationUrl?: string;
49
+ }
50
+
51
+ export interface PreflightResponse {
52
+ decision: CartComplianceDecision;
53
+ /** Present only on the live-provider path; `null` on sandbox / no provider. */
54
+ verification: LiveVerificationMeta | null;
55
+ }
56
+
57
+ /**
58
+ * Where the shopper is in the live verification trip. `"none"` is the
59
+ * sandbox/no-provider path — the dialog then behaves EXACTLY as it did before
60
+ * this flow existed.
61
+ */
62
+ export type VerificationPhase =
63
+ | "none"
64
+ | "collect"
65
+ | "continue"
66
+ | "waiting"
67
+ | "denied"
68
+ | "cleared";
69
+
70
+ const DECISION_STATUSES: ReadonlySet<string> = new Set([
71
+ "clear",
72
+ "action_required",
73
+ "blocked",
74
+ ]);
75
+
76
+ const AGE_STATUSES: ReadonlySet<string> = new Set([
77
+ "satisfied",
78
+ "required",
79
+ "blocked",
80
+ "not_required",
81
+ ]);
82
+
83
+ /** Read the `verification` meta fail-closed. Returns `null` when absent/unusable. */
84
+ export function parseVerificationMeta(raw: unknown): LiveVerificationMeta | null {
85
+ if (!raw || typeof raw !== "object") return null;
86
+ const r = raw as Record<string, unknown>;
87
+ const meta: LiveVerificationMeta = {};
88
+ if (typeof r.ref === "string" && r.ref.trim()) meta.ref = r.ref.trim();
89
+ if (typeof r.status === "string" && LIVE_STATUSES.has(r.status)) {
90
+ meta.status = r.status as LiveVerificationStatus;
91
+ }
92
+ if (typeof r.continuationUrl === "string" && r.continuationUrl.trim()) {
93
+ meta.continuationUrl = r.continuationUrl.trim();
94
+ }
95
+ // An empty-but-present block still means "live provider" — that is the tell.
96
+ return meta;
97
+ }
98
+
99
+ function isDecision(raw: unknown): raw is CartComplianceDecision {
100
+ if (!raw || typeof raw !== "object") return false;
101
+ const d = raw as Record<string, unknown>;
102
+ if (typeof d.status !== "string" || !DECISION_STATUSES.has(d.status)) return false;
103
+ if (!Array.isArray(d.blocks)) return false;
104
+ const req = d.requirements as Record<string, unknown> | undefined;
105
+ if (!req || typeof req !== "object") return false;
106
+ return typeof req.ageVerification === "string" && AGE_STATUSES.has(req.ageVerification);
107
+ }
108
+
109
+ /**
110
+ * Parse a preflight body. `null` for ANY shape we cannot fully read — the caller
111
+ * treats that exactly like a transport failure and does not proceed.
112
+ */
113
+ export function parsePreflightResponse(raw: unknown): PreflightResponse | null {
114
+ if (!raw || typeof raw !== "object") return null;
115
+ const r = raw as Record<string, unknown>;
116
+ if (!isDecision(r.decision)) return null;
117
+ return {
118
+ decision: r.decision,
119
+ verification: "verification" in r ? parseVerificationMeta(r.verification) : null,
120
+ };
121
+ }
122
+
123
+ /** True when the decision carries the provider's real "did not pass" block. */
124
+ function hasVerificationFailure(decision: CartComplianceDecision): boolean {
125
+ return decision.blocks.some(
126
+ (b) => b.code === "verification_failed" && b.failedClosed !== true,
127
+ );
128
+ }
129
+
130
+ /**
131
+ * Where a preflight result leaves the shopper. Pure.
132
+ *
133
+ * No `verification` block → `"none"`: the sandbox / no-provider path, whose UI
134
+ * must stay byte-for-byte what it was.
135
+ */
136
+ export function verificationPhase(res: PreflightResponse): VerificationPhase {
137
+ const v = res.verification;
138
+ if (!v) return "none";
139
+ if (v.status === "rejected" || hasVerificationFailure(res.decision)) return "denied";
140
+ if (res.decision.status === "clear") return "cleared";
141
+ if (res.decision.requirements.ageVerification === "satisfied") return "cleared";
142
+ if (v.status === "interactive" && v.continuationUrl) return "continue";
143
+ if (v.status === "pending" || v.status === "conditions_met" || v.status === "cleared") {
144
+ return "waiting";
145
+ }
146
+ // "not_started", or nothing started at all: we still need shopper details to
147
+ // START a verification. A stale ref with no provider record starts over.
148
+ return "collect";
149
+ }
150
+
151
+ // --- Polling -----------------------------------------------------------------
152
+
153
+ /** Re-read cadence while the shopper completes verification on their own device. */
154
+ export const POLL_INTERVAL_MS = 4_000;
155
+ /** Hard ceiling on a single wait. Never poll forever. */
156
+ export const POLL_TIMEOUT_MS = 4 * 60_000;
157
+
158
+ export type PollOutcome =
159
+ | { kind: "cleared"; response: PreflightResponse }
160
+ | { kind: "denied"; response: PreflightResponse }
161
+ | { kind: "halted"; response: PreflightResponse }
162
+ | { kind: "error" }
163
+ | { kind: "timeout" }
164
+ | { kind: "cancelled" };
165
+
166
+ export interface PollDeps {
167
+ /** Re-POST preflight with the persisted ref. `null` = unusable result. */
168
+ poll: () => Promise<PreflightResponse | null>;
169
+ sleep: (ms: number) => Promise<void>;
170
+ now: () => number;
171
+ cancelled?: () => boolean;
172
+ intervalMs?: number;
173
+ timeoutMs?: number;
174
+ }
175
+
176
+ /**
177
+ * Re-read the server's decision until it resolves. The client NEVER decides the
178
+ * shopper passed — it only relays `decision.status === "clear"`.
179
+ *
180
+ * Stops on: cleared, denied, any other resolved/blocked decision, an unusable
181
+ * response (fail closed), cancellation, or the deadline.
182
+ */
183
+ export async function pollVerification(deps: PollDeps): Promise<PollOutcome> {
184
+ const intervalMs = deps.intervalMs ?? POLL_INTERVAL_MS;
185
+ const timeoutMs = deps.timeoutMs ?? POLL_TIMEOUT_MS;
186
+ const startedAt = deps.now();
187
+
188
+ for (;;) {
189
+ if (deps.cancelled?.()) return { kind: "cancelled" };
190
+ await deps.sleep(intervalMs);
191
+ if (deps.cancelled?.()) return { kind: "cancelled" };
192
+
193
+ let res: PreflightResponse | null;
194
+ try {
195
+ res = await deps.poll();
196
+ } catch {
197
+ return { kind: "error" };
198
+ }
199
+ if (!res) return { kind: "error" };
200
+
201
+ const phase = verificationPhase(res);
202
+ if (phase === "denied") return { kind: "denied", response: res };
203
+ if (res.decision.status === "clear") return { kind: "cleared", response: res };
204
+ // Verification itself is settled (or the cart is blocked for another reason)
205
+ // but checkout still isn't clear — nothing more polling can change.
206
+ if (phase === "cleared" || res.decision.status === "blocked") {
207
+ return { kind: "halted", response: res };
208
+ }
209
+ if (deps.now() - startedAt >= timeoutMs) return { kind: "timeout" };
210
+ }
211
+ }
212
+
213
+ // --- Ref persistence ---------------------------------------------------------
214
+
215
+ /**
216
+ * sessionStorage key for a checkout attempt's verification ref, so a reload
217
+ * during the verification trip keeps the correlation. Scoped by the preflight
218
+ * URL (tenant/base path) and the exact cart line, so refs never cross carts.
219
+ */
220
+ export function verificationRefKey(
221
+ preflightUrl: string,
222
+ line: { handle: string; sku?: string; variantId?: string },
223
+ ): string {
224
+ const variant = line.variantId ?? line.sku ?? "";
225
+ return `tot:verify-ref:${preflightUrl}:${line.handle}:${variant}`;
226
+ }
227
+
228
+ export function readStoredRef(key: string): string | null {
229
+ try {
230
+ const v = sessionStorage.getItem(key);
231
+ return v && v.trim() ? v : null;
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
236
+
237
+ export function writeStoredRef(key: string, ref: string): void {
238
+ try {
239
+ sessionStorage.setItem(key, ref);
240
+ } catch {
241
+ /* storage disabled — the flow still works, it just can't survive a reload */
242
+ }
243
+ }
244
+
245
+ export function clearStoredRef(key: string): void {
246
+ try {
247
+ sessionStorage.removeItem(key);
248
+ } catch {
249
+ /* storage disabled */
250
+ }
251
+ }
@@ -1,9 +1,13 @@
1
1
  /**
2
- * Per-tenant compliance RULE DATA — the data-driven substance the enforcement
2
+ * Compliance RULE DATA by PROFILE — the data-driven substance the enforcement
3
3
  * engine (`@tot/public-runtime` compliance) evaluates. Keeping the rules here as
4
4
  * DATA (not `if` branches in code) is the whole point: a jurisdiction change is a
5
5
  * data edit reviewed against the ruleset shape, not a code change.
6
6
  *
7
+ * A tenant selects a profile via `compliance.ruleProfile`; this file never names
8
+ * tenants. Onboarding a regulated merchant is therefore a declaration in that
9
+ * merchant's own tenant config, not an edit here.
10
+ *
7
11
  * ⚠️ LEGAL PROVENANCE: the state rules below are a REPRESENTATIVE starter set for
8
12
  * the pilot vape tenants, not legal advice and not a guaranteed-current list. The
9
13
  * authoritative flavor-ban / no-ship / per-order-limit / excise data and the PACT
@@ -16,7 +20,7 @@
16
20
  * A regulated tenant with no provider configured FAILS CLOSED (checkout blocks,
17
21
  * evidence is not promotable), which is the correct safe default.
18
22
  */
19
- import type { ComplianceRuleset } from "@tot/public-runtime";
23
+ import type { ComplianceRuleset, ComplianceRuleProfile } from "@tot/public-runtime";
20
24
 
21
25
  /**
22
26
  * Shared vape/ENDS rule template. `tenantId` is filled per tenant. The state list
@@ -71,9 +75,16 @@ function vapeRuleset(tenantId: string): ComplianceRuleset {
71
75
  };
72
76
  }
73
77
 
74
- /** Ruleset data keyed by tenant_id. Regulated tenants MUST have an entry. */
75
- export const COMPLIANCE_RULESETS: Record<string, ComplianceRuleset> = {
76
- "sample-store.com": vapeRuleset("sample-store.com"),
77
- "sample-store-2.net": vapeRuleset("sample-store-2.net"),
78
- "sample-store-3.com": vapeRuleset("sample-store-3.com"),
78
+ /**
79
+ * Rule data keyed by the PROFILE a tenant declares (`compliance.ruleProfile`),
80
+ * never by which tenant it is. Any tenant declaring a profile gets that profile's
81
+ * rules; a tenant declaring none resolves to no ruleset and fails closed. Onboarding
82
+ * a regulated merchant is a declaration in that tenant's own config, not an edit
83
+ * here — this file holds only the reviewed rule bodies.
84
+ */
85
+ export const COMPLIANCE_RULE_PROFILES: Record<
86
+ ComplianceRuleProfile,
87
+ (tenantId: string) => ComplianceRuleset
88
+ > = {
89
+ "vape-nicotine-us": vapeRuleset,
79
90
  };
@@ -23,6 +23,8 @@
23
23
  * tenancy-resolver-fence — the concrete registry stays behind the config boundary.
24
24
  */
25
25
  import type { DeepPartial, TenantConfig, ThemeTokens } from "@tot/public-runtime";
26
+ import { rootPublicationSpaceConfig } from "./publicationSpaceConfig";
27
+ import { loadTenantAnalyticsDeclarations } from "@/lib/integration/tenantAnalyticsDeclarations";
26
28
 
27
29
  /** Per-dir overrides the resolver derives from disk before synthesis. */
28
30
  export interface DevTenantHints {
@@ -40,6 +42,13 @@ export interface DevTenantHints {
40
42
  * render from them; when false we degrade to a marketing home (no catalog).
41
43
  */
42
44
  hasCatalog?: boolean;
45
+ /**
46
+ * The tenant's own `tenants/<id>/analytics.json` content (already-parsed JSON),
47
+ * or undefined when the tenant declares none. Materialized into `integrations`
48
+ * fail-closed, so a developer self-serves a reviewed measurement destination from
49
+ * their OWN tree — the TIER 1 tenant-owned analytics surface — with no platform edit.
50
+ */
51
+ analyticsRaw?: unknown;
43
52
  }
44
53
 
45
54
  /**
@@ -59,6 +68,9 @@ export function synthesizeDevTenant(
59
68
  ): TenantConfig {
60
69
  // Prefer the .tot/config.json scope for a slug tenant; else colocation.
61
70
  const appDomain = hints.appDomain ?? id;
71
+ // The tenant's OWN tree analytics.json → the render plane's declaration seam,
72
+ // fail-closed (a missing/invalid file yields no declarations, never a throw).
73
+ const integrations = loadTenantAnalyticsDeclarations(hints.analyticsRaw, id);
62
74
  const base: TenantConfig = {
63
75
  tenant_id: id,
64
76
  appDomain,
@@ -72,6 +84,14 @@ export function synthesizeDevTenant(
72
84
  currency: "USD",
73
85
  locale: "en-US",
74
86
  theme_tokens: themeTokens,
87
+ publicationSpaces: [rootPublicationSpaceConfig(id, {
88
+ forge: "fixture",
89
+ repo: id,
90
+ defaultBranch: "main",
91
+ })],
92
+ // Only set when the tenant actually declared one — an undeclared tenant keeps
93
+ // `integrations` absent, so its resolved plan is byte-identical to before.
94
+ ...(integrations.length > 0 ? { integrations } : {}),
75
95
  };
76
96
  if (hints.hasCatalog) {
77
97
  // Catalog fixtures on disk → a real commerce tenant (siteType omitted ==
@@ -94,6 +114,8 @@ export interface DevTenantSeedOptions {
94
114
  scopeFor?: (id: string) => string | undefined;
95
115
  /** Whether catalog fixtures are committed on disk for a given data scope. */
96
116
  hasCatalog?: (scope: string) => boolean;
117
+ /** The tenant's parsed `tenants/<id>/analytics.json`, or undefined when it has none. */
118
+ analyticsFor?: (id: string) => unknown;
97
119
  }
98
120
 
99
121
  /**
@@ -121,7 +143,8 @@ export function devTenantsToSeed(
121
143
  const appDomain = opts.scopeFor?.(id);
122
144
  const scope = appDomain ?? id;
123
145
  const hasCatalog = opts.hasCatalog?.(scope) ?? false;
124
- out.push(synthesizeDevTenant(id, themeFor(id), { appDomain, hasCatalog }));
146
+ const analyticsRaw = opts.analyticsFor?.(id);
147
+ out.push(synthesizeDevTenant(id, themeFor(id), { appDomain, hasCatalog, analyticsRaw }));
125
148
  }
126
149
  return out;
127
150
  }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The evidence-URL tenant roster, derived from the live tenant registry.
3
+ *
4
+ * Lives in `src/config` (not a `pages`/`lib` file) so it — not the `.well-known` route — is the
5
+ * one place allowed to import the concrete `staticTenants` map (`tenancy-resolver-fence`:
6
+ * pages/components/layouts/lib may depend only on the `TenantResolver` interface, never the
7
+ * concrete static map, so a constant single-tenant resolver stays a drop-in).
8
+ */
9
+ import { listedTenants } from "@tot/public-runtime";
10
+ import { staticTenants } from "@/config/tenants";
11
+
12
+ /**
13
+ * Tenant id -> canonical domain for every real, registered tenant. For a real tenant
14
+ * `tenant_id === appDomain === canonicalDomain` (see `config/tenants.ts`), so onboarding a
15
+ * tenant in the registry is enough to publish its evidence URL — no separate hand-maintained
16
+ * map to update. Internal/e2e test tenants are excluded (never real merchants, never a real
17
+ * evidence surface).
18
+ */
19
+ export function evidenceRegistryRoster(): Record<string, string> {
20
+ const roster: Record<string, string> = {};
21
+ for (const tenant of listedTenants(staticTenants)) {
22
+ roster[tenant.tenant_id] = tenant.canonicalDomain;
23
+ }
24
+ return roster;
25
+ }
@@ -0,0 +1,25 @@
1
+ import type {
2
+ PublicationSpaceManifestV1,
3
+ PublicationSpaceSourceV1,
4
+ } from "@tot/public-runtime";
5
+
6
+ export function rootPublicationSpaceConfig(
7
+ tenant: string,
8
+ source: PublicationSpaceSourceV1,
9
+ ): PublicationSpaceManifestV1 {
10
+ return {
11
+ version: 1,
12
+ id: "root",
13
+ tenant,
14
+ source,
15
+ routes: { canonicalMount: "/" },
16
+ visibility: "public",
17
+ authorization: { policy: "public" },
18
+ discovery: { sitemap: true, feeds: true, publicSearch: true, llms: true },
19
+ assets: { namespace: "root", access: "public" },
20
+ renderer: {
21
+ contractVersion: "storefront-v1",
22
+ themeContractVersion: "tenant-theme-v1",
23
+ },
24
+ };
25
+ }
@@ -30,6 +30,7 @@ import {
30
30
  import { KvTenantResolver, putTenant } from "@/config/kvTenantResolver";
31
31
  import { getTenantTheme } from "@/themes/tenants";
32
32
  import { devTenantsToSeed } from "@/config/devTenantSeed";
33
+ import { tenantAnalyticsRawById } from "@/config/tenantAnalyticsFiles";
33
34
  import { MemoryKv } from "@/lib/memoryKv";
34
35
 
35
36
  // LOCAL dev loop discovery: enumerate the colocated tenant dirs on disk,
@@ -120,9 +121,13 @@ function devFallbackResolver(): TenantResolver {
120
121
  const staticIds = new Set(staticTenants.map((t) => t.tenant_id));
121
122
  const withCatalog = catalogScopes();
122
123
  const scopes = slugScopes();
124
+ // The SAME tenant-owned analytics read the static registry uses; here it serves only
125
+ // the tenants that have NO registry entry (see config/tenantAnalyticsFiles.ts).
126
+ const analytics = tenantAnalyticsRawById();
123
127
  const dev = devTenantsToSeed(localTenantDirIds(), staticIds, getTenantTheme, {
124
128
  scopeFor: (id) => scopes.get(id),
125
129
  hasCatalog: (scope) => withCatalog.has(scope),
130
+ analyticsFor: (id) => analytics.get(id),
126
131
  });
127
132
  if (dev.length === 0) return staticResolver;
128
133
  // registry[0] stays the static default (HOME_TENANT) for unknown-host fallback.
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The colocated tenant-owned analytics declarations on disk (TIER 1), keyed by tenant id.
3
+ *
4
+ * A tenant declares its measurement destinations in its OWN tree, at
5
+ * `tenants/<id>/analytics.json` — the self-serve surface a merchant edits without a
6
+ * platform change. This module is the SINGLE build-time read of that surface, shared by
7
+ * both producers of a `TenantConfig`: the static registry (config/tenants.ts, which
8
+ * attaches a declaration to the matching registry entry) and dev-loop synthesis
9
+ * (config/resolver.ts, which synthesizes a config for a local dir that has no registry
10
+ * entry). One read, one key space, so a tenant is never reachable by one producer and
11
+ * invisible to the other.
12
+ *
13
+ * `import.meta.glob` is a build-time PATTERN match, not a module resolution: a registered
14
+ * tenant's tree is Gitea-materialized and gitignored, so it does NOT exist in a bare
15
+ * checkout or CI, and zero matches is a valid, typed result (an empty object) rather than
16
+ * a compile error. Absent ⇒ that tenant declares nothing, which is the undeclared norm.
17
+ *
18
+ * Both colocation tiers are globbed: real tenants at `tenants/<id>/`, platform test
19
+ * tenants one level deeper at `tenants/e2e/<id>/` (`tenantDirRelative`). Vite needs
20
+ * literal patterns, so the nesting is spelled out and `tenantIdFromPath` — the one inverse
21
+ * of that mapping — recovers the id from either shape. Depth: from `src/config/` the repo
22
+ * root is four levels up (config→src→storefront→apps→root).
23
+ */
24
+ import { tenantIdFromPath } from "@tot/public-runtime";
25
+
26
+ const tenantAnalyticsFiles = import.meta.glob<unknown>(
27
+ ["../../../../tenants/*/analytics.json", "../../../../tenants/e2e/*/analytics.json"],
28
+ { eager: true, import: "default" },
29
+ );
30
+
31
+ /**
32
+ * Tenant id → the parsed `analytics.json` content, for every tenant that ships one in
33
+ * this checkout. The key is the colocated dir name, which IS the tenant id
34
+ * (dir == tenant_id == appDomain). A tenant with no file is simply absent from the map.
35
+ */
36
+ export function tenantAnalyticsRawById(): Map<string, unknown> {
37
+ const map = new Map<string, unknown>();
38
+ for (const [key, raw] of Object.entries(tenantAnalyticsFiles)) {
39
+ const id = tenantIdFromPath(key);
40
+ if (id) map.set(id, raw);
41
+ }
42
+ return map;
43
+ }
@@ -13,6 +13,7 @@
13
13
  */
14
14
  import type { TenantConfig, TenantResolver } from "@tot/public-runtime";
15
15
  import { getTenantTheme } from "@/themes/tenants";
16
+ import { rootPublicationSpaceConfig } from "@/config/publicationSpaceConfig";
16
17
 
17
18
  // Default landing tenant — the fallback for unmatched hosts / bare localhost.
18
19
  // Non-commerce "coming soon" placeholder; never a fake merchant. Registered first
@@ -31,6 +32,11 @@ export const HOME_TENANT: TenantConfig = {
31
32
  currency: "USD",
32
33
  locale: "en-US",
33
34
  theme_tokens: getTenantTheme("home"),
35
+ publicationSpaces: [rootPublicationSpaceConfig("home", {
36
+ forge: "fixture",
37
+ repo: "home",
38
+ defaultBranch: "main",
39
+ })],
34
40
  siteType: "marketing",
35
41
  };
36
42
 
@@ -50,6 +56,11 @@ export const SAMPLE_TENANT: TenantConfig = {
50
56
  currency: "USD",
51
57
  locale: "en-US",
52
58
  theme_tokens: getTenantTheme("sample-store.example"),
59
+ publicationSpaces: [rootPublicationSpaceConfig("sample-store.example", {
60
+ forge: "fixture",
61
+ repo: "sample-store.example",
62
+ defaultBranch: "main",
63
+ })],
53
64
  // Regulated-store demo: shows the 21+/PACT compliance gate is CONFIG-driven.
54
65
  compliance: {
55
66
  minAge: 21,
@@ -103,7 +103,11 @@ const { tenant, themeStyle, canonicalBase, basePath, cspNonce, features, capabil
103
103
  // boundaries. `rawHtmlResponse` is the other mount of the SAME engine, so both
104
104
  // documents carry byte-identical phase markup. The nonce is stamped per response
105
105
  // from a placeholder by the middleware, never serialized (ADR 0018).
106
- const integrationPhases = renderIntegrationPhases(Astro.locals.integrationPlan, cspNonce);
106
+ const integrationPhases = renderIntegrationPhases(
107
+ Astro.locals.integrationPlan,
108
+ cspNonce,
109
+ Astro.locals.consentSnapshot,
110
+ );
107
111
 
108
112
  // Organization JSON-LD is emitted site-wide (brief §8); pages add their own
109
113
  // graphs (Product, BreadcrumbList…) via the jsonLd prop.
@@ -24,7 +24,7 @@
24
24
  * dev/tests immediately, and a Worker secret takes effect from the first primed
25
25
  * request of each isolate.
26
26
  */
27
- import { readEnv } from "../env.js";
27
+ import { readEnv } from "../runtimeEnv.js";
28
28
 
29
29
  /** The one canonical flag name — shared verbatim by the server and the CLI mirror. */
30
30
  export const ACTIVITY_KILL_SWITCH_FLAG = "ACTIVITY_TELEMETRY_DISABLED";
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Client-side wiring for `/admin/consent` (ADR 0013 client-island pattern: a
3
+ * typed `lib/` module imported from a processed `<script>`, never mirrored
4
+ * inline). Reads/writes `GET`/`POST /api/admin/consent-config`.
5
+ */
6
+
7
+ interface ConsentConfigRevision {
8
+ revision: number;
9
+ createdAt: string;
10
+ actor: string;
11
+ note: string | null;
12
+ }
13
+
14
+ /** Builds the history table with safe DOM methods only — `note` is admin-authored free text and must never be interpolated as HTML. */
15
+ function renderHistoryTable(rows: ConsentConfigRevision[]): HTMLTableElement {
16
+ const table = document.createElement("table");
17
+ const thead = document.createElement("thead");
18
+ const headRow = document.createElement("tr");
19
+ for (const label of ["Rev", "When", "Actor", "Note"]) {
20
+ const th = document.createElement("th");
21
+ th.textContent = label;
22
+ headRow.appendChild(th);
23
+ }
24
+ thead.appendChild(headRow);
25
+ table.appendChild(thead);
26
+
27
+ const tbody = document.createElement("tbody");
28
+ for (const r of rows) {
29
+ const tr = document.createElement("tr");
30
+ const cells = [`#${r.revision}`, new Date(r.createdAt).toLocaleString(), r.actor, r.note ?? ""];
31
+ for (const value of cells) {
32
+ const td = document.createElement("td");
33
+ td.textContent = value;
34
+ tr.appendChild(td);
35
+ }
36
+ tbody.appendChild(tr);
37
+ }
38
+ table.appendChild(tbody);
39
+ return table;
40
+ }
41
+
42
+ /**
43
+ * Wire the consent settings form + history panel. A no-op when the page did
44
+ * not render the authorized form (e.g. a signed-in non-owner) — the elements
45
+ * this looks for simply won't exist.
46
+ */
47
+ export function initConsentSettingsPage(apiPath: string): void {
48
+ const form = document.getElementById("consent-form") as HTMLFormElement | null;
49
+ const statusEl = document.getElementById("status");
50
+ const historyBody = document.getElementById("history-body");
51
+ if (!form || !statusEl || !historyBody) return;
52
+
53
+ function setStatus(text: string, tone: "ok" | "error" | null): void {
54
+ statusEl!.textContent = text;
55
+ if (tone) statusEl!.setAttribute("data-tone", tone);
56
+ else statusEl!.removeAttribute("data-tone");
57
+ }
58
+
59
+ form.querySelectorAll<HTMLInputElement>(".enable-toggle").forEach((toggle) => {
60
+ toggle.addEventListener("change", () => {
61
+ const group = toggle.closest(".purpose")?.querySelector<HTMLElement>(".bases");
62
+ group?.setAttribute("data-disabled", toggle.checked ? "false" : "true");
63
+ });
64
+ });
65
+
66
+ async function loadHistory(): Promise<void> {
67
+ try {
68
+ const res = await fetch(apiPath, { headers: { accept: "application/json" } });
69
+ const data = (await res.json()) as { ok: boolean; history?: ConsentConfigRevision[] };
70
+ if (!data.ok || !data.history || data.history.length === 0) {
71
+ historyBody!.textContent = "No saved revisions yet.";
72
+ return;
73
+ }
74
+ historyBody!.replaceChildren(renderHistoryTable(data.history));
75
+ } catch {
76
+ historyBody!.textContent = "Could not load history.";
77
+ }
78
+ }
79
+ void loadHistory();
80
+
81
+ form.addEventListener("submit", async (event) => {
82
+ event.preventDefault();
83
+ const purposes: Array<{ purpose: string; legalBasis: string }> = [];
84
+ form.querySelectorAll<HTMLInputElement>(".enable-toggle").forEach((toggle) => {
85
+ if (!toggle.checked) return;
86
+ const purpose = toggle.getAttribute("data-purpose") ?? "";
87
+ const checkedBasis = form.querySelector<HTMLInputElement>(`input[name="basis-${purpose}"]:checked`);
88
+ purposes.push({ purpose, legalBasis: checkedBasis?.value ?? "consent" });
89
+ });
90
+
91
+ setStatus("Saving…", null);
92
+ const saveButton = form.querySelector<HTMLButtonElement>("button.save");
93
+ if (saveButton) saveButton.disabled = true;
94
+ try {
95
+ const res = await fetch(apiPath, {
96
+ method: "POST",
97
+ headers: { "content-type": "application/json" },
98
+ body: JSON.stringify({ purposes }),
99
+ });
100
+ const data = (await res.json()) as { ok: boolean; message?: string; revision?: { revision: number } };
101
+ if (!data.ok) {
102
+ setStatus(data.message || "Save failed.", "error");
103
+ } else {
104
+ setStatus(`Saved as revision #${data.revision?.revision}.`, "ok");
105
+ void loadHistory();
106
+ }
107
+ } catch {
108
+ setStatus("Save failed — network error.", "error");
109
+ } finally {
110
+ if (saveButton) saveButton.disabled = false;
111
+ }
112
+ });
113
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Read the current tenant consent configuration for rendering.
3
+ *
4
+ * This is the renderer-shared half of consent persistence: it exposes only the
5
+ * latest validated configuration and none of the authoring or history surface.
6
+ */
7
+ import type { TenantConsentConfig } from "@tot/public-runtime";
8
+ import type { Queryable } from "../d1/catalog.js";
9
+
10
+ interface CurrentConsentConfigRow {
11
+ config: string;
12
+ }
13
+
14
+ export async function readCurrentTenantConsentConfig(
15
+ db: Queryable,
16
+ tenantId: string,
17
+ ): Promise<TenantConsentConfig | null> {
18
+ const row = await db.first<CurrentConsentConfigRow>(
19
+ `SELECT config FROM consent_config_revisions WHERE tenant_id = ?
20
+ ORDER BY revision DESC LIMIT 1`,
21
+ tenantId,
22
+ );
23
+ return row ? (JSON.parse(row.config) as TenantConsentConfig) : null;
24
+ }