@tokenoftrust/storefront-runner 3.10.4 → 3.11.0
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/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx +30 -0
- package/apps/storefront/src/lib/checkout/browser/checkoutRequirementsForm.ts +17 -2
- package/apps/storefront/src/lib/checkout/commerce/checkoutRequirements.ts +8 -7
- package/apps/storefront/src/lib/checkout/commerce/sharedStoreAdmission.ts +4 -8
- package/apps/storefront/src/lib/compliance/browser/checkoutVerificationFlow.ts +14 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +6 -3
- package/apps/storefront/src/pages/products/[handle].astro +4 -5
- package/package.json +1 -1
- package/packages/public-runtime/scripts/generate-managed-analytics-api-v1-schemas.mjs +3 -2
- package/packages/public-runtime/src/activity/catalog.ts +11 -3
- package/packages/public-runtime/src/checkout-style.ts +2 -2
- package/packages/public-runtime/src/customization-runtime.ts +75 -18
- package/scripts/dev/integration-guardrails.mjs +2 -1
- package/scripts/lib/is-main-module.mjs +20 -0
- package/scripts/tenant/lint-embeds.mjs +2 -1
- package/scripts/tenant/materialize-tenant-content.mjs +3 -4
- package/scripts/tenant/validate.mjs +2 -3
package/apps/storefront/src/components/compliance/storefront/islands/CheckoutComplianceGate.tsx
CHANGED
|
@@ -25,6 +25,7 @@
|
|
|
25
25
|
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
|
|
26
26
|
import type { CartComplianceDecision } from "@tot/public-runtime";
|
|
27
27
|
import {
|
|
28
|
+
bypassesComplianceGate,
|
|
28
29
|
clearStoredRef,
|
|
29
30
|
isStoreNotPublicRefusal,
|
|
30
31
|
parsePreflightResponse,
|
|
@@ -118,6 +119,8 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
118
119
|
const pollToken = useRef<{ cancelled: boolean } | null>(null);
|
|
119
120
|
/** Set by the last preflight POST when the store-not-public gate answered it. */
|
|
120
121
|
const gateRefused = useRef(false);
|
|
122
|
+
/** `proceed`, reachable from the submit listener registered before it is defined. */
|
|
123
|
+
const proceedRef = useRef<((line: PendingLine, ref: string | null) => void) | null>(null);
|
|
121
124
|
|
|
122
125
|
const stopPolling = useCallback(() => {
|
|
123
126
|
if (pollToken.current) pollToken.current.cancelled = true;
|
|
@@ -144,6 +147,32 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
144
147
|
quantity,
|
|
145
148
|
title: form.dataset.productTitle ?? "this item",
|
|
146
149
|
};
|
|
150
|
+
// Whether the store asks for this step is the server's call at click time, read from the
|
|
151
|
+
// store's current settings: a static page carries no decision frozen at build time. Only a
|
|
152
|
+
// readable "not-required" + clear answer skips the dialog; anything else opens it.
|
|
153
|
+
void probe(line).then((res) => {
|
|
154
|
+
if (bypassesComplianceGate(res)) proceedRef.current?.(line, null);
|
|
155
|
+
else open(line);
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
async function probe(line: PendingLine): Promise<PreflightResponse | null> {
|
|
159
|
+
try {
|
|
160
|
+
const res = await fetch(preflightUrl, {
|
|
161
|
+
method: "POST",
|
|
162
|
+
headers: { "content-type": "application/json" },
|
|
163
|
+
body: JSON.stringify({
|
|
164
|
+
lines: [{ handle: line.handle, sku: line.sku, variantId: line.variantId, quantity: line.quantity }],
|
|
165
|
+
destination: null,
|
|
166
|
+
ageAffirmed: false,
|
|
167
|
+
}),
|
|
168
|
+
});
|
|
169
|
+
if (!res.ok) return null;
|
|
170
|
+
return parsePreflightResponse(await res.json());
|
|
171
|
+
} catch {
|
|
172
|
+
return null;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function open(line: PendingLine) {
|
|
147
176
|
setPending(line);
|
|
148
177
|
setDecision(null);
|
|
149
178
|
setError(null);
|
|
@@ -200,6 +229,7 @@ export default function CheckoutComplianceGate({ preflightUrl, minAge }: Props)
|
|
|
200
229
|
if (typeof line.form.requestSubmit === "function") line.form.requestSubmit();
|
|
201
230
|
else line.form.submit();
|
|
202
231
|
}, [preflightUrl, stopPolling]);
|
|
232
|
+
proceedRef.current = proceed;
|
|
203
233
|
|
|
204
234
|
/**
|
|
205
235
|
* One preflight POST. `null` for a transport error or any body we cannot fully
|
|
@@ -3,16 +3,31 @@
|
|
|
3
3
|
type StatusState = "ok" | "err";
|
|
4
4
|
|
|
5
5
|
interface SaveResponse {
|
|
6
|
+
readBack?: string;
|
|
6
7
|
notice?: string;
|
|
7
8
|
error?: string;
|
|
8
9
|
errors?: unknown;
|
|
9
10
|
}
|
|
10
11
|
|
|
12
|
+
export const SAVED_IN_EFFECT = "Saved. In effect on your preview and live store.";
|
|
13
|
+
|
|
11
14
|
export function saveFailureMessage(status: number, data: SaveResponse): string {
|
|
12
15
|
if (Array.isArray(data.errors) && data.errors.length) return data.errors.join(" ");
|
|
13
16
|
return data.error || `Save failed (${status})`;
|
|
14
17
|
}
|
|
15
18
|
|
|
19
|
+
/**
|
|
20
|
+
* What the form reports for a save response. "Saved" only when the server read the save back
|
|
21
|
+
* through the readers' own resolver (`readBack: "in-effect"`); an accepted save the edge has not
|
|
22
|
+
* picked up yet reports its notice without the ok state.
|
|
23
|
+
*/
|
|
24
|
+
export function saveResultStatus(httpOk: boolean, status: number, data: SaveResponse): { message: string; state?: StatusState } {
|
|
25
|
+
if (!httpOk) return { message: saveFailureMessage(status, data), state: "err" };
|
|
26
|
+
if (data.readBack === "in-effect") return { message: SAVED_IN_EFFECT, state: "ok" };
|
|
27
|
+
if (data.readBack === "pending") return { message: data.notice || "Accepted. Reload to confirm it is in effect." };
|
|
28
|
+
return { message: "The save could not be confirmed in effect. Reload to check.", state: "err" };
|
|
29
|
+
}
|
|
30
|
+
|
|
16
31
|
export function mountCheckoutRequirementsForm(form: HTMLElement): void {
|
|
17
32
|
const endpoint = form.dataset.endpoint || "/api/preview/checkout-requirements";
|
|
18
33
|
const targetTenant = form.dataset.targetTenant || "";
|
|
@@ -43,8 +58,8 @@ export function mountCheckoutRequirementsForm(form: HTMLElement): void {
|
|
|
43
58
|
} catch {
|
|
44
59
|
// non-JSON error body
|
|
45
60
|
}
|
|
46
|
-
|
|
47
|
-
|
|
61
|
+
const result = saveResultStatus(res.ok, res.status, data);
|
|
62
|
+
setStatus(result.message, result.state);
|
|
48
63
|
} catch {
|
|
49
64
|
setStatus("Save failed — network error", "err");
|
|
50
65
|
} finally {
|
|
@@ -3,13 +3,14 @@
|
|
|
3
3
|
* /admin → Settings → Checkout requirements.
|
|
4
4
|
*
|
|
5
5
|
* They are merchant-operated commerce settings (ADR 0035 "operational"), saved as the
|
|
6
|
-
* tenant-owned artifact `tenants/<id>/checkout-requirements.json`
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
6
|
+
* tenant-owned artifact `tenants/<id>/checkout-requirements.json` to the store's operational
|
|
7
|
+
* selection. A save is in effect at once on the store's preview AND its live store — every
|
|
8
|
+
* reader resolves it through `resolveArtifactSelection`, with no promote. Every switch defaults
|
|
9
|
+
* off and can only make checkout stricter, so no write can weaken what the platform enforces.
|
|
10
|
+
* They apply only while the store checks out on the shared test store; a store with its own
|
|
11
|
+
* checkout store, and production, keep the full fail-closed contract regardless of what is saved here.
|
|
11
12
|
*/
|
|
12
|
-
import { readPublishedArtifact, type CustomizationEnv } from "@tot/public-runtime";
|
|
13
|
+
import { operationalSettingArtifactPath, readPublishedArtifact, type CustomizationEnv } from "@tot/public-runtime";
|
|
13
14
|
import { readKv } from "@/lib/publication/shared/runtimeEnv";
|
|
14
15
|
|
|
15
16
|
export interface CheckoutRequirements {
|
|
@@ -30,7 +31,7 @@ export const DEFAULT_CHECKOUT_REQUIREMENTS: Readonly<CheckoutRequirements> = Obj
|
|
|
30
31
|
});
|
|
31
32
|
|
|
32
33
|
export function checkoutRequirementsArtifactPath(tenantId: string): string {
|
|
33
|
-
return
|
|
34
|
+
return operationalSettingArtifactPath(tenantId, "checkout-requirements.json");
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
/**
|
|
@@ -82,14 +82,10 @@ export async function usesSharedTestStore(commerce: { storeSubDomain?: string }
|
|
|
82
82
|
}
|
|
83
83
|
|
|
84
84
|
/**
|
|
85
|
-
* Whether the storefront's
|
|
86
|
-
* test store's policy for a tenant checking out there; every other checkout keeps
|
|
85
|
+
* Whether the storefront's pre-cart compliance step runs, for a resolved policy. It follows the
|
|
86
|
+
* shared test store's policy for a tenant checking out there; `null` (every other checkout) keeps
|
|
87
|
+
* it. Decided per request by `/api/compliance/preflight`, never at page build time.
|
|
87
88
|
*/
|
|
88
|
-
export
|
|
89
|
-
tenant: Pick<TenantConfig, "tenant_id">,
|
|
90
|
-
commerce: { storeSubDomain?: string } | null | undefined,
|
|
91
|
-
env: CustomizationEnv,
|
|
92
|
-
): Promise<boolean> {
|
|
93
|
-
const policy = await sharedStorePolicy(tenant, commerce, env);
|
|
89
|
+
export function complianceGateRequiredFor(policy: SharedStoreEvidencePolicy | null): boolean {
|
|
94
90
|
return !policy || policy.rules === "enforce" || policy.verification === "required";
|
|
95
91
|
}
|
|
@@ -52,6 +52,11 @@ export interface PreflightResponse {
|
|
|
52
52
|
decision: CartComplianceDecision;
|
|
53
53
|
/** Present only on the live-provider path; `null` on sandbox / no provider. */
|
|
54
54
|
verification: LiveVerificationMeta | null;
|
|
55
|
+
/**
|
|
56
|
+
* Whether this store requires the pre-cart compliance step, decided by the server from the
|
|
57
|
+
* store's current settings at click time. Absent is read as `"required"`.
|
|
58
|
+
*/
|
|
59
|
+
gate?: "required" | "not-required";
|
|
55
60
|
}
|
|
56
61
|
|
|
57
62
|
/**
|
|
@@ -117,9 +122,18 @@ export function parsePreflightResponse(raw: unknown): PreflightResponse | null {
|
|
|
117
122
|
return {
|
|
118
123
|
decision: r.decision,
|
|
119
124
|
verification: "verification" in r ? parseVerificationMeta(r.verification) : null,
|
|
125
|
+
gate: r.gate === "not-required" ? "not-required" : "required",
|
|
120
126
|
};
|
|
121
127
|
}
|
|
122
128
|
|
|
129
|
+
/**
|
|
130
|
+
* True when a click-time probe lets the item go straight to the cart: the store does not require
|
|
131
|
+
* the pre-cart step and the cart is already clear. Anything else, including no answer, opens it.
|
|
132
|
+
*/
|
|
133
|
+
export function bypassesComplianceGate(res: PreflightResponse | null): boolean {
|
|
134
|
+
return res !== null && res.gate === "not-required" && res.decision.status === "clear";
|
|
135
|
+
}
|
|
136
|
+
|
|
123
137
|
/** True when the decision carries the provider's real "did not pass" block. */
|
|
124
138
|
function hasVerificationFailure(decision: CartComplianceDecision): boolean {
|
|
125
139
|
return decision.blocks.some(
|
|
@@ -34,7 +34,7 @@ import {
|
|
|
34
34
|
type TotVerificationClient,
|
|
35
35
|
type TotVerificationStatus,
|
|
36
36
|
} from "@/lib/compliance/verification/totVerificationClient";
|
|
37
|
-
import { sharedStorePolicy } from "@/lib/checkout/commerce/sharedStoreAdmission";
|
|
37
|
+
import { complianceGateRequiredFor, sharedStorePolicy } from "@/lib/checkout/commerce/sharedStoreAdmission";
|
|
38
38
|
|
|
39
39
|
export const prerender = false;
|
|
40
40
|
|
|
@@ -282,9 +282,12 @@ export async function POST(context: APIContext): Promise<Response> {
|
|
|
282
282
|
}
|
|
283
283
|
|
|
284
284
|
// The shared test store applies the store's own saved requirements, computed here from the
|
|
285
|
-
// tenant's config — never from the request, so a shopper cannot waive one.
|
|
285
|
+
// tenant's config — never from the request, so a shopper cannot waive one. `gate` tells the
|
|
286
|
+
// product page's checkout gate, at click time, whether the store asks for the pre-cart dialog
|
|
287
|
+
// at all, so a static page never carries a decision frozen at build time.
|
|
286
288
|
const policy = await sharedStorePolicy(context.locals.tenant, context.locals.commerce, context.locals.customizationEnv);
|
|
287
|
-
|
|
289
|
+
const gate = complianceGateRequiredFor(policy) ? "required" : "not-required";
|
|
290
|
+
return json({ ...(await evaluatePreflight(context, body, policy ? { verification: policy.verification, rules: policy.rules } : {})), gate });
|
|
288
291
|
}
|
|
289
292
|
|
|
290
293
|
export interface PreflightOptions {
|
|
@@ -44,7 +44,6 @@ import type {
|
|
|
44
44
|
import { resolveCartSigning, shopperCheckoutUnavailableHtml } from "@/lib/checkout/shared/checkoutCommerce";
|
|
45
45
|
import { isLocalSampleCheckout } from "@/config/declaredTenantConfig";
|
|
46
46
|
import { isRegulatedTenant } from "@/lib/compliance/enforcement";
|
|
47
|
-
import { checkoutComplianceGateRequired } from "@/lib/checkout/commerce/sharedStoreAdmission";
|
|
48
47
|
import { resolveMemberContext } from "@/lib/membership/eligibility";
|
|
49
48
|
import { truncate } from "@/lib/content/shared/format";
|
|
50
49
|
import { productLd, breadcrumbLd, reviewLd } from "@/lib/content/shared/jsonld";
|
|
@@ -240,7 +239,6 @@ let checkoutCart:
|
|
|
240
239
|
| undefined;
|
|
241
240
|
let checkoutUnavailable = false;
|
|
242
241
|
const sampleHandoff = isLocalSampleCheckout(tenant.tenant_id);
|
|
243
|
-
const complianceGateRequired = await checkoutComplianceGateRequired(tenant, commerce, Astro.locals.customizationEnv);
|
|
244
242
|
if (commerce || sampleHandoff) {
|
|
245
243
|
// Fail closed for production signed checkout: a signed tenant with no
|
|
246
244
|
// cart-validation secret builds NO cart (never a tamperable form). See
|
|
@@ -434,9 +432,10 @@ const structuredData = {
|
|
|
434
432
|
/>
|
|
435
433
|
{/* Regulated-tenant checkout ENFORCEMENT gate. Intercepts the
|
|
436
434
|
managed-cart handoff to clear age + ship-to compliance server-side
|
|
437
|
-
before the external checkout.
|
|
438
|
-
tenant is regulated
|
|
439
|
-
|
|
435
|
+
before the external checkout. Mounts when a store is wired and the
|
|
436
|
+
tenant is regulated; whether the store's policy asks for the dialog
|
|
437
|
+
is answered by preflight at click time, never frozen into this page. */}
|
|
438
|
+
{checkoutCart && !sampleHandoff && isRegulatedTenant(tenant) && compliance?.minAge && (
|
|
440
439
|
<CheckoutComplianceGate
|
|
441
440
|
client:load
|
|
442
441
|
preflightUrl={withBase(apiBasePath, "/api/compliance/preflight")}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tokenoftrust/storefront-runner",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.11.0",
|
|
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",
|
|
@@ -9,8 +9,9 @@
|
|
|
9
9
|
import { readdirSync, readFileSync } from "node:fs";
|
|
10
10
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
11
11
|
import { dirname, relative, resolve } from "node:path";
|
|
12
|
-
import { fileURLToPath
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
13
|
|
|
14
|
+
import { isMainModule } from "../../../scripts/lib/is-main-module.mjs";
|
|
14
15
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
16
|
const repoRoot = resolve(here, "../../..");
|
|
16
17
|
export const OUTPUT_DIR = relative(repoRoot, resolve(here, "../schemas/managed-analytics/v1"));
|
|
@@ -92,6 +93,6 @@ async function write() {
|
|
|
92
93
|
for (const [path, text] of files) await writeFile(resolve(repoRoot, path), text, "utf8");
|
|
93
94
|
}
|
|
94
95
|
|
|
95
|
-
if (
|
|
96
|
+
if (isMainModule(import.meta.url)) {
|
|
96
97
|
await (process.argv.includes("--check") ? check() : write());
|
|
97
98
|
}
|
|
@@ -61,6 +61,14 @@ const HIGH_VOLUME: readonly ActivitySink[] = ["logs", "analytics"];
|
|
|
61
61
|
const LIFECYCLE: readonly ActivitySink[] = ["logs", "timeline"];
|
|
62
62
|
const PROVENANCE: readonly ActivitySink[] = ["logs", "analytics", "timeline"];
|
|
63
63
|
|
|
64
|
+
// What an operational-setting save read back: which selection each env's reader resolved
|
|
65
|
+
// (`readBackDiagnostics` in the storefront's operational-read-back.ts emits exactly these).
|
|
66
|
+
const OPERATIONAL_READ_BACK_ARGS = [
|
|
67
|
+
"readBack",
|
|
68
|
+
"testSource", "testVersionId", "testMatches", "testRev",
|
|
69
|
+
"liveSource", "liveVersionId", "liveMatches", "liveRev",
|
|
70
|
+
] as const;
|
|
71
|
+
|
|
64
72
|
/**
|
|
65
73
|
* THE CATALOG. Add a new action by adding a key here (and its JS mirror for the
|
|
66
74
|
* CLI).
|
|
@@ -320,14 +328,14 @@ export const ACTION_CATALOG = {
|
|
|
320
328
|
// route's own auth (resolveOwnerSession): a merchant saving their own tenant, or
|
|
321
329
|
// a ToT-staff operator "operating as" another tenant via the admin picker.
|
|
322
330
|
actors: ["merchant", "admin"], source: "server", sinks: LIFECYCLE,
|
|
323
|
-
argsAllow: ["versionId", "scope"], renderedAllow: [],
|
|
331
|
+
argsAllow: ["versionId", "scope", ...OPERATIONAL_READ_BACK_ARGS], renderedAllow: [],
|
|
324
332
|
desc: "A tenant's checkout styling was saved to a version.",
|
|
325
333
|
},
|
|
326
334
|
"content.checkout_requirements.saved": {
|
|
327
335
|
// A store's shared-test-store checkout requirements (Settings → Checkout requirements)
|
|
328
|
-
// were saved to
|
|
336
|
+
// were saved to the store's operational selection. Same actors, spine and read-back args as checkout styling.
|
|
329
337
|
actors: ["merchant", "admin"], source: "server", sinks: LIFECYCLE,
|
|
330
|
-
argsAllow: ["versionId", "scope"], renderedAllow: [],
|
|
338
|
+
argsAllow: ["versionId", "scope", ...OPERATIONAL_READ_BACK_ARGS], renderedAllow: [],
|
|
331
339
|
desc: "A store's checkout requirements were saved to a version.",
|
|
332
340
|
},
|
|
333
341
|
"change.go_live": {
|
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
*/
|
|
21
21
|
import type { CustomizationEnv } from "./customization-preview.js";
|
|
22
22
|
import type { DeepPartial, ThemeTokens } from "./tenant.js";
|
|
23
|
-
import { readPublishedArtifact, type KvGet } from "./customization-runtime.js";
|
|
23
|
+
import { operationalSettingArtifactPath, readPublishedArtifact, type KvGet } from "./customization-runtime.js";
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
26
|
* Color levers exposed on the checkout page. Deliberately a small, opinionated
|
|
@@ -187,7 +187,7 @@ function fontStackFromFamily(family: unknown, fallback: string): string {
|
|
|
187
187
|
* per environment.
|
|
188
188
|
*/
|
|
189
189
|
export function checkoutStyleArtifactPath(tenantId: string): string {
|
|
190
|
-
return
|
|
190
|
+
return operationalSettingArtifactPath(tenantId, "checkout-style.json");
|
|
191
191
|
}
|
|
192
192
|
|
|
193
193
|
/**
|
|
@@ -79,6 +79,67 @@ export function derivedArtifactPath(tenantId: string, name: string): string {
|
|
|
79
79
|
/** The derived catalog listing projection (`catalogListingProjection` in the kit's catalog module). */
|
|
80
80
|
export const CATALOG_LISTING_ARTIFACT = "catalog-listing.json";
|
|
81
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Operational settings (ADR 0035): records a merchant edits from /admin, in effect with no
|
|
84
|
+
* deploy and no promote. Each is saved to the store's operational selection (the pointer's
|
|
85
|
+
* per-path `test` selection) and read from there for EVERY env and channel, so a save is what the
|
|
86
|
+
* preview, the live store and the final-cart authority all read. A path not listed here is
|
|
87
|
+
* repo-authored content: a channel pointer, when engaged, owns it.
|
|
88
|
+
*/
|
|
89
|
+
export const OPERATIONAL_SETTING_FILES = Object.freeze(["checkout-requirements.json", "checkout-style.json"] as const);
|
|
90
|
+
export type OperationalSettingFile = (typeof OPERATIONAL_SETTING_FILES)[number];
|
|
91
|
+
|
|
92
|
+
export function operationalSettingArtifactPath(tenantId: string, file: OperationalSettingFile): string {
|
|
93
|
+
return `tenants/${tenantId}/${file}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function isOperationalSettingPath(path: string): boolean {
|
|
97
|
+
const match = /^tenants\/[^/]+\/([^/]+)$/.exec(path);
|
|
98
|
+
return match?.[1] !== undefined && (OPERATIONAL_SETTING_FILES as readonly string[]).includes(match[1]);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Which selection a reader of (env, path) consults on `pointer`:
|
|
103
|
+
* - `operational`: an operational setting the store has saved — the same selection for every env;
|
|
104
|
+
* - `channel`: the channel's immutable site version (authoritative once engaged);
|
|
105
|
+
* - `legacy`: the per-path selection of a tenant not cut over to channels.
|
|
106
|
+
* `null` when nothing is selected. Readers ({@link resolvePublishedArtifactKey}) and the writer
|
|
107
|
+
* (`publishToTest`, which refuses a save this does not select) both resolve through it.
|
|
108
|
+
*/
|
|
109
|
+
export type ArtifactSelection =
|
|
110
|
+
| { source: "operational"; versionId: string }
|
|
111
|
+
| { source: "channel"; channel: ChannelName }
|
|
112
|
+
| { source: "legacy"; env: CustomizationEnv; versionId: string };
|
|
113
|
+
|
|
114
|
+
export function resolveArtifactSelection(
|
|
115
|
+
pointer: PointerRecord,
|
|
116
|
+
env: CustomizationEnv,
|
|
117
|
+
path: string,
|
|
118
|
+
): ArtifactSelection | null {
|
|
119
|
+
if (isOperationalSettingPath(path)) {
|
|
120
|
+
const versionId = pointer.test?.[path];
|
|
121
|
+
if (versionId) return { source: "operational", versionId };
|
|
122
|
+
}
|
|
123
|
+
const channel = channelFromEnv(env);
|
|
124
|
+
if (pointer.channels?.[channel]) return { source: "channel", channel };
|
|
125
|
+
const versionId = pointer[env]?.[path];
|
|
126
|
+
return versionId ? { source: "legacy", env, versionId } : null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* The selection {@link resolvePublishedArtifactKey} consults for (tenant, env, path), read fresh,
|
|
131
|
+
* with the revision of the pointer record it was resolved from (`null` when there is no record).
|
|
132
|
+
*/
|
|
133
|
+
export async function readArtifactSelection(
|
|
134
|
+
get: KvGet,
|
|
135
|
+
tenantId: string,
|
|
136
|
+
env: CustomizationEnv,
|
|
137
|
+
path: string,
|
|
138
|
+
): Promise<{ rev: number; selection: ArtifactSelection | null } | null> {
|
|
139
|
+
const pointer = await readPointer(get, tenantId);
|
|
140
|
+
return pointer ? { rev: pointer.rev, selection: resolveArtifactSelection(pointer, env, path) } : null;
|
|
141
|
+
}
|
|
142
|
+
|
|
82
143
|
async function readPointer(
|
|
83
144
|
get: KvGet,
|
|
84
145
|
tenantId: string,
|
|
@@ -269,7 +330,9 @@ export async function readVersionedArtifact(
|
|
|
269
330
|
* content). Never throws on malformed data — returns null so the caller falls
|
|
270
331
|
* back to the build-time artifact.
|
|
271
332
|
*
|
|
272
|
-
* Resolution
|
|
333
|
+
* Resolution follows {@link resolveArtifactSelection}:
|
|
334
|
+
* - An operational setting the store has saved resolves to that saved version
|
|
335
|
+
* for every env and channel.
|
|
273
336
|
* - If the tenant has a channel pointer for the resolved channel
|
|
274
337
|
* (`channelFromEnv(env)`), resolve via the immutable site version. A channel
|
|
275
338
|
* pointer is AUTHORITATIVE — if the snapshot omits this path we return null
|
|
@@ -298,24 +361,18 @@ export async function resolvePublishedArtifactKey(
|
|
|
298
361
|
env: CustomizationEnv,
|
|
299
362
|
path: string,
|
|
300
363
|
): Promise<string | null> {
|
|
301
|
-
const
|
|
302
|
-
if (!
|
|
364
|
+
const selection = (await readArtifactSelection(get, tenantId, env, path))?.selection;
|
|
365
|
+
if (!selection) return null;
|
|
366
|
+
if (selection.source !== "channel") return customizationArtifactKey(tenantId, selection.versionId);
|
|
303
367
|
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
? resolveCapturedVersionArtifactKey(get, tenantId, versionId, path)
|
|
313
|
-
: resolveVersionArtifactKey(get, tenantId, versionId, path);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// Legacy per-path selection.
|
|
317
|
-
const versionId = pointer?.[env]?.[path];
|
|
318
|
-
return versionId ? customizationArtifactKey(tenantId, versionId) : null;
|
|
368
|
+
// Versioned model engaged for this channel — authoritative. The shared preview renders a
|
|
369
|
+
// version's captured images the way a pinned preview of it does; `live` never reads the
|
|
370
|
+
// overlay (its public path is the published bundle, which carries its own rewrites).
|
|
371
|
+
const { versionId } = await resolveServableChannelVersion(get, tenantId, selection.channel);
|
|
372
|
+
if (!versionId) return null;
|
|
373
|
+
return selection.channel === "preview"
|
|
374
|
+
? resolveCapturedVersionArtifactKey(get, tenantId, versionId, path)
|
|
375
|
+
: resolveVersionArtifactKey(get, tenantId, versionId, path);
|
|
319
376
|
}
|
|
320
377
|
|
|
321
378
|
/**
|
|
@@ -43,6 +43,7 @@
|
|
|
43
43
|
*/
|
|
44
44
|
import { execFileSync } from "node:child_process";
|
|
45
45
|
|
|
46
|
+
import { isMainModule } from "../lib/is-main-module.mjs";
|
|
46
47
|
/** Umbrella branch this epic folds into. */
|
|
47
48
|
export const DEFAULT_UMBRELLA = "feature/8500-storefront-integration";
|
|
48
49
|
|
|
@@ -493,6 +494,6 @@ function renderReport(r) {
|
|
|
493
494
|
}
|
|
494
495
|
|
|
495
496
|
// Run only when invoked directly (not when imported by the test).
|
|
496
|
-
if (import.meta.url
|
|
497
|
+
if (isMainModule(import.meta.url)) {
|
|
497
498
|
main();
|
|
498
499
|
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { realpathSync } from "node:fs";
|
|
2
|
+
import { fileURLToPath } from "node:url";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Whether the module at `moduleUrl` is the script node was asked to run. Compared by real path on
|
|
6
|
+
* both sides: node reports `import.meta.url` resolved through symlinks but leaves `process.argv[1]`
|
|
7
|
+
* as typed, so a plain comparison is false whenever the script is reached through a symlink, and
|
|
8
|
+
* then main never runs and the process exits 0 having done nothing.
|
|
9
|
+
* @param {string} moduleUrl
|
|
10
|
+
* @param {string | undefined} [argv1]
|
|
11
|
+
* @returns {boolean}
|
|
12
|
+
*/
|
|
13
|
+
export function isMainModule(moduleUrl, argv1 = process.argv[1]) {
|
|
14
|
+
if (!argv1) return false;
|
|
15
|
+
try {
|
|
16
|
+
return realpathSync(argv1) === realpathSync(fileURLToPath(moduleUrl));
|
|
17
|
+
} catch {
|
|
18
|
+
return false;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
*/
|
|
27
27
|
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
28
28
|
import { join, extname } from "node:path";
|
|
29
|
+
import { isMainModule } from "../lib/is-main-module.mjs";
|
|
29
30
|
import { EMBED_PROVIDERS } from "../../packages/public-runtime/src/embed-catalog.mjs";
|
|
30
31
|
|
|
31
32
|
/**
|
|
@@ -169,4 +170,4 @@ function main() {
|
|
|
169
170
|
}
|
|
170
171
|
|
|
171
172
|
// Only run main when invoked directly, not when imported by the parity test.
|
|
172
|
-
if (import.meta.url
|
|
173
|
+
if (isMainModule(import.meta.url)) main();
|
|
@@ -69,7 +69,8 @@ import {
|
|
|
69
69
|
import { execFileSync } from "node:child_process";
|
|
70
70
|
import { tmpdir } from "node:os";
|
|
71
71
|
import { dirname, join, resolve, sep } from "node:path";
|
|
72
|
-
import { fileURLToPath
|
|
72
|
+
import { fileURLToPath } from "node:url";
|
|
73
|
+
import { isMainModule } from "../lib/is-main-module.mjs";
|
|
73
74
|
import { tenantDirRelative } from "../lib/tenant-dirs.mjs";
|
|
74
75
|
import { parseMappingTarget } from "../../agency-kit/tools/lib/tot-repo-mappings.mjs";
|
|
75
76
|
|
|
@@ -648,9 +649,7 @@ function main() {
|
|
|
648
649
|
}
|
|
649
650
|
}
|
|
650
651
|
|
|
651
|
-
|
|
652
|
-
process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
653
|
-
if (invokedDirectly) {
|
|
652
|
+
if (isMainModule(import.meta.url)) {
|
|
654
653
|
try {
|
|
655
654
|
main();
|
|
656
655
|
} catch (e) {
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
import { readFileSync, existsSync, readdirSync, statSync } from "node:fs";
|
|
29
29
|
import { dirname, resolve, join, relative } from "node:path";
|
|
30
30
|
import { fileURLToPath } from "node:url";
|
|
31
|
+
import { isMainModule } from "../lib/is-main-module.mjs";
|
|
31
32
|
import { KNOWN_EMBEDS, lintTenantEmbeds } from "./lint-embeds.mjs";
|
|
32
33
|
import { listTenantDirIds, tenantDirSegments } from "../lib/tenant-dirs.mjs";
|
|
33
34
|
import {
|
|
@@ -352,7 +353,6 @@ function validateThemeHeadings(theme, file) {
|
|
|
352
353
|
return out;
|
|
353
354
|
}
|
|
354
355
|
|
|
355
|
-
|
|
356
356
|
const SUPPORTED_BLOCK_CONTRACTS = new Set(BLOCK_PALETTE_SUPPORTED_CONTRACTS);
|
|
357
357
|
const KNOWN_BLOCKS = new Set([
|
|
358
358
|
"hero",
|
|
@@ -1388,8 +1388,7 @@ function resolveTenantDir(argv) {
|
|
|
1388
1388
|
return { dir: tenantDir(id), tenantId: id };
|
|
1389
1389
|
}
|
|
1390
1390
|
|
|
1391
|
-
|
|
1392
|
-
if (isMain) {
|
|
1391
|
+
if (isMainModule(import.meta.url)) {
|
|
1393
1392
|
const argv = process.argv.slice(2);
|
|
1394
1393
|
const asJson = argv.includes("--json");
|
|
1395
1394
|
const profileIndex = argv.indexOf("--profile");
|