@tokenoftrust/storefront-runner 1.3.4-rc.4 → 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/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 +433 -69
- 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,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* FoxyOrderClient — the boundary to tot-foxycart's order-read surface
|
|
3
|
+
* (PrivateApps epic, D6 Chunk A/B). `ordersStore.ts` and the read
|
|
4
|
+
* routes / event receiver (later chunks) never call `fetch` directly; they go
|
|
5
|
+
* through this seam so a fake can stand in for tot-foxycart in tests.
|
|
6
|
+
*
|
|
7
|
+
* RECONCILED (Chunk B) against the real service —
|
|
8
|
+
* `tot-foxycart/routes/foxyOrderApi.js` + `modules/integrations/foxyOrderMapper.node.js`:
|
|
9
|
+
* a `GET {baseUrl}/commerce/orders?appDomain=&limit=&offset=&since=&until=&status=`
|
|
10
|
+
* list endpoint returning `{ orders, limit, offset, total, hasMore }` (limit/offset,
|
|
11
|
+
* NOT page/pageSize — chunk A guessed page/pageSize), and a
|
|
12
|
+
* `GET {baseUrl}/commerce/orders/{externalOrderId}?appDomain=` detail endpoint
|
|
13
|
+
* returning `{ order }` (an envelope, not the bare order — chunk A guessed bare),
|
|
14
|
+
* both Bearer-authenticated with the tenant's Foxy partner API key. `FoxyOrder`
|
|
15
|
+
* below now mirrors `buildOrderPayload()`'s actual normalized DTO — NOT the
|
|
16
|
+
* contract's minimized `Order` shape (`contract/openapi.yaml`); mapping one to
|
|
17
|
+
* the other is `orderMapper.ts`'s job.
|
|
18
|
+
*
|
|
19
|
+
* Modeled on `../../messaging/messagesClient.ts`'s interface + Http impl +
|
|
20
|
+
* factory shape and `../../webhooks/deliveryEngine.ts`'s bounded-timeout
|
|
21
|
+
* `fetch` (AbortController, injectable `fetchImpl`) — but adds a settable
|
|
22
|
+
* test seam (`setFoxyOrderClientForTesting`) since this client, unlike
|
|
23
|
+
* `messagesClient.ts`, is a shared singleton multiple later chunks (read
|
|
24
|
+
* routes, event receiver) will each independently resolve.
|
|
25
|
+
*/
|
|
26
|
+
import { readEnv } from "@/lib/env";
|
|
27
|
+
|
|
28
|
+
/** One order line item, as tot-foxycart's `buildOrderPayload()` returns it. */
|
|
29
|
+
export interface FoxyOrderLineItem {
|
|
30
|
+
sku: string;
|
|
31
|
+
name?: string;
|
|
32
|
+
quantity: number;
|
|
33
|
+
unitPrice?: number;
|
|
34
|
+
totalPrice?: number;
|
|
35
|
+
/** Collapsed `fx:item_options` ({ name: value }); `flavor` below mirrors the common one. */
|
|
36
|
+
options?: Record<string, string>;
|
|
37
|
+
flavor?: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One order, as tot-foxycart's `buildOrderPayload()` actually returns it
|
|
42
|
+
* (`foxyOrderMapper.node.js`). Deliberately does NOT model every upstream
|
|
43
|
+
* field — `shippingAddress`/`billingAddress`/`paymentRef`/`paymentMetadata`/
|
|
44
|
+
* `exciseTax` are compliance-floor/PII-adjacent internals the contract's
|
|
45
|
+
* minimized `Order` never surfaces, so they're left off here rather than
|
|
46
|
+
* risk a caller reaching for them.
|
|
47
|
+
*
|
|
48
|
+
* There is no raw customer id field — only `customerEmail`/`customerName`.
|
|
49
|
+
* `orderMapper.ts` hashes `customerEmail` (never persisted or forwarded raw)
|
|
50
|
+
* as the input to `customerHash.ts`.
|
|
51
|
+
*
|
|
52
|
+
* `status` is free text passed straight through from Foxy (only `"completed"`
|
|
53
|
+
* is observed in tot-foxycart's own fixtures; its own fallback is
|
|
54
|
+
* `"confirmed"`) — NOT the contract's closed `created|fulfilled|cancelled`
|
|
55
|
+
* enum. `orderMapper.ts` normalizes it.
|
|
56
|
+
*/
|
|
57
|
+
export interface FoxyOrder {
|
|
58
|
+
/** tot-foxycart's transaction id — this becomes `orders.externalOrderId`. */
|
|
59
|
+
externalId: string;
|
|
60
|
+
/** Human-facing order number (Foxy's `display_id`). */
|
|
61
|
+
externalNumber?: string;
|
|
62
|
+
/** ISO 8601-*like* — Foxy's offset omits the colon (`-0500` not `-05:00`). */
|
|
63
|
+
orderedAt: string;
|
|
64
|
+
currency: string;
|
|
65
|
+
status: string;
|
|
66
|
+
customerEmail?: string;
|
|
67
|
+
customerName?: string;
|
|
68
|
+
subtotal?: number;
|
|
69
|
+
shipping?: number;
|
|
70
|
+
tax?: number;
|
|
71
|
+
total: number;
|
|
72
|
+
lineItems: FoxyOrderLineItem[];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface FoxyOrderPage {
|
|
76
|
+
orders: FoxyOrder[];
|
|
77
|
+
limit: number;
|
|
78
|
+
offset: number;
|
|
79
|
+
total: number;
|
|
80
|
+
hasMore: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export interface FoxyOrderListOptions {
|
|
84
|
+
limit?: number;
|
|
85
|
+
offset?: number;
|
|
86
|
+
since?: string;
|
|
87
|
+
until?: string;
|
|
88
|
+
status?: string;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface FoxyOrderClient {
|
|
92
|
+
/** List orders for one tenant (identified by its Foxy-facing `appDomain`). */
|
|
93
|
+
listOrders(tenantAppDomain: string, options?: FoxyOrderListOptions): Promise<FoxyOrderPage>;
|
|
94
|
+
/** Fetch one order by tot-foxycart's id, or `null` if it doesn't exist. */
|
|
95
|
+
getOrder(tenantAppDomain: string, externalOrderId: string): Promise<FoxyOrder | null>;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class FoxyOrderClientError extends Error {}
|
|
99
|
+
|
|
100
|
+
/** Bounded per-request timeout — mirrors `deliveryEngine.ts`'s `DEFAULT_TIMEOUT_MS`. */
|
|
101
|
+
const DEFAULT_TIMEOUT_MS = 10_000;
|
|
102
|
+
|
|
103
|
+
const FOXY_ORDER_API_URL_ENV = "FOXY_ORDER_API_URL";
|
|
104
|
+
const FOXY_ORDER_API_KEY_ENV = "FOXY_ORDER_API_KEY";
|
|
105
|
+
|
|
106
|
+
export interface HttpFoxyOrderClientConfig {
|
|
107
|
+
/** tot-foxycart base URL (e.g. `https://commerce.tokenoftrust.com`). No trailing slash required. */
|
|
108
|
+
baseUrl: string;
|
|
109
|
+
/** Per-store Foxy partner API key, sent as a Bearer token. */
|
|
110
|
+
apiKey: string;
|
|
111
|
+
/** Defaults to the global `fetch`. Override in tests with a fake. */
|
|
112
|
+
fetchImpl?: typeof fetch;
|
|
113
|
+
timeoutMs?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export class HttpFoxyOrderClient implements FoxyOrderClient {
|
|
117
|
+
private readonly baseUrl: string;
|
|
118
|
+
private readonly apiKey: string;
|
|
119
|
+
private readonly fetchImpl: typeof fetch;
|
|
120
|
+
private readonly timeoutMs: number;
|
|
121
|
+
|
|
122
|
+
constructor(config: HttpFoxyOrderClientConfig) {
|
|
123
|
+
if (!config.baseUrl) throw new FoxyOrderClientError("foxyOrderClient: FOXY_ORDER_API_URL is not configured");
|
|
124
|
+
if (!config.apiKey) throw new FoxyOrderClientError("foxyOrderClient: FOXY_ORDER_API_KEY is not configured");
|
|
125
|
+
this.baseUrl = config.baseUrl.replace(/\/+$/, "");
|
|
126
|
+
this.apiKey = config.apiKey;
|
|
127
|
+
this.fetchImpl = config.fetchImpl ?? fetch;
|
|
128
|
+
this.timeoutMs = config.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
async listOrders(tenantAppDomain: string, options: FoxyOrderListOptions = {}): Promise<FoxyOrderPage> {
|
|
132
|
+
const params = new URLSearchParams({ appDomain: tenantAppDomain });
|
|
133
|
+
if (options.limit != null) params.set("limit", String(options.limit));
|
|
134
|
+
if (options.offset != null) params.set("offset", String(options.offset));
|
|
135
|
+
if (options.since != null) params.set("since", options.since);
|
|
136
|
+
if (options.until != null) params.set("until", options.until);
|
|
137
|
+
if (options.status != null) params.set("status", options.status);
|
|
138
|
+
return this.request<FoxyOrderPage>(`/commerce/orders?${params}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
async getOrder(tenantAppDomain: string, externalOrderId: string): Promise<FoxyOrder | null> {
|
|
142
|
+
const params = new URLSearchParams({ appDomain: tenantAppDomain });
|
|
143
|
+
const result = await this.request<{ order: FoxyOrder } | null>(
|
|
144
|
+
`/commerce/orders/${encodeURIComponent(externalOrderId)}?${params}`,
|
|
145
|
+
{ allowNotFound: true },
|
|
146
|
+
);
|
|
147
|
+
return result ? result.order : null;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
private async request<T>(path: string, opts: { allowNotFound?: boolean } = {}): Promise<T> {
|
|
151
|
+
const controller = new AbortController();
|
|
152
|
+
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
|
153
|
+
let res: Response;
|
|
154
|
+
try {
|
|
155
|
+
res = await this.fetchImpl(`${this.baseUrl}${path}`, {
|
|
156
|
+
method: "GET",
|
|
157
|
+
headers: { authorization: `Bearer ${this.apiKey}`, accept: "application/json" },
|
|
158
|
+
signal: controller.signal,
|
|
159
|
+
});
|
|
160
|
+
} catch (cause) {
|
|
161
|
+
const isTimeout = cause instanceof Error && cause.name === "AbortError";
|
|
162
|
+
throw new FoxyOrderClientError(
|
|
163
|
+
`foxyOrderClient: request to ${path} failed (${isTimeout ? "timeout" : "network error"})`,
|
|
164
|
+
);
|
|
165
|
+
} finally {
|
|
166
|
+
clearTimeout(timer);
|
|
167
|
+
}
|
|
168
|
+
if (opts.allowNotFound && res.status === 404) return null as T;
|
|
169
|
+
if (!res.ok) {
|
|
170
|
+
throw new FoxyOrderClientError(`foxyOrderClient: ${path} returned ${res.status}`);
|
|
171
|
+
}
|
|
172
|
+
return (await res.json()) as T;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// --- Test seam ---------------------------------------------------------------
|
|
177
|
+
// Later chunks (read routes, event receiver) each resolve their own client
|
|
178
|
+
// instance via `getFoxyOrderClient()`; a module-level override lets a single
|
|
179
|
+
// `setFoxyOrderClientForTesting` call stand in for tot-foxycart across all of
|
|
180
|
+
// them in one test, without threading a fake through every call site.
|
|
181
|
+
let testOverride: FoxyOrderClient | null = null;
|
|
182
|
+
|
|
183
|
+
export function setFoxyOrderClientForTesting(client: FoxyOrderClient): void {
|
|
184
|
+
testOverride = client;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function resetFoxyOrderClientForTesting(): void {
|
|
188
|
+
testOverride = null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Resolve the active `FoxyOrderClient`: the test override if one is set,
|
|
193
|
+
* otherwise a real `HttpFoxyOrderClient` built from env (fails closed —
|
|
194
|
+
* throws {@link FoxyOrderClientError} — when unconfigured).
|
|
195
|
+
*/
|
|
196
|
+
export async function getFoxyOrderClient(
|
|
197
|
+
overrides: Partial<HttpFoxyOrderClientConfig> = {},
|
|
198
|
+
): Promise<FoxyOrderClient> {
|
|
199
|
+
if (testOverride) return testOverride;
|
|
200
|
+
const baseUrl = overrides.baseUrl ?? (await readEnv(FOXY_ORDER_API_URL_ENV)) ?? "";
|
|
201
|
+
const apiKey = overrides.apiKey ?? (await readEnv(FOXY_ORDER_API_KEY_ENV)) ?? "";
|
|
202
|
+
return new HttpFoxyOrderClient({ ...overrides, baseUrl, apiKey });
|
|
203
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idempotency-Key ledger — PrivateApps epic, D6 (Chunk C). Backs the
|
|
3
|
+
* `idempotency_keys` table `schema.ts` declares: the FIRST real (not
|
|
4
|
+
* presence-only) idempotency ledger in this codebase, and the one
|
|
5
|
+
* `POST /attribution` (`docs/private-apps/contract/openapi.yaml`'s
|
|
6
|
+
* `writeAttribution`) requires — a reused key with a body hash that doesn't
|
|
7
|
+
* match the one first stored is a 409, not a silent overwrite or a second
|
|
8
|
+
* row. Contrast D4's replay endpoint (`webhooks/deliveries/{id}/replay.ts`),
|
|
9
|
+
* which only checks the header's presence because it has no request body to
|
|
10
|
+
* hash and no table backing it yet.
|
|
11
|
+
*
|
|
12
|
+
* Pure over the `Queryable` seam, same shape every other D6 store uses.
|
|
13
|
+
* Route-agnostic by design: `attribution.ts` is the first caller, but any
|
|
14
|
+
* future idempotent write route can reuse this unchanged.
|
|
15
|
+
*/
|
|
16
|
+
import { sha256Hex } from "@tot/public-runtime";
|
|
17
|
+
import type { Queryable } from "../../d1/catalog.js";
|
|
18
|
+
|
|
19
|
+
export type IdempotencyCheck =
|
|
20
|
+
| { status: "proceed" }
|
|
21
|
+
| { status: "replay"; responseBody: unknown }
|
|
22
|
+
| { status: "conflict" };
|
|
23
|
+
|
|
24
|
+
interface IdempotencyKeyRow {
|
|
25
|
+
install_id: string;
|
|
26
|
+
idempotency_key: string;
|
|
27
|
+
body_hash: string;
|
|
28
|
+
response_body: string;
|
|
29
|
+
created_at: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Recursively sort object keys before `JSON.stringify` so two requests with
|
|
34
|
+
* the same logical content — but different key order or whitespace — hash
|
|
35
|
+
* identically. Arrays keep their order (order is meaningful there).
|
|
36
|
+
*/
|
|
37
|
+
function canonicalize(value: unknown): unknown {
|
|
38
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
39
|
+
if (value !== null && typeof value === "object") {
|
|
40
|
+
const sorted: Record<string, unknown> = {};
|
|
41
|
+
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
|
|
42
|
+
sorted[key] = canonicalize((value as Record<string, unknown>)[key]);
|
|
43
|
+
}
|
|
44
|
+
return sorted;
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Hash a request body into the `bodyHash` the ledger keys idempotency on. */
|
|
50
|
+
export function computeBodyHash(body: unknown): Promise<string> {
|
|
51
|
+
return sha256Hex(JSON.stringify(canonicalize(body)));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Look up a prior `(installId, idempotencyKey)` write. A matching
|
|
56
|
+
* `bodyHash` is a stable replay — the caller should return the ORIGINAL
|
|
57
|
+
* response body, not re-run the write, so a retried request gets back
|
|
58
|
+
* exactly what the first one produced. A mismatched `bodyHash` is a
|
|
59
|
+
* conflict: the same key was reused for a genuinely different request. No
|
|
60
|
+
* row at all is a green light to proceed.
|
|
61
|
+
*/
|
|
62
|
+
export async function checkIdempotency(
|
|
63
|
+
db: Queryable,
|
|
64
|
+
installId: string,
|
|
65
|
+
idempotencyKey: string,
|
|
66
|
+
bodyHash: string,
|
|
67
|
+
): Promise<IdempotencyCheck> {
|
|
68
|
+
const row = await db.first<IdempotencyKeyRow>(
|
|
69
|
+
`SELECT * FROM idempotency_keys WHERE install_id = ? AND idempotency_key = ?`,
|
|
70
|
+
installId,
|
|
71
|
+
idempotencyKey,
|
|
72
|
+
);
|
|
73
|
+
if (!row) return { status: "proceed" };
|
|
74
|
+
if (row.body_hash !== bodyHash) return { status: "conflict" };
|
|
75
|
+
return { status: "replay", responseBody: JSON.parse(row.response_body) };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Record a fresh `(installId, idempotencyKey)` write alongside the response
|
|
80
|
+
* body a replay should return. Callers run this AFTER the write it guards
|
|
81
|
+
* succeeds, never before — a failed write must leave no idempotency row
|
|
82
|
+
* behind to (wrongly) short-circuit a legitimate retry.
|
|
83
|
+
*/
|
|
84
|
+
export async function recordIdempotency(
|
|
85
|
+
db: Queryable,
|
|
86
|
+
installId: string,
|
|
87
|
+
idempotencyKey: string,
|
|
88
|
+
bodyHash: string,
|
|
89
|
+
responseBody: unknown,
|
|
90
|
+
): Promise<void> {
|
|
91
|
+
await db.run(
|
|
92
|
+
`INSERT INTO idempotency_keys (install_id, idempotency_key, body_hash, response_body, created_at)
|
|
93
|
+
VALUES (?,?,?,?,?)`,
|
|
94
|
+
installId,
|
|
95
|
+
idempotencyKey,
|
|
96
|
+
bodyHash,
|
|
97
|
+
JSON.stringify(responseBody),
|
|
98
|
+
new Date().toISOString(),
|
|
99
|
+
);
|
|
100
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Inbound order-forward receiver — PrivateApps epic, D6 (Chunk D). The
|
|
3
|
+
* transport-free core `pages/api/apps/internal/order-forward.ts` calls once
|
|
4
|
+
* it has verified the HMAC signature: given one reconciled order from
|
|
5
|
+
* tot-foxycart, upsert it into `ordersStore.ts` and decide whether that write
|
|
6
|
+
* is worth telling the world about.
|
|
7
|
+
*
|
|
8
|
+
* `docs/private-apps/order-forward-contract.md` is the normative spec this
|
|
9
|
+
* module implements — read that first for the wire shape; this file is the
|
|
10
|
+
* decision logic only.
|
|
11
|
+
*
|
|
12
|
+
* Emit decision (idempotent — a re-forward of the same status is a no-op,
|
|
13
|
+
* never a duplicate event; see `ordersStore.ts`'s `upsertOrder` header for
|
|
14
|
+
* why `previousStatus` is the signal, not a second read):
|
|
15
|
+
* - brand-new row (`previousStatus === null`) -> `order.created`, always;
|
|
16
|
+
* if it arrives already `fulfilled` (e.g. instant digital fulfillment),
|
|
17
|
+
* ALSO `order.fulfilled` — both transitions genuinely happened, just
|
|
18
|
+
* collapsed into one delivery.
|
|
19
|
+
* - existing row, `created` -> `fulfilled` -> `order.fulfilled`.
|
|
20
|
+
* - anything else (same status re-forwarded, or a transition with no
|
|
21
|
+
* corresponding topic — e.g. -> `cancelled`, which V1 has no topic for)
|
|
22
|
+
* -> no emit; the row still updates, silently.
|
|
23
|
+
*
|
|
24
|
+
* Customer identity: `rawCustomerId` is optional (tot-foxycart's own
|
|
25
|
+
* order-forward payload doesn't always carry one — mirrors `FoxyOrder`'s
|
|
26
|
+
* own optional `customerEmail`). This receiver never derives, stores, or
|
|
27
|
+
* emits a hash from it: `orders` rows here have no `installId` (this is a
|
|
28
|
+
* tenant-wide write, not an install-owned one), so `customerHash.ts`'s
|
|
29
|
+
* per-(tenant,install) `customerHash` doesn't fit — and per decision
|
|
30
|
+
* `orders-event-customer-hash-omitted`, a fan-out event carries NO customer
|
|
31
|
+
* hash at all, not even a coarser per-tenant one, so there's nothing to
|
|
32
|
+
* compute one FOR either. `rawCustomerId` is accepted and simply unused;
|
|
33
|
+
* see `docs/private-apps/order-forward-contract.md` for the full rationale.
|
|
34
|
+
*/
|
|
35
|
+
import { OrdersStore, type OrderLineItem, type OrderStatus, type Order } from "./ordersStore.js";
|
|
36
|
+
import { emitWebhookEvent } from "../../webhooks/emit.js";
|
|
37
|
+
import type { WebhookTopic } from "../../webhooks/deliveryStore.js";
|
|
38
|
+
import type { Queryable } from "../../d1/catalog.js";
|
|
39
|
+
|
|
40
|
+
/** One reconciled order, as tot-foxycart forwards it. See the contract doc for the wire (JSON) shape. */
|
|
41
|
+
export interface OrderForwardInput {
|
|
42
|
+
tenantId: string;
|
|
43
|
+
externalOrderId: string;
|
|
44
|
+
env: string;
|
|
45
|
+
status: OrderStatus;
|
|
46
|
+
currency: string;
|
|
47
|
+
total: number;
|
|
48
|
+
subtotal?: number;
|
|
49
|
+
lineItems: OrderLineItem[];
|
|
50
|
+
/** Raw, tenant-scoped customer identifier. Accepted but unused — see the module doc's "Customer identity" note; never stored or emitted, raw or hashed. */
|
|
51
|
+
rawCustomerId?: string;
|
|
52
|
+
/** ISO timestamp the order was actually created at tot-foxycart. Defaults to receipt time when omitted (e.g. a backfill missing it) — only matters for a brand-new row; ignored on an update. */
|
|
53
|
+
createdAt?: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface OrderForwardResult {
|
|
57
|
+
order: Order;
|
|
58
|
+
previousStatus: OrderStatus | null;
|
|
59
|
+
/** Topics actually emitted, in emit order — `[]` for a no-op re-forward. */
|
|
60
|
+
emitted: WebhookTopic[];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** The `OrderData` wire shape (`contract/asyncapi.yaml`) — never the internal snake_case row. */
|
|
64
|
+
function toOrderData(order: Order): Record<string, unknown> {
|
|
65
|
+
const data: Record<string, unknown> = {
|
|
66
|
+
id: order.externalOrderId,
|
|
67
|
+
status: order.status,
|
|
68
|
+
currency: order.currency,
|
|
69
|
+
lineItems: order.lineItems,
|
|
70
|
+
total: order.total,
|
|
71
|
+
};
|
|
72
|
+
if (order.subtotal != null) data.subtotal = order.subtotal;
|
|
73
|
+
// No customerHash field, ever — decision orders-event-customer-hash-omitted:
|
|
74
|
+
// a tenant-wide fan-out has no single install to salt a hash against, and a
|
|
75
|
+
// coarser per-tenant hash is deliberately not used as a substitute.
|
|
76
|
+
return data;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Which topics this upsert's status transition warrants — `[]` for none. */
|
|
80
|
+
function topicsFor(previousStatus: OrderStatus | null, status: OrderStatus): WebhookTopic[] {
|
|
81
|
+
if (previousStatus === null) {
|
|
82
|
+
return status === "fulfilled" ? ["order.created", "order.fulfilled"] : ["order.created"];
|
|
83
|
+
}
|
|
84
|
+
if (previousStatus === "created" && status === "fulfilled") return ["order.fulfilled"];
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Upsert one forwarded order and emit whatever topics its status transition
|
|
90
|
+
* warrants. The caller (the route) is responsible for having already
|
|
91
|
+
* verified the request's HMAC signature — this function never re-checks
|
|
92
|
+
* authenticity and must never be reached from an unverified request.
|
|
93
|
+
*/
|
|
94
|
+
export async function receiveForwardedOrder(db: Queryable, input: OrderForwardInput): Promise<OrderForwardResult> {
|
|
95
|
+
const store = new OrdersStore(db);
|
|
96
|
+
// input.rawCustomerId is intentionally not read here — see the module doc's
|
|
97
|
+
// "Customer identity" note for why no hash is derived from it.
|
|
98
|
+
|
|
99
|
+
const { previousStatus, order } = await store.upsertOrder({
|
|
100
|
+
tenantId: input.tenantId,
|
|
101
|
+
externalOrderId: input.externalOrderId,
|
|
102
|
+
env: input.env,
|
|
103
|
+
status: input.status,
|
|
104
|
+
currency: input.currency,
|
|
105
|
+
total: input.total,
|
|
106
|
+
subtotal: input.subtotal ?? null,
|
|
107
|
+
lineItems: input.lineItems,
|
|
108
|
+
createdAt: input.createdAt ?? new Date().toISOString(),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
const emitted = topicsFor(previousStatus, input.status);
|
|
112
|
+
for (const topic of emitted) {
|
|
113
|
+
await emitWebhookEvent(topic, input.tenantId, toOrderData(order), {});
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return { order, previousStatus, emitted };
|
|
117
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure mapper: `FoxyOrderClient`'s `FoxyOrder` (tot-foxycart's normalized
|
|
3
|
+
* transaction DTO) -> the contract's minimized `Order` shape (PrivateApps
|
|
4
|
+
* epic, D6 Chunk B; `docs/private-apps/contract/openapi.yaml`'s `Order`).
|
|
5
|
+
*
|
|
6
|
+
* Drops everything the contract's `additionalProperties: false` Order
|
|
7
|
+
* doesn't declare — no raw checkout payload, no signed-cart material, no tax
|
|
8
|
+
* internals — and never lets a raw customer identifier leave this module:
|
|
9
|
+
* customer identity is always the per-(tenant, install) `customerHash`.
|
|
10
|
+
*/
|
|
11
|
+
import { customerHash } from "./customerHash.js";
|
|
12
|
+
import type { FoxyOrder, FoxyOrderLineItem } from "./foxyOrderClient.js";
|
|
13
|
+
|
|
14
|
+
export type ContractOrderStatus = "created" | "fulfilled" | "cancelled";
|
|
15
|
+
|
|
16
|
+
export interface ContractOrderLineItem {
|
|
17
|
+
sku: string;
|
|
18
|
+
handle?: string;
|
|
19
|
+
quantity: number;
|
|
20
|
+
lineTotal?: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface ContractOrder {
|
|
24
|
+
id: string;
|
|
25
|
+
status: ContractOrderStatus;
|
|
26
|
+
currency: string;
|
|
27
|
+
customerHash: string;
|
|
28
|
+
subtotal?: number;
|
|
29
|
+
total?: number;
|
|
30
|
+
lineItems?: ContractOrderLineItem[];
|
|
31
|
+
createdAt: string;
|
|
32
|
+
fulfilledAt?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface ToContractOrderContext {
|
|
36
|
+
tenantId: string;
|
|
37
|
+
installId: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Foxy's transaction `status` is free text passed straight through by
|
|
42
|
+
* `buildOrderPayload()` (only `"completed"` is observed in tot-foxycart's own
|
|
43
|
+
* fixtures; its own fallback is `"confirmed"`) — there is no fulfillment/
|
|
44
|
+
* shipping signal in the normalized DTO at all, so this mapper can only ever
|
|
45
|
+
* tell a cancelled-ish terminal state apart from everything else. `fulfilled`
|
|
46
|
+
* is never produced here; flagged to the team as a gap (may need a separate
|
|
47
|
+
* signal, e.g. a shipment event, once one exists).
|
|
48
|
+
*/
|
|
49
|
+
const CANCELLED_STATUS_TOKENS = new Set(["cancelled", "canceled", "refunded", "voided", "rejected", "declined"]);
|
|
50
|
+
|
|
51
|
+
function toContractStatus(foxyStatus: string): ContractOrderStatus {
|
|
52
|
+
return CANCELLED_STATUS_TOKENS.has(foxyStatus.toLowerCase()) ? "cancelled" : "created";
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Foxy's `transaction_date` omits the timezone-offset colon (`-0500`, not
|
|
57
|
+
* `-05:00`), which some strict RFC 3339 validators reject. Round-trip through
|
|
58
|
+
* `Date` to normalize to a UTC ISO string; falls back to the raw value on an
|
|
59
|
+
* unparseable input rather than throwing (a read route shouldn't 500 over a
|
|
60
|
+
* date it can't fully trust).
|
|
61
|
+
*/
|
|
62
|
+
function toIsoDateTime(foxyDate: string): string {
|
|
63
|
+
const parsed = new Date(foxyDate);
|
|
64
|
+
return Number.isNaN(parsed.getTime()) ? foxyDate : parsed.toISOString();
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function toContractLineItem(item: FoxyOrderLineItem): ContractOrderLineItem {
|
|
68
|
+
return {
|
|
69
|
+
sku: item.sku,
|
|
70
|
+
quantity: item.quantity,
|
|
71
|
+
lineTotal: item.totalPrice,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Map one `FoxyOrder` to the contract `Order` shape. `customerEmail` is the
|
|
77
|
+
* only customer identifier tot-foxycart's DTO exposes (no raw customer id) —
|
|
78
|
+
* it's hashed here and never appears in the returned shape.
|
|
79
|
+
*/
|
|
80
|
+
export async function toContractOrder(order: FoxyOrder, ctx: ToContractOrderContext): Promise<ContractOrder> {
|
|
81
|
+
return {
|
|
82
|
+
id: order.externalId,
|
|
83
|
+
status: toContractStatus(order.status),
|
|
84
|
+
currency: order.currency,
|
|
85
|
+
customerHash: await customerHash(ctx.tenantId, ctx.installId, order.customerEmail ?? order.externalId),
|
|
86
|
+
subtotal: order.subtotal,
|
|
87
|
+
total: order.total,
|
|
88
|
+
lineItems: order.lineItems.map(toContractLineItem),
|
|
89
|
+
createdAt: toIsoDateTime(order.orderedAt),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `orders` read-model store — PrivateApps epic, D6 (Chunk A). Owns the
|
|
3
|
+
* `orders` rows `schema.ts` declares: the diffing surface a later chunk's
|
|
4
|
+
* event emission reads (`upsertOrder`'s returned `previousStatus` is how it
|
|
5
|
+
* detects a created→fulfilled transition without a second query) and the
|
|
6
|
+
* lookup surface the attribution route (also later) uses for its cross-tenant
|
|
7
|
+
* ownership check.
|
|
8
|
+
*
|
|
9
|
+
* Pure over the `Queryable` seam (see `../../d1/catalog.ts`) — same shape
|
|
10
|
+
* `AppRegistryService` uses, so this runs unchanged against real D1 in the
|
|
11
|
+
* Worker and an in-memory `node:sqlite` in tests.
|
|
12
|
+
*/
|
|
13
|
+
import type { Queryable } from "../../d1/catalog.js";
|
|
14
|
+
|
|
15
|
+
export type OrderStatus = "created" | "fulfilled" | "cancelled";
|
|
16
|
+
|
|
17
|
+
/** One order line item (JSON-as-text in the row; see `Order.lineItems` in the contract). */
|
|
18
|
+
export interface OrderLineItem {
|
|
19
|
+
sku: string;
|
|
20
|
+
handle?: string;
|
|
21
|
+
quantity: number;
|
|
22
|
+
lineTotal?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Domain shape of one `orders` row. */
|
|
26
|
+
export interface Order {
|
|
27
|
+
tenantId: string;
|
|
28
|
+
externalOrderId: string;
|
|
29
|
+
env: string;
|
|
30
|
+
status: OrderStatus;
|
|
31
|
+
currency: string;
|
|
32
|
+
total: number;
|
|
33
|
+
subtotal: number | null;
|
|
34
|
+
customerHash: string | null;
|
|
35
|
+
lineItems: OrderLineItem[];
|
|
36
|
+
createdAt: string;
|
|
37
|
+
updatedAt: string;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** What `upsertOrder` takes — same shape as `Order` but `updatedAt` is optional (defaults to now). */
|
|
41
|
+
export interface UpsertOrderInput {
|
|
42
|
+
tenantId: string;
|
|
43
|
+
externalOrderId: string;
|
|
44
|
+
env: string;
|
|
45
|
+
status: OrderStatus;
|
|
46
|
+
currency: string;
|
|
47
|
+
total: number;
|
|
48
|
+
subtotal?: number | null;
|
|
49
|
+
customerHash?: string | null;
|
|
50
|
+
lineItems: OrderLineItem[];
|
|
51
|
+
createdAt: string;
|
|
52
|
+
updatedAt?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* `upsertOrder`'s return: the row's status BEFORE this write (`null` for a
|
|
57
|
+
* brand-new order) alongside the row as it now stands — the diffing signal a
|
|
58
|
+
* later chunk's event emission needs to tell "just created" apart from
|
|
59
|
+
* "created → fulfilled" apart from "no-op re-delivery of the same status",
|
|
60
|
+
* without a second round trip.
|
|
61
|
+
*/
|
|
62
|
+
export interface UpsertOrderResult {
|
|
63
|
+
previousStatus: OrderStatus | null;
|
|
64
|
+
order: Order;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Raw `orders` row shape (snake_case columns, JSON-as-text). */
|
|
68
|
+
interface OrderRow {
|
|
69
|
+
tenant_id: string;
|
|
70
|
+
external_order_id: string;
|
|
71
|
+
env: string;
|
|
72
|
+
status: string;
|
|
73
|
+
currency: string;
|
|
74
|
+
total: number;
|
|
75
|
+
subtotal: number | null;
|
|
76
|
+
customer_hash: string | null;
|
|
77
|
+
line_items: string;
|
|
78
|
+
created_at: string;
|
|
79
|
+
updated_at: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function toDomain(row: OrderRow): Order {
|
|
83
|
+
return {
|
|
84
|
+
tenantId: row.tenant_id,
|
|
85
|
+
externalOrderId: row.external_order_id,
|
|
86
|
+
env: row.env,
|
|
87
|
+
status: row.status as OrderStatus,
|
|
88
|
+
currency: row.currency,
|
|
89
|
+
total: row.total,
|
|
90
|
+
subtotal: row.subtotal,
|
|
91
|
+
customerHash: row.customer_hash,
|
|
92
|
+
lineItems: JSON.parse(row.line_items) as OrderLineItem[],
|
|
93
|
+
createdAt: row.created_at,
|
|
94
|
+
updatedAt: row.updated_at,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export class OrdersStore {
|
|
99
|
+
constructor(
|
|
100
|
+
private readonly db: Queryable,
|
|
101
|
+
private readonly now: () => string = () => new Date().toISOString(),
|
|
102
|
+
) {}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Insert a brand-new order, or update an existing one keyed by
|
|
106
|
+
* `(tenantId, externalOrderId)` — a re-delivered/replayed event for the
|
|
107
|
+
* same order is idempotent (last-write-wins on the mutable fields), not a
|
|
108
|
+
* duplicate row. Reads the prior row FIRST so `previousStatus` reflects
|
|
109
|
+
* what was there before this write, not after.
|
|
110
|
+
*/
|
|
111
|
+
async upsertOrder(input: UpsertOrderInput): Promise<UpsertOrderResult> {
|
|
112
|
+
const existing = await this.getOrder(input.tenantId, input.externalOrderId);
|
|
113
|
+
const updatedAt = input.updatedAt ?? this.now();
|
|
114
|
+
|
|
115
|
+
if (existing) {
|
|
116
|
+
await this.db.run(
|
|
117
|
+
`UPDATE orders
|
|
118
|
+
SET env = ?, status = ?, currency = ?, total = ?, subtotal = ?,
|
|
119
|
+
customer_hash = ?, line_items = ?, updated_at = ?
|
|
120
|
+
WHERE tenant_id = ? AND external_order_id = ?`,
|
|
121
|
+
input.env,
|
|
122
|
+
input.status,
|
|
123
|
+
input.currency,
|
|
124
|
+
input.total,
|
|
125
|
+
input.subtotal ?? null,
|
|
126
|
+
input.customerHash ?? null,
|
|
127
|
+
JSON.stringify(input.lineItems),
|
|
128
|
+
updatedAt,
|
|
129
|
+
input.tenantId,
|
|
130
|
+
input.externalOrderId,
|
|
131
|
+
);
|
|
132
|
+
} else {
|
|
133
|
+
await this.db.run(
|
|
134
|
+
`INSERT INTO orders
|
|
135
|
+
(tenant_id, external_order_id, env, status, currency, total, subtotal,
|
|
136
|
+
customer_hash, line_items, created_at, updated_at)
|
|
137
|
+
VALUES (?,?,?,?,?,?,?,?,?,?,?)`,
|
|
138
|
+
input.tenantId,
|
|
139
|
+
input.externalOrderId,
|
|
140
|
+
input.env,
|
|
141
|
+
input.status,
|
|
142
|
+
input.currency,
|
|
143
|
+
input.total,
|
|
144
|
+
input.subtotal ?? null,
|
|
145
|
+
input.customerHash ?? null,
|
|
146
|
+
JSON.stringify(input.lineItems),
|
|
147
|
+
input.createdAt,
|
|
148
|
+
updatedAt,
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const order = await this.getOrder(input.tenantId, input.externalOrderId);
|
|
153
|
+
return { previousStatus: existing?.status ?? null, order: order! };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Read one order, scoped to the caller's own tenant. */
|
|
157
|
+
async getOrder(tenantId: string, externalOrderId: string): Promise<Order | null> {
|
|
158
|
+
const row = await this.db.first<OrderRow>(
|
|
159
|
+
`SELECT * FROM orders WHERE tenant_id = ? AND external_order_id = ?`,
|
|
160
|
+
tenantId,
|
|
161
|
+
externalOrderId,
|
|
162
|
+
);
|
|
163
|
+
return row ? toDomain(row) : null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* Look up an order by `externalOrderId` alone, with NO tenant scoping —
|
|
168
|
+
* for the attribution route's cross-tenant ownership check (contract:
|
|
169
|
+
* "MUST belong to the calling tenant/install; a cross-tenant orderId is
|
|
170
|
+
* rejected with 403"), which needs to learn WHICH tenant an order belongs
|
|
171
|
+
* to before it can compare that against the caller. Never expose this
|
|
172
|
+
* result to a caller without checking `order.tenantId` first.
|
|
173
|
+
*/
|
|
174
|
+
async getByExternalId(externalOrderId: string): Promise<Order | null> {
|
|
175
|
+
const row = await this.db.first<OrderRow>(
|
|
176
|
+
`SELECT * FROM orders WHERE external_order_id = ? LIMIT 1`,
|
|
177
|
+
externalOrderId,
|
|
178
|
+
);
|
|
179
|
+
return row ? toDomain(row) : null;
|
|
180
|
+
}
|
|
181
|
+
}
|