@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.
- package/apps/storefront/env.d.ts +21 -0
- package/apps/storefront/migrations-apps/0012_test_destinations.sql +29 -0
- package/apps/storefront/src/components/blog/ReactionBar.astro +1 -1
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +365 -49
- package/apps/storefront/src/components/islands/checkoutVerificationFlow.ts +251 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +18 -7
- package/apps/storefront/src/config/devTenantSeed.ts +24 -1
- package/apps/storefront/src/config/evidenceTenantRoster.ts +25 -0
- package/apps/storefront/src/config/publicationSpaceConfig.ts +25 -0
- package/apps/storefront/src/config/resolver.ts +5 -0
- package/apps/storefront/src/config/tenantAnalyticsFiles.ts +43 -0
- package/apps/storefront/src/config/tenants.ts +11 -0
- package/apps/storefront/src/layouts/Layout.astro +5 -1
- package/apps/storefront/src/lib/activity/killSwitch.ts +1 -1
- package/apps/storefront/src/lib/admin/consentSettingsClient.ts +113 -0
- package/apps/storefront/src/lib/apps/consentConfigRead.ts +24 -0
- package/apps/storefront/src/lib/apps/gtmMigrationDraft.ts +103 -0
- package/apps/storefront/src/lib/auth/brokerAssertion.ts +18 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +11 -1
- package/apps/storefront/src/lib/auth/internalContentAuthority.ts +153 -0
- package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
- package/apps/storefront/src/lib/auth/session.ts +3 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
- package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
- package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
- package/apps/storefront/src/lib/edgeCache.ts +17 -1
- package/apps/storefront/src/lib/env.ts +13 -111
- package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
- package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
- package/apps/storefront/src/lib/publication/runtimeSource.ts +97 -0
- package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
- package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
- package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
- package/apps/storefront/src/middleware/candidatePin.ts +35 -0
- package/apps/storefront/src/middleware/controlPlane.ts +291 -0
- package/apps/storefront/src/middleware/index.ts +200 -793
- package/apps/storefront/src/middleware/publicRender.ts +43 -0
- package/apps/storefront/src/middleware/renderChain.ts +730 -0
- package/apps/storefront/src/middleware/routeBranch.ts +192 -0
- package/apps/storefront/src/middleware/staticBundle.ts +109 -0
- package/apps/storefront/src/pages/[...slug].astro +1 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +1 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +1 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
- package/apps/storefront/src/pages/index.astro +1 -0
- package/apps/storefront/vitest.config.ts +5 -0
- package/package.json +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
- package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
- package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
- package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
- package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
- package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
- package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
- package/packages/public-runtime/src/activity/catalog.ts +1 -1
- package/packages/public-runtime/src/candidate-index.ts +6 -0
- package/packages/public-runtime/src/customization-reconcile.ts +8 -0
- package/packages/public-runtime/src/html-cache-policy.ts +57 -0
- package/packages/public-runtime/src/index.ts +9 -0
- package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
- package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
- package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
- package/packages/public-runtime/src/integration/render-phases.ts +8 -3
- package/packages/public-runtime/src/publication-artifact.ts +220 -0
- package/packages/public-runtime/src/publication-reader.ts +187 -0
- package/packages/public-runtime/src/publication-routing.ts +246 -0
- package/packages/public-runtime/src/publication-space.ts +853 -0
- package/packages/public-runtime/src/publication-version.ts +602 -0
- package/packages/public-runtime/src/route-tier.ts +257 -0
- package/packages/public-runtime/src/static-bundle.ts +18 -5
- package/packages/public-runtime/src/tenant-assets.ts +85 -17
- package/packages/public-runtime/src/tenant.ts +70 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
- package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
- package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
- package/scripts/dev/ai-edit.mjs +16 -3
- package/scripts/dev/port-check.mjs +18 -3
- package/scripts/dev/publish.mjs +26 -3
- 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
|
-
*
|
|
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
|
-
/**
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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(
|
|
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 "../
|
|
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
|
+
}
|