@tokenoftrust/storefront-runner 1.3.4-rc.3 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (225) 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/obstacleStore.ts +214 -0
  94. package/apps/storefront/src/lib/dev/previewStatus.ts +112 -0
  95. package/apps/storefront/src/lib/dev/rendezvousBroker.ts +241 -0
  96. package/apps/storefront/src/lib/dev/subjectReissue.ts +67 -0
  97. package/apps/storefront/src/lib/email/magicLinkInviteEmail.ts +10 -10
  98. package/apps/storefront/src/lib/env.ts +12 -0
  99. package/apps/storefront/src/lib/jsonld.ts +12 -17
  100. package/apps/storefront/src/lib/membership/eligibility.ts +37 -0
  101. package/apps/storefront/src/lib/monitoring/manifest.ts +302 -0
  102. package/apps/storefront/src/lib/privacy/emailHint.ts +13 -5
  103. package/apps/storefront/src/lib/publish/apex-readiness.ts +337 -0
  104. package/apps/storefront/src/lib/publish/dispatchHealth.ts +269 -0
  105. package/apps/storefront/src/lib/publish/domainState.ts +351 -0
  106. package/apps/storefront/src/lib/publish/shipWorkspace.ts +362 -0
  107. package/apps/storefront/src/lib/rawChrome.ts +34 -3
  108. package/apps/storefront/src/lib/storyblok/content-model.ts +34 -2
  109. package/apps/storefront/src/lib/storyblok/provider.ts +11 -4
  110. package/apps/storefront/src/lib/subscription/model.ts +114 -0
  111. package/apps/storefront/src/lib/tot/ToTClient.ts +3 -3
  112. package/apps/storefront/src/lib/tot/query.ts +32 -0
  113. package/apps/storefront/src/lib/webhooks/cloudflareQueueDispatcher.ts +82 -0
  114. package/apps/storefront/src/lib/webhooks/deliveryEngine.ts +245 -0
  115. package/apps/storefront/src/lib/webhooks/deliveryMapper.ts +34 -0
  116. package/apps/storefront/src/lib/webhooks/deliveryStore.ts +668 -0
  117. package/apps/storefront/src/lib/webhooks/dispatcher.ts +168 -0
  118. package/apps/storefront/src/lib/webhooks/emit.ts +167 -0
  119. package/apps/storefront/src/lib/webhooks/endpointGuard.ts +135 -0
  120. package/apps/storefront/src/lib/webhooks/events.ts +98 -0
  121. package/apps/storefront/src/lib/webhooks/getDispatcher.ts +49 -0
  122. package/apps/storefront/src/lib/webhooks/signing.ts +29 -0
  123. package/apps/storefront/src/lib/webhooks/webhookSigningKey.ts +146 -0
  124. package/apps/storefront/src/middleware/index.ts +28 -13
  125. package/apps/storefront/src/pages/404.astro +21 -9
  126. package/apps/storefront/src/pages/[...slug].astro +36 -2
  127. package/apps/storefront/src/pages/admin.astro +29 -3
  128. package/apps/storefront/src/pages/api/apps/admin/credentials/rotate.ts +60 -0
  129. package/apps/storefront/src/pages/api/apps/admin/health.ts +44 -0
  130. package/apps/storefront/src/pages/api/apps/admin/install.ts +100 -0
  131. package/apps/storefront/src/pages/api/apps/admin/list.ts +26 -0
  132. package/apps/storefront/src/pages/api/apps/admin/resume.ts +56 -0
  133. package/apps/storefront/src/pages/api/apps/admin/suspend.ts +57 -0
  134. package/apps/storefront/src/pages/api/apps/admin/uninstall.ts +74 -0
  135. package/apps/storefront/src/pages/api/apps/admin/update.ts +103 -0
  136. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries/[deliveryId]/replay.ts +59 -0
  137. package/apps/storefront/src/pages/api/apps/admin/webhooks/deliveries.ts +51 -0
  138. package/apps/storefront/src/pages/api/apps/internal/order-forward.ts +172 -0
  139. package/apps/storefront/src/pages/api/apps/oauth/token.ts +87 -0
  140. package/apps/storefront/src/pages/api/apps/v1/attribution.ts +142 -0
  141. package/apps/storefront/src/pages/api/apps/v1/catalog/products/[handle].ts +39 -0
  142. package/apps/storefront/src/pages/api/apps/v1/catalog/products.ts +49 -0
  143. package/apps/storefront/src/pages/api/apps/v1/health.ts +32 -0
  144. package/apps/storefront/src/pages/api/apps/v1/inventory.ts +59 -0
  145. package/apps/storefront/src/pages/api/apps/v1/orders/[id].ts +48 -0
  146. package/apps/storefront/src/pages/api/apps/v1/orders.ts +73 -0
  147. package/apps/storefront/src/pages/api/apps/v1/reports.ts +21 -0
  148. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries/[deliveryId]/replay.ts +62 -0
  149. package/apps/storefront/src/pages/api/apps/v1/webhooks/deliveries.ts +47 -0
  150. package/apps/storefront/src/pages/api/auth/magic-exchange.ts +48 -1
  151. package/apps/storefront/src/pages/api/auth/step-up-send.ts +57 -0
  152. package/apps/storefront/src/pages/api/auth/step-up-verify.ts +129 -0
  153. package/apps/storefront/src/pages/api/auth/verify.ts +23 -0
  154. package/apps/storefront/src/pages/api/compliance/preflight.ts +206 -0
  155. package/apps/storefront/src/pages/api/rum/vitals.ts +54 -0
  156. package/apps/storefront/src/pages/api/test/dev-session.ts +133 -0
  157. package/apps/storefront/src/pages/auth/login.astro +197 -41
  158. package/apps/storefront/src/pages/auth/magic.astro +75 -43
  159. package/apps/storefront/src/pages/blog/[slug].astro +107 -0
  160. package/apps/storefront/src/pages/blog/index.astro +98 -0
  161. package/apps/storefront/src/pages/capabilities.astro +8 -0
  162. package/apps/storefront/src/pages/cockpit.astro +496 -74
  163. package/apps/storefront/src/pages/collections/[handle].astro +8 -0
  164. package/apps/storefront/src/pages/collections/index.astro +10 -2
  165. package/apps/storefront/src/pages/dashboard/[appDomain]/apps/index.astro +119 -0
  166. package/apps/storefront/src/pages/dashboard/[appDomain]/index.astro +5 -0
  167. package/apps/storefront/src/pages/index.astro +57 -0
  168. package/apps/storefront/src/pages/llms.txt.ts +31 -10
  169. package/apps/storefront/src/pages/products/[handle].astro +100 -9
  170. package/apps/storefront/src/pages/sitemap.xml.ts +21 -4
  171. package/apps/storefront/src/pages/style-guide/[tenant]/[theme].astro +198 -0
  172. package/apps/storefront/src/pages/style-guide/[tenant]/chrome/[theme].astro +7 -0
  173. package/apps/storefront/src/pages/style-guide/[tenant]/guide/[theme].astro +7 -0
  174. package/apps/storefront/src/pages/style-guide/[tenant]/index.astro +7 -0
  175. package/apps/storefront/src/pages/style-guide/index.astro +10 -0
  176. package/apps/storefront/src/styles/fonts.css +54 -0
  177. package/apps/storefront/src/styles/global.css +22 -2
  178. package/apps/storefront/src/themes/schema.ts +3 -25
  179. package/apps/storefront/tsconfig.json +1 -1
  180. package/apps/storefront/vitest.config.ts +4 -1
  181. package/package.json +1 -1
  182. package/packages/public-runtime/src/candidate-index.ts +311 -0
  183. package/packages/public-runtime/src/checkout.ts +94 -2
  184. package/packages/public-runtime/src/compliance/evaluate.ts +265 -0
  185. package/packages/public-runtime/src/compliance/evidence-signals.ts +81 -0
  186. package/packages/public-runtime/src/compliance/index.ts +22 -0
  187. package/packages/public-runtime/src/compliance/pact-report.ts +94 -0
  188. package/packages/public-runtime/src/compliance/profile.ts +198 -0
  189. package/packages/public-runtime/src/compliance/ruleset.ts +117 -0
  190. package/packages/public-runtime/src/compliance/verification.ts +81 -0
  191. package/packages/public-runtime/src/csp.ts +27 -3
  192. package/packages/public-runtime/src/customization-reconcile.ts +35 -0
  193. package/packages/public-runtime/src/customization-runtime.ts +8 -0
  194. package/packages/public-runtime/src/customization-versioning.ts +17 -0
  195. package/packages/public-runtime/src/extension-contract.ts +2 -1
  196. package/packages/public-runtime/src/hash.ts +25 -0
  197. package/packages/public-runtime/src/index.ts +6 -0
  198. package/packages/public-runtime/src/membership.ts +353 -0
  199. package/packages/public-runtime/src/product.ts +24 -2
  200. package/packages/public-runtime/src/review-trust-proof.ts +194 -0
  201. package/packages/public-runtime/src/tenant-assets.ts +40 -5
  202. package/packages/public-runtime/src/tenant.ts +236 -0
  203. package/packages/public-runtime/src/widget-postmessage.ts +205 -0
  204. package/scripts/dev/publish.mjs +158 -0
  205. package/scripts/dev/transient-files.mjs +2 -1
  206. package/tenants/home/public/fonts/inter-latin-400-normal.woff2 +0 -0
  207. package/tenants/home/public/fonts/inter-latin-500-normal.woff2 +0 -0
  208. package/tenants/home/public/fonts/inter-latin-600-normal.woff2 +0 -0
  209. package/tenants/home/public/fonts/inter-latin-ext-400-normal.woff2 +0 -0
  210. package/tenants/home/public/fonts/inter-latin-ext-500-normal.woff2 +0 -0
  211. package/tenants/home/public/fonts/inter-latin-ext-600-normal.woff2 +0 -0
  212. package/tenants/home/public/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  213. package/tenants/home/public/fonts/jetbrains-mono-latin-500-normal.woff2 +0 -0
  214. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-400-normal.woff2 +0 -0
  215. package/tenants/home/public/fonts/jetbrains-mono-latin-ext-500-normal.woff2 +0 -0
  216. package/tenants/home/public/fonts/plus-jakarta-sans-latin-500-normal.woff2 +0 -0
  217. package/tenants/home/public/fonts/plus-jakarta-sans-latin-600-normal.woff2 +0 -0
  218. package/tenants/home/public/fonts/plus-jakarta-sans-latin-700-normal.woff2 +0 -0
  219. package/tenants/home/public/fonts/plus-jakarta-sans-latin-800-normal.woff2 +0 -0
  220. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-500-normal.woff2 +0 -0
  221. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-600-normal.woff2 +0 -0
  222. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-700-normal.woff2 +0 -0
  223. package/tenants/home/public/fonts/plus-jakarta-sans-latin-ext-800-normal.woff2 +0 -0
  224. package/tenants/home/public/pages/storefront.css +23 -0
  225. package/apps/storefront/src/lib/dev/hostedCockpit.ts +0 -169
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Retry/backoff dispatcher — PrivateApps epic, D4 (Chunk C: Dispatcher +
3
+ * Delivery Engine). Owns WHEN a delivery gets attempted: the `WebhookDispatcher`
4
+ * transport seam that Chunk D (emit hooks) and Chunk E (routes — e.g. manual
5
+ * replay) enqueue work through, the pure backoff policy every dispatcher
6
+ * implementation shares, and `InMemoryDispatcher`, the V1 implementation that
7
+ * per `d4-design-resolutions` IS the production path.
8
+ *
9
+ * `cloudflareQueueDispatcher.ts` builds a Cloudflare Queues adapter behind the
10
+ * SAME seam, but it is not wired into the Worker entry yet — per the design
11
+ * resolution, V1 does not provision a real Queue. Investigation into whether
12
+ * that's even possible with the current adapter: `@astrojs/cloudflare` v14
13
+ * generates the Worker entry as `{ fetch: handle }` only (see
14
+ * `node_modules/@astrojs/cloudflare/dist/entrypoints/server.js`) — there is no
15
+ * hook to also export a `queue()` consumer alongside the generated `fetch()`.
16
+ * `dist/index.js` DOES thread `queues.producers` through to the underlying
17
+ * `@cloudflare/vite-plugin` config (for `env.WEBHOOK_DELIVERY_QUEUE.send()` to
18
+ * work as a producer binding), but nothing analogous exists for consumers.
19
+ * TODO(infra decision — wire the Queue consumer entry): once a real Queue is
20
+ * provisioned, either (a) upgrade `@astrojs/cloudflare` in case a later
21
+ * version adds consumer support, or (b) hand-write a small wrapper worker
22
+ * module that re-exports the generated `fetch` and ALSO exports a `queue()`
23
+ * that calls `cloudflareQueueDispatcher.ts`'s `queue()` — replacing
24
+ * `wrangler.toml`'s implicit "main is generated" note with an explicit `main`
25
+ * pointing at that wrapper. Neither is done here; this file and
26
+ * `cloudflareQueueDispatcher.ts` only build the seam + adapter, gated behind
27
+ * `getDispatcher.ts`'s binding-presence check (no `[[queues]]` binding is
28
+ * declared in `wrangler.toml`, so that branch is dead code until infra adds one).
29
+ *
30
+ * Deliberately decoupled from `deliveryEngine.ts`: the dispatcher's job is
31
+ * scheduling ("run this deliveryId now / at this time"), not HTTP delivery.
32
+ * `InMemoryDispatcher` takes the engine's `attemptDelivery` as an injected
33
+ * `AttemptDeliveryFn` rather than importing `deliveryEngine.ts` directly, so
34
+ * tests can drive it with a fake attempt function with no store/registry/
35
+ * signing/fetch involved at all.
36
+ */
37
+
38
+ /** One unit of dispatcher work: attempt (or retry) delivering one delivery. */
39
+ export interface DispatchJob {
40
+ deliveryId: string;
41
+ /** ISO timestamp; the dispatcher must not run the job before this. Omitted = run ASAP. */
42
+ notBefore?: string;
43
+ }
44
+
45
+ /** The transport seam `deliveryEngine.ts`'s callers (Chunk D/E) enqueue work through. */
46
+ export interface WebhookDispatcher {
47
+ enqueue(job: DispatchJob): Promise<void>;
48
+ }
49
+
50
+ /**
51
+ * `attemptDelivery`'s outcome, as classified by `deliveryEngine.ts` — this is
52
+ * the type the dispatcher branches on to decide whether to reschedule.
53
+ * `"retrying"`/`"dead-lettered"` mirror `DeliveryState` names on purpose (see
54
+ * `deliveryStore.ts`); `"delivered"`/`"dead-lettered"` are terminal — only
55
+ * `"retrying"` ever reschedules.
56
+ */
57
+ export type AttemptOutcome =
58
+ | { kind: "delivered" }
59
+ | { kind: "retrying"; nextAttemptAt: string }
60
+ | { kind: "dead-lettered"; reason: "install_unavailable" | "max_attempts" | "endpoint_blocked" };
61
+
62
+ /** One attempt of one delivery. Implemented by `deliveryEngine.ts`'s `attemptDelivery`, partially applied over its deps. */
63
+ export type AttemptDeliveryFn = (deliveryId: string) => Promise<AttemptOutcome>;
64
+
65
+ /** Deliveries stop retrying and are dead-lettered after this many attempts. */
66
+ export const MAX_DELIVERY_ATTEMPTS = 6;
67
+
68
+ const BASE_DELAY_SECONDS = 30;
69
+ const MAX_DELAY_SECONDS = 60 * 60; // 1h cap
70
+
71
+ /**
72
+ * Exponential backoff with full jitter, capped at 1h:
73
+ * `min(BASE * 2^(attempt-1), cap)`, then a uniform pick in `(0, cap]`.
74
+ * `attempt` is 1-indexed — the attempt number that just failed (so attempt 1
75
+ * ⇒ up to 30s, attempt 2 ⇒ up to 60s, … capping out well before attempt 6 /
76
+ * {@link MAX_DELIVERY_ATTEMPTS}).
77
+ */
78
+ export function computeBackoffDelaySeconds(attempt: number): number {
79
+ const exp = BASE_DELAY_SECONDS * 2 ** Math.max(0, attempt - 1);
80
+ const cap = Math.min(exp, MAX_DELAY_SECONDS);
81
+ return Math.floor(Math.random() * cap) + 1; // never 0 — always some delay before retrying
82
+ }
83
+
84
+ interface PendingJob {
85
+ deliveryId: string;
86
+ notBefore: number;
87
+ }
88
+
89
+ export interface InMemoryDispatcherOptions {
90
+ /** Injected clock (epoch ms). Defaults to `Date.now`. */
91
+ now?: () => number;
92
+ /**
93
+ * Default `true` (production behavior): an immediate job runs on a
94
+ * microtask and a delayed retry schedules a real `setTimeout`. Set `false`
95
+ * in tests — jobs queue internally instead of touching real timers; drain
96
+ * them deterministically with {@link InMemoryDispatcher.runDueNow}.
97
+ */
98
+ useRealTimers?: boolean;
99
+ }
100
+
101
+ /**
102
+ * V1's WORKING production dispatcher (per `d4-design-resolutions`) — an
103
+ * in-process queue with no external infra. `enqueue()` never awaits the
104
+ * actual HTTP attempt; it schedules it and returns.
105
+ */
106
+ export class InMemoryDispatcher implements WebhookDispatcher {
107
+ private readonly now: () => number;
108
+ private readonly useRealTimers: boolean;
109
+ private readonly pending: PendingJob[] = [];
110
+ private readonly timers = new Set<ReturnType<typeof setTimeout>>();
111
+
112
+ constructor(
113
+ private readonly attempt: AttemptDeliveryFn,
114
+ options: InMemoryDispatcherOptions = {},
115
+ ) {
116
+ this.now = options.now ?? (() => Date.now());
117
+ this.useRealTimers = options.useRealTimers ?? true;
118
+ }
119
+
120
+ async enqueue(job: DispatchJob): Promise<void> {
121
+ const notBefore = job.notBefore ? Date.parse(job.notBefore) : this.now();
122
+ this.scheduleOrRun(job.deliveryId, notBefore);
123
+ }
124
+
125
+ /**
126
+ * Test-only deterministic drain: repeatedly runs every job whose
127
+ * `notBefore` is `<= now()` — including retries a just-run attempt itself
128
+ * schedules, if those are also due — until nothing is due. Only meaningful
129
+ * when constructed with `useRealTimers: false`; with real timers, jobs never
130
+ * sit in `pending` (they're already scheduled via microtask/setTimeout).
131
+ */
132
+ async runDueNow(): Promise<void> {
133
+ for (;;) {
134
+ const now = this.now();
135
+ const dueIndex = this.pending.findIndex((j) => j.notBefore <= now);
136
+ if (dueIndex === -1) return;
137
+ const [job] = this.pending.splice(dueIndex, 1);
138
+ await this.run(job!.deliveryId);
139
+ }
140
+ }
141
+
142
+ private scheduleOrRun(deliveryId: string, notBefore: number): void {
143
+ if (!this.useRealTimers) {
144
+ this.pending.push({ deliveryId, notBefore });
145
+ return;
146
+ }
147
+ const delayMs = notBefore - this.now();
148
+ if (delayMs <= 0) {
149
+ queueMicrotask(() => {
150
+ void this.run(deliveryId);
151
+ });
152
+ return;
153
+ }
154
+ const timer = setTimeout(() => {
155
+ this.timers.delete(timer);
156
+ void this.run(deliveryId);
157
+ }, delayMs);
158
+ if (typeof timer.unref === "function") timer.unref(); // don't hold the process open for a pending retry
159
+ this.timers.add(timer);
160
+ }
161
+
162
+ private async run(deliveryId: string): Promise<void> {
163
+ const outcome = await this.attempt(deliveryId);
164
+ if (outcome.kind === "retrying") {
165
+ this.scheduleOrRun(deliveryId, Date.parse(outcome.nextAttemptAt));
166
+ }
167
+ }
168
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Webhook event emitter — PrivateApps epic, D4 (Chunk D: Emit hooks). The one
3
+ * place a caller (today: the two D2 admin routes; a future catalog/order/etc.
4
+ * trigger later) turns "something happened" into a persisted event + one
5
+ * delivery per eligible install + an enqueued dispatcher job per delivery.
6
+ * Targeting is resolved HERE so callers never hand-roll it: an install is
7
+ * eligible for a topic when it's `active` (`app.uninstalled` is the one
8
+ * exception — see {@link isEligibleTarget}, and `deliveryEngine.ts`'s
9
+ * matching `isDeadInstall` exemption), has a `webhookEndpoint`, and its
10
+ * `scopes` include `TOPIC_REQUIRED_SCOPE[topic]` when that topic requires one
11
+ * (`events.ts`) — an app only receives events it's scoped for.
12
+ *
13
+ * Two targeting modes, chosen by whether `opts.installId` is given:
14
+ * - Given: scoped to exactly that one install (still subject to
15
+ * {@link isEligibleTarget}). This is REQUIRED for the two lifecycle
16
+ * topics (`app.installed`/`app.uninstalled` — {@link LIFECYCLE_TOPICS}):
17
+ * each is inherently about ONE subject install, never a tenant-wide
18
+ * broadcast — otherwise every other app on the tenant would learn about
19
+ * (and, for `app.uninstalled`, keep getting redelivered) a DIFFERENT
20
+ * app's lifecycle transitions, a cross-app isolation leak. Omitting it
21
+ * for a lifecycle topic is refused (logged, `null`), not silently
22
+ * broadened.
23
+ * - Omitted: tenant-wide fan-out to every eligible, topic-subscribed
24
+ * install — correct for data topics (`order.created`, etc.), where
25
+ * multiple installed apps legitimately subscribe to the same data.
26
+ *
27
+ * Mirrors `adminService.ts`'s `getRegistryService()` composition-root shape —
28
+ * reuses it outright for the registry, and builds its own
29
+ * `WebhookDeliveryStore` over the same `STOREFRONT_APPS_DB` binding (the D1
30
+ * binding is one binding shared by every composition root; there is no
31
+ * separate "events DB").
32
+ *
33
+ * Best-effort, not authoritative: an unconfigured runtime, or an unconfigured
34
+ * dispatcher (`getDispatcher()` returning its 503), is logged and swallowed
35
+ * here rather than thrown — a webhook-plumbing outage must never fail the
36
+ * business mutation (install/uninstall) that triggered the emit. Callers
37
+ * should still wrap their own call in try/catch as a second layer (belt +
38
+ * suspenders), since an unexpected store/registry error is NOT swallowed —
39
+ * see the two admin routes this wires into.
40
+ *
41
+ * Payload minimization is enforced at the call site, not here: `data` is
42
+ * passed through to the CloudEvents envelope verbatim, so never pass secrets
43
+ * or raw PII. Per the contract, `app.installed`/`app.uninstalled` carry no
44
+ * customer data at all (envelope identity only — `{}`); any future topic
45
+ * needing customer identity must use the per-(tenant, app-install) opaque
46
+ * hash, never a raw identifier.
47
+ */
48
+ import { WebhookDeliveryStore, type WebhookTopic } from "./deliveryStore.js";
49
+ import { TOPIC_REQUIRED_SCOPE } from "./events.js";
50
+ import { getDispatcher } from "./getDispatcher.js";
51
+ import { getRegistryService } from "../apps/adminService.js";
52
+ import type { AppInstall } from "../apps/registryService.js";
53
+ import { fromD1 } from "../d1/catalog.js";
54
+ import { readD1 } from "../env.js";
55
+
56
+ /** Contract-scoped payload schema version — `asyncapi.yaml`'s `dataschemaversion` is `const: "1"` for V1. */
57
+ const DATASCHEMA_VERSION = "1";
58
+
59
+ export interface EmitWebhookEventOptions {
60
+ /**
61
+ * Restrict delivery to exactly this one install — the event's subject.
62
+ * REQUIRED for `app.installed`/`app.uninstalled` (see
63
+ * {@link LIFECYCLE_TOPICS}); omit for a tenant-wide data-topic fan-out.
64
+ */
65
+ installId?: string;
66
+ /** Override for a future contract version bump. Defaults to {@link DATASCHEMA_VERSION}. */
67
+ dataschemaVersion?: string;
68
+ }
69
+
70
+ /** `null` when no install on the tenant was eligible — not an error; no event/delivery rows are written in that case. */
71
+ export type EmitWebhookEventResult = { eventId: string; deliveryCount: number } | null;
72
+
73
+ /**
74
+ * Topics that are inherently about ONE subject install, never a tenant-wide
75
+ * broadcast — `emitWebhookEvent` refuses to run one of these without
76
+ * `opts.installId` (see the header + the guard below).
77
+ */
78
+ const LIFECYCLE_TOPICS: ReadonlySet<WebhookTopic> = new Set(["app.installed", "app.uninstalled"]);
79
+
80
+ /**
81
+ * An install is eligible for `topic` when it has a webhook endpoint and
82
+ * carries the topic's required scope, if any, and — with one exception — is
83
+ * `active`. `app.uninstalled` waives the active check: it's the defined
84
+ * farewell event, fired AFTER `registry.uninstall()` has already flipped the
85
+ * install to `uninstalled`, so requiring `active` here would mean it never
86
+ * fires at all. It does NOT waive the endpoint requirement.
87
+ */
88
+ function isEligibleTarget(install: AppInstall, topic: WebhookTopic): boolean {
89
+ if (!install.webhookEndpoint) return false;
90
+ if (install.status !== "active" && topic !== "app.uninstalled") return false;
91
+ const requiredScope = TOPIC_REQUIRED_SCOPE[topic];
92
+ return requiredScope == null || install.scopes.includes(requiredScope);
93
+ }
94
+
95
+ /** Swallow-and-log a soft failure — logging itself must never throw. Same `[diag:...]` shape as `grants/developerInvites.ts`. */
96
+ function logDiag(fields: Record<string, unknown>): void {
97
+ try {
98
+ console.warn("[diag:webhooks:emit]", JSON.stringify(fields));
99
+ } catch {
100
+ /* diagnostics must never break the caller */
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Emit one webhook event for `tenantId`: resolve eligible installs, persist
106
+ * the event + one `pending` delivery per eligible install, then enqueue each
107
+ * delivery on the dispatcher. Returns `null` (not an error) when nothing on
108
+ * the tenant was eligible, when the runtime isn't configured for webhooks at
109
+ * all, or when a lifecycle topic was called without the required
110
+ * `opts.installId` (a caller bug — logged, never silently broadened to a
111
+ * tenant-wide broadcast).
112
+ */
113
+ export async function emitWebhookEvent(
114
+ topic: WebhookTopic,
115
+ tenantId: string,
116
+ data: unknown,
117
+ opts: EmitWebhookEventOptions = {},
118
+ ): Promise<EmitWebhookEventResult> {
119
+ if (LIFECYCLE_TOPICS.has(topic) && !opts.installId) {
120
+ logDiag({ topic, tenantId, reason: "lifecycle_topic_requires_installId" });
121
+ return null;
122
+ }
123
+
124
+ const d1 = await readD1("STOREFRONT_APPS_DB");
125
+ if (!d1) {
126
+ logDiag({ topic, tenantId, reason: "storefront_apps_db_unconfigured" });
127
+ return null;
128
+ }
129
+ const store = new WebhookDeliveryStore(fromD1(d1));
130
+
131
+ const registry = await getRegistryService();
132
+ if (registry instanceof Response) {
133
+ // Shouldn't happen given the STOREFRONT_APPS_DB check above (same
134
+ // binding), but stay fail-closed rather than assume.
135
+ logDiag({ topic, tenantId, reason: "registry_unconfigured" });
136
+ return null;
137
+ }
138
+
139
+ // `listByTenant` is the tenant-ownership check for a given `installId`, for
140
+ // free: an install belonging to a different tenant simply never appears in
141
+ // `candidates`, so a cross-tenant `installId` yields zero eligible targets
142
+ // rather than leaking across tenants.
143
+ const candidates = await registry.listByTenant(tenantId);
144
+ const scoped = opts.installId ? candidates.filter((install) => install.installId === opts.installId) : candidates;
145
+ const eligible = scoped.filter((install) => isEligibleTarget(install, topic));
146
+ if (eligible.length === 0) return null;
147
+
148
+ const { event, deliveries } = await store.createEvent(
149
+ { tenantId, topic, dataschemaVersion: opts.dataschemaVersion ?? DATASCHEMA_VERSION, payload: data },
150
+ eligible.map((install) => ({ installId: install.installId })),
151
+ );
152
+
153
+ const dispatcher = await getDispatcher();
154
+ if (dispatcher instanceof Response) {
155
+ // The event + delivery rows are already persisted `pending` — nothing is
156
+ // lost, just not yet scheduled. A future scan/replay path (Chunk E) can
157
+ // still pick them up once the dispatcher IS configured.
158
+ logDiag({ topic, tenantId, eventId: event.id, deliveryCount: deliveries.length, reason: "dispatcher_unconfigured" });
159
+ return { eventId: event.id, deliveryCount: deliveries.length };
160
+ }
161
+
162
+ for (const delivery of deliveries) {
163
+ await dispatcher.enqueue({ deliveryId: delivery.deliveryId });
164
+ }
165
+
166
+ return { eventId: event.id, deliveryCount: deliveries.length };
167
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * Webhook endpoint guard — PrivateApps epic, D4 SSRF hardening. The manifest
3
+ * schema (`docs/private-apps/contract/tot-app.schema.json`) promises
4
+ * `webhookEndpoint` is https-only; nothing enforced that promise at runtime
5
+ * until this guard existed. It is applied at two points:
6
+ * - Write time (`../apps/registryService.ts`'s `install`/`update`) — refuse
7
+ * to persist a bad endpoint at all (fail closed, no row written).
8
+ * - Delivery time (`deliveryEngine.ts`, defense-in-depth) — re-checked right
9
+ * before the signed POST, in case a row somehow predates this guard or a
10
+ * future write path forgets to call it.
11
+ *
12
+ * `new URL()` does most of the hard work for us: it lowercases hostnames and
13
+ * canonicalizes IPv4 literals written in decimal/octal/hex/shorthand forms
14
+ * (`0x7f000001`, `017700000001`, `127.1`, …) all the way down to dotted-quad,
15
+ * and normalizes IPv6 literals to their shortest lowercase-hex form —
16
+ * including collapsing an embedded IPv4-mapped address (`::ffff:127.0.0.1`)
17
+ * into its hex-group equivalent. That means the range checks below only ever
18
+ * see canonical output, not every obfuscated spelling of the same address.
19
+ *
20
+ * RESIDUAL RISK — NOT defended here: DNS rebinding. A public hostname
21
+ * (`hooks.example.com`) can resolve to a private IP (`127.0.0.1`,
22
+ * `169.254.169.254`, …) at fetch time, after this check already passed
23
+ * against the literal hostname. Cloudflare Workers' `fetch()` doesn't expose
24
+ * or let a caller pin the resolved IP, so there is no hook here to catch
25
+ * that case — this guard is scheme + literal-host based only. A full fix
26
+ * would need a resolve-and-pin step or an egress proxy that resolves DNS
27
+ * itself and refuses to connect to a private target; neither exists yet.
28
+ * Tracked as a follow-up, not a V1 blocker.
29
+ */
30
+
31
+ export type WebhookEndpointValidation = { ok: true } | { ok: false; reason: string };
32
+
33
+ /** One IPv4 octet quartet, each 0-255. */
34
+ type IPv4Octets = readonly [number, number, number, number];
35
+
36
+ function parseIPv4(host: string): IPv4Octets | null {
37
+ const m = /^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/.exec(host);
38
+ if (!m) return null;
39
+ const octets = [Number(m[1]), Number(m[2]), Number(m[3]), Number(m[4])] as const;
40
+ return octets.every((o) => o <= 255) ? octets : null;
41
+ }
42
+
43
+ /** 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16 (incl. the 169.254.169.254 cloud metadata host), 0.0.0.0/8. */
44
+ function isPrivateIPv4(octets: IPv4Octets): boolean {
45
+ const [a, b] = octets;
46
+ if (a === 127) return true;
47
+ if (a === 10) return true;
48
+ if (a === 172 && b >= 16 && b <= 31) return true;
49
+ if (a === 192 && b === 168) return true;
50
+ if (a === 169 && b === 254) return true;
51
+ if (a === 0) return true;
52
+ return false;
53
+ }
54
+
55
+ /** Expand a canonical (possibly `::`-compressed) IPv6 address into its 8 uint16 groups. */
56
+ function parseIPv6Groups(addr: string): number[] | null {
57
+ const parts = addr.split("::");
58
+ if (parts.length > 2) return null;
59
+
60
+ const head = parts[0] ? parts[0].split(":") : [];
61
+ const tail = parts.length === 2 && parts[1] ? parts[1].split(":") : [];
62
+ const explicit = parts.length === 2 ? head.length + tail.length : head.length;
63
+ if (parts.length === 1 && explicit !== 8) return null;
64
+
65
+ const filled = parts.length === 2 ? 8 - explicit : 0;
66
+ if (filled < 0) return null;
67
+ const groups = [...head, ...Array(filled).fill("0"), ...tail];
68
+ if (groups.length !== 8) return null;
69
+
70
+ const values = groups.map((g) => Number.parseInt(g || "0", 16));
71
+ return values.every((v) => Number.isInteger(v) && v >= 0 && v <= 0xffff) ? values : null;
72
+ }
73
+
74
+ /** `::1` loopback, `::` unspecified, `fc00::/7` unique-local, `fe80::/10` link-local, and any IPv4-mapped form of an {@link isPrivateIPv4} address. */
75
+ function isPrivateIPv6(groups: number[]): boolean {
76
+ if (groups.every((g) => g === 0)) return true; // ::
77
+ if (groups.slice(0, 7).every((g) => g === 0) && groups[7] === 1) return true; // ::1
78
+ if ((groups[0]! & 0xfe00) === 0xfc00) return true; // fc00::/7
79
+ if ((groups[0]! & 0xffc0) === 0xfe80) return true; // fe80::/10
80
+
81
+ // IPv4-mapped: ::ffff:0:0/96 — the last two groups are the mapped address.
82
+ if (groups.slice(0, 5).every((g) => g === 0) && groups[5] === 0xffff) {
83
+ const mapped: IPv4Octets = [
84
+ (groups[6]! >> 8) & 0xff,
85
+ groups[6]! & 0xff,
86
+ (groups[7]! >> 8) & 0xff,
87
+ groups[7]! & 0xff,
88
+ ];
89
+ if (isPrivateIPv4(mapped)) return true;
90
+ }
91
+ return false;
92
+ }
93
+
94
+ const PRIVATE_HOST_REASON = "endpoint cannot target a private, loopback, or link-local address";
95
+
96
+ /**
97
+ * Validate an app-supplied `webhookEndpoint`. https-only, no IP-literal
98
+ * target in a private/loopback/link-local/reserved range (v4 or v6,
99
+ * including IPv4-mapped IPv6), and no `localhost`/`*.localhost` hostname.
100
+ * See the module header for what this deliberately does NOT cover.
101
+ */
102
+ export function validateWebhookEndpoint(raw: string): WebhookEndpointValidation {
103
+ let url: URL;
104
+ try {
105
+ url = new URL(raw);
106
+ } catch {
107
+ return { ok: false, reason: "endpoint is not a valid URL" };
108
+ }
109
+
110
+ if (url.protocol !== "https:") {
111
+ return { ok: false, reason: "endpoint must use https" };
112
+ }
113
+
114
+ const hostname = url.hostname; // already lowercased by URL parsing
115
+ if (hostname === "localhost" || hostname.endsWith(".localhost")) {
116
+ return { ok: false, reason: "endpoint cannot target localhost" };
117
+ }
118
+
119
+ const ipv4 = parseIPv4(hostname);
120
+ if (ipv4 && isPrivateIPv4(ipv4)) {
121
+ return { ok: false, reason: PRIVATE_HOST_REASON };
122
+ }
123
+
124
+ if (hostname.startsWith("[") && hostname.endsWith("]")) {
125
+ const groups = parseIPv6Groups(hostname.slice(1, -1));
126
+ // A hostname the URL parser accepted as `[...]` is a syntactically valid
127
+ // IPv6 literal; a parse failure here means our own expansion has a gap,
128
+ // not that the address is benign — fail closed rather than let it through.
129
+ if (!groups || isPrivateIPv6(groups)) {
130
+ return { ok: false, reason: PRIVATE_HOST_REASON };
131
+ }
132
+ }
133
+
134
+ return { ok: true };
135
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * CloudEvents envelope builder — PrivateApps epic, D4 (Chunk C). Builds the
3
+ * EXACT wire shape `docs/private-apps/contract/asyncapi.yaml`'s
4
+ * `CloudEventEnvelope` schema requires (field-for-field, including the
5
+ * CloudEvents extensions), plus `TOPIC_REQUIRED_SCOPE` — the topic→required-
6
+ * scope map from `docs/private-apps/api-event-reference.md` §"Scope →
7
+ * endpoint / topic matrix", which `events.test.ts` checks against that table
8
+ * directly (no machine-readable topic-scope JSON exists, unlike
9
+ * `scopes.json`) — same drift-proofing discipline `scopes.test.ts` uses for
10
+ * `V1_SCOPES` vs. `contract/scopes.json`.
11
+ */
12
+ import type { WebhookTopic } from "./deliveryStore.js";
13
+
14
+ export const CLOUDEVENTS_SPECVERSION = "1.0";
15
+ export const CLOUDEVENTS_DATACONTENTTYPE = "application/json";
16
+
17
+ /**
18
+ * Required scope per topic (asyncapi.yaml channel descriptions +
19
+ * api-event-reference.md's topic matrix). `null` = no event scope gates it —
20
+ * `app.installed`/`app.uninstalled` deliver to any install with a webhook
21
+ * endpoint, per "(any valid token)" in the matrix.
22
+ */
23
+ export const TOPIC_REQUIRED_SCOPE: Record<WebhookTopic, string | null> = {
24
+ "app.installed": null,
25
+ "app.uninstalled": null,
26
+ "catalog.product.updated": "catalog:read",
27
+ "inventory.changed": "inventory:read",
28
+ "order.created": "orders:webhook",
29
+ "order.fulfilled": "orders:webhook",
30
+ "customer.marketing_consent.updated": "customers:read:minimal",
31
+ "attribution.finalized": "attribution:write",
32
+ };
33
+
34
+ /** `type`'s reverse-DNS prefix (asyncapi.yaml `CloudEventEnvelope.type`). */
35
+ export function eventType(topic: WebhookTopic): string {
36
+ return `com.tokenoftrust.storefront.${topic}`;
37
+ }
38
+
39
+ /** `source` — the emitting gateway + tenant (asyncapi.yaml: e.g. `/tenants/acme/storefront`). */
40
+ export function envelopeSource(tenantId: string): string {
41
+ return `/tenants/${tenantId}/storefront`;
42
+ }
43
+
44
+ export interface BuildEnvelopeInput {
45
+ /** The wire `id` — this delivery's id, stable across its own retries (never the eventId). */
46
+ deliveryId: string;
47
+ tenantId: string;
48
+ topic: WebhookTopic;
49
+ dataschemaVersion: string;
50
+ /** The event-specific payload — opaque here, shaped by `topic` (OrderData, etc. in the contract). */
51
+ data: unknown;
52
+ appInstallId: string;
53
+ /** Delivery attempt count so far, 0 on the first attempt (CloudEvents `retrycount` extension). */
54
+ retryCount: number;
55
+ /** Correlation extensions — omitted from the envelope entirely when not supplied. */
56
+ totRequestId?: string;
57
+ traceparent?: string;
58
+ /** Injected clock; defaults to `Date`. Override in tests for a deterministic `time`. */
59
+ now?: () => Date;
60
+ }
61
+
62
+ /** Mirrors `asyncapi.yaml`'s `CloudEventEnvelope` schema exactly — required + optional extension fields. */
63
+ export interface CloudEventEnvelope {
64
+ specversion: "1.0";
65
+ id: string;
66
+ source: string;
67
+ type: string;
68
+ time: string;
69
+ datacontenttype: "application/json";
70
+ dataschemaversion: string;
71
+ tenantid: string;
72
+ appinstallid: string;
73
+ retrycount: number;
74
+ totrequestid?: string;
75
+ traceparent?: string;
76
+ data: unknown;
77
+ }
78
+
79
+ /** Build one delivery's CloudEvents envelope. Never adds anything beyond `data` — payload minimization is the emitter's (Chunk D's) job. */
80
+ export function buildCloudEventEnvelope(input: BuildEnvelopeInput): CloudEventEnvelope {
81
+ const now = input.now ?? (() => new Date());
82
+ const envelope: CloudEventEnvelope = {
83
+ specversion: CLOUDEVENTS_SPECVERSION,
84
+ id: input.deliveryId,
85
+ source: envelopeSource(input.tenantId),
86
+ type: eventType(input.topic),
87
+ time: now().toISOString(),
88
+ datacontenttype: CLOUDEVENTS_DATACONTENTTYPE,
89
+ dataschemaversion: input.dataschemaVersion,
90
+ tenantid: input.tenantId,
91
+ appinstallid: input.appInstallId,
92
+ retrycount: input.retryCount,
93
+ data: input.data,
94
+ };
95
+ if (input.totRequestId) envelope.totrequestid = input.totRequestId;
96
+ if (input.traceparent) envelope.traceparent = input.traceparent;
97
+ return envelope;
98
+ }
@@ -0,0 +1,49 @@
1
+ /**
2
+ * Composition root for the webhook dispatcher — PrivateApps epic, D4 (Chunk
3
+ * C). Mirrors `../apps/adminService.ts`'s `getRegistryService()`: builds the
4
+ * real `WebhookDeliveryStore` + `AppRegistryService` over the
5
+ * `STOREFRONT_APPS_DB` binding, wires them into `attemptDelivery` as the `AttemptDeliveryFn` every
6
+ * dispatcher implementation runs per job, and returns the SAME 503-when-
7
+ * unconfigured `Response` shape `getRegistryService()` does when that binding
8
+ * is missing.
9
+ *
10
+ * Selects the dispatcher implementation by binding presence:
11
+ * `env.WEBHOOK_DELIVERY_QUEUE` present → `CloudflareQueueDispatcher`, else
12
+ * `InMemoryDispatcher` — V1's actual production path (no `[[queues]]` binding
13
+ * is declared in `wrangler.toml` yet, so the Cloudflare branch is unreachable
14
+ * until infra provisions one; see `dispatcher.ts`'s header for the full
15
+ * design-resolution + entry-wiring context).
16
+ */
17
+ import { WebhookDeliveryStore } from "./deliveryStore.js";
18
+ import { attemptDelivery } from "./deliveryEngine.js";
19
+ import { InMemoryDispatcher, type AttemptDeliveryFn, type WebhookDispatcher } from "./dispatcher.js";
20
+ import { CloudflareQueueDispatcher, type CloudflareQueueSender } from "./cloudflareQueueDispatcher.js";
21
+ import { AppRegistryService } from "../apps/registryService.js";
22
+ import { D1CredentialStore } from "../apps/credentials.js";
23
+ import { DEFAULT_APP_GATEWAY_KID, APP_GATEWAY_KID_ENV } from "../apps/gatewayKeys.js";
24
+ import { json } from "../apps/adminService.js";
25
+ import { fromD1 } from "../d1/catalog.js";
26
+ import { readD1, readEnv, readQueue } from "../env.js";
27
+
28
+ /** Binding name `getDispatcher()` checks to pick the Cloudflare Queues adapter. Not yet declared in `wrangler.toml`. */
29
+ export const WEBHOOK_QUEUE_BINDING = "WEBHOOK_DELIVERY_QUEUE";
30
+
31
+ /**
32
+ * Build a `WebhookDispatcher` over the real `STOREFRONT_APPS_DB` binding, or
33
+ * return a 503 `Response` when that binding isn't configured in this
34
+ * runtime — same fail-closed shape as `getRegistryService()`.
35
+ */
36
+ export async function getDispatcher(): Promise<WebhookDispatcher | Response> {
37
+ const d1 = await readD1("STOREFRONT_APPS_DB");
38
+ if (!d1) return json({ error: "webhook dispatcher not configured" }, 503);
39
+ const db = fromD1(d1);
40
+
41
+ const store = new WebhookDeliveryStore(db);
42
+ const activeKid = (await readEnv(APP_GATEWAY_KID_ENV)) ?? DEFAULT_APP_GATEWAY_KID;
43
+ const registry = new AppRegistryService(db, new D1CredentialStore(db, activeKid));
44
+ const attempt: AttemptDeliveryFn = (deliveryId) => attemptDelivery(deliveryId, { store, registry });
45
+
46
+ const queueSender = (await readQueue(WEBHOOK_QUEUE_BINDING)) as CloudflareQueueSender | undefined;
47
+ if (queueSender) return new CloudflareQueueDispatcher(queueSender);
48
+ return new InMemoryDispatcher(attempt);
49
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * HTTP Message Signatures (RFC 9421) + Content-Digest (RFC 9530) signer for
3
+ * outbound webhook delivery (PrivateApps epic, D4 Chunk B).
4
+ *
5
+ * The implementation MOVED to `@tokenoftrust/private-apps-devkit`
6
+ * (PrivateApps epic D6 Chunk B) so the `tot app` CLI harness (D6 Chunk D) can
7
+ * verify webhook signatures without importing Astro-internal code — without
8
+ * forking security-critical crypto into a second, drifting copy. This file
9
+ * is now a thin re-export; the devkit package is the ONE source of truth.
10
+ * See `packages/private-apps-devkit/src/signing.ts` for the full scope
11
+ * notes (hand-rolled/narrowly-scoped, not a general httpsig implementation)
12
+ * and `packages/private-apps-devkit/tests/signing.test.ts` for the RFC-vector
13
+ * proofs. `signing.test.ts` alongside this file still imports from here —
14
+ * unchanged — so it now exercises those same functions through this
15
+ * re-export.
16
+ */
17
+ export {
18
+ SIGNATURE_LABEL,
19
+ WEBHOOK_SIGNATURE_COVERED_COMPONENTS,
20
+ computeContentDigestSha256,
21
+ buildSignatureParamsValue,
22
+ buildSignatureBase,
23
+ signWebhookRequest,
24
+ verifyWebhookSignature,
25
+ type SignWebhookRequestInput,
26
+ type SignWebhookRequestResult,
27
+ type WebhookVerifyResult,
28
+ type VerifyWebhookSignatureInput,
29
+ } from "@tokenoftrust/private-apps-devkit";