@tokenoftrust/storefront-runner 2.0.17 → 2.0.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/apps/storefront/env.d.ts +21 -0
  2. package/apps/storefront/migrations-apps/0012_test_destinations.sql +29 -0
  3. package/apps/storefront/src/components/blog/ReactionBar.astro +1 -1
  4. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +365 -49
  5. package/apps/storefront/src/components/islands/checkoutVerificationFlow.ts +251 -0
  6. package/apps/storefront/src/config/compliance/rulesets.ts +18 -7
  7. package/apps/storefront/src/config/devTenantSeed.ts +24 -1
  8. package/apps/storefront/src/config/evidenceTenantRoster.ts +25 -0
  9. package/apps/storefront/src/config/publicationSpaceConfig.ts +25 -0
  10. package/apps/storefront/src/config/resolver.ts +5 -0
  11. package/apps/storefront/src/config/tenantAnalyticsFiles.ts +43 -0
  12. package/apps/storefront/src/config/tenants.ts +11 -0
  13. package/apps/storefront/src/layouts/Layout.astro +5 -1
  14. package/apps/storefront/src/lib/activity/killSwitch.ts +1 -1
  15. package/apps/storefront/src/lib/admin/consentSettingsClient.ts +113 -0
  16. package/apps/storefront/src/lib/apps/consentConfigRead.ts +24 -0
  17. package/apps/storefront/src/lib/apps/gtmMigrationDraft.ts +103 -0
  18. package/apps/storefront/src/lib/auth/brokerAssertion.ts +18 -0
  19. package/apps/storefront/src/lib/auth/identityToken.ts +11 -1
  20. package/apps/storefront/src/lib/auth/internalContentAuthority.ts +153 -0
  21. package/apps/storefront/src/lib/auth/loginGate.ts +25 -0
  22. package/apps/storefront/src/lib/auth/session.ts +3 -0
  23. package/apps/storefront/src/lib/checkoutCommerce.ts +1 -1
  24. package/apps/storefront/src/lib/compliance/enforcement.ts +25 -3
  25. package/apps/storefront/src/lib/d1/schema-apps.ts +44 -0
  26. package/apps/storefront/src/lib/edgeCache.ts +17 -1
  27. package/apps/storefront/src/lib/env.ts +13 -111
  28. package/apps/storefront/src/lib/integration/tenantAnalyticsDeclarations.ts +198 -0
  29. package/apps/storefront/src/lib/monitoring/manifest.ts +1 -1
  30. package/apps/storefront/src/lib/publication/contentProvider.ts +159 -0
  31. package/apps/storefront/src/lib/publication/runtimeSource.ts +97 -0
  32. package/apps/storefront/src/lib/rawMarketingHtml.ts +9 -1
  33. package/apps/storefront/src/lib/runtimeEnv.ts +124 -0
  34. package/apps/storefront/src/lib/verification/totVerificationClient.ts +485 -0
  35. package/apps/storefront/src/middleware/candidatePin.ts +35 -0
  36. package/apps/storefront/src/middleware/controlPlane.ts +291 -0
  37. package/apps/storefront/src/middleware/index.ts +200 -793
  38. package/apps/storefront/src/middleware/publicRender.ts +43 -0
  39. package/apps/storefront/src/middleware/renderChain.ts +730 -0
  40. package/apps/storefront/src/middleware/routeBranch.ts +192 -0
  41. package/apps/storefront/src/middleware/staticBundle.ts +109 -0
  42. package/apps/storefront/src/pages/[...slug].astro +1 -0
  43. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +1 -0
  44. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +1 -0
  45. package/apps/storefront/src/pages/api/auth/verify.ts +1 -0
  46. package/apps/storefront/src/pages/api/compliance/preflight.ts +179 -2
  47. package/apps/storefront/src/pages/index.astro +1 -0
  48. package/apps/storefront/vitest.config.ts +5 -0
  49. package/package.json +1 -1
  50. package/packages/public-runtime/package.json +1 -0
  51. package/packages/public-runtime/schemas/managed-analytics/v1/deliver-test-event-request.schema.json +122 -0
  52. package/packages/public-runtime/schemas/managed-analytics/v1/get-test-destination-delivery-request.schema.json +79 -0
  53. package/packages/public-runtime/schemas/managed-analytics/v1/register-test-destination-request.schema.json +93 -0
  54. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination-delivery.schema.json +106 -0
  55. package/packages/public-runtime/schemas/managed-analytics/v1/test-destination.schema.json +77 -0
  56. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +195 -0
  57. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +80 -0
  58. package/packages/public-runtime/schemas/publication-space/v1/ref.schema.json +20 -0
  59. package/packages/public-runtime/schemas/publication-version/v1/pointer.schema.json +28 -0
  60. package/packages/public-runtime/schemas/publication-version/v1/transition-request.schema.json +44 -0
  61. package/packages/public-runtime/schemas/publication-version/v1/transition.schema.json +47 -0
  62. package/packages/public-runtime/schemas/publication-version/v1/version.schema.json +58 -0
  63. package/packages/public-runtime/src/activity/catalog.ts +1 -1
  64. package/packages/public-runtime/src/candidate-index.ts +6 -0
  65. package/packages/public-runtime/src/customization-reconcile.ts +8 -0
  66. package/packages/public-runtime/src/html-cache-policy.ts +57 -0
  67. package/packages/public-runtime/src/index.ts +9 -0
  68. package/packages/public-runtime/src/integration/consent-broker.ts +115 -0
  69. package/packages/public-runtime/src/integration/interim-bridge.ts +111 -27
  70. package/packages/public-runtime/src/integration/managed-analytics-api-v1.schemas.ts +65 -0
  71. package/packages/public-runtime/src/integration/managed-analytics-api-v1.ts +81 -0
  72. package/packages/public-runtime/src/integration/render-phases.ts +8 -3
  73. package/packages/public-runtime/src/publication-artifact.ts +220 -0
  74. package/packages/public-runtime/src/publication-reader.ts +187 -0
  75. package/packages/public-runtime/src/publication-routing.ts +246 -0
  76. package/packages/public-runtime/src/publication-space.ts +853 -0
  77. package/packages/public-runtime/src/publication-version.ts +602 -0
  78. package/packages/public-runtime/src/route-tier.ts +257 -0
  79. package/packages/public-runtime/src/static-bundle.ts +18 -5
  80. package/packages/public-runtime/src/tenant-assets.ts +85 -17
  81. package/packages/public-runtime/src/tenant.ts +70 -0
  82. package/packages/public-runtime/tests/fixtures/publication-spaces/internal.json +32 -0
  83. package/packages/public-runtime/tests/fixtures/publication-spaces/mounted-public.json +38 -0
  84. package/packages/public-runtime/tests/fixtures/publication-spaces/normalized-mounts.json +39 -0
  85. package/packages/public-runtime/tests/fixtures/publication-spaces/ref.json +5 -0
  86. package/packages/public-runtime/tests/fixtures/publication-spaces/root-public.json +31 -0
  87. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v1.json +20 -0
  88. package/packages/public-runtime/tests/fixtures/publication-versions/articles-v2.json +20 -0
  89. package/packages/public-runtime/tests/fixtures/publication-versions/internal-v1.json +20 -0
  90. package/packages/public-runtime/tests/fixtures/publication-versions/live-pointer.json +11 -0
  91. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition-request.json +14 -0
  92. package/packages/public-runtime/tests/fixtures/publication-versions/live-transition.json +17 -0
  93. package/packages/public-runtime/tests/fixtures/publication-versions/root-v1.json +20 -0
  94. package/packages/public-runtime/tests/fixtures/publication-versions/root-v2.json +20 -0
  95. package/scripts/dev/ai-edit.mjs +16 -3
  96. package/scripts/dev/port-check.mjs +18 -3
  97. package/scripts/dev/publish.mjs +26 -3
  98. package/scripts/tenant/validate.mjs +14 -1
@@ -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
+ }
@@ -79,6 +79,17 @@ export interface MintBrokerAssertionOverrides {
79
79
  issuer?: string;
80
80
  }
81
81
 
82
+ /** Mint a broker assertion whose subject is an opaque verified identity id. */
83
+ export async function mintOpaqueBrokerAssertion(
84
+ opaqueSubject: string,
85
+ overrides: MintBrokerAssertionOverrides = {},
86
+ ): Promise<string> {
87
+ if (!opaqueSubject || opaqueSubject.trim().length === 0) {
88
+ throw new BrokerAssertionError("broker assertion: an opaque subject is required");
89
+ }
90
+ return mintBrokerAssertionSubject(opaqueSubject, overrides);
91
+ }
92
+
82
93
  // Import the signing key once per distinct PEM (importPKCS8 is not free). Keyed on
83
94
  // the base64 string so a key rotation (new env value) transparently re-imports.
84
95
  const keyCache = new Map<string, CryptoKey>();
@@ -137,6 +148,13 @@ export async function mintBrokerAssertion(
137
148
  if (!sub) {
138
149
  throw new BrokerAssertionError("broker assertion: a subject email is required");
139
150
  }
151
+ return mintBrokerAssertionSubject(sub, overrides);
152
+ }
153
+
154
+ async function mintBrokerAssertionSubject(
155
+ sub: string,
156
+ overrides: MintBrokerAssertionOverrides,
157
+ ): Promise<string> {
140
158
  const privateKeyB64 = overrides.privateKeyB64 ?? (await readEnv(BROKER_PRIVATE_KEY_ENV));
141
159
  if (!privateKeyB64) {
142
160
  throw new BrokerAssertionError(
@@ -104,6 +104,10 @@ export function toIdentityClaims(payload: Record<string, unknown>): IdentityClai
104
104
  if (typeof email !== "string" || !email) {
105
105
  throw new Error("identity assertion missing `email` claim");
106
106
  }
107
+ const subject = payload.sub;
108
+ if (typeof subject !== "string" || subject.trim().length === 0) {
109
+ throw new Error("identity assertion missing `sub` claim");
110
+ }
107
111
  const tenantsRaw = Array.isArray(payload.tenants) ? payload.tenants : [];
108
112
  const tenants: TenantMembership[] = tenantsRaw.map((t): TenantMembership => {
109
113
  const entry = t as { resource?: unknown; role?: unknown };
@@ -118,7 +122,7 @@ export function toIdentityClaims(payload: Record<string, unknown>): IdentityClai
118
122
 
119
123
  const claims: IdentityClaims = {
120
124
  iss: String(payload.iss ?? ""),
121
- sub: String(payload.sub ?? email),
125
+ sub: subject,
122
126
  aud: String(payload.aud ?? ""),
123
127
  iat: Number(payload.iat ?? 0),
124
128
  exp: Number(payload.exp ?? 0),
@@ -281,6 +285,8 @@ export async function getFederatedVerifier(
281
285
 
282
286
  /** The session fields derived from the VERIFIED token — the Phase-3 source of truth. */
283
287
  export interface DerivedSessionFields {
288
+ /** Opaque identity-provider subject, preserved exactly for live authorization. */
289
+ subject: string;
284
290
  email: string;
285
291
  resource: string;
286
292
  /** Coarse standing on THIS resource, surfaced as the display capability. */
@@ -307,6 +313,7 @@ export function sessionFromClaims(claims: IdentityClaims, resource: string): Der
307
313
  throw new Error(`verified assertion carries no membership for resource "${resource}"`);
308
314
  }
309
315
  return {
316
+ subject: claims.sub,
310
317
  email: claims.email,
311
318
  resource,
312
319
  capability: membership.role,
@@ -318,6 +325,8 @@ export function sessionFromClaims(claims: IdentityClaims, resource: string): Der
318
325
 
319
326
  /** The multi-tenant fields derived from a verified FEDERATED assertion. */
320
327
  export interface FederatedSessionFields {
328
+ /** Opaque identity-provider subject, preserved exactly for live authorization. */
329
+ subject: string;
321
330
  email: string;
322
331
  /** The verified `aud` — the storefront app identity this console session is for. */
323
332
  resource: string;
@@ -341,6 +350,7 @@ export interface FederatedSessionFields {
341
350
  */
342
351
  export function sessionFromFederatedClaims(claims: IdentityClaims): FederatedSessionFields {
343
352
  const fields: FederatedSessionFields = {
353
+ subject: claims.sub,
344
354
  email: claims.email,
345
355
  resource: claims.aud,
346
356
  tenants: claims.tenants,
@@ -0,0 +1,153 @@
1
+ import type { PublicationRoutePlanV1, ResolvedRouteTierV1 } from "@tot/public-runtime";
2
+ import type { SessionRecord, SessionStore } from "./session.js";
3
+ import { readSession } from "./session.js";
4
+
5
+ export const INTERNAL_CONTENT_CAPABILITY = "internal-content-reader" as const;
6
+ export const INTERNAL_CONTENT_AUTHORITY_TIMEOUT_MS = 5_000;
7
+
8
+ export type InternalContentAuthorityDecision =
9
+ | { allowed: true; tenant: string; spaceId: string; audience: "owner" | "developer" }
10
+ | { allowed: false; reason: "denied" | "unavailable" };
11
+
12
+ export type InternalContentGrant = Extract<InternalContentAuthorityDecision, { allowed: true }>;
13
+
14
+ interface BrokerContext {
15
+ appDomain: string;
16
+ audience: string;
17
+ }
18
+
19
+ export interface InternalContentAuthorityInput {
20
+ route: ResolvedRouteTierV1;
21
+ sessionStore?: SessionStore;
22
+ sessionId?: string | null;
23
+ coreUrl?: string | null;
24
+ mintAssertion: (subject: string) => Promise<string>;
25
+ fetchImpl?: typeof fetch;
26
+ nowSeconds?: number;
27
+ }
28
+
29
+ function routeIsDeclaredInternal(route: ResolvedRouteTierV1): route is ResolvedRouteTierV1 & {
30
+ space: NonNullable<ResolvedRouteTierV1["space"]>;
31
+ } {
32
+ return route.origin === "publication-space" &&
33
+ route.requiresAuthorization &&
34
+ route.visibility === "internal" &&
35
+ route.authorization.policy === "capability" &&
36
+ route.authorization.capability === INTERNAL_CONTENT_CAPABILITY &&
37
+ route.assets.access === "authorized" &&
38
+ !!route.space &&
39
+ route.space.tenant === route.tenant;
40
+ }
41
+
42
+ function brokerContexts(value: unknown): BrokerContext[] | null {
43
+ const unwrapped = value && typeof value === "object" && "content" in value
44
+ ? (value as { content?: unknown }).content
45
+ : value;
46
+ if (!unwrapped || typeof unwrapped !== "object") return null;
47
+ const contexts = (unwrapped as { contexts?: unknown }).contexts;
48
+ if (!Array.isArray(contexts)) return null;
49
+ const parsed: BrokerContext[] = [];
50
+ for (const context of contexts) {
51
+ if (!context || typeof context !== "object") return null;
52
+ const appDomain = (context as { appDomain?: unknown }).appDomain;
53
+ const audience = (context as { audience?: unknown }).audience;
54
+ if (typeof appDomain !== "string" || typeof audience !== "string") return null;
55
+ parsed.push({ appDomain, audience });
56
+ }
57
+ return parsed;
58
+ }
59
+
60
+ /**
61
+ * Resolve internal-content authority from fresh broker state. The session is
62
+ * identity proof only: cached memberships, roles, headers, and preview state do
63
+ * not grant access. Every store/read/signing fault is an explicit unavailable
64
+ * result and callers serve the same non-disclosing denial response.
65
+ */
66
+ export async function authorizeInternalContentRequest(
67
+ input: InternalContentAuthorityInput,
68
+ ): Promise<InternalContentAuthorityDecision> {
69
+ if (!routeIsDeclaredInternal(input.route)) return { allowed: false, reason: "denied" };
70
+ if (!input.sessionStore || !input.sessionId) return { allowed: false, reason: "denied" };
71
+
72
+ let session: SessionRecord | null;
73
+ try {
74
+ session = await readSession(
75
+ input.sessionStore,
76
+ input.sessionId,
77
+ input.nowSeconds ?? Math.floor(Date.now() / 1000),
78
+ );
79
+ } catch {
80
+ return { allowed: false, reason: "unavailable" };
81
+ }
82
+ const subject = session?.subject;
83
+ if (!subject || subject.trim().length === 0) {
84
+ return { allowed: false, reason: "denied" };
85
+ }
86
+
87
+ const coreUrl = (input.coreUrl ?? "").replace(/\/+$/, "");
88
+ if (!coreUrl) return { allowed: false, reason: "unavailable" };
89
+
90
+ let assertion: string;
91
+ try {
92
+ assertion = await input.mintAssertion(subject);
93
+ } catch {
94
+ return { allowed: false, reason: "unavailable" };
95
+ }
96
+
97
+ let response: Response;
98
+ try {
99
+ response = await (input.fetchImpl ?? fetch)(`${coreUrl}/api/broker/appDomains`, {
100
+ method: "GET",
101
+ headers: {
102
+ accept: "application/json",
103
+ "X-ToT-Broker-Assertion": assertion,
104
+ },
105
+ signal: AbortSignal.timeout(INTERNAL_CONTENT_AUTHORITY_TIMEOUT_MS),
106
+ });
107
+ } catch {
108
+ return { allowed: false, reason: "unavailable" };
109
+ }
110
+ if (!response.ok) return { allowed: false, reason: "unavailable" };
111
+
112
+ let contexts: BrokerContext[] | null;
113
+ try {
114
+ contexts = brokerContexts(await response.json());
115
+ } catch {
116
+ contexts = null;
117
+ }
118
+ if (!contexts) return { allowed: false, reason: "unavailable" };
119
+
120
+ const matching = contexts.filter((context) => context.appDomain === input.route.tenant);
121
+ if (matching.length !== 1) return { allowed: false, reason: "denied" };
122
+ const audience = matching[0]!.audience;
123
+ if (audience !== "owner" && audience !== "developer") {
124
+ return { allowed: false, reason: "denied" };
125
+ }
126
+ return {
127
+ allowed: true,
128
+ tenant: input.route.tenant,
129
+ spaceId: input.route.space.spaceId,
130
+ audience,
131
+ };
132
+ }
133
+
134
+ /** Prove the carried grant still names the exact server-resolved F2 space. */
135
+ export function internalContentGrantMatchesPlan(
136
+ grant: InternalContentGrant | undefined,
137
+ plan: PublicationRoutePlanV1,
138
+ ): boolean {
139
+ return !!grant && plan.action === "render" &&
140
+ plan.manifest.visibility === "internal" &&
141
+ plan.manifest.authorization.policy === "capability" &&
142
+ plan.manifest.authorization.capability === INTERNAL_CONTENT_CAPABILITY &&
143
+ plan.manifest.assets.access === "authorized" &&
144
+ grant.tenant === plan.route.tenant &&
145
+ grant.tenant === plan.manifest.tenant &&
146
+ grant.spaceId === plan.manifest.id;
147
+ }
148
+
149
+ /** Internal responses are never indexable or eligible for shared storage. */
150
+ export function applyInternalContentResponsePolicy(headers: Headers): void {
151
+ headers.set("Cache-Control", "private, no-store");
152
+ headers.set("X-Robots-Tag", "noindex, nofollow");
153
+ }
@@ -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)}` };
@@ -50,6 +50,8 @@ export const DEFAULT_SESSION_TTL_SECONDS = 7 * 24 * 3600;
50
50
  * are the multi-tenant claims carried in the verified token.
51
51
  */
52
52
  export interface SessionRecord {
53
+ /** Opaque identity-provider subject from the verified assertion. */
54
+ subject?: string;
53
55
  /** Verified email (lowercased) — the principal that passed the grant check. */
54
56
  email: string;
55
57
  /** The resource (appDomain/tenant, e.g. "sample-store.example") this session authorizes. */
@@ -166,6 +168,7 @@ export async function readSession(
166
168
  !record ||
167
169
  typeof record.email !== "string" ||
168
170
  typeof record.resource !== "string" ||
171
+ (record.subject !== undefined && typeof record.subject !== "string") ||
169
172
  typeof record.exp !== "number"
170
173
  ) {
171
174
  await store.delete(id);
@@ -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).
@@ -64,6 +64,13 @@ export function pageCacheKey(
64
64
  search: string,
65
65
  version: number,
66
66
  planRevision?: string,
67
+ publication?: {
68
+ spaceId: string;
69
+ channel: "preview" | "live";
70
+ pointerRevision: number;
71
+ versionId: string;
72
+ artifactDigest: string;
73
+ },
67
74
  ): Request {
68
75
  // A stored page carries the CSP header it was rendered with, INCLUDING any
69
76
  // version-scoped integration origins. Folding the resolved `planRevision` into
@@ -73,8 +80,17 @@ export function pageCacheKey(
73
80
  // integration plane resolved) the key is byte-identical to the non-integration
74
81
  // form, so a plain tenant pays nothing and its cache is never busted.
75
82
  const planSegment = planRevision ? `/pr:${planRevision}` : "";
83
+ const publicationSegment = publication
84
+ ? `/publication:${encodeURIComponent([
85
+ publication.spaceId,
86
+ publication.channel,
87
+ publication.pointerRevision,
88
+ publication.versionId,
89
+ publication.artifactDigest,
90
+ ].join(":"))}`
91
+ : "";
76
92
  return new Request(
77
- `${CACHE_ORIGIN}/${tenantId}/v${version}${planSegment}${pathname}${search}`,
93
+ `${CACHE_ORIGIN}/${tenantId}/v${version}${planSegment}${publicationSegment}${pathname}${search}`,
78
94
  );
79
95
  }
80
96