@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,192 @@
1
+ /**
2
+ * Which of the three middleware branches serves this request.
3
+ *
4
+ * The TIER answer is F2's, carried by `resolvePublicationRoutePlanV1`, and is
5
+ * never re-derived here. This module only maps that one answer onto the three
6
+ * serving shapes the Worker actually has, and applies the literal path tables
7
+ * that correct the resolver's deliberate fail-closed over-restriction of
8
+ * platform-reserved paths.
9
+ *
10
+ * Those tables are NOT a second classification. Every reserved path already has
11
+ * one answer (Tier 3, authorization required) and these do not change it: they
12
+ * only say HOW three fixed sets of reserved paths are served, because "run the
13
+ * control plane" is the wrong shape for all three.
14
+ *
15
+ * - {@link PLATFORM_STATIC_ASSET_PREFIXES} — built and public/ bytes. Identical
16
+ * for every visitor, no tenant state at all. Serving them through origin
17
+ * compute is exactly what ADR 0013 exists to prevent.
18
+ * - {@link PLATFORM_PUBLIC_DISCOVERY_PATHS} — robots/sitemap/llms. Anonymous-
19
+ * identical, but rendered from the tenant's own catalog + content, so they
20
+ * are not raw bytes; they are ordinary Tier 0 public output that happens to
21
+ * live on a reserved mount.
22
+ * - {@link PLATFORM_PUBLIC_APP_ROUTE_PREFIXES} — the app's own catalog browse
23
+ * pages. Reserved so a tenant mount cannot claim them, which is a different
24
+ * question from who may read them: they are the storefront's most public
25
+ * surface, and `isCacheablePath` already whitelists the same set.
26
+ *
27
+ * Contract: docs/architecture/decisions/0013-public-serving-contract.md
28
+ */
29
+ import {
30
+ resolvePublicationRoutePlanV1,
31
+ type PublicationRoutePlanV1,
32
+ type PublicationRouteRedirectPlanV1,
33
+ type RouteTierIndexV1,
34
+ type ResolvedRouteTierV1,
35
+ } from "@tot/public-runtime";
36
+
37
+ /**
38
+ * The three serving shapes.
39
+ *
40
+ * static-passthrough — platform bytes; `next()` and baseline headers, nothing else.
41
+ * public-render — anonymous tenant output; no D1 read at request time.
42
+ * control-plane — authorization-gated; admin, API, preview, the dev loop.
43
+ */
44
+ export type MiddlewareBranchV1 = "static-passthrough" | "public-render" | "control-plane";
45
+
46
+ /**
47
+ * Built output and `public/` roots. Reached by the Worker only where the
48
+ * platform's static handler did not already answer them (plain `astro dev`, the
49
+ * local runner); serving them from the render chain would put origin compute in
50
+ * front of bytes that never vary.
51
+ */
52
+ export const PLATFORM_STATIC_ASSET_PREFIXES = [
53
+ "/_astro",
54
+ "/apps",
55
+ "/assets",
56
+ "/brand",
57
+ "/favicon.svg",
58
+ "/js",
59
+ "/platform",
60
+ "/shared",
61
+ ] as const;
62
+
63
+ /**
64
+ * Per-tenant discovery documents. Every one is generated from `locals.tot` +
65
+ * `locals.content` (see src/pages/robots.txt.ts, sitemap.xml.ts, llms.txt.ts),
66
+ * so they need the public render chain — they are not static bytes and cannot be
67
+ * passed through.
68
+ */
69
+ export const PLATFORM_PUBLIC_DISCOVERY_PATHS = [
70
+ "/llms.txt",
71
+ "/robots.txt",
72
+ "/sitemap.xml",
73
+ ] as const;
74
+
75
+ /**
76
+ * The storefront app's own catalog browse routes (src/pages/products,
77
+ * collections, search.astro, saved.astro). They are reserved mounts because a
78
+ * tenant publication space may not claim them — not because reading one needs
79
+ * authorization. `isCacheablePath` names the same set as the core public
80
+ * cacheable surface, and routing them through the control plane would put origin
81
+ * compute plus its D1 reads in front of a tenant's highest-traffic public pages.
82
+ *
83
+ * Dynamic Astro pages, not bytes, so `public-render` — never passthrough.
84
+ */
85
+ export const PLATFORM_PUBLIC_APP_ROUTE_PREFIXES = [
86
+ "/collections",
87
+ "/products",
88
+ "/saved",
89
+ "/search",
90
+ ] as const;
91
+
92
+ /**
93
+ * Case-folded, matching `isPlatformReserved`'s own folding in the tier resolver:
94
+ * a path it recognizes as reserved (`/Robots.txt`, `/ASSETS/app.js`) must reach
95
+ * the table that says HOW to serve it, or it falls through to the control plane.
96
+ * Every entry below is lowercase ASCII.
97
+ */
98
+ function matchesPathOrPrefix(path: string, entry: string): boolean {
99
+ const caseFolded = path.toLowerCase();
100
+ return caseFolded === entry || caseFolded.startsWith(`${entry}/`);
101
+ }
102
+
103
+ export function isPlatformStaticAssetPath(renderPath: string): boolean {
104
+ return PLATFORM_STATIC_ASSET_PREFIXES.some((entry) => matchesPathOrPrefix(renderPath, entry));
105
+ }
106
+
107
+ export function isPlatformPublicDiscoveryPath(renderPath: string): boolean {
108
+ return PLATFORM_PUBLIC_DISCOVERY_PATHS.some((entry) => matchesPathOrPrefix(renderPath, entry));
109
+ }
110
+
111
+ export function isPlatformPublicAppRoutePath(renderPath: string): boolean {
112
+ return PLATFORM_PUBLIC_APP_ROUTE_PREFIXES.some((entry) =>
113
+ matchesPathOrPrefix(renderPath, entry),
114
+ );
115
+ }
116
+
117
+ export interface MiddlewareBranchInputV1 {
118
+ index: RouteTierIndexV1;
119
+ /**
120
+ * The TENANT-RELATIVE path — `renderPath` from `resolveTenantRouting`, with any
121
+ * `/<domain>` selection prefix already stripped. Never `url.pathname`: on a
122
+ * path-routed deployment `/<tenant>/admin` arrives with the prefix attached, and
123
+ * feeding that to the resolver misses the reserved-mount check entirely, which
124
+ * resolves the control plane as public Tier 0.
125
+ */
126
+ renderPath: string;
127
+ /**
128
+ * True when this HOST is withheld behind sign-in (`DEMO_GATE_MODE=login` and the
129
+ * host is in `DEMO_GATE_HOSTS`). A host property, resolved before and
130
+ * independently of any route: under ADR 0013 the Worker keeps "preview, draft
131
+ * overlay, admin, the dev loop, and gated member areas", so a login-gated host
132
+ * is not serving a public path at all and every route on it is control plane.
133
+ */
134
+ hostLoginGated: boolean;
135
+ }
136
+
137
+ export interface ResolvedMiddlewareBranchV1 {
138
+ branch: MiddlewareBranchV1;
139
+ /** F2's answer for this request, surfaced on `locals.routeTier`. */
140
+ route: ResolvedRouteTierV1;
141
+ /** One normalized publication-space answer shared by dispatch and rendering. */
142
+ publication: PublicationRoutePlanV1;
143
+ }
144
+
145
+ /** Astro rewrite target for tenant-prefix stripping or canonical render aliases. */
146
+ export function middlewareRewriteTargetV1(input: {
147
+ requestPath: string;
148
+ basePath: string;
149
+ renderPath: string;
150
+ search: string;
151
+ }): string | undefined {
152
+ return input.basePath !== "" || input.renderPath !== input.requestPath
153
+ ? `${input.renderPath}${input.search}`
154
+ : undefined;
155
+ }
156
+
157
+ /** Absolute 308 location for host- and path-routed publication aliases. */
158
+ export function publicationRedirectLocationV1(input: {
159
+ origin: string;
160
+ basePath: string;
161
+ plan: PublicationRouteRedirectPlanV1;
162
+ search: string;
163
+ }): string {
164
+ return `${input.origin}${input.basePath}${input.plan.locationPath}${input.search}`;
165
+ }
166
+
167
+ /**
168
+ * Resolve the route once and pick its branch. Fail-closed throughout: F2 answers
169
+ * Tier 3 with authorization required for anything it cannot resolve, and that
170
+ * lands on the control plane.
171
+ */
172
+ export function resolveMiddlewareBranchV1(
173
+ input: MiddlewareBranchInputV1,
174
+ ): ResolvedMiddlewareBranchV1 {
175
+ const publication = resolvePublicationRoutePlanV1(input.index, input.renderPath);
176
+ const route = publication.route;
177
+ if (input.hostLoginGated) return { branch: "control-plane", route, publication };
178
+ if (isPlatformStaticAssetPath(input.renderPath)) {
179
+ return { branch: "static-passthrough", route, publication };
180
+ }
181
+ if (
182
+ isPlatformPublicDiscoveryPath(input.renderPath) ||
183
+ isPlatformPublicAppRoutePath(input.renderPath)
184
+ ) {
185
+ return { branch: "public-render", route, publication };
186
+ }
187
+ return {
188
+ branch: route.requiresAuthorization ? "control-plane" : "public-render",
189
+ route,
190
+ publication,
191
+ };
192
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Digest-addressed static-bundle serving (ADR 0012/0013) — CDN → R2, no render.
3
+ *
4
+ * This is the public path's real substrate, so BOTH branches that can be asked
5
+ * for public bytes reach it: the render chain (route documents) and the static
6
+ * passthrough (`/_astro`, `/assets` and the other platform roots, whose per-version
7
+ * hashed names exist only inside a tenant's bundle, never in the Worker's own
8
+ * build output). It reads R2 and KV and never D1.
9
+ */
10
+ import { readR2 } from "@/lib/runtimeEnv";
11
+ import { getCachedPage, putCachedPage, staticBundleCacheKey } from "@/lib/edgeCache";
12
+ import { resolveChannelDigest, serveStaticBundle } from "@tot/public-runtime";
13
+ import type { StaticBundleR2BucketLike, TenantConfig } from "@tot/public-runtime";
14
+
15
+ export interface StaticBundleAttemptInput {
16
+ request: Request;
17
+ url: URL;
18
+ tenant: TenantConfig;
19
+ basePath: string;
20
+ cache: Cache | undefined;
21
+ kv: KVNamespace | undefined;
22
+ isPreviewHost: boolean;
23
+ /** `?__preview=` or the `tot_preview` cookie. */
24
+ hasPreviewToken: boolean;
25
+ hasCandidatePin: boolean;
26
+ hasDraftCookie: boolean;
27
+ /** Managed installs inject document markup, so a bundled DOCUMENT would be stale. */
28
+ hasManagedInstalls: boolean;
29
+ }
30
+
31
+ /**
32
+ * Serve this request from the tenant's live bundle, or return null to let the
33
+ * caller own it.
34
+ *
35
+ * For a static-publish tenant's PRODUCTION reads only (never preview/test — the
36
+ * "Worker serves interactive preview, R2 serves prod" split). A missing digest or
37
+ * object fails closed inside `serveStaticBundle`, which is why this must fully own
38
+ * the response once eligible rather than degrading into a render.
39
+ *
40
+ * Host-resolved reads only (`basePath === ""`): bundle bytes are served VERBATIM
41
+ * with root-absolute links, so a path-prefixed view (/<domain>/… on the shared
42
+ * .store host) would escape its prefix on every href — those views keep SSR, which
43
+ * rewrites hrefs through basePath.
44
+ */
45
+ export async function tryServeStaticBundle(
46
+ input: StaticBundleAttemptInput,
47
+ ): Promise<Response | null> {
48
+ const { request, url, tenant, cache, kv } = input;
49
+ const eligible =
50
+ tenant.staticPublish != null &&
51
+ tenant.staticPublish.enabled !== false &&
52
+ input.basePath === "" &&
53
+ request.method.toUpperCase() === "GET" &&
54
+ !input.isPreviewHost &&
55
+ !input.hasPreviewToken &&
56
+ !input.hasCandidatePin &&
57
+ !input.hasDraftCookie &&
58
+ !input.hasManagedInstalls &&
59
+ !url.pathname.startsWith("/api/");
60
+ if (!eligible) return null;
61
+
62
+ const bundleBucket = await readR2("STATIC_BUNDLES");
63
+ // No STATIC_BUNDLES binding (plain `astro dev`, or unconfigured) — degrading to
64
+ // the caller rather than a hard failure keeps `astro dev`/tests usable without
65
+ // provisioning R2.
66
+ if (!bundleBucket) return null;
67
+
68
+ const kvGet = kv ? (key: string) => kv.get(key) : async () => null;
69
+ // The R2 bundle key is the `treeDigest`, NOT the `live` channel versionId (the
70
+ // Gitea sha). Read the reconciled digest companion (`channelDigests.live`) —
71
+ // `gatedPromote`/`rollbackCandidateAtomic` write it in the same CAS flip as the
72
+ // versionId. No digest = this tenant has never staged a bundle — the caller
73
+ // serves it (a static-publish tenant must be able to bootstrap, and its app
74
+ // surfaces must never be downed by an unstaged static plane).
75
+ const digest = await resolveChannelDigest(kvGet, tenant.tenant_id, "live");
76
+ if (!digest) {
77
+ console.error(
78
+ `[static-bundle] no live digest for ${tenant.tenant_id} — SSR until a bundle is staged`,
79
+ );
80
+ return null;
81
+ }
82
+
83
+ const bundleCacheKey = cache
84
+ ? staticBundleCacheKey(tenant.tenant_id, digest, url.pathname, url.search)
85
+ : null;
86
+ if (bundleCacheKey && cache) {
87
+ const hit = await getCachedPage(cache, bundleCacheKey);
88
+ if (hit) return hit;
89
+ }
90
+ const bundleResponse = await serveStaticBundle(
91
+ {
92
+ bucket: bundleBucket as unknown as StaticBundleR2BucketLike,
93
+ digest,
94
+ onFailClosed: (detail) => {
95
+ console.error(
96
+ `[static-bundle] fail-closed for ${tenant.tenant_id} ${url.pathname}: ${detail}`,
97
+ );
98
+ },
99
+ },
100
+ url.pathname,
101
+ );
102
+ // `null` = the manifest proves this path is outside the bundle's static surface
103
+ // (an app surface like /admin) — the caller owns it.
104
+ if (!bundleResponse) return null;
105
+ if (bundleCacheKey && cache) {
106
+ await putCachedPage(cache, bundleCacheKey, bundleResponse);
107
+ }
108
+ return bundleResponse;
109
+ }
@@ -49,6 +49,7 @@ if (rawHtml && rawHtmlPolicy(tenant).allowed) {
49
49
  basePath,
50
50
  chromeWrapper,
51
51
  plan: Astro.locals.integrationPlan,
52
+ consentSnapshot: Astro.locals.consentSnapshot,
52
53
  });
53
54
  }
54
55
 
@@ -125,6 +125,7 @@ export async function POST(context: APIContext): Promise<Response> {
125
125
  const { id } = await createSession(
126
126
  store,
127
127
  {
128
+ subject: derived.subject,
128
129
  email: derived.email,
129
130
  resource: derived.resource,
130
131
  roles: [],
@@ -110,6 +110,7 @@ export async function POST(context: APIContext): Promise<Response> {
110
110
  const { id } = await createSession(
111
111
  store,
112
112
  {
113
+ subject: derived.subject,
113
114
  email: derived.email,
114
115
  resource: derived.resource,
115
116
  roles: [],
@@ -137,6 +137,7 @@ export async function POST(context: APIContext): Promise<Response> {
137
137
  const { id } = await createSession(
138
138
  store,
139
139
  {
140
+ subject: derived.subject,
140
141
  email: derived.email,
141
142
  // The verified aud (storefront app identity) — the console session's scope.
142
143
  resource: derived.resource,
@@ -22,11 +22,18 @@ import {
22
22
  type VerificationState,
23
23
  type CartComplianceDecision,
24
24
  } from "@tot/public-runtime";
25
+ import type { ComplianceRuleset } from "@tot/public-runtime";
25
26
  import {
27
+ isLiveTotVerificationProvider,
26
28
  isRegulatedTenant,
27
29
  isSandboxVerificationProvider,
28
30
  resolveTenantRuleset,
29
31
  } from "@/lib/compliance/enforcement";
32
+ import {
33
+ createTotVerificationClient,
34
+ type TotVerificationClient,
35
+ type TotVerificationStatus,
36
+ } from "@/lib/verification/totVerificationClient";
30
37
 
31
38
  export const prerender = false;
32
39
 
@@ -46,6 +53,31 @@ interface PreflightBody {
46
53
  evidenceRef?: string;
47
54
  } | null;
48
55
  ageAffirmed?: boolean;
56
+ /**
57
+ * LIVE provider only: the `appTransactionId` correlating this checkout attempt
58
+ * to a verification already started with ToT. Present on the resume leg of the
59
+ * shopper redirect trip. It is a CORRELATION HANDLE, not a claim — the server
60
+ * re-reads the real status for it on every call.
61
+ */
62
+ verificationRef?: string;
63
+ /** LIVE provider only: the shopper details a new verification is started from. */
64
+ shopper?: {
65
+ email?: string;
66
+ givenName?: string;
67
+ familyName?: string;
68
+ dateOfBirth?: string;
69
+ } | null;
70
+ }
71
+
72
+ /**
73
+ * What the live provider leg resolved, echoed back so the (downstream) checkout
74
+ * verification flow can send the shopper on and resume with the same ref. Purely
75
+ * informational — the DECISION is `decision`, computed server-side.
76
+ */
77
+ interface LiveVerificationMeta {
78
+ ref?: string;
79
+ status?: TotVerificationStatus | "conditions_met" | "interactive";
80
+ continuationUrl?: string;
49
81
  }
50
82
 
51
83
  function json(body: unknown, status = 200): Response {
@@ -107,6 +139,134 @@ function normalizeDestination(
107
139
  };
108
140
  }
109
141
 
142
+ // --- Live ToT verification provider -----------------------------------------
143
+
144
+ let clientOverride: TotVerificationClient | null | undefined;
145
+
146
+ /** Test seam: inject a client (or `null` to simulate an unconfigured env). */
147
+ export function __setTotVerificationClientForTests(
148
+ client: TotVerificationClient | null | undefined,
149
+ ): void {
150
+ clientOverride = client;
151
+ }
152
+
153
+ async function resolveClient(): Promise<TotVerificationClient | null> {
154
+ if (clientOverride !== undefined) return clientOverride;
155
+ return await createTotVerificationClient();
156
+ }
157
+
158
+ /** Map a live provider status onto the pure engine's verification state. */
159
+ function stateForStatus(
160
+ status: TotVerificationStatus,
161
+ ref: string,
162
+ ): VerificationState {
163
+ switch (status) {
164
+ case "cleared":
165
+ // The provider cleared its age/identity workflow for this app. It does not
166
+ // report a numeric age, so `verifiedAge` stays undefined — the engine then
167
+ // treats "verified" as satisfying the tenant's minAge, which is exactly the
168
+ // provider's own contract (the app's workflow encodes the age threshold).
169
+ return { outcome: "verified", evidenceRef: ref };
170
+ case "rejected":
171
+ return { outcome: "failed", evidenceRef: ref };
172
+ case "pending":
173
+ return { outcome: "pending", evidenceRef: ref };
174
+ case "not_started":
175
+ return { outcome: "unknown" };
176
+ }
177
+ }
178
+
179
+ interface LiveVerificationResolution {
180
+ state: VerificationState;
181
+ meta: LiveVerificationMeta;
182
+ }
183
+
184
+ /**
185
+ * Resolve the shopper's verification from the LIVE provider, server-side.
186
+ *
187
+ * Returns `null` on ANY adapter failure (unconfigured env, transport error,
188
+ * non-2xx, malformed payload) — the caller turns that into the same fail-closed
189
+ * BLOCK as "no provider configured". Never throws.
190
+ */
191
+ async function resolveLiveVerification(
192
+ body: PreflightBody,
193
+ ): Promise<LiveVerificationResolution | null> {
194
+ let client: TotVerificationClient | null;
195
+ try {
196
+ client = await resolveClient();
197
+ } catch {
198
+ return null;
199
+ }
200
+ if (!client) return null;
201
+
202
+ const ref =
203
+ typeof body.verificationRef === "string" && body.verificationRef.trim()
204
+ ? body.verificationRef.trim()
205
+ : null;
206
+
207
+ try {
208
+ if (ref) {
209
+ const read = await client.readVerification({ appTransactionId: ref });
210
+ if (!read.ok) return null;
211
+ if (read.status !== "not_started") {
212
+ return { state: stateForStatus(read.status, ref), meta: { ref, status: read.status } };
213
+ }
214
+ // "not_started" for a known ref falls through to a create with the same ref.
215
+ }
216
+
217
+ const email =
218
+ typeof body.shopper?.email === "string" ? body.shopper.email.trim() : "";
219
+ if (!email) {
220
+ // Nothing started and nothing to start from: the shopper still has to
221
+ // verify. NOT an adapter failure — "unknown" is the engine's "do the step".
222
+ return {
223
+ state: { outcome: "unknown" },
224
+ meta: ref ? { ref, status: "not_started" } : {},
225
+ };
226
+ }
227
+
228
+ const appTransactionId = ref ?? crypto.randomUUID();
229
+ const created = await client.createVerification({
230
+ appTransactionId,
231
+ person: {
232
+ primaryEmail: email,
233
+ ...(body.shopper?.givenName ? { givenName: body.shopper.givenName } : {}),
234
+ ...(body.shopper?.familyName ? { familyName: body.shopper.familyName } : {}),
235
+ ...(body.shopper?.dateOfBirth ? { dateOfBirth: body.shopper.dateOfBirth } : {}),
236
+ },
237
+ });
238
+ if (!created.ok) return null;
239
+ const meta: LiveVerificationMeta = {
240
+ ref: appTransactionId,
241
+ status: created.nextStep === "conditions_met" ? "conditions_met" : "interactive",
242
+ };
243
+ if (created.continuationUrl) meta.continuationUrl = created.continuationUrl;
244
+ return {
245
+ state:
246
+ created.nextStep === "conditions_met"
247
+ ? { outcome: "verified", evidenceRef: appTransactionId }
248
+ : { outcome: "pending", evidenceRef: appTransactionId },
249
+ meta,
250
+ };
251
+ } catch {
252
+ // Belt-and-braces: the client is typed never-throws, but an unexpected
253
+ // exception must still fail closed rather than escape this route.
254
+ return null;
255
+ }
256
+ }
257
+
258
+ /**
259
+ * Strip the provider from a ruleset so the pure engine emits its existing
260
+ * "no verification provider configured" outcome — BLOCK + `failedClosed: true`.
261
+ * This is how an adapter failure fails closed WITHOUT touching the engine.
262
+ */
263
+ function withProviderCleared(ruleset: ComplianceRuleset): ComplianceRuleset {
264
+ return {
265
+ ...ruleset,
266
+ verification: { ...ruleset.verification, provider: undefined },
267
+ };
268
+ }
269
+
110
270
  export async function POST(context: APIContext): Promise<Response> {
111
271
  const { tenant, tot } = context.locals;
112
272
  const regulated = isRegulatedTenant(tenant);
@@ -121,9 +281,26 @@ export async function POST(context: APIContext): Promise<Response> {
121
281
  : json({ decision: evaluateEmpty() });
122
282
  }
123
283
 
124
- const ruleset = await resolveTenantRuleset(tenant);
284
+ let ruleset = await resolveTenantRuleset(tenant);
125
285
  let verification = normalizeVerification(body.verification);
126
286
  const destination = normalizeDestination(body.destination);
287
+ let liveVerification: LiveVerificationMeta | undefined;
288
+
289
+ // LIVE provider: the decision is made here, from a real server-side API call.
290
+ // The client-supplied `verification` field is DISCARDED (it is a shopper input,
291
+ // not evidence) and the affirmation shortcut below is unreachable — "tot" is
292
+ // never in SANDBOX_VERIFICATION_PROVIDERS. Any adapter failure clears the
293
+ // provider so the engine blocks fail-closed, exactly as when none is wired.
294
+ if (ruleset && isLiveTotVerificationProvider(ruleset.verification.provider)) {
295
+ const resolved = await resolveLiveVerification(body);
296
+ if (!resolved) {
297
+ ruleset = withProviderCleared(ruleset);
298
+ verification = { outcome: "unknown" };
299
+ } else {
300
+ verification = resolved.state;
301
+ liveVerification = resolved.meta;
302
+ }
303
+ }
127
304
 
128
305
  // Test/sandbox verification profile (the mode the 8501 demo/preview runs in):
129
306
  // when the resolved provider is an explicit sandbox value and no live provider
@@ -187,7 +364,7 @@ export async function POST(context: APIContext): Promise<Response> {
187
364
  ageAffirmed: body.ageAffirmed === true,
188
365
  });
189
366
 
190
- return json({ decision });
367
+ return json(liveVerification ? { decision, verification: liveVerification } : { decision });
191
368
  }
192
369
 
193
370
  /** The trivial "nothing to enforce" decision for an unregulated empty request. */
@@ -63,6 +63,7 @@ if (rawHtml && rawHtmlPolicy(tenant).allowed) {
63
63
  basePath,
64
64
  chromeWrapper,
65
65
  plan: Astro.locals.integrationPlan,
66
+ consentSnapshot: Astro.locals.consentSnapshot,
66
67
  });
67
68
  }
68
69
 
@@ -13,6 +13,11 @@ export default defineConfig({
13
13
  resolve: {
14
14
  alias: {
15
15
  "@": fileURLToPath(new URL("./src", import.meta.url)),
16
+ // `astro:middleware` is a virtual id Astro's own Vite plugin maps onto this
17
+ // real module. Mapping it the same way lets src/middleware/index.ts — the
18
+ // branch-to-handler dispatch — be invoked in a unit test instead of only
19
+ // read as text.
20
+ "astro:middleware": "astro/virtual-modules/middleware.js",
16
21
  },
17
22
  },
18
23
  test: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tokenoftrust/storefront-runner",
3
- "version": "2.0.17",
3
+ "version": "2.0.19",
4
4
  "license": "SEE LICENSE IN LICENSE",
5
5
  "description": "World-shareable storefront runner: multi-tenant renderer on Astro/Cloudflare. No control plane.",
6
6
  "packageManager": "pnpm@11.9.0",
@@ -18,6 +18,7 @@
18
18
  "generate:managed-analytics-schemas": "node --experimental-strip-types scripts/generate-managed-analytics-api-v1-schemas.mjs"
19
19
  },
20
20
  "devDependencies": {
21
+ "ajv": "^8.20.0",
21
22
  "typescript": "^5.6.3",
22
23
  "vitest": "^2.1.8"
23
24
  }