@tokenoftrust/storefront-runner 2.5.6 → 3.0.1

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 (79) hide show
  1. package/agency-kit/tools/lib/public-binaries.mjs +111 -0
  2. package/apps/storefront/astro.config.mjs +7 -0
  3. package/apps/storefront/env.d.ts +3 -0
  4. package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +5 -4
  5. package/apps/storefront/src/components/catalog/storefront/commerce/PriceDisplay.astro +2 -1
  6. package/apps/storefront/src/components/catalog/storefront/islands/SearchTypeahead.tsx +17 -20
  7. package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
  8. package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
  9. package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
  10. package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
  11. package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
  12. package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
  13. package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
  14. package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
  15. package/apps/storefront/src/config/devTenantSeed.ts +2 -1
  16. package/apps/storefront/src/layouts/Layout.astro +1 -1
  17. package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
  18. package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
  19. package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
  20. package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
  21. package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
  22. package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
  23. package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
  24. package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
  25. package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
  26. package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
  27. package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
  28. package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
  29. package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
  30. package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
  31. package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
  32. package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
  33. package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
  34. package/apps/storefront/src/lib/content/storyblok/provider.ts +4 -2
  35. package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
  36. package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
  37. package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
  38. package/apps/storefront/src/middleware/controlPlane.ts +14 -11
  39. package/apps/storefront/src/middleware/index.ts +33 -12
  40. package/apps/storefront/src/middleware/renderChain.ts +52 -26
  41. package/apps/storefront/src/pages/[...slug].astro +3 -0
  42. package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
  43. package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
  44. package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
  45. package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
  46. package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
  47. package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
  48. package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
  49. package/apps/storefront/src/pages/collections/[handle].astro +13 -198
  50. package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
  51. package/apps/storefront/src/pages/collections/index.astro +1 -1
  52. package/apps/storefront/src/pages/products/[handle].astro +10 -5
  53. package/apps/storefront/src/pages/search.astro +33 -33
  54. package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
  55. package/apps/storefront/vitest.config.cts +3 -0
  56. package/package.json +1 -1
  57. package/packages/cli/src/declared-config.mjs +318 -32
  58. package/packages/cli/src/publication-spaces.mjs +1 -1
  59. package/packages/public-runtime/package.json +1 -0
  60. package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
  61. package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
  62. package/packages/public-runtime/src/activity/catalog.ts +8 -1
  63. package/packages/public-runtime/src/candidate-index.ts +5 -0
  64. package/packages/public-runtime/src/capabilities.ts +12 -9
  65. package/packages/public-runtime/src/capture-rewrites.ts +687 -0
  66. package/packages/public-runtime/src/checkout-orders.ts +6 -7
  67. package/packages/public-runtime/src/checkout.ts +190 -6
  68. package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
  69. package/packages/public-runtime/src/compliance/profile.ts +90 -14
  70. package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
  71. package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
  72. package/packages/public-runtime/src/customization-runtime.ts +64 -2
  73. package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
  74. package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
  75. package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
  76. package/packages/public-runtime/src/membership.ts +3 -0
  77. package/packages/public-runtime/src/publication-space.ts +1 -0
  78. package/packages/public-runtime/src/tenant.ts +28 -4
  79. package/scripts/tenant/validate.mjs +17 -8
@@ -25,7 +25,7 @@ import { getTenantResolver } from "@/config/resolver";
25
25
  import { readEnv, readKv, readR2 } from "@/lib/publication/shared/runtimeEnv";
26
26
  import { resolveCdnForwardRouting, resolveTenantRouting } from "@/lib/access/shared/tenantRouting";
27
27
  import { crossSiteFormRefusal, verifyCdnForward } from "@/lib/publication/shared/cdnForward";
28
- import { compileRouteTierIndexV1, resolvePublicationRoutePlanV1, type RouteTierIndexV1 , storefrontModeRequiresSignIn } from "@tot/public-runtime";
28
+ import { SAMPLE_CART_HANDOFF_URL, compileRouteTierIndexV1, resolvePublicationRoutePlanV1, type RouteTierIndexV1 , storefrontModeRequiresSignIn } from "@tot/public-runtime";
29
29
  import type { PublicationArtifactBucketV1, TenantConfig } from "@tot/public-runtime";
30
30
  import { DEV_DRAFT_COOKIE } from "@/lib/developer-experience/dev/devDraft";
31
31
  import { applySecurityHeaders } from "@/lib/access/shared/securityHeaders";
@@ -36,6 +36,7 @@ import { parseGateMode } from "@/lib/access/auth/loginGate";
36
36
  import { evaluateDemoGate, hostInGateList } from "@/lib/access/shared/demoGate";
37
37
  import { isMaintenanceOn, maintenanceResponse } from "@/lib/access/shared/maintenance";
38
38
  import { HEALTH_PATH, healthResponse } from "@/lib/observability/shared/health";
39
+ import { ServerTiming, withServerTiming } from "@/lib/observability/shared/serverTiming";
39
40
  import { observeDeployVersion } from "@/lib/activity/deployVersion";
40
41
  import { primeActivityKillSwitch } from "@/lib/activity/killSwitch";
41
42
  import {
@@ -73,20 +74,27 @@ function routeTierIndexFor(tenant: TenantConfig): RouteTierIndexV1 {
73
74
  * appended after the branch has read or written any edge cache.
74
75
  */
75
76
  export const onRequest = defineMiddleware(async (context, next) => {
77
+ // An in-process rewrite re-enters this middleware with the same locals; the outer pass owns
78
+ // the recorder and the header.
79
+ const outer = !context.locals.serverTiming;
80
+ const timing = context.locals.serverTiming ?? new ServerTiming();
81
+ context.locals.serverTiming = timing;
76
82
  const seen: { cdnForwarded: boolean; assetBasePath?: string } = { cdnForwarded: false };
77
- const response = await serveRequest(context, next, seen);
83
+ const response = await serveRequest(context, next, seen, timing);
78
84
  const { scopePreviewPinnedImageUrls } = await import("@/lib/assets/preview-pinned-image-urls");
79
85
  const scoped = await scopePreviewPinnedImageUrls(response, context.locals.apiBasePath ?? seen.assetBasePath ?? "");
80
- return withVisitorRegionCookie(
86
+ const finished = withVisitorRegionCookie(
81
87
  scoped,
82
88
  visitorRegionFor(context.request, { cdnForwarded: seen.cdnForwarded, dev: import.meta.env.DEV }),
83
89
  );
90
+ return outer ? withServerTiming(finished, timing) : finished;
84
91
  });
85
92
 
86
93
  async function serveRequest(
87
94
  context: APIContext,
88
95
  next: MiddlewareNext,
89
96
  seen: { cdnForwarded: boolean; assetBasePath?: string },
97
+ timing: ServerTiming,
90
98
  ): Promise<Response> {
91
99
  const { request, locals } = context;
92
100
  const url = new URL(request.url);
@@ -137,6 +145,16 @@ async function serveRequest(
137
145
  const crossSite = crossSiteFormRefusal(request, origin);
138
146
  if (crossSite) return crossSite;
139
147
 
148
+ // The canonical TEST sample GET signs server catalog facts without shopper state.
149
+ const sampleHandoff = new URL(SAMPLE_CART_HANDOFF_URL);
150
+ if (request.method === "GET" && !cdn && url.origin === sampleHandoff.origin &&
151
+ host === sampleHandoff.host && url.pathname === sampleHandoff.pathname) {
152
+ const { GET } = await import("@/pages/api/commerce/checkout-handoff");
153
+ const response = await GET(context);
154
+ applySecurityHeaders(response.headers);
155
+ return response;
156
+ }
157
+
140
158
  // Demo access gate for named hosts (DEMO_GATE_HOSTS). Two modes, switched by
141
159
  // DEMO_GATE_MODE:
142
160
  // "password" (default) — interim Basic-Auth gate, enforced HERE (host-level,
@@ -158,10 +176,12 @@ async function serveRequest(
158
176
  // Host first; if the host owns no tenant, a leading /<domain>/ path segment
159
177
  // selects one (DNS-free tenant selection on localhost/preview). The resolver
160
178
  // is KV-backed in prod (many-tenant) and static in plain dev.
161
- const resolver = await getTenantResolver();
162
- const { tenant, basePath, renderPath } = cdn
163
- ? await resolveCdnForwardRouting(cdn, url.pathname, resolver)
164
- : await resolveTenantRouting(host, url.pathname, resolver);
179
+ const { tenant, basePath, renderPath } = await timing.time("tenant", async () => {
180
+ const resolver = await getTenantResolver();
181
+ return cdn
182
+ ? await resolveCdnForwardRouting(cdn, url.pathname, resolver)
183
+ : await resolveTenantRouting(host, url.pathname, resolver);
184
+ });
165
185
  if (!tenant) {
166
186
  return new Response("Unknown host", { status: 404 });
167
187
  }
@@ -214,13 +234,14 @@ async function serveRequest(
214
234
  let internalContentGrant: InternalContentGrant | undefined;
215
235
  if (publication.action !== "fail-closed" && route.requiresAuthorization) {
216
236
  const sessionKv = await readKv("SESSION");
217
- const decision = await authorizeInternalContentRequest({
237
+ const coreUrl = await readEnv("TOT_CORE_URL");
238
+ const decision = await timing.time("internal-auth", () => authorizeInternalContentRequest({
218
239
  route,
219
240
  sessionStore: sessionKv ? new KvSessionStore(sessionKv) : undefined,
220
241
  sessionId: context.cookies.get(SESSION_COOKIE)?.value ?? null,
221
- coreUrl: await readEnv("TOT_CORE_URL"),
242
+ coreUrl,
222
243
  mintAssertion: mintOpaqueBrokerAssertion,
223
- });
244
+ }));
224
245
  if (decision.allowed) internalContentGrant = decision;
225
246
  if (!internalContentGrant) {
226
247
  const response = new Response("Not Found", { status: 404 });
@@ -288,7 +309,7 @@ async function serveRequest(
288
309
  (capturedRevisionId || renderPath.startsWith("/_a/"))
289
310
  ? ((await readR2("PRODUCT_IMAGES")) ?? (await readR2("TENANT_ASSETS")) ?? null)
290
311
  : null;
291
- const bundled = await tryServeStaticBundle({
312
+ const bundled = await timing.time("bundle", () => tryServeStaticBundle({
292
313
  request,
293
314
  url,
294
315
  tenant,
@@ -309,7 +330,7 @@ async function serveRequest(
309
330
  },
310
331
  }
311
332
  : {}),
312
- });
333
+ }));
313
334
  if (bundled) return bundled;
314
335
  const rootLocation = platformStaticRootLocationV1({
315
336
  origin,
@@ -104,9 +104,10 @@ import {
104
104
  resolveIntegrationRenderState,
105
105
  } from "@/lib/integrations/integration/renderIntegrationState";
106
106
  import { recordActivity } from "@/lib/activity/recordActivity";
107
+ import { timed } from "@/lib/observability/shared/serverTiming";
107
108
  import { middlewareRewriteTargetV1 } from "./routeBranch.js";
108
109
  import { declaredTenantOrigins } from "@/lib/access/shared/tenantOrigins";
109
- import { withDeclaredTenantConfig } from "@/config/declaredTenantConfig";
110
+ import { DeclaredTenantConfigUnreadError, withDeclaredTenantConfig } from "@/config/declaredTenantConfig";
110
111
  import {
111
112
  applyInternalContentResponsePolicy,
112
113
  internalContentGrantMatchesPlan,
@@ -226,14 +227,15 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
226
227
  });
227
228
  }
228
229
 
229
- const publicationSource = await resolvePublicationRuntimeSourceV1({
230
+ const timing = locals.serverTiming;
231
+ const publicationSource = await timed(timing, "source", () => resolvePublicationRuntimeSourceV1({
230
232
  index: input.routeTierIndex,
231
233
  requestPath: input.publicationRequestPath,
232
234
  plan: input.publicationRoute,
233
235
  channel: channelFromEnv(customization.env),
234
236
  store: kv ? { get: (key) => kv.get(key) } : undefined,
235
237
  bucket: input.publicationBucket,
236
- });
238
+ }));
237
239
  if (publicationSource.status === "unavailable") {
238
240
  const response = new Response("Not Found", {
239
241
  status: 404,
@@ -264,12 +266,26 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
264
266
  // features). Everything below — static bundle, notices, gates, features,
265
267
  // capabilities — reads this, never the bare record. A candidate-pinned render
266
268
  // takes the candidate version's declaration, so a reviewer sees the notices the
267
- // change under review declares.
268
- const tenant = await withDeclaredTenantConfig(
269
- tenantRecord,
270
- kv ? (key: string) => kv.get(key) : async () => null,
271
- { env: customization.env, ...(candidatePin ? { pinnedVersionId: candidatePin.versionId } : {}) },
272
- );
269
+ // change under review declares. A declaration that cannot be read refuses the
270
+ // render: the store's compliance is unknown, and its gates and notices with it.
271
+ let tenant: TenantConfig;
272
+ try {
273
+ tenant = await timed(timing, "config", () => withDeclaredTenantConfig(
274
+ tenantRecord,
275
+ kv ? (key: string) => kv.get(key) : async () => null,
276
+ { env: customization.env, ...(candidatePin ? { pinnedVersionId: candidatePin.versionId } : {}) },
277
+ ));
278
+ } catch (err) {
279
+ if (!(err instanceof DeclaredTenantConfigUnreadError)) throw err;
280
+ console.error(err.message);
281
+ const response = new Response("Service Unavailable", {
282
+ status: 503,
283
+ headers: { "Cache-Control": "private, no-store", "Retry-After": "30" },
284
+ });
285
+ applySecurityHeaders(response.headers);
286
+ if (isInternalPublication) applyInternalContentResponsePolicy(response.headers);
287
+ return response;
288
+ }
273
289
  // Request-scoped read memo: every content read resolves the same pointer and
274
290
  // version manifest, and a catalog render reads one artifact per catalog file.
275
291
  const kvReads = new Map<string, Promise<string | null>>();
@@ -351,12 +367,18 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
351
367
  secret: previewSecret,
352
368
  nowSeconds,
353
369
  });
354
- const capturedRevisionId = candidatePin?.versionId ?? cookieRevision;
370
+ // The shared preview sets no version cookie, yet its documents name `/_a/` captures of the
371
+ // version the preview channel serves (capture overlay); those resolve against that version.
372
+ const sharedPreviewRevision = !candidatePin && !cookieRevision && customization.env === "test" &&
373
+ renderPath.startsWith("/_a/")
374
+ ? (await resolveServableChannelVersion(kvGet, tenant.tenant_id, "preview")).versionId
375
+ : null;
376
+ const capturedRevisionId = candidatePin?.versionId ?? cookieRevision ?? sharedPreviewRevision;
355
377
  const originalsBucket =
356
378
  (capturedRevisionId || renderPath.startsWith("/_a/"))
357
379
  ? ((await readR2("PRODUCT_IMAGES")) ?? (await readR2("TENANT_ASSETS")) ?? null)
358
380
  : null;
359
- const bundled = publicationSource.status === "legacy-root" ? await tryServeStaticBundle({
381
+ const bundled = publicationSource.status === "legacy-root" ? await timed(timing, "bundle", () => tryServeStaticBundle({
360
382
  request,
361
383
  url,
362
384
  tenant,
@@ -376,7 +398,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
376
398
  },
377
399
  }
378
400
  : {}),
379
- }) : null;
401
+ })) : null;
380
402
  if (bundled) return bundled;
381
403
 
382
404
  // ---- Tenant-local integration materialization (ext-kernel-09) --------------
@@ -496,10 +518,10 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
496
518
  !candidatePin &&
497
519
  !draftCookie &&
498
520
  !isInternalPublication &&
499
- isCacheablePath(url.pathname, url.searchParams);
521
+ isCacheablePath(url.pathname);
500
522
  let pageKey: Request | null = null;
501
523
  if (pageCacheEligible && cache && kv) {
502
- const version = await readCacheVersion(kv, tenant.tenant_id);
524
+ const version = await timed(timing, "page-cache", () => readCacheVersion(kv, tenant.tenant_id));
503
525
  // Fold the resolved integration plan revision into the key so a warm entry is
504
526
  // version-scoped: a rollback/uninstall resolves a new revision → old entry
505
527
  // unreachable → clean re-render without the removed origins (never a TTL wait).
@@ -514,7 +536,9 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
514
536
  locals.integrationCsp?.planRevision,
515
537
  publicationSource.status === "selected" ? publicationSource.cacheScope : undefined,
516
538
  );
517
- const hit = await getCachedPage(cache, pageKey);
539
+ const key = pageKey;
540
+ const hit = await timed(timing, "page-cache", () => getCachedPage(cache, key));
541
+ timing?.note("cache", hit ? "hit" : "miss");
518
542
  // The cached artifact is nonce-free (carries the placeholder). Stamp a fresh
519
543
  // per-response nonce into its body + CSP header so a warm hit is served with a
520
544
  // brand-new nonce, never the one an earlier response rendered with.
@@ -544,7 +568,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
544
568
  // below still run).
545
569
  if (kv && !candidatePin) {
546
570
  const channel = channelFromEnv(customization.env);
547
- await resolveServableChannelVersion(kvGet, tenant.tenant_id, channel, ({ versionId }) => {
571
+ await timed(timing, "channel", () => resolveServableChannelVersion(kvGet, tenant.tenant_id, channel, ({ versionId }) => {
548
572
  recordActivity({
549
573
  action: "preview.build.requested",
550
574
  actor: { kind: "system", id: "storefront" },
@@ -553,7 +577,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
553
577
  scope: { tenantId: tenant.tenant_id },
554
578
  payload: { args: { changeId: versionId } },
555
579
  });
556
- }).catch(() => {
580
+ })).catch(() => {
557
581
  /* fail-open: self-heal detection never breaks the request */
558
582
  });
559
583
  }
@@ -570,7 +594,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
570
594
  draftClaims && draftClaims.tenantId === tenant.tenant_id ? draftClaims : null;
571
595
 
572
596
  let theme = mergeTheme(referenceTheme, tenant.theme_tokens);
573
- const publishedTheme = await readRuntimeArtifact(themeArtifactPath(tenant.tenant_id));
597
+ const publishedTheme = await timed(timing, "artifacts", () => readRuntimeArtifact(themeArtifactPath(tenant.tenant_id)));
574
598
  if (publishedTheme) {
575
599
  try {
576
600
  theme = mergeTheme(theme, JSON.parse(publishedTheme) as DeepPartial<ThemeTokens>);
@@ -596,7 +620,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
596
620
  // commit (localhost=working tree, preview=PR head, live=promoted). So a
597
621
  // capability change is PR-gated and separable per environment.
598
622
  let publishedCaps: Partial<CapabilityConfig> | null = null;
599
- const publishedCapsRaw = await readRuntimeArtifact(capabilitiesArtifactPath(tenant.tenant_id));
623
+ const publishedCapsRaw = await timed(timing, "artifacts", () => readRuntimeArtifact(capabilitiesArtifactPath(tenant.tenant_id)));
600
624
  if (publishedCapsRaw) {
601
625
  try {
602
626
  publishedCaps = JSON.parse(publishedCapsRaw) as Partial<CapabilityConfig>;
@@ -612,7 +636,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
612
636
  // tenant enables a catalogued embed by committing embeds.json — no platform
613
637
  // change — but can never introduce a non-catalog origin.
614
638
  let publishedEmbeds: string[] | null = null;
615
- const publishedEmbedsRaw = await readRuntimeArtifact(embedsArtifactPath(tenant.tenant_id));
639
+ const publishedEmbedsRaw = await timed(timing, "artifacts", () => readRuntimeArtifact(embedsArtifactPath(tenant.tenant_id)));
616
640
  if (publishedEmbedsRaw) {
617
641
  try {
618
642
  const parsed = JSON.parse(publishedEmbedsRaw);
@@ -648,7 +672,7 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
648
672
  // loader + signed add-to-cart, and the CSP additions below.
649
673
  const commerce =
650
674
  locals.features.catalog && capabilities.cartCheckout.enabled
651
- ? await resolveTenantCommerce(tenant)
675
+ ? await timed(timing, "commerce", () => resolveTenantCommerce(tenant))
652
676
  : null;
653
677
  locals.commerce = commerce ?? undefined;
654
678
  // Developer-draft content overlay — serves the AI-edited artifacts to the draft
@@ -666,7 +690,8 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
666
690
  // are read through the SAME content source just resolved, so they follow the
667
691
  // request's selected version exactly as pages and the blog do. Only the control
668
692
  // plane supplies the D1 projection; the public branch never reads D1.
669
- locals.tot = await createToTClient(tenant, await input.readCatalogD1?.(), locals.content);
693
+ locals.tot = await timed(timing, "catalog", async () =>
694
+ createToTClient(tenant, await input.readCatalogD1?.(), locals.content));
670
695
  locals.themeStyle = themeToCssVars(theme);
671
696
  locals.canonicalBase = canonicalBase;
672
697
  locals.customizationEnv = customization.env;
@@ -733,9 +758,9 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
733
758
  // request. Rewriting there discards the route it already chose.
734
759
  routePattern: context.routePattern,
735
760
  });
736
- let response = rewriteTarget !== undefined
737
- ? await next(rewriteTarget)
738
- : await next();
761
+ let response = await timed(timing, "render", () => rewriteTarget !== undefined
762
+ ? next(rewriteTarget)
763
+ : next());
739
764
 
740
765
  // A nested pass of this middleware (an in-process `rewrite()`) already decorated
741
766
  // and nonce-stamped the response. Serve it verbatim: decorating again would inject
@@ -869,7 +894,8 @@ export async function runRenderChain(input: RenderChainInput): Promise<Response>
869
894
  );
870
895
  // Store the nonce-free artifact (the placeholder is still in the body + CSP
871
896
  // header) BEFORE stamping — so the cache never holds a live/reusable nonce.
872
- await putCachedPage(cache, pageKey, response);
897
+ const key = pageKey;
898
+ await timed(timing, "page-cache", () => putCachedPage(cache, key, response));
873
899
  }
874
900
  // MATERIALIZE-ONLY capture (ADR 0013): expose the tenant's resolved route-document
875
901
  // Cache-Control so the publish/materialize plane can bake it into the bundle
@@ -40,6 +40,9 @@ if (
40
40
  // dir, so the pages live under `/style-guide/…` and we rewrite the underscored URL
41
41
  // onto them, under the same tenant prefix. These are noindex + sitemap-excluded and
42
42
  // never reskin the live site.
43
+ // Its query, like the `__preview` flag below, only matters to a Worker render: the
44
+ // static copy of a page is the published, non-preview render.
45
+ export const STATIC_QUERY = "render-only";
43
46
  const styleGuideTarget = styleGuideAliasRewrite(basePath, slug, Astro.url.search);
44
47
  if (styleGuideTarget !== undefined) {
45
48
  return Astro.rewrite(styleGuideTarget);
@@ -2,7 +2,7 @@
2
2
  import type { APIContext } from "astro";
3
3
  import { createLocalJWKSet, decodeProtectedHeader, jwtVerify, type JSONWebKeySet } from "jose";
4
4
  import {
5
- commerceTenantId, totalsMatch, canonicalJson, deriveProductProfile,
5
+ commerceTenantId, totalsMatch, canonicalJson, shippingMinorFor,
6
6
  type CallerToken, type CatalogProduct, type CommerceEvidence, type CommerceEvidenceProvenance,
7
7
  type CommerceProviderCartSnapshot, type FinalCartBinding, type TenantConfig,
8
8
  } from "@tot/public-runtime";
@@ -12,14 +12,20 @@ import { evaluateFinalCart, type SeamView } from "@/lib/checkout/commerce/finalC
12
12
  import { d1CommerceRunner, reserveAllocation } from "@/lib/inventory/allocations.js";
13
13
  import { readMirrorStock } from "@/lib/inventory/mirrorStock.js";
14
14
  import { admissionMatches, readCommerceAdmission, admissionSnapshotStatement } from "@/lib/checkout/commerce/commerceAdmission.js";
15
- import { isRegulatedTenant } from "@/lib/compliance/enforcement.js";
16
- import { POST as compliancePreflight } from "../compliance/preflight.js";
17
- import { resolveAdminTargetTenant } from "@/config/adminTargetTenant";
18
- import { withDeclaredTenantConfig } from "@/config/declaredTenantConfig";
15
+ import { evaluatePreflight } from "../compliance/preflight.js";
16
+ import {
17
+ admitSharedStoreTenant, testStoreEvidencePolicy, SHARED_STORE_POLICY_REVISION, SHARED_STORE_POLICY_SOURCE,
18
+ SHARED_STORE_VERIFICATION_SOURCE, type SharedStoreEvidencePolicy,
19
+ } from "@/lib/checkout/commerce/sharedStoreAdmission.js";
20
+ import { readCheckoutRequirements } from "@/lib/checkout/commerce/checkoutRequirements.js";
21
+ import { DeclaredTenantConfigUnreadError, withDeclaredTenantConfig } from "@/config/declaredTenantConfig";
22
+ import { resolveStoreCurrency } from "@tot/public-runtime/declared-tenant-config";
19
23
  import { createContentProvider } from "@/lib/content/storyblok/provider";
20
24
  import { createToTClient } from "@/lib/catalog/tot/ToTClient";
21
25
  export const prerender = false;
22
26
  const HEX = /^[a-f0-9]{64}$/;
27
+ /** The shopper's verification correlation handle, as the signed `verification` cart option carries it. */
28
+ const VERIFICATION_REF = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,199}$/;
23
29
  function json(body: unknown, status = 200): Response {
24
30
  return new Response(JSON.stringify(body), { status, headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" } });
25
31
  }
@@ -46,8 +52,10 @@ function readSnapshot(value: unknown): CommerceProviderCartSnapshot | null {
46
52
  if (!value || typeof value !== "object" || Array.isArray(value)) return null;
47
53
  const v = value as Record<string, unknown>;
48
54
  const allowed = ["provider", "providerCartId", "providerCartDigest", "tenantId", "stage", "currency", "lines", "totals", "destinationDigest", "destination"];
49
- if (Object.keys(v).some(k => !allowed.includes(k) && k !== "expectedAuthorizationId") || allowed.some(k => !(k in v))) return null;
55
+ const optional = ["expectedAuthorizationId", "verificationRef"];
56
+ if (Object.keys(v).some(k => !allowed.includes(k) && !optional.includes(k)) || allowed.some(k => !(k in v))) return null;
50
57
  if ("expectedAuthorizationId" in v && (typeof v.expectedAuthorizationId !== "string" || !/^[a-z0-9][a-z0-9-]{0,80}$/.test(v.expectedAuthorizationId))) return null;
58
+ if ("verificationRef" in v && (typeof v.verificationRef !== "string" || !VERIFICATION_REF.test(v.verificationRef))) return null;
51
59
  if (v.provider !== "foxycart" || typeof v.providerCartId !== "string" || !/^[a-zA-Z0-9._:-]{1,128}$/.test(v.providerCartId) ||
52
60
  typeof v.providerCartDigest !== "string" || !HEX.test(v.providerCartDigest) || typeof v.destinationDigest !== "string" || !HEX.test(v.destinationDigest) ||
53
61
  typeof v.tenantId !== "string" || !["preview", "publication"].includes(String(v.stage)) || typeof v.currency !== "string" || !/^[A-Z]{3}$/.test(v.currency) ||
@@ -64,19 +72,46 @@ function readSnapshot(value: unknown): CommerceProviderCartSnapshot | null {
64
72
  if (!totalsMatch(snapshot.totals) || snapshot.lines.reduce((sum, line) => sum + line.quantity * line.unitMinor, 0) !== snapshot.totals.subtotalMinor) return null;
65
73
  return snapshot;
66
74
  }
67
- async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot, products: readonly CatalogProduct[]): Promise<SeamView> {
68
- const response = await compliancePreflight({
69
- request: new Request("https://checkout.invalid/api/compliance/preflight", {
70
- method: "POST", headers: { "content-type": "application/json" },
71
- body: JSON.stringify({ lines: snapshot.lines.map(line => ({ handle: products.find(p => p.variants.some(v => v.sku === line.sku))!.handle, sku: line.sku, quantity: line.quantity })), destination: snapshot.destination }),
72
- }), locals: { tenant: context.locals.tenant, tot: context.locals.tot },
73
- } as APIContext);
75
+ /**
76
+ * The compliance seam under the shared test store's policy. When verification is required the
77
+ * cart's verification ref goes to preflight, whose live provider re-reads its real status
78
+ * server-side; `verified` is true only when that read satisfied the requirement.
79
+ */
80
+ async function seam(context: APIContext, snapshot: CommerceProviderCartSnapshot, selected: ReadonlyMap<string, CatalogProduct>, policy: SharedStoreEvidencePolicy): Promise<SeamView & { verified: boolean }> {
81
+ if (policy.rules === "off" && policy.verification === "not-required") return { status: "clear", verified: false };
82
+ const products = [...selected.values()];
74
83
  try {
75
- const body = await response.json() as { decision?: { status?: string; failedClosed?: boolean; blocks?: { code?: string }[] } };
76
- if (!body.decision) return { status: "absent" };
77
- if (body.decision.status === "clear") return { status: "clear" };
78
- return { status: "blocked", failedClosed: body.decision.failedClosed === true, codes: body.decision.blocks?.map(b => b.code ?? "") };
79
- } catch { return { status: "absent" }; }
84
+ const { decision } = await evaluatePreflight({ locals: { tenant: context.locals.tenant, tot: {
85
+ getProductByHandle: async (handle: string) => selected.get(handle) ?? null,
86
+ } } } as APIContext, {
87
+ lines: snapshot.lines.map(line => ({ handle: products.find(p => p.variants.some(v => v.sku === line.sku))!.handle, sku: line.sku, quantity: line.quantity })),
88
+ destination: snapshot.destination,
89
+ ...(policy.verification === "required" && snapshot.verificationRef ? { verificationRef: snapshot.verificationRef } : {}),
90
+ }, { verification: policy.verification, rules: policy.rules });
91
+ const verified = policy.verification === "required" && decision.requirements.ageVerification === "satisfied";
92
+ const blocks = decision.blocks;
93
+ if (blocks.length === 0) return { status: "clear", verified };
94
+ const flavors = [...new Set(blocks.flatMap(block => block.code === "flavored_prohibited" ? block.flavors ?? [] : []))];
95
+ return { status: "blocked", verified, failedClosed: blocks.some(block => block.failedClosed === true), codes: blocks.map(block => block.code),
96
+ ...(flavors.length ? { flavors } : {}) };
97
+ } catch { return { status: "absent", verified: false }; }
98
+ }
99
+ function fullCartProduct(value: unknown, handle: string, scope: string): value is CatalogProduct {
100
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
101
+ const product = value as CatalogProduct;
102
+ return product.handle === handle && product.tenant_id === scope && typeof product.id === "string" && !!product.id &&
103
+ product.status === "active" && (product.visibility === undefined || product.visibility === "public") &&
104
+ typeof product.title === "string" && (product.product_type === undefined || typeof product.product_type === "string") &&
105
+ Array.isArray(product.tags) && product.tags.every(tag => typeof tag === "string") &&
106
+ Array.isArray(product.metafields) && product.metafields.every(field => field && typeof field === "object" && !Array.isArray(field) &&
107
+ typeof field.namespace === "string" && typeof field.key === "string" && typeof field.value === "string") &&
108
+ Array.isArray(product.variants) && product.variants.length > 0 && product.variants.every(variant => variant && typeof variant === "object" && !Array.isArray(variant) &&
109
+ variant.tenant_id === scope && variant.product_id === product.id && typeof variant.id === "string" && !!variant.id &&
110
+ (variant.sku === undefined || typeof variant.sku === "string") && Number.isFinite(variant.price) && variant.price >= 0 &&
111
+ typeof variant.currency === "string" && /^[A-Z]{3}$/.test(variant.currency) &&
112
+ variant.option_values && typeof variant.option_values === "object" && !Array.isArray(variant.option_values) &&
113
+ Object.values(variant.option_values).every(option => typeof option === "string") &&
114
+ variant.inventory && typeof variant.inventory === "object" && !Array.isArray(variant.inventory) && typeof variant.inventory.available === "boolean");
80
115
  }
81
116
  export async function POST(context: APIContext): Promise<Response> {
82
117
  if (Number(context.request.headers.get("content-length") ?? 0) > 65536) return deny("malformed-request");
@@ -102,28 +137,58 @@ export async function POST(context: APIContext): Promise<Response> {
102
137
  token = { scheme: "asymmetric-jwks", signatureValid: true, kid: header.kid, issuer: payload.iss ?? "", audience,
103
138
  expiresAt: new Date(payload.exp * 1000).toISOString(), jti: payload.jti ?? "", tenantId: snapshot.tenantId, stage: snapshot.stage };
104
139
  } catch { return deny("signature", 401); }
105
- const allowedTenants = list(await readEnv("COMMERCE_TENANT_ALLOWLIST"));
106
- if (!allowedTenants || !allowedTenants.includes(snapshot.tenantId)) return deny("tenant", 401);
107
- const registered = await resolveAdminTargetTenant(snapshot.tenantId);
108
- if (!registered || commerceTenantId(registered) !== snapshot.tenantId) return deny("tenant-unregistered", 401);
140
+ const admission = await admitSharedStoreTenant(snapshot.tenantId);
141
+ if (!admission.ok) return deny(admission.finding, 401);
142
+ const registered = admission.tenant;
109
143
  const kv = await readKv("TENANT_CACHE");
110
144
  const kvGet = async (key: string): Promise<string | null> => kv ? kv.get(key) : null;
111
145
  const contentEnv = snapshot.stage === "publication" ? "live" : "test";
112
- const tenant = await withDeclaredTenantConfig(registered, kvGet, { env: contentEnv });
146
+ let tenant: TenantConfig;
147
+ try { tenant = await withDeclaredTenantConfig(registered, kvGet, { env: contentEnv }); } catch (err) {
148
+ if (err instanceof DeclaredTenantConfigUnreadError) return deny("config-unreadable", 503);
149
+ throw err;
150
+ }
113
151
  const content = createContentProvider(tenant, { get: kvGet, env: contentEnv });
114
152
  const tot = await createToTClient(tenant, await readD1("CATALOG_DB"), content);
115
153
  const authorityContext = { ...context, locals: { ...context.locals, tenant, tot, content } } as APIContext;
116
154
  const { tenantId, stage, currency, totals, destinationDigest } = snapshot;
117
155
  const lines = [...snapshot.lines].sort((a, b) => a.sku.localeCompare(b.sku) || a.unitMinor - b.unitMinor || a.quantity - b.quantity);
118
156
  if (snapshot.providerCartDigest !== await digest(canonicalJson({ tenantId, stage, currency, lines, totals, destinationDigest }))) return deny("provider-cart-digest");
119
- let products: CatalogProduct[];
120
- try { products = await tot.getAllActiveProducts(); } catch { return deny("catalog-unavailable", 503); }
157
+ let listing: CatalogProduct[];
158
+ try { listing = await tot.getAllActiveProducts(); } catch { return deny("catalog-unavailable", 503); }
159
+ if (!Array.isArray(listing)) return deny("catalog-unavailable", 503);
160
+ const selected = new Map<string, CatalogProduct>();
161
+ if (snapshot.currency !== resolveStoreCurrency(tenant.currency)) return deny("catalog-cart-mismatch");
121
162
  for (const line of snapshot.lines) {
122
- const matches = products.flatMap(product => product.status === "active" && (!product.visibility || product.visibility === "public") && product.tenant_id === tenant.tot_data_scope
123
- ? product.variants.filter(v => v.sku === line.sku).map(variant => ({ product, variant })) : []);
163
+ const matches = listing.flatMap(product => product && product.status === "active" && (!product.visibility || product.visibility === "public") && product.tenant_id === tenant.tot_data_scope && Array.isArray(product.variants)
164
+ ? product.variants.filter(v => v && v.sku === line.sku).map(variant => ({ product, variant })) : []);
165
+ if (matches.length !== 1) return deny("catalog-cart-mismatch");
166
+ const match = matches[0]!;
167
+ const handle = match.product.handle;
168
+ if (typeof handle !== "string" || !handle) return deny("catalog-cart-mismatch");
169
+ if (!selected.has(handle)) {
170
+ let detail: CatalogProduct | null;
171
+ try { detail = await tot.getProductByHandle(handle); } catch { return deny("catalog-unavailable", 503); }
172
+ if (!fullCartProduct(detail, handle, tenant.tot_data_scope)) return deny("catalog-cart-mismatch");
173
+ selected.set(handle, detail);
174
+ }
175
+ const detail = selected.get(handle)!;
176
+ if (detail.id !== match.product.id) return deny("catalog-cart-mismatch");
177
+ const variants = detail.variants.filter(variant => variant.sku === line.sku);
178
+ if (variants.length !== 1 || variants[0]!.id !== match.variant.id) return deny("catalog-cart-mismatch");
179
+ const variant = variants[0]!;
124
180
  const digits = new Intl.NumberFormat("en", { style: "currency", currency: snapshot.currency }).resolvedOptions().maximumFractionDigits ?? 2;
125
- const scaled = (matches[0]?.variant.price ?? -1) * 10 ** digits;
126
- if (matches.length !== 1 || !matches[0]!.variant.inventory.available || matches[0]!.variant.currency !== snapshot.currency || Math.abs(scaled - Math.round(scaled)) > 0.000001 || Math.round(scaled) !== line.unitMinor) return deny("catalog-cart-mismatch");
181
+ const scaled = variant.price * 10 ** digits;
182
+ if (!variant.inventory.available || variant.currency !== snapshot.currency || Math.abs(scaled - Math.round(scaled)) > 0.000001 || Math.round(scaled) !== line.unitMinor) return deny("catalog-cart-mismatch");
183
+ }
184
+ const products = [...selected.values()];
185
+ if (snapshot.lines.some(line => products.flatMap(product => product.variants.filter(variant => variant.sku === line.sku)).length !== 1)) return deny("catalog-cart-mismatch");
186
+ // Shipping is re-derived from the store's own declaration, never from the cart: a cart that
187
+ // dropped, replayed or altered its signed `shipping_policy` option was charged another amount.
188
+ if (tenant.shipping) {
189
+ let owed: number;
190
+ try { owed = shippingMinorFor(tenant.shipping, snapshot.currency, snapshot.totals.subtotalMinor); } catch { return deny("shipping-unavailable"); }
191
+ if (snapshot.totals.shippingMinor !== owed) return deny("shipping-changed");
127
192
  }
128
193
  const database = await readD1("STOREFRONT_APPS_DB");
129
194
  if (!database) return deny("store-unconfigured", 503);
@@ -142,35 +207,40 @@ export async function POST(context: APIContext): Promise<Response> {
142
207
  const existing = prior ? await readCommerceAdmission(queryable, snapshot.tenantId, snapshot.stage, prior.authorization_id) : null;
143
208
  if (prior && (!existing || existing.expiresAt <= now)) return deny("admission-expired");
144
209
  if (existing && !admissionMatches(existing, snapshot)) return deny("provider-cart-changed");
145
- const policy = tenant.commerce?.authorityPolicy;
146
- const testPolicy = policy?.purpose === "synthetic-nonbillable" && typeof policy.revision === "string" && /^[A-Za-z0-9._:-]{1,128}$/.test(policy.revision) &&
147
- Number.isFinite(Date.parse(policy.expiresAt)) && policy.expiresAt > now && await readEnv("COMMERCE_DEMO_CHECKOUT_TIER") === "test" &&
148
- tenant.commerce?.checkout?.storeSubDomain === "tot-preview" && tenant.commerce.checkout.mode === "signed" &&
149
- ["verification", "tax", "risk"].every(field => ["required", "not-required"].includes(policy.requirements?.[field as "verification" | "tax" | "risk"]));
150
- const regulated = isRegulatedTenant(tenant) || !!tenant.compliance?.pactAct || !!tenant.compliance?.exciseTax ||
151
- snapshot.lines.some(line => products.some(p => p.variants.some(v => v.sku === line.sku && ["ends", "e-liquid", "other-nicotine"].includes(deriveProductProfile(p, v).category))));
152
- if (testPolicy && regulated && (policy.requirements.verification === "not-required" || policy.requirements.tax === "not-required")) return deny("authority-policy-regulated");
210
+ const requirements = await readCheckoutRequirements(tenant.tenant_id, contentEnv);
211
+ if (!requirements) return deny("checkout-requirements-unreadable", 503);
212
+ const policy = testStoreEvidencePolicy(requirements);
153
213
  const authorities = [];
154
214
  for (const sku of new Set(snapshot.lines.map(line => line.sku))) {
155
215
  authorities.push(await readMirrorStock(queryable, { tenantId: snapshot.tenantId, stage: snapshot.stage, sku, locationId: "unspecified", now, maxAgeMs: 15 * 60_000 }));
156
216
  }
157
217
  const stockExpiry = Math.min(...authorities.flatMap(authority => authority ? [Date.parse(authority.asOf) + 15 * 60_000] : []));
158
218
  const evidence: CommerceEvidence = { verification: "absent", tax: "absent", risk: "absent", stock: "fresh" };
159
- const expiresAt = existing?.expiresAt ?? new Date(Math.min(Date.now() + 5 * 60_000, stockExpiry, testPolicy ? Date.parse(policy.expiresAt) : Infinity)).toISOString();
219
+ const expiresAt = existing?.expiresAt ?? new Date(Math.min(Date.now() + 5 * 60_000, stockExpiry)).toISOString();
160
220
  const provenance = {} as CommerceEvidenceProvenance;
161
221
  for (const field of ["verification", "tax", "risk"] as const) {
162
- if (testPolicy && policy.requirements?.[field] === "not-required") {
222
+ if (policy[field] === "not-required") {
163
223
  evidence[field] = "not-required";
164
- provenance[field] = { requirement: "not-required", state: "not-required", source: "declared-synthetic-policy", policyRevision: policy.revision, expiresAt };
224
+ provenance[field] = { requirement: "not-required", state: "not-required", source: SHARED_STORE_POLICY_SOURCE, policyRevision: SHARED_STORE_POLICY_REVISION, expiresAt };
165
225
  }
166
226
  }
167
227
  provenance.stock = { requirement: "required", state: "satisfied", source: "tenant-inventory-movements", policyRevision: "mirror-v1", expiresAt };
168
228
  const presented: FinalCartBinding = { tenantId: snapshot.tenantId, stage: snapshot.stage, currency: snapshot.currency, lines: snapshot.lines,
169
229
  destinationDigest: snapshot.destinationDigest, verificationExpiresAt: expiresAt, taxExpiresAt: expiresAt };
230
+ const seamView = await seam(authorityContext, snapshot, selected, policy);
231
+ if (policy.verification === "required" && seamView.verified) {
232
+ evidence.verification = "fresh";
233
+ provenance.verification = { requirement: "required", state: "satisfied", source: SHARED_STORE_VERIFICATION_SOURCE, policyRevision: SHARED_STORE_POLICY_REVISION, expiresAt };
234
+ }
170
235
  const decision = await evaluateFinalCart({ now, routedTenantId: commerceTenantId(tenant), quoted: presented, presented, evidence,
171
- seam: await seam(authorityContext, snapshot, products), catalogAvailable: true, browserConfirmed: false, token,
236
+ seam: seamView, catalogAvailable: true, browserConfirmed: false, token,
172
237
  trustedIssuers: issuers, expectedAudience: audience, activeKids, authorities });
173
- if (!decision.ok) return deny(decision.finding, 409, decision.owner);
238
+ if (!decision.ok) {
239
+ if (decision.finding === "verification-refused:flavored_prohibited" && seamView.flavors) {
240
+ return json({ ok: false, finding: decision.finding, flavors: seamView.flavors }, 409);
241
+ }
242
+ return deny(decision.finding, 409, decision.owner);
243
+ }
174
244
  const authorizationId = existing?.authorizationId ?? `a${crypto.randomUUID()}`;
175
245
  // Retries of the same verified provider cart share one reservation, including concurrent callbacks.
176
246
  const cartDigest = existing?.cartDigest ?? await digest(`commerce-provider-cart-v1\n${snapshot.providerCartId}\n${snapshot.providerCartDigest}`);