@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,291 @@
1
+ /**
2
+ * The control-plane branch: everything a route reaches only with authorization,
3
+ * plus every route on a login-gated host.
4
+ *
5
+ * It owns the three request-time D1 reads (managed installs, consent, the catalog
6
+ * edge replica), the sign-in gate and its admin landing, and the `/dev` alias —
7
+ * then delegates to the shared render chain with those values supplied.
8
+ */
9
+ import type { APIContext, MiddlewareNext } from "astro";
10
+ import { readEnv, readD1, readKv } from "@/lib/env";
11
+ import { fromD1, type D1Like } from "@/lib/d1/catalog";
12
+ import { readCurrentTenantConsentConfig } from "@/lib/apps/consentConfigRead";
13
+ import { loadActiveManagedInstallDeclarations } from "@/lib/integration/managedInstallDeclarations";
14
+ import { buildTenantConsentBroker, verifyPreviewToken } from "@tot/public-runtime";
15
+ import type {
16
+ ConsentSnapshot,
17
+ IntegrationDeclarationInput,
18
+ PublicationArtifactBucketV1,
19
+ PublicationRoutePlanV1,
20
+ RouteTierIndexV1,
21
+ TenantConfig,
22
+ } from "@tot/public-runtime";
23
+ import {
24
+ evaluateLoginGate,
25
+ isAuthPath,
26
+ isAdminPath,
27
+ sessionAdmitsHost,
28
+ sessionHostCapability,
29
+ PUBLIC_HOME_TENANT_ID,
30
+ type GateMode,
31
+ } from "@/lib/auth/loginGate";
32
+ import { resolveAdminLanding, isAdminSelectPath } from "@/lib/auth/adminLanding";
33
+ import { resolveAdminTenantMode } from "@/lib/adminTenantEnvelope";
34
+ import { gatedHoldingResponse } from "@/lib/auth/gatePage";
35
+ import { readSession, KvSessionStore, SESSION_COOKIE } from "@/lib/auth/session";
36
+ import { runRenderChain } from "./renderChain.js";
37
+ import type { InternalContentGrant } from "@/lib/auth/internalContentAuthority";
38
+
39
+ export interface ControlPlaneInput {
40
+ context: APIContext;
41
+ next: MiddlewareNext;
42
+ host: string;
43
+ url: URL;
44
+ tenant: TenantConfig;
45
+ basePath: string;
46
+ renderPath: string;
47
+ cache: Cache | undefined;
48
+ kv: KVNamespace | undefined;
49
+ routeTierIndex: RouteTierIndexV1;
50
+ publicationRequestPath: string;
51
+ publicationRoute: PublicationRoutePlanV1;
52
+ publicationBucket: PublicationArtifactBucketV1 | undefined;
53
+ gateMode: GateMode;
54
+ /** `DEMO_GATE_HOSTS`, raw. */
55
+ demoGateHosts: string | null | undefined;
56
+ /** True when this host is withheld behind sign-in — see `resolveMiddlewareBranchV1`. */
57
+ hostLoginGated: boolean;
58
+ /** Exact per-request authorization for an F2-selected internal space. */
59
+ internalContentGrant?: InternalContentGrant;
60
+ }
61
+
62
+ export async function serveControlPlane(input: ControlPlaneInput): Promise<Response> {
63
+ const { context, next, host, url, tenant, basePath, renderPath, cache, kv } = input;
64
+ const { request, locals } = context;
65
+
66
+ // Login-gate enforcement (DEMO_GATE_MODE="login"): runs before the render so a
67
+ // gated page is never served to an unauthenticated viewer. The platform's own
68
+ // "self" home tenant is EXEMPT — it renders publicly (browse + sign up); every
69
+ // other tenant on a gated host is withheld behind sign-in. A valid,
70
+ // resource-bound tot_session sets locals.viewer (email + roles) for chrome;
71
+ // otherwise an unauthenticated visitor gets a "Coming soon" holding page with
72
+ // a Sign-in button (auth paths are exempt so sign-in itself is reachable). The
73
+ // grant `resource` is the tenant's appDomain — never input.
74
+ if (input.hostLoginGated && !input.internalContentGrant) {
75
+ const tenantExempt = tenant.tenant_id === PUBLIC_HOME_TENANT_ID;
76
+ // UNIFIED on the federated session: read the FULL session (no resource filter —
77
+ // federated sign-in's aud is the storefront app, not this tenant) and admit the
78
+ // viewer iff they hold a MEMBERSHIP for the host's tenant. One session model for
79
+ // the console + the preview gate. Deny-closed: no session / no membership → gate.
80
+ let viewerRecord = null;
81
+ if (!isAuthPath(url.pathname)) {
82
+ const sessionKv = await readKv("SESSION");
83
+ if (sessionKv) {
84
+ const sid = context.cookies.get(SESSION_COOKIE)?.value ?? null;
85
+ viewerRecord = await readSession(
86
+ new KvSessionStore(sessionKv),
87
+ sid,
88
+ Math.floor(Date.now() / 1000),
89
+ );
90
+ }
91
+ }
92
+ const nowSeconds = Math.floor(Date.now() / 1000);
93
+ const admitted = sessionAdmitsHost(viewerRecord, tenant.appDomain, nowSeconds);
94
+ // QA content-bypass: only when the server has configured DEMO_GATE_BYPASS_TOKEN
95
+ // (never in a prod slot) AND the request presents a matching X-Demo-Gate-Bypass
96
+ // header. Unset token ⇒ false ⇒ fully inert; a client cannot self-grant. The
97
+ // public-content-only restriction (admin/cockpit/api excluded) is enforced
98
+ // inside evaluateLoginGate via isBypassPrivilegedPath.
99
+ const bypassToken = await readEnv("DEMO_GATE_BYPASS_TOKEN");
100
+ const qaBypass =
101
+ !!bypassToken && request.headers.get("x-demo-gate-bypass") === bypassToken;
102
+
103
+ // Customization-preview admission, available to EVERY tenant: a signed,
104
+ // unexpired `?__preview=` token (or the `tot_preview` cookie it persists as)
105
+ // whose `tenantId` claim matches THIS tenant admits the viewer to that
106
+ // tenant's public content. Verified here — before the gate decides — because
107
+ // previewing an unpublished storefront is the token's whole purpose, and a
108
+ // viewer holding one must not be handed the "Coming soon" holding page.
109
+ // Tenant-bound and HMAC-signed, so one tenant's token never opens another's,
110
+ // and an absent CUSTOMIZATION_PREVIEW_SECRET leaves it false (fail-closed).
111
+ // The admin/cockpit/api exclusion is enforced in evaluateLoginGate.
112
+ const gatePreviewSecret = await readEnv("CUSTOMIZATION_PREVIEW_SECRET");
113
+ const gatePreviewToken =
114
+ url.searchParams.get("__preview") ??
115
+ context.cookies.get("tot_preview")?.value ??
116
+ null;
117
+ let previewTokenValid = false;
118
+ if (gatePreviewSecret && gatePreviewToken) {
119
+ const verifiedPreview = await verifyPreviewToken(
120
+ gatePreviewSecret,
121
+ gatePreviewToken,
122
+ nowSeconds,
123
+ );
124
+ previewTokenValid =
125
+ verifiedPreview.ok && verifiedPreview.claims.tenantId === tenant.tenant_id;
126
+ }
127
+
128
+ // Canonical admin tenant landing: an AUTHENTICATED viewer who is not a
129
+ // member/staff of THIS host's tenant must be routed to their OWN tenant
130
+ // (auto when sole) or the tenant picker (when several / none) — never bounced
131
+ // into a sign-in loop and never silently shown the wrong tenant. Only for
132
+ // admin paths, only for an authed non-member, and never on the /admin/select
133
+ // picker itself (whose whole purpose is to be reachable by such a viewer).
134
+ if (
135
+ isAdminPath(url.pathname) &&
136
+ !isAdminSelectPath(url.pathname) &&
137
+ !admitted &&
138
+ viewerRecord &&
139
+ !qaBypass
140
+ ) {
141
+ const landing = resolveAdminLanding({
142
+ record: viewerRecord,
143
+ routedTenant: tenant.appDomain,
144
+ basePath,
145
+ intended: url.pathname + url.search,
146
+ nowSeconds,
147
+ });
148
+ if (landing.action === "auto" || landing.action === "select") {
149
+ return context.redirect(landing.location, 303);
150
+ }
151
+ }
152
+
153
+ // The /admin/select picker requires only a VALID SESSION (any authed viewer),
154
+ // not membership of the routed tenant — it lists the viewer's OWN tenants. So
155
+ // it passes the gate on the strength of the session alone.
156
+ const hasValidSession =
157
+ admitted || (isAdminSelectPath(url.pathname) && !!viewerRecord);
158
+ const decision = evaluateLoginGate({
159
+ host,
160
+ pathname: url.pathname,
161
+ search: url.search,
162
+ mode: input.gateMode,
163
+ gatedHosts: input.demoGateHosts,
164
+ hasValidSession,
165
+ tenantExempt,
166
+ qaBypass,
167
+ previewTokenValid,
168
+ });
169
+ if (decision.action === "redirect") {
170
+ // ADMIN (and any privileged surface) hard-redirects to sign-in rather than
171
+ // rendering the merchant-facing "Coming soon" holding page: an unauthenticated
172
+ // admin visitor must land on the sign-in prompt, never an ambiguous 200 that
173
+ // reads as "this store isn't ready." The holding page's whole audience is a
174
+ // public browser of a gated STOREFRONT; it is the wrong signal for /admin. The
175
+ // gate already decided to withhold — this only changes HOW (303 vs holding
176
+ // page); it never weakens the gate. `decision.location` already carries
177
+ // `?next=` so the admin returns to exactly where they were headed.
178
+ if (isAdminPath(url.pathname)) {
179
+ return context.redirect(decision.location, 303);
180
+ }
181
+ // Withhold content but offer sign-in (carrying the intended destination).
182
+ return gatedHoldingResponse(decision.location);
183
+ }
184
+ if (admitted && viewerRecord) {
185
+ // Surface the viewer's standing FOR THIS host's tenant as chrome, BOUND to
186
+ // the host so a capability the session holds for another tenant never leaks
187
+ // in: a member sees their tenants[] role; the owner of their own tenant sees
188
+ // the resource-scoped session's capability; a ToT-staff viewer admitted via
189
+ // an explicit vendor selection sees the staffRoles-scoped capability. The
190
+ // ship gates (decideIsOwner/resolveShipPrincipal) read this capability,
191
+ // so binding it here keeps owner-resolution server-side + tenant-correct.
192
+ //
193
+ // On an admin path, "the host" a staff viewer is really acting on is the
194
+ // ?asTenant= impersonation target (mirrors admin.astro/AdminPublishTab.astro's
195
+ // own resolveAdminTenantMode call) — resolving capability against the routed
196
+ // host instead left a staff viewer who selected another tenant reading as
197
+ // "not an owner" of it even while holding a real grant, because their
198
+ // staffSelection is bound to the SELECTED tenant, never the host app.
199
+ const capabilityHost = isAdminPath(url.pathname)
200
+ ? resolveAdminTenantMode({
201
+ session: viewerRecord,
202
+ adminAppTenant: tenant.appDomain,
203
+ requestedTarget: url.searchParams.get("asTenant"),
204
+ nowSeconds,
205
+ }).envelope.targetTenant
206
+ : tenant.appDomain;
207
+ const hostCapability = sessionHostCapability(viewerRecord, capabilityHost, nowSeconds);
208
+ locals.viewer = {
209
+ email: viewerRecord.email,
210
+ roles: hostCapability ? [hostCapability] : viewerRecord.roles,
211
+ capability: hostCapability ?? viewerRecord.capability,
212
+ };
213
+ }
214
+ }
215
+
216
+ // /dev was renamed to /cockpit (2026-07-20). 302-redirect any old /dev link —
217
+ // host-routed (/dev) or path-routed (/<vendor>/dev) — to the new path, preserving
218
+ // the tenant prefix + query. Temporary (302) while the cockpit UX is still evolving.
219
+ if (renderPath === "/dev" || renderPath.startsWith("/dev/")) {
220
+ const rest = renderPath.slice("/dev".length); // "" or "/sub…"
221
+ return Response.redirect(`${url.origin}${basePath}/cockpit${rest}${url.search}`, 302);
222
+ }
223
+
224
+ // Active managed installs share the same declaration/resolver/CSP/broker/cache
225
+ // plane as tenant-config integrations. Read only canonical active rows and their
226
+ // current immutable snapshot; candidates, suspended rows, and drafts produce no
227
+ // declaration. A missing/unmigrated binding fails closed to no managed effects.
228
+ const appsD1 = await readD1("STOREFRONT_APPS_DB");
229
+ let managedIntegrationDeclarations: IntegrationDeclarationInput[] = [];
230
+ if (appsD1) {
231
+ try {
232
+ managedIntegrationDeclarations = await loadActiveManagedInstallDeclarations(
233
+ fromD1(appsD1),
234
+ tenant.tenant_id,
235
+ );
236
+ } catch {
237
+ managedIntegrationDeclarations = [];
238
+ }
239
+ }
240
+
241
+ return runRenderChain({
242
+ context,
243
+ next,
244
+ host,
245
+ url,
246
+ tenant,
247
+ basePath,
248
+ renderPath,
249
+ // Internal output never receives a shared-cache handle. The render chain
250
+ // also refuses its page-cache predicate, making this a two-sided invariant.
251
+ cache: input.internalContentGrant ? undefined : cache,
252
+ kv,
253
+ routeTierIndex: input.routeTierIndex,
254
+ publicationRequestPath: input.publicationRequestPath,
255
+ publicationRoute: input.publicationRoute,
256
+ publicationBucket: input.publicationBucket,
257
+ managedIntegrationDeclarations,
258
+ readTenantConsentSnapshot: () => readSavedConsentSnapshot(tenant.tenant_id),
259
+ // The D1 catalog edge read replica, when this deploy opts into it. Deferred to
260
+ // the chain's own catalog build so a request the static bundle or the edge
261
+ // cache answers never pays for it. The public branch never resolves it at all
262
+ // — see `publicRender`.
263
+ readCatalogD1: async (): Promise<D1Like | undefined> =>
264
+ (await readEnv("USE_D1_CATALOG")) === "1" ? await readD1("CATALOG_DB") : undefined,
265
+ internalContentGrant: input.internalContentGrant,
266
+ });
267
+ }
268
+
269
+ /**
270
+ * The live Worker serving preview/admin/gated areas (NOT the public path per ADR
271
+ * 0013) reads the current consent from D1. Kept FAIL-SOFT: a bound D1 whose schema
272
+ * is absent (a schemaless ephemeral preview D1) must never 500 the render — it
273
+ * returns null and the caller keeps the essential-only default. Fail-soft, NOT
274
+ * fail-quiet: consent silently downgrading on a regulated storefront is its own
275
+ * incident class, so log loudly. Expected only against an ephemeral preview D1;
276
+ * against a production D1 it is a real signal to investigate.
277
+ */
278
+ async function readSavedConsentSnapshot(tenantId: string): Promise<ConsentSnapshot | null> {
279
+ const consentD1 = await readD1("STOREFRONT_APPS_DB");
280
+ if (!consentD1) return null;
281
+ try {
282
+ const config = await readCurrentTenantConsentConfig(fromD1(consentD1), tenantId);
283
+ return config ? buildTenantConsentBroker(config).getSnapshot() : null;
284
+ } catch (err) {
285
+ console.error(
286
+ `consent snapshot read failed for tenant ${tenantId}; serving the ` +
287
+ `fail-closed essential-only default: ${err instanceof Error ? err.message : String(err)}`,
288
+ );
289
+ return null;
290
+ }
291
+ }