@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.
Files changed (224) hide show
  1. package/apps/storefront/astro.config.mjs +15 -0
  2. package/apps/storefront/dev-plugins/dev-publish.mjs +55 -0
  3. package/apps/storefront/dev-plugins/tenant-hot-reload.mjs +86 -7
  4. package/apps/storefront/drizzle.config.apps.ts +13 -0
  5. package/apps/storefront/env.d.ts +10 -0
  6. package/apps/storefront/migrations/README.md +13 -7
  7. package/apps/storefront/migrations-apps/0000_fast_millenium_guard.sql +129 -0
  8. package/apps/storefront/migrations-apps/meta/0000_snapshot.json +843 -0
  9. package/apps/storefront/migrations-apps/meta/_journal.json +13 -0
  10. package/apps/storefront/package.json +13 -1
  11. package/apps/storefront/perf/README.md +64 -0
  12. package/apps/storefront/perf/assert-budgets.ts +159 -0
  13. package/apps/storefront/playwright.config.ts +23 -0
  14. package/apps/storefront/public/js/dashboard-apps.js +173 -0
  15. package/apps/storefront/public/shared/commerce-marketing.css +221 -0
  16. package/apps/storefront/src/components/CollectionCard.astro +1 -0
  17. package/apps/storefront/src/components/ProductCard.astro +1 -0
  18. package/apps/storefront/src/components/admin/AdminPublishTab.astro +1959 -0
  19. package/apps/storefront/src/components/apps/AppWidgetFrame.astro +30 -0
  20. package/apps/storefront/src/components/chrome/NavDropdown.astro +6 -3
  21. package/apps/storefront/src/components/chrome/SiteFooter.astro +10 -0
  22. package/apps/storefront/src/components/chrome/SiteHeader.astro +10 -0
  23. package/apps/storefront/src/components/commerce/RatingStars.astro +3 -2
  24. package/apps/storefront/src/components/content/Callout.astro +75 -0
  25. package/apps/storefront/src/components/content/NeedsReviewCallout.astro +66 -0
  26. package/apps/storefront/src/components/content/ProseSections.astro +121 -0
  27. package/apps/storefront/src/components/content/ProseToc.astro +34 -0
  28. package/apps/storefront/src/components/content/RichText.astro +44 -0
  29. package/apps/storefront/src/components/content/TrustStrip.astro +46 -0
  30. package/apps/storefront/src/components/home/Hero.astro +14 -0
  31. package/apps/storefront/src/components/islands/CheckoutComplianceGate.tsx +295 -0
  32. package/apps/storefront/src/components/islands/ImageGallery.tsx +42 -21
  33. package/apps/storefront/src/components/islands/VariantSelector.tsx +62 -12
  34. package/apps/storefront/src/components/plp/FacetSidebar.astro +2 -2
  35. package/apps/storefront/src/components/subscription/ManageSubscriptionEntry.astro +1 -0
  36. package/apps/storefront/src/config/compliance/rulesets.ts +79 -0
  37. package/apps/storefront/src/config/storeName.ts +34 -0
  38. package/apps/storefront/src/layouts/Layout.astro +97 -23
  39. package/apps/storefront/src/lib/analytics/budgets.json +69 -0
  40. package/apps/storefront/src/lib/analytics/lighthouseReport.ts +109 -0
  41. package/apps/storefront/src/lib/analytics/perfBudgets.ts +452 -0
  42. package/apps/storefront/src/lib/analytics/rumAlert.ts +179 -0
  43. package/apps/storefront/src/lib/analytics/webVitals.ts +269 -0
  44. package/apps/storefront/src/lib/apps/adminService.ts +106 -0
  45. package/apps/storefront/src/lib/apps/adminSession.ts +205 -0
  46. package/apps/storefront/src/lib/apps/apiAuth.ts +91 -0
  47. package/apps/storefront/src/lib/apps/apiRoute.ts +35 -0
  48. package/apps/storefront/src/lib/apps/catalogMapper.ts +39 -0
  49. package/apps/storefront/src/lib/apps/credentials.ts +153 -0
  50. package/apps/storefront/src/lib/apps/gatewayKeys.ts +156 -0
  51. package/apps/storefront/src/lib/apps/healthAggregate.ts +66 -0
  52. package/apps/storefront/src/lib/apps/orders/attributionService.ts +206 -0
  53. package/apps/storefront/src/lib/apps/orders/customerHash.ts +28 -0
  54. package/apps/storefront/src/lib/apps/orders/foxyOrderClient.ts +203 -0
  55. package/apps/storefront/src/lib/apps/orders/idempotency.ts +100 -0
  56. package/apps/storefront/src/lib/apps/orders/orderForwardReceiver.ts +117 -0
  57. package/apps/storefront/src/lib/apps/orders/orderMapper.ts +91 -0
  58. package/apps/storefront/src/lib/apps/orders/ordersStore.ts +181 -0
  59. package/apps/storefront/src/lib/apps/registryService.ts +579 -0
  60. package/apps/storefront/src/lib/apps/scopes.ts +79 -0
  61. package/apps/storefront/src/lib/apps/tokenIssuer.ts +121 -0
  62. package/apps/storefront/src/lib/apps/tokenVerifier.ts +148 -0
  63. package/apps/storefront/src/lib/apps/widgets/eligibility.ts +18 -0
  64. package/apps/storefront/src/lib/apps/widgets/frameProps.ts +52 -0
  65. package/apps/storefront/src/lib/apps/widgets/launchToken.ts +84 -0
  66. package/apps/storefront/src/lib/apps/widgets/placements.ts +57 -0
  67. package/apps/storefront/src/lib/apps/widgets/renderSlot.ts +111 -0
  68. package/apps/storefront/src/lib/auth/adminEntry.ts +119 -0
  69. package/apps/storefront/src/lib/auth/identityToken.ts +21 -2
  70. package/apps/storefront/src/lib/auth/loginGate.ts +102 -18
  71. package/apps/storefront/src/lib/auth/mcpClientAssertion.ts +215 -0
  72. package/apps/storefront/src/lib/auth/route.ts +16 -1
  73. package/apps/storefront/src/lib/auth/session.ts +8 -0
  74. package/apps/storefront/src/lib/auth/stepUpChallenge.ts +107 -0
  75. package/apps/storefront/src/lib/auth/totAccessClient.ts +208 -0
  76. package/apps/storefront/src/lib/blog/provider.ts +39 -0
  77. package/apps/storefront/src/lib/blog/types.ts +26 -0
  78. package/apps/storefront/src/lib/checkoutCommerce.ts +39 -1
  79. package/apps/storefront/src/lib/chrome/model.ts +11 -0
  80. package/apps/storefront/src/lib/compliance/enforcement.ts +95 -0
  81. package/apps/storefront/src/lib/content/callout.ts +78 -0
  82. package/apps/storefront/src/lib/content/index.ts +27 -0
  83. package/apps/storefront/src/lib/content/needsReview.ts +58 -0
  84. package/apps/storefront/src/lib/content/prose.ts +186 -0
  85. package/apps/storefront/src/lib/content/richtext.ts +76 -0
  86. package/apps/storefront/src/lib/content/trustStrip.ts +74 -0
  87. package/apps/storefront/src/lib/content-edit/client.ts +70 -14
  88. package/apps/storefront/src/lib/d1/catalog.ts +12 -0
  89. package/apps/storefront/src/lib/d1/schema-apps.ts +213 -0
  90. package/apps/storefront/src/lib/dev/apiBase.ts +8 -2
  91. package/apps/storefront/src/lib/dev/cliSignInCode.ts +65 -115
  92. package/apps/storefront/src/lib/dev/cockpitStore.ts +65 -0
  93. package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
  94. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  95. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  96. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  97. package/apps/storefront/src/lib/env.ts +12 -0
  98. package/apps/storefront/src/lib/jsonld.ts +12 -17
  99. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  100. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  101. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  102. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  103. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  104. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  105. package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
  106. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  107. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  108. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  109. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  110. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  111. package/apps/storefront/src/lib/tot/query.ts +32 -0
  112. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  113. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  114. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  115. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  116. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  117. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  118. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  119. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  120. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  121. package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
  122. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
  123. package/apps/storefront/src/middleware/index.ts +28 -13
  124. package/apps/storefront/src/pages/404.astro +21 -9
  125. package/apps/storefront/src/pages/[...slug].astro +36 -2
  126. package/apps/storefront/src/pages/admin.astro +29 -3
  127. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
  128. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  129. package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
  130. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  131. package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
  132. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
  133. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
  134. package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
  135. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
  136. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  137. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
  138. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  139. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
  140. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  141. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  142. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  143. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  144. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  145. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  146. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  147. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
  148. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  149. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  150. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  151. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  152. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  153. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  154. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  155. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  156. package/apps/storefront/src/pages/auth/login.astro +197 -41
  157. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  158. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  159. package/apps/storefront/src/pages/blog/index.astro +98 -0
  160. package/apps/storefront/src/pages/capabilities.astro +8 -0
  161. package/apps/storefront/src/pages/cockpit.astro +433 -69
  162. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  163. package/apps/storefront/src/pages/collections/index.astro +10 -2
  164. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  165. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  166. package/apps/storefront/src/pages/index.astro +57 -0
  167. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  168. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  169. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  170. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  171. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  172. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  173. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  174. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  175. package/apps/storefront/src/styles/fonts.css +54 -0
  176. package/apps/storefront/src/styles/global.css +22 -2
  177. package/apps/storefront/src/themes/schema.ts +3 -25
  178. package/apps/storefront/tsconfig.json +1 -1
  179. package/apps/storefront/vitest.config.ts +4 -1
  180. package/package.json +1 -1
  181. package/packages/public-runtime/src/candidate-index.ts +311 -0
  182. package/packages/public-runtime/src/checkout.ts +94 -2
  183. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  184. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  185. package/packages/public-runtime/src/compliance/index.ts +22 -0
  186. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  187. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  188. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  189. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  190. package/packages/public-runtime/src/csp.ts +27 -3
  191. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  192. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  193. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  194. package/packages/public-runtime/src/extension-contract.ts +2 -1
  195. package/packages/public-runtime/src/hash.ts +25 -0
  196. package/packages/public-runtime/src/index.ts +6 -0
  197. package/packages/public-runtime/src/membership.ts +353 -0
  198. package/packages/public-runtime/src/product.ts +24 -2
  199. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  200. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  201. package/packages/public-runtime/src/tenant.ts +236 -0
  202. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  203. package/scripts/dev/publish.mjs +158 -0
  204. package/scripts/dev/transient-files.mjs +2 -1
  205. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  206. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  207. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  208. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  209. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  210. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  211. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  212. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  213. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  214. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  215. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  216. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  217. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  218. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  219. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  220. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  221. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  222. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  223. package/tenants/home/public/pages/storefront.css +23 -0
  224. 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
+ }