@tokenoftrust/storefront-runner 1.3.4-rc.3 → 1.4.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/astro.config.mjs +15 -0
- package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
- package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
- package/apps/storefront/drizzle.config.apps.ts +13 -0
- package/apps/storefront/env.d.ts +10 -0
- package/apps/storefront/migrations/README.md +13 -7
- package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
- package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
- package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
- package/apps/storefront/package.json +13 -1
- package/apps/storefront/perf/README.md +64 -0
- package/apps/storefront/perf/assert-budgets.ts +159 -0
- package/apps/storefront/playwright.config.ts +23 -0
- package/apps/storefront/public/js/dashboard-apps.js +173 -0
- package/apps/storefront/public/shared/commerce-marketing.css +221 -0
- package/apps/storefront/src/components/CollectionCard.astro +1 -0
- package/apps/storefront/src/components/ProductCard.astro +1 -0
- package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
- package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
- package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
- package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
- package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
- package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
- package/apps/storefront/src/components/content/Callout.astro +75 -0
- package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
- package/apps/storefront/src/components/content/ProseSections.astro +121 -0
- package/apps/storefront/src/components/content/ProseToc.astro +34 -0
- package/apps/storefront/src/components/content/RichText.astro +44 -0
- package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
- package/apps/storefront/src/components/home/Hero.astro +14 -0
- package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
- package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
- package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
- package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
- package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
- package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
- package/apps/storefront/src/config/storeName.ts +34 -0
- package/apps/storefront/src/layouts/Layout.astro +97 -23
- package/apps/storefront/src/lib/analytics/budgets.json +69 -0
- package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
- package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
- package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
- package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
- package/apps/storefront/src/lib/apps/adminService.ts +106 -0
- package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
- package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
- package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
- package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
- package/apps/storefront/src/lib/apps/credentials.ts +153 -0
- package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
- package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
- package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
- package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
- package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
- package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
- package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
- package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
- package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
- package/apps/storefront/src/lib/apps/registryService.ts +579 -0
- package/apps/storefront/src/lib/apps/scopes.ts +79 -0
- package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
- package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
- package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
- package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
- package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
- package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
- package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
- package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
- package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
- package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
- package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
- package/apps/storefront/src/lib/auth/route.ts +16 -1
- package/apps/storefront/src/lib/auth/session.ts +8 -0
- package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
- package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
- package/apps/storefront/src/lib/blog/provider.ts +39 -0
- package/apps/storefront/src/lib/blog/types.ts +26 -0
- package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
- package/apps/storefront/src/lib/chrome/model.ts +11 -0
- package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
- package/apps/storefront/src/lib/content/callout.ts +78 -0
- package/apps/storefront/src/lib/content/index.ts +27 -0
- package/apps/storefront/src/lib/content/needsReview.ts +58 -0
- package/apps/storefront/src/lib/content/prose.ts +186 -0
- package/apps/storefront/src/lib/content/richtext.ts +76 -0
- package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
- package/apps/storefront/src/lib/content-edit/client.ts +70 -14
- package/apps/storefront/src/lib/d1/catalog.ts +12 -0
- package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
- package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
- package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
- package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
- package/apps/storefront/src/lib/dev/obstacleStore.ts +214 -0
- package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
- package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
- package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
- package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
- package/apps/storefront/src/lib/env.ts +12 -0
- package/apps/storefront/src/lib/jsonld.ts +12 -17
- package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
- package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
- package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
- package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
- package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
- package/apps/storefront/src/lib/publish/domainState.ts +351 -0
- package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
- package/apps/storefront/src/lib/rawChrome.ts +34 -3
- package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
- package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
- package/apps/storefront/src/lib/subscription/model.ts +114 -0
- package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
- package/apps/storefront/src/lib/tot/query.ts +32 -0
- package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
- package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
- package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
- package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
- package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
- package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
- package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
- package/apps/storefront/src/lib/webhooks/events.ts +98 -0
- package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
- package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
- package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
- package/apps/storefront/src/middleware/index.ts +28 -13
- package/apps/storefront/src/pages/404.astro +21 -9
- package/apps/storefront/src/pages/[...slug].astro +36 -2
- package/apps/storefront/src/pages/admin.astro +29 -3
- package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
- package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
- package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
- package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
- package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
- package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
- package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
- package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
- package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
- package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
- package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
- package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
- package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
- package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
- package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
- package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
- package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
- package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
- package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
- package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
- package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
- package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
- package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
- package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
- package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
- package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
- package/apps/storefront/src/pages/auth/login.astro +197 -41
- package/apps/storefront/src/pages/auth/magic.astro +75 -43
- package/apps/storefront/src/pages/blog/[slug].astro +107 -0
- package/apps/storefront/src/pages/blog/index.astro +98 -0
- package/apps/storefront/src/pages/capabilities.astro +8 -0
- package/apps/storefront/src/pages/cockpit.astro +496 -74
- package/apps/storefront/src/pages/collections/[handle].astro +8 -0
- package/apps/storefront/src/pages/collections/index.astro +10 -2
- package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
- package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
- package/apps/storefront/src/pages/index.astro +57 -0
- package/apps/storefront/src/pages/llms.txt.ts +31 -10
- package/apps/storefront/src/pages/products/[handle].astro +100 -9
- package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
- package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
- package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
- package/apps/storefront/src/pages/style-guide/index.astro +10 -0
- package/apps/storefront/src/styles/fonts.css +54 -0
- package/apps/storefront/src/styles/global.css +22 -2
- package/apps/storefront/src/themes/schema.ts +3 -25
- package/apps/storefront/tsconfig.json +1 -1
- package/apps/storefront/vitest.config.ts +4 -1
- package/package.json +1 -1
- package/packages/public-runtime/src/candidate-index.ts +311 -0
- package/packages/public-runtime/src/checkout.ts +94 -2
- package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
- package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
- package/packages/public-runtime/src/compliance/index.ts +22 -0
- package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
- package/packages/public-runtime/src/compliance/profile.ts +198 -0
- package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
- package/packages/public-runtime/src/compliance/verification.ts +81 -0
- package/packages/public-runtime/src/csp.ts +27 -3
- package/packages/public-runtime/src/customization-reconcile.ts +35 -0
- package/packages/public-runtime/src/customization-runtime.ts +8 -0
- package/packages/public-runtime/src/customization-versioning.ts +17 -0
- package/packages/public-runtime/src/extension-contract.ts +2 -1
- package/packages/public-runtime/src/hash.ts +25 -0
- package/packages/public-runtime/src/index.ts +6 -0
- package/packages/public-runtime/src/membership.ts +353 -0
- package/packages/public-runtime/src/product.ts +24 -2
- package/packages/public-runtime/src/review-trust-proof.ts +194 -0
- package/packages/public-runtime/src/tenant-assets.ts +40 -5
- package/packages/public-runtime/src/tenant.ts +236 -0
- package/packages/public-runtime/src/widget-postmessage.ts +205 -0
- package/scripts/dev/publish.mjs +158 -0
- package/scripts/dev/transient-files.mjs +2 -1
- package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
- package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
- package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
- package/tenants/home/public/pages/storefront.css +23 -0
- package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* KV data layer for the `ReviewEnvironment` projection — the canonical read model
|
|
3
|
+
* that answers, for one change/PR, *"what is the hosted preview, is it current, is
|
|
4
|
+
* it reviewable, and can it be merged?"* (see `docs/review-environment-contract.md`,
|
|
5
|
+
* the u0 keystone this unit — s1 — implements).
|
|
6
|
+
*
|
|
7
|
+
* This module is the STORE + TYPES only: it persists and enumerates records; it
|
|
8
|
+
* does NOT assemble/derive `status` (that join over `ChangeState` + `PreviewState`
|
|
9
|
+
* is a separate concern) and it never authors any of the projected fields — every
|
|
10
|
+
* field is a projection of an upstream fact.
|
|
11
|
+
*
|
|
12
|
+
* Backends are injected via a `get`/`put` KV interface (Cloudflare KV in prod, an
|
|
13
|
+
* in-memory map in dev + tests), mirroring `tenant-assets.ts`'s `AssetsKv` pattern,
|
|
14
|
+
* so this module stays storage-agnostic and unit-testable.
|
|
15
|
+
*
|
|
16
|
+
* NOTE (contract discrepancy, resolved): the u0 doc types `evidence` as the
|
|
17
|
+
* `EvidenceReport` from `@tot/private-controlplane`. This package is the public,
|
|
18
|
+
* edge-shipped runtime and MUST NOT import `@tot/private-controlplane` (machine-
|
|
19
|
+
* enforced — see index.ts). So `evidence` uses the structural mirror this package
|
|
20
|
+
* already carries for exactly this reason: the same shape `PreviewState.evidence`
|
|
21
|
+
* uses in `customization-reconcile.ts` (`PreviewStateEvidenceCheck[]` + `promotable`
|
|
22
|
+
* + `generatedAt`). It is surfaced from the reconcile route's evidence, never
|
|
23
|
+
* re-derived here.
|
|
24
|
+
*/
|
|
25
|
+
import type {
|
|
26
|
+
PreviewStateEvidenceCheck,
|
|
27
|
+
PreviewStateFinding,
|
|
28
|
+
PreviewStateOverride,
|
|
29
|
+
} from "./customization-reconcile.js";
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The hosted review-environment status — a fourth, deliberately narrow vocabulary
|
|
33
|
+
* scoped to the review environment (distinct from controlplane `ChangeState`,
|
|
34
|
+
* storefront `ChangeStatus`, and evidence `EvidenceStatus`). Derived by joining the
|
|
35
|
+
* change lifecycle with the reconcile state; see the u0 contract's "Deriving status".
|
|
36
|
+
*/
|
|
37
|
+
export type ReviewEnvironmentStatus =
|
|
38
|
+
| "queued"
|
|
39
|
+
| "reconciling"
|
|
40
|
+
| "ready"
|
|
41
|
+
| "blocked"
|
|
42
|
+
| "merged"
|
|
43
|
+
| "closed";
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The compliance + SEO verdict carried on a `ReviewEnvironment`. Structural mirror
|
|
47
|
+
* of `@tot/private-controlplane`'s `EvidenceReport` (this package cannot import it —
|
|
48
|
+
* see the module note), identical to `PreviewState.evidence`'s shape.
|
|
49
|
+
*/
|
|
50
|
+
export interface ReviewEnvironmentEvidence {
|
|
51
|
+
checks: PreviewStateEvidenceCheck[];
|
|
52
|
+
/** Derived upstream: every `REQUIRED_FOR_PROMOTE` check is `pass`. */
|
|
53
|
+
promotable: boolean;
|
|
54
|
+
generatedAt: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* One canonical read model for a change's hosted review environment. Assembled
|
|
59
|
+
* (never authored) from the change record, the forge PR, and the preview reconcile
|
|
60
|
+
* state + its evidence verdict. Persisted at `cust:change:<tenantId>:<changeId>`.
|
|
61
|
+
*/
|
|
62
|
+
export interface ReviewEnvironment {
|
|
63
|
+
/** Tenant/owner scope (matches `PreviewState.tenantId` + the `cust:*` key scope). */
|
|
64
|
+
tenantId: string;
|
|
65
|
+
/** Opaque change-record id — the projection is keyed on it. */
|
|
66
|
+
changeId: string;
|
|
67
|
+
/** Forge PR number, or `null` in a change-first flow before a PR exists. */
|
|
68
|
+
prNumber: number | null;
|
|
69
|
+
/** Source branch the PR ships from, or `null` until a branch/PR exists. */
|
|
70
|
+
branch: string | null;
|
|
71
|
+
/** PR head commit — the reconcile TARGET; may not yet be what renders (`versionId`). */
|
|
72
|
+
headSha: string | null;
|
|
73
|
+
/** Target-branch tip the PR merges into (merge base), or `null` before a PR. */
|
|
74
|
+
baseSha: string | null;
|
|
75
|
+
/**
|
|
76
|
+
* The immutable site `versionId` the `preview` channel currently points at (last
|
|
77
|
+
* successfully materialized commit). Equals `headSha` when `ready`; LAGS it when
|
|
78
|
+
* `blocked` (last-good preview keeps rendering). `null` before the first reconcile.
|
|
79
|
+
*/
|
|
80
|
+
versionId: string | null;
|
|
81
|
+
/** Projected review-environment status (the join). Always present. */
|
|
82
|
+
status: ReviewEnvironmentStatus;
|
|
83
|
+
/** Absolute deep-link to the hosted preview, or `null` until a preview exists. */
|
|
84
|
+
previewUrl: string | null;
|
|
85
|
+
/** Compliance + SEO verdict for the rendered preview, or `null` before evidence. */
|
|
86
|
+
evidence: ReviewEnvironmentEvidence | null;
|
|
87
|
+
/**
|
|
88
|
+
* Validation findings from the candidate's last reconcile — advisory findings on a
|
|
89
|
+
* materialized candidate, or the blocking-safety findings that stopped one —
|
|
90
|
+
* surfaced in the PR review console. Absent/empty ⇒ validated clean.
|
|
91
|
+
*/
|
|
92
|
+
findings?: PreviewStateFinding[];
|
|
93
|
+
/** Auditable safety-block override applied to this candidate (who/why), if any. */
|
|
94
|
+
overriddenBy?: PreviewStateOverride | null;
|
|
95
|
+
/**
|
|
96
|
+
* Environment-side merge eligibility only: `evidence?.promotable === true` AND no
|
|
97
|
+
* merge conflict AND `status === "ready"`. Deliberately excludes the acting
|
|
98
|
+
* operator's per-viewer capability (resolved server-side by `acceptGate`).
|
|
99
|
+
*/
|
|
100
|
+
mergeable: boolean;
|
|
101
|
+
/** Human-legible reason the environment is not healthy, or `null` when healthy. */
|
|
102
|
+
lastError: string | null;
|
|
103
|
+
/** ISO 8601 timestamp of the last time this projection was recomputed. */
|
|
104
|
+
updatedAt: string;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** KV key for one tenant-change `ReviewEnvironment` projection. */
|
|
108
|
+
export function reviewEnvironmentKey(tenantId: string, changeId: string): string {
|
|
109
|
+
return `cust:change:${tenantId}:${changeId}`;
|
|
110
|
+
}
|
|
111
|
+
/** KV key for a tenant's change index (list of changeIds). */
|
|
112
|
+
export function reviewEnvironmentIndexKey(tenantId: string): string {
|
|
113
|
+
return `cust:changes:${tenantId}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Injectable KV backend for the candidate store (Cloudflare KV in prod, an
|
|
118
|
+
* in-memory map in dev + tests). Mirrors `tenant-assets.ts`'s `AssetsKv`.
|
|
119
|
+
*/
|
|
120
|
+
export interface CandidateKv {
|
|
121
|
+
get(key: string): Promise<string | null>;
|
|
122
|
+
put(key: string, value: string): Promise<void>;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* A {@link CandidateKv} that can also DELETE a key — the eviction surface the
|
|
127
|
+
* retire/GC path (unit U7) needs. Kept separate from `CandidateKv` so the many
|
|
128
|
+
* write-only `{ get, put }` adapters (the webhook reconcile, build-on-demand,
|
|
129
|
+
* accept) are unaffected: only the retire endpoint constructs a delete-capable
|
|
130
|
+
* adapter. A real Cloudflare KV namespace satisfies this structurally.
|
|
131
|
+
*/
|
|
132
|
+
export interface CandidateKvDeletable extends CandidateKv {
|
|
133
|
+
delete(key: string): Promise<void>;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Read one tenant-change `ReviewEnvironment`, or `null` when absent/malformed.
|
|
138
|
+
* Never throws — a malformed record reads as `null` (fall back to a recompute).
|
|
139
|
+
*/
|
|
140
|
+
export async function readReviewEnvironment(
|
|
141
|
+
kv: Pick<CandidateKv, "get">,
|
|
142
|
+
tenantId: string,
|
|
143
|
+
changeId: string,
|
|
144
|
+
): Promise<ReviewEnvironment | null> {
|
|
145
|
+
const raw = await kv.get(reviewEnvironmentKey(tenantId, changeId));
|
|
146
|
+
if (!raw) return null;
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(raw) as ReviewEnvironment;
|
|
149
|
+
} catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Read a tenant's change index (the list of `changeId`s), or `[]` when unset or
|
|
156
|
+
* malformed. Never throws — the index is a read-optimization, never a write gate.
|
|
157
|
+
*/
|
|
158
|
+
export async function readChangeIndex(
|
|
159
|
+
kv: Pick<CandidateKv, "get">,
|
|
160
|
+
tenantId: string,
|
|
161
|
+
): Promise<string[]> {
|
|
162
|
+
const raw = await kv.get(reviewEnvironmentIndexKey(tenantId));
|
|
163
|
+
if (!raw) return [];
|
|
164
|
+
try {
|
|
165
|
+
const parsed = JSON.parse(raw);
|
|
166
|
+
return Array.isArray(parsed)
|
|
167
|
+
? parsed.filter((id): id is string => typeof id === "string")
|
|
168
|
+
: [];
|
|
169
|
+
} catch {
|
|
170
|
+
return [];
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* In-process serialization for the per-tenant change-index read-modify-write.
|
|
176
|
+
*
|
|
177
|
+
* The tenant index (`cust:changes:<tenantId>`) is updated by a read-modify-write:
|
|
178
|
+
* read the current id list, append if absent, write it back. The injected KV is a
|
|
179
|
+
* bare Cloudflare KV namespace in prod (`{ get, put }` — see the `candidateKv`
|
|
180
|
+
* adapter in `apps/storefront/src/pages/api/changes/accept.ts`) with NO
|
|
181
|
+
* compare-and-swap / conditional-write primitive, so two concurrent writes for
|
|
182
|
+
* different candidates of one tenant can interleave — both read the index before
|
|
183
|
+
* either writes it back — and the second `put` silently drops the first's append.
|
|
184
|
+
*
|
|
185
|
+
* Chaining every index write for a given key through one promise removes that
|
|
186
|
+
* interleaving *within a Worker isolate*, where concurrent requests share this
|
|
187
|
+
* module instance — which is the realistic concurrent-reconcile shape (two open
|
|
188
|
+
* PRs for one tenant reconciling on two near-simultaneous invocations on the same
|
|
189
|
+
* isolate). Cross-isolate writes to the same key remain last-write-wins; CF KV
|
|
190
|
+
* exposes no primitive to prevent that, so this closes the in-isolate race the
|
|
191
|
+
* repro test exercises rather than pretending to a distributed lock the backend
|
|
192
|
+
* cannot provide.
|
|
193
|
+
*/
|
|
194
|
+
const indexWriteChains = new Map<string, Promise<unknown>>();
|
|
195
|
+
|
|
196
|
+
function serializeIndexWrite<T>(
|
|
197
|
+
key: string,
|
|
198
|
+
task: () => Promise<T>,
|
|
199
|
+
): Promise<T> {
|
|
200
|
+
const prev = indexWriteChains.get(key) ?? Promise.resolve();
|
|
201
|
+
// Run `task` after the prior write settles (success OR failure), so one
|
|
202
|
+
// rejected write never wedges the chain for the key.
|
|
203
|
+
const run = prev.then(task, task);
|
|
204
|
+
const settled = run.then(
|
|
205
|
+
() => undefined,
|
|
206
|
+
() => undefined,
|
|
207
|
+
);
|
|
208
|
+
indexWriteChains.set(key, settled);
|
|
209
|
+
void settled.then(() => {
|
|
210
|
+
if (indexWriteChains.get(key) === settled) indexWriteChains.delete(key);
|
|
211
|
+
});
|
|
212
|
+
return run;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Test-only: clear the per-index write-serialization chains. `indexWriteChains` is a
|
|
217
|
+
* MODULE singleton (deliberately — the in-isolate serialization is process-wide), so
|
|
218
|
+
* under a shared-module test runner it leaks ACROSS test files: a settled chain left
|
|
219
|
+
* by one file is inherited by the next, making otherwise-isolated tests order-
|
|
220
|
+
* dependent. Call in `beforeEach` to give each test a clean slate. No-op in prod.
|
|
221
|
+
*/
|
|
222
|
+
export function resetReviewEnvIndexWriteChainsForTest(): void {
|
|
223
|
+
indexWriteChains.clear();
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Upsert one `ReviewEnvironment` record and add its `changeId` to the tenant index
|
|
228
|
+
* (idempotent — a re-write of an existing change leaves the index unchanged). The
|
|
229
|
+
* per-record write goes to its own key (`cust:change:<tenantId>:<changeId>`) with
|
|
230
|
+
* no cross-candidate contention, so it stays outside the lock. The index update is
|
|
231
|
+
* a read-modify-write over the SHARED per-tenant key, so it is serialized per index
|
|
232
|
+
* key (see `serializeIndexWrite`) to keep concurrent writes for different
|
|
233
|
+
* candidates from clobbering each other's append.
|
|
234
|
+
*/
|
|
235
|
+
export async function writeReviewEnvironment(
|
|
236
|
+
kv: CandidateKv,
|
|
237
|
+
record: ReviewEnvironment,
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
await kv.put(
|
|
240
|
+
reviewEnvironmentKey(record.tenantId, record.changeId),
|
|
241
|
+
JSON.stringify(record),
|
|
242
|
+
);
|
|
243
|
+
await serializeIndexWrite(reviewEnvironmentIndexKey(record.tenantId), async () => {
|
|
244
|
+
const index = await readChangeIndex(kv, record.tenantId);
|
|
245
|
+
if (!index.includes(record.changeId)) {
|
|
246
|
+
await kv.put(
|
|
247
|
+
reviewEnvironmentIndexKey(record.tenantId),
|
|
248
|
+
JSON.stringify([...index, record.changeId]),
|
|
249
|
+
);
|
|
250
|
+
}
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Evict one `ReviewEnvironment` record AND remove its `changeId` from the tenant
|
|
256
|
+
* index — the KV half of retire/GC (unit U7). The per-record key is deleted
|
|
257
|
+
* outright; the tenant index is updated by the SAME serialized read-modify-write
|
|
258
|
+
* `writeReviewEnvironment` uses (see {@link serializeIndexWrite}), so a retire that
|
|
259
|
+
* races a concurrent write for a DIFFERENT candidate never resurrects a dropped
|
|
260
|
+
* entry or leaves a dangling index id (the corruption risk this helper exists to
|
|
261
|
+
* prevent — the mirror-image of `writeReviewEnvironment`'s append).
|
|
262
|
+
*
|
|
263
|
+
* IDEMPOTENT by contract: deleting an already-absent candidate is a clean no-op.
|
|
264
|
+
* Returns whether anything was actually removed (`true` iff the record existed OR
|
|
265
|
+
* the index carried the id), so the caller can report `evicted` honestly while a
|
|
266
|
+
* repeat retire still succeeds.
|
|
267
|
+
*/
|
|
268
|
+
export async function deleteReviewEnvironment(
|
|
269
|
+
kv: CandidateKvDeletable,
|
|
270
|
+
tenantId: string,
|
|
271
|
+
changeId: string,
|
|
272
|
+
): Promise<boolean> {
|
|
273
|
+
// Did the record exist before we removed it? (drives the honest `evicted` flag).
|
|
274
|
+
const recordExisted =
|
|
275
|
+
(await kv.get(reviewEnvironmentKey(tenantId, changeId))) !== null;
|
|
276
|
+
await kv.delete(reviewEnvironmentKey(tenantId, changeId));
|
|
277
|
+
|
|
278
|
+
// Update the SHARED per-tenant index under the same per-key serialization as the
|
|
279
|
+
// append, so a concurrent write for another candidate can't clobber the removal
|
|
280
|
+
// (and vice-versa). Only rewrite when the id is actually present — an idempotent
|
|
281
|
+
// repeat leaves the index byte-identical.
|
|
282
|
+
let indexHadId = false;
|
|
283
|
+
await serializeIndexWrite(reviewEnvironmentIndexKey(tenantId), async () => {
|
|
284
|
+
const index = await readChangeIndex(kv, tenantId);
|
|
285
|
+
if (index.includes(changeId)) {
|
|
286
|
+
indexHadId = true;
|
|
287
|
+
await kv.put(
|
|
288
|
+
reviewEnvironmentIndexKey(tenantId),
|
|
289
|
+
JSON.stringify(index.filter((id) => id !== changeId)),
|
|
290
|
+
);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
return recordExisted || indexHadId;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Enumerate a tenant's `ReviewEnvironment` records via the index (one `get` per
|
|
299
|
+
* indexed change, no scan). Skips any index entry whose record is missing/malformed,
|
|
300
|
+
* so a dangling index id never breaks the list.
|
|
301
|
+
*/
|
|
302
|
+
export async function listReviewEnvironments(
|
|
303
|
+
kv: Pick<CandidateKv, "get">,
|
|
304
|
+
tenantId: string,
|
|
305
|
+
): Promise<ReviewEnvironment[]> {
|
|
306
|
+
const index = await readChangeIndex(kv, tenantId);
|
|
307
|
+
const records = await Promise.all(
|
|
308
|
+
index.map((changeId) => readReviewEnvironment(kv, tenantId, changeId)),
|
|
309
|
+
);
|
|
310
|
+
return records.filter((r): r is ReviewEnvironment => r !== null);
|
|
311
|
+
}
|
|
@@ -24,6 +24,7 @@ import type {
|
|
|
24
24
|
ProductPurchaseOption,
|
|
25
25
|
SubscriptionPurchaseOption,
|
|
26
26
|
} from "./product.js";
|
|
27
|
+
import { DEFAULT_ONE_TIME_PURCHASE_OPTION } from "./product.js";
|
|
27
28
|
|
|
28
29
|
/**
|
|
29
30
|
* Per-tenant managed-checkout wiring. Public config only — the signing SECRET is never
|
|
@@ -95,6 +96,26 @@ export interface BuildVariantCartPayloadOptions {
|
|
|
95
96
|
mode: ManagedCheckoutConfig["mode"];
|
|
96
97
|
secret?: string;
|
|
97
98
|
purchaseOption?: ProductPurchaseOption;
|
|
99
|
+
/**
|
|
100
|
+
* The member-adjusted EFFECTIVE price to sign (F11b), computed server-side via
|
|
101
|
+
* membership.ts `priceForPurchaseOption` for the shopper's resolved eligibility.
|
|
102
|
+
* When set it REPLACES the list/subscription price as the single signed source
|
|
103
|
+
* of truth, so a given price is only ever valid for the member context it was
|
|
104
|
+
* signed under (see {@link ../membership.ts buildMemberSignedCartPayload}).
|
|
105
|
+
* Absent → the list/subscription price (anonymous shopper). Never a client-
|
|
106
|
+
* supplied value: only the server, holding the cart secret, ever signs it.
|
|
107
|
+
*/
|
|
108
|
+
signedPrice?: number;
|
|
109
|
+
/**
|
|
110
|
+
* Whether Autopilot (subscription purchase options) is enabled for the tenant.
|
|
111
|
+
* `visiblePurchaseOptions` already drops subscription options when Autopilot is
|
|
112
|
+
* off, so a subscription option reaching the signer means that filter was
|
|
113
|
+
* bypassed; when `false` we REFUSE to sign it (hide-at-launch). The server thus
|
|
114
|
+
* never mints a signed subscription payload, so a hidden Autopilot plan can't be
|
|
115
|
+
* resurrected via a crafted form post. Defaults to `true` (the framework default
|
|
116
|
+
* — subscriptions visible) so existing callers are unaffected.
|
|
117
|
+
*/
|
|
118
|
+
autopilotEnabled?: boolean;
|
|
98
119
|
}
|
|
99
120
|
|
|
100
121
|
const DEFAULT_CHECKOUT_HOST_SUFFIX = ".foxycart.com";
|
|
@@ -126,11 +147,18 @@ export function cartFieldsForVariant(
|
|
|
126
147
|
product: CatalogProduct,
|
|
127
148
|
variant: CatalogVariant,
|
|
128
149
|
purchaseOption?: ProductPurchaseOption,
|
|
150
|
+
opts?: { signedPrice?: number },
|
|
129
151
|
): { code: string; fields: CartField[] } {
|
|
130
152
|
const code = variant.sku && variant.sku.length ? variant.sku : variant.id;
|
|
131
153
|
const image = product.images.find((i) => i.id === variant.image_id)?.url ??
|
|
132
154
|
product.images[0]?.url;
|
|
133
|
-
|
|
155
|
+
// The signed price is either the caller's member-adjusted EFFECTIVE price
|
|
156
|
+
// (F11b — the single signed source of truth, computed server-side from the
|
|
157
|
+
// shopper's resolved eligibility) or, absent that, the list/subscription price.
|
|
158
|
+
const price =
|
|
159
|
+
opts?.signedPrice !== undefined
|
|
160
|
+
? assertEffectivePrice(opts.signedPrice, variant.price)
|
|
161
|
+
: purchaseOptionPrice(variant.price, purchaseOption);
|
|
134
162
|
const fields: CartField[] = [
|
|
135
163
|
{ name: "name", value: variant.title || product.title },
|
|
136
164
|
{ name: "price", value: price.toFixed(2) },
|
|
@@ -143,12 +171,45 @@ export function cartFieldsForVariant(
|
|
|
143
171
|
for (const [optName, optVal] of Object.entries(variant.option_values)) {
|
|
144
172
|
if (optName && optVal) fields.push({ name: optName, value: optVal });
|
|
145
173
|
}
|
|
174
|
+
// Bind the SELECTED purchase option into the signed payload so a shopper can't
|
|
175
|
+
// switch one-time↔subscription (or between plans) after the form is signed —
|
|
176
|
+
// e.g. keep a subscription's discounted `price` while dropping the recurring
|
|
177
|
+
// commitment. The synthesized default one-time option carries no real choice,
|
|
178
|
+
// so we omit it to avoid a redundant "Purchase option: One-time purchase" line
|
|
179
|
+
// on every non-subscription cart. Any authored option (a real one-time plan or
|
|
180
|
+
// a subscription) is emitted and signed → modifying it fails validation.
|
|
181
|
+
if (purchaseOption && !isDefaultOneTimeOption(purchaseOption)) {
|
|
182
|
+
fields.push({ name: "Purchase option", value: purchaseOption.label });
|
|
183
|
+
}
|
|
146
184
|
if (purchaseOption?.kind === "subscription") {
|
|
147
185
|
fields.push(...subscriptionCartFields(purchaseOption));
|
|
148
186
|
}
|
|
149
187
|
return { code, fields };
|
|
150
188
|
}
|
|
151
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Validate a caller-supplied EFFECTIVE price before it becomes the signed source
|
|
192
|
+
* of truth (F11b). The member-adjusted price (membership.ts
|
|
193
|
+
* `priceForPurchaseOption`) is computed server-side and threaded in so the SIGNED
|
|
194
|
+
* price reflects the shopper's resolved eligibility. It must be a finite,
|
|
195
|
+
* non-negative amount no greater than the list price — an effective price is
|
|
196
|
+
* always a discount off list, never an increase, so anything above list signals a
|
|
197
|
+
* pricing bug and is refused rather than signed.
|
|
198
|
+
*/
|
|
199
|
+
function assertEffectivePrice(signedPrice: number, listPrice: number): number {
|
|
200
|
+
if (!Number.isFinite(signedPrice) || signedPrice < 0) {
|
|
201
|
+
throw new Error(
|
|
202
|
+
`signed price must be a finite non-negative number (got ${signedPrice})`,
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
if (signedPrice > listPrice) {
|
|
206
|
+
throw new Error(
|
|
207
|
+
`signed price ${signedPrice} exceeds the list price ${listPrice} — an effective price must be a discount off list`,
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
return Number(signedPrice.toFixed(2));
|
|
211
|
+
}
|
|
212
|
+
|
|
152
213
|
export function purchaseOptionPrice(
|
|
153
214
|
basePrice: number,
|
|
154
215
|
purchaseOption?: ProductPurchaseOption,
|
|
@@ -184,6 +245,19 @@ export function isSubscriptionFrequency(value: string): boolean {
|
|
|
184
245
|
return Number.isFinite(amount) && amount > 0;
|
|
185
246
|
}
|
|
186
247
|
|
|
248
|
+
/**
|
|
249
|
+
* True for the synthesized default one-time option (see
|
|
250
|
+
* `DEFAULT_ONE_TIME_PURCHASE_OPTION`) — the "no real choice" case where binding a
|
|
251
|
+
* "Purchase option" field would only add cart-line noise. An authored one-time
|
|
252
|
+
* option (distinct id) is a genuine choice and is NOT treated as the default.
|
|
253
|
+
*/
|
|
254
|
+
function isDefaultOneTimeOption(option: ProductPurchaseOption): boolean {
|
|
255
|
+
return (
|
|
256
|
+
option.kind === "one_time" &&
|
|
257
|
+
option.id === DEFAULT_ONE_TIME_PURCHASE_OPTION.id
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
187
261
|
function subscriptionCartFields(
|
|
188
262
|
option: SubscriptionPurchaseOption,
|
|
189
263
|
): CartField[] {
|
|
@@ -193,8 +267,10 @@ function subscriptionCartFields(
|
|
|
193
267
|
);
|
|
194
268
|
}
|
|
195
269
|
|
|
270
|
+
// NB: the shopper-facing "Purchase option" line is emitted (and signed) by the
|
|
271
|
+
// general purchase-option branch in cartFieldsForVariant, not here — this adds
|
|
272
|
+
// only the checkout engine's reserved subscription params.
|
|
196
273
|
const fields: CartField[] = [
|
|
197
|
-
{ name: "Purchase option", value: option.label },
|
|
198
274
|
{ name: "sub_frequency", value: option.frequency },
|
|
199
275
|
];
|
|
200
276
|
if (option.startDate) {
|
|
@@ -249,10 +325,26 @@ export async function buildVariantCartPayload(
|
|
|
249
325
|
variant: CatalogVariant,
|
|
250
326
|
opts: BuildVariantCartPayloadOptions,
|
|
251
327
|
): Promise<VariantCartPayload> {
|
|
328
|
+
// Hide-at-launch guard (F11b): refuse to SIGN a subscription option while
|
|
329
|
+
// Autopilot is disabled. `visiblePurchaseOptions` removes subscription options
|
|
330
|
+
// for such tenants, so one reaching the signer means the visibility filter was
|
|
331
|
+
// bypassed (e.g. a crafted request) — signing it would mint a valid subscription
|
|
332
|
+
// payload that a crafted form post could then replay into the cart. Fail closed.
|
|
333
|
+
if (
|
|
334
|
+
opts.autopilotEnabled === false &&
|
|
335
|
+
opts.purchaseOption?.kind === "subscription"
|
|
336
|
+
) {
|
|
337
|
+
throw new Error(
|
|
338
|
+
"buildVariantCartPayload: refusing to sign a subscription purchase option " +
|
|
339
|
+
"while Autopilot is disabled (hide-at-launch) — visiblePurchaseOptions must " +
|
|
340
|
+
"be applied before signing",
|
|
341
|
+
);
|
|
342
|
+
}
|
|
252
343
|
const { code, fields } = cartFieldsForVariant(
|
|
253
344
|
product,
|
|
254
345
|
variant,
|
|
255
346
|
opts.purchaseOption,
|
|
347
|
+
{ signedPrice: opts.signedPrice },
|
|
256
348
|
);
|
|
257
349
|
const signed = opts.mode === "signed";
|
|
258
350
|
if (signed && !opts.secret) {
|