@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.
- package/agency-kit/tools/lib/public-binaries.mjs +111 -0
- package/apps/storefront/astro.config.mjs +7 -0
- package/apps/storefront/env.d.ts +3 -0
- package/apps/storefront/src/components/catalog/storefront/ProductCard.astro +5 -4
- package/apps/storefront/src/components/catalog/storefront/commerce/PriceDisplay.astro +2 -1
- package/apps/storefront/src/components/catalog/storefront/islands/SearchTypeahead.tsx +17 -20
- package/apps/storefront/src/components/catalog/storefront/islands/VariantSelector.tsx +13 -21
- package/apps/storefront/src/components/catalog/storefront/plp/Pagination.astro +9 -5
- package/apps/storefront/src/components/checkout/storefront/commerce/CheckoutLoader.astro +18 -6
- package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +19 -4
- package/apps/storefront/src/components/content/storefront/Badge.astro +1 -0
- package/apps/storefront/src/components/content/storefront/blog/BlogIndexView.astro +10 -1
- package/apps/storefront/src/components/publication/storefront/islands/CatalogQueryView.tsx +321 -0
- package/apps/storefront/src/config/declaredTenantConfig.ts +59 -27
- package/apps/storefront/src/config/devTenantSeed.ts +2 -1
- package/apps/storefront/src/layouts/Layout.astro +1 -1
- package/apps/storefront/src/lib/access/auth/gatePage.ts +29 -14
- package/apps/storefront/src/lib/access/shared/publicAccess.ts +95 -0
- package/apps/storefront/src/lib/access/shared/tenantRouting.ts +19 -0
- package/apps/storefront/src/lib/catalog/shared/catalogIndex.ts +109 -0
- package/apps/storefront/src/lib/catalog/shared/catalogQueryView.ts +60 -0
- package/apps/storefront/src/lib/catalog/shared/collectionPaging.ts +78 -0
- package/apps/storefront/src/lib/catalog/shared/stockStatus.ts +23 -0
- package/apps/storefront/src/lib/catalog/tot/query.ts +8 -0
- package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +54 -0
- package/apps/storefront/src/lib/checkout/commerce/checkoutHandoffRoute.ts +103 -35
- package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +88 -0
- package/apps/storefront/src/lib/checkout/commerce/finalCartAuthorization.ts +2 -0
- package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +95 -0
- package/apps/storefront/src/lib/checkout/shared/lazyCheckoutLoader.ts +191 -0
- package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +9 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +7 -5
- package/apps/storefront/src/lib/content/seo/structuredData.ts +12 -3
- package/apps/storefront/src/lib/content/storyblok/provider.ts +4 -2
- package/apps/storefront/src/lib/observability/shared/serverTiming.ts +110 -0
- package/apps/storefront/src/lib/publication/shared/edgeCache.ts +3 -4
- package/apps/storefront/src/lib/publication/shared/staticQuery.ts +64 -0
- package/apps/storefront/src/middleware/controlPlane.ts +14 -11
- package/apps/storefront/src/middleware/index.ts +33 -12
- package/apps/storefront/src/middleware/renderChain.ts +52 -26
- package/apps/storefront/src/pages/[...slug].astro +3 -0
- package/apps/storefront/src/pages/api/commerce/final-cart-authorization.ts +112 -42
- package/apps/storefront/src/pages/api/compliance/preflight.ts +61 -13
- package/apps/storefront/src/pages/blog/author/[author].astro +1 -1
- package/apps/storefront/src/pages/blog/category/[category].astro +1 -1
- package/apps/storefront/src/pages/blog/tag/[tag].astro +1 -1
- package/apps/storefront/src/pages/catalog-index.json.ts +20 -0
- package/apps/storefront/src/pages/collections/[handle]/page/[n].astro +28 -0
- package/apps/storefront/src/pages/collections/[handle].astro +13 -198
- package/apps/storefront/src/pages/collections/_CollectionView.astro +251 -0
- package/apps/storefront/src/pages/collections/index.astro +1 -1
- package/apps/storefront/src/pages/products/[handle].astro +10 -5
- package/apps/storefront/src/pages/search.astro +33 -33
- package/apps/storefront/src/pages/sitemap.xml.ts +7 -1
- package/apps/storefront/vitest.config.cts +3 -0
- package/package.json +1 -1
- package/packages/cli/src/declared-config.mjs +318 -32
- package/packages/cli/src/publication-spaces.mjs +1 -1
- package/packages/public-runtime/package.json +1 -0
- package/packages/public-runtime/schemas/publication-space/v1/manifest.schema.json +1 -1
- package/packages/public-runtime/schemas/publication-space/v1/normalized-mount.schema.json +1 -1
- package/packages/public-runtime/src/activity/catalog.ts +8 -1
- package/packages/public-runtime/src/candidate-index.ts +5 -0
- package/packages/public-runtime/src/capabilities.ts +12 -9
- package/packages/public-runtime/src/capture-rewrites.ts +687 -0
- package/packages/public-runtime/src/checkout-orders.ts +6 -7
- package/packages/public-runtime/src/checkout.ts +190 -6
- package/packages/public-runtime/src/compliance/evaluate.ts +7 -3
- package/packages/public-runtime/src/compliance/profile.ts +90 -14
- package/packages/public-runtime/src/compliance-coherence.d.mts +10 -1
- package/packages/public-runtime/src/compliance-coherence.mjs +129 -29
- package/packages/public-runtime/src/customization-runtime.ts +64 -2
- package/packages/public-runtime/src/declared-tenant-config.d.mts +13 -2
- package/packages/public-runtime/src/declared-tenant-config.mjs +83 -1
- package/packages/public-runtime/src/image-preview-rewrite-overlay.ts +13 -37
- package/packages/public-runtime/src/membership.ts +3 -0
- package/packages/public-runtime/src/publication-space.ts +1 -0
- package/packages/public-runtime/src/tenant.ts +28 -4
- 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
|
-
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
?
|
|
738
|
-
:
|
|
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
|
-
|
|
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,
|
|
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 {
|
|
16
|
-
import {
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
106
|
-
if (!
|
|
107
|
-
const registered =
|
|
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
|
-
|
|
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
|
|
120
|
-
try {
|
|
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 =
|
|
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 =
|
|
126
|
-
if (
|
|
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
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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 (
|
|
222
|
+
if (policy[field] === "not-required") {
|
|
163
223
|
evidence[field] = "not-required";
|
|
164
|
-
provenance[field] = { requirement: "not-required", state: "not-required", source:
|
|
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:
|
|
236
|
+
seam: seamView, catalogAvailable: true, browserConfirmed: false, token,
|
|
172
237
|
trustedIssuers: issuers, expectedAudience: audience, activeKids, authorities });
|
|
173
|
-
if (!decision.ok)
|
|
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}`);
|