@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,668 @@
1
+ /**
2
+ * Webhook event/delivery storage — PrivateApps epic, D4 (Chunk A: Storage
3
+ * only). Owns the `webhook_events` / `webhook_deliveries` /
4
+ * `webhook_delivery_attempts` / `webhook_install_health` rows described in
5
+ * `../d1/schema.ts`. Signing (Chunk B), the retry/backoff dispatcher
6
+ * (Chunk C), event emission/targeting (Chunk D), and the `/webhooks/*`
7
+ * routes (Chunk E) all build on this store; none of that policy lives here.
8
+ *
9
+ * Pure over the `Queryable` seam (see `../d1/catalog.ts`) — the 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
+ * Two ids matter and are easy to conflate:
14
+ * - `eventId` (`webhook_events.id`) — the semantic event; ONE per business
15
+ * occurrence (e.g. one order.created). Never sent on the wire directly.
16
+ * - `deliveryId` (`webhook_deliveries.delivery_id`) — the CloudEvents
17
+ * envelope `id` for one (event × target install) delivery. This is what
18
+ * goes on the wire and is what receivers dedupe on; it stays stable
19
+ * across that delivery's own retries AND across a manual replay (Chunk
20
+ * E's `requeueForReplay` resets state/attempts on the SAME row rather
21
+ * than minting a new one — a receiver that already acked a delivered
22
+ * delivery must still dedupe a replayed resend of it).
23
+ *
24
+ * Responsibility split for the two mutating paths a dispatcher (Chunk C)
25
+ * drives per attempt:
26
+ * - `recordAttempt` is dumb bookkeeping of ONE HTTP attempt's outcome: it
27
+ * appends to `webhook_delivery_attempts`, bumps the delivery's
28
+ * `attempts`/`last_*` columns, and folds the outcome into the install's
29
+ * health rollup (`lastSuccessAt`/`failureStreak`/`last_*`/recent trace
30
+ * ids). It never changes `state`.
31
+ * - `transitionState` is the retry-POLICY decision (how many attempts,
32
+ * what backoff, when to give up) — deliberately left to the caller, not
33
+ * computed here. It only moves `state` (and `next_attempt_at`), and
34
+ * bumps `dlqCount` on the one transition that means "gave up":
35
+ * dead-lettering.
36
+ *
37
+ * The `Queryable` seam has no multi-statement transaction primitive, so
38
+ * "transactionally-in-spirit" here means: do the writes in a fixed order
39
+ * (attempt row, then delivery row, then health rollup) and always re-read
40
+ * the delivery via `getOrThrowDelivery` before returning it, the same
41
+ * read-after-write discipline `AppRegistryService` uses.
42
+ */
43
+ import type { Queryable } from "../d1/catalog.js";
44
+
45
+ /** Mirrors the `Delivery.topic` / CloudEvents channel enum in the D1/D2 contract. */
46
+ export type WebhookTopic =
47
+ | "app.installed"
48
+ | "app.uninstalled"
49
+ | "catalog.product.updated"
50
+ | "inventory.changed"
51
+ | "order.created"
52
+ | "order.fulfilled"
53
+ | "customer.marketing_consent.updated"
54
+ | "attribution.finalized";
55
+
56
+ /** Mirrors `Delivery.state` in `docs/private-apps/contract/openapi.yaml` EXACTLY. */
57
+ export type DeliveryState = "pending" | "retrying" | "delivered" | "dead-lettered";
58
+
59
+ export interface WebhookEvent {
60
+ id: string;
61
+ tenantId: string;
62
+ topic: WebhookTopic;
63
+ dataschemaVersion: string;
64
+ /** The CloudEvents `data` payload — event-specific shape, opaque to the store. */
65
+ payload: unknown;
66
+ createdAt: string;
67
+ }
68
+
69
+ export interface WebhookDelivery {
70
+ deliveryId: string;
71
+ eventId: string;
72
+ installId: string;
73
+ tenantId: string;
74
+ topic: WebhookTopic;
75
+ state: DeliveryState;
76
+ attempts: number;
77
+ lastStatus: number | null;
78
+ lastErrorClass: string | null;
79
+ lastTraceId: string | null;
80
+ nextAttemptAt: string | null;
81
+ createdAt: string;
82
+ updatedAt: string;
83
+ }
84
+
85
+ /** The openapi `InstallHealth` shape — no field is required there, so a fresh install reads as healthy/zeroed. */
86
+ export interface InstallHealth {
87
+ status: "healthy" | "degraded" | "unhealthy";
88
+ lastSuccessAt?: string;
89
+ failureStreak: number;
90
+ dlqCount: number;
91
+ /** p95 delivery latency (ms) across the install's recent attempts — see {@link WebhookDeliveryStore.getInstallHealth}. Absent when no attempt has a recorded duration. */
92
+ p95DeliveryMs?: number;
93
+ lastStatus?: number;
94
+ lastErrorClass?: string;
95
+ recentTraceIds: string[];
96
+ }
97
+
98
+ export interface CreateEventInput {
99
+ /** Optional caller-supplied event id (e.g. for a caller-owned idempotency key). Defaults to a fresh UUID. */
100
+ eventId?: string;
101
+ tenantId: string;
102
+ topic: WebhookTopic;
103
+ dataschemaVersion: string;
104
+ payload: unknown;
105
+ }
106
+
107
+ export interface DeliveryTarget {
108
+ installId: string;
109
+ /** Optional caller-supplied delivery id. Defaults to a fresh UUID (the wire CloudEvents `id`). */
110
+ deliveryId?: string;
111
+ }
112
+
113
+ export interface CreateEventResult {
114
+ event: WebhookEvent;
115
+ deliveries: WebhookDelivery[];
116
+ }
117
+
118
+ export interface ListDeliveriesOptions {
119
+ state?: DeliveryState;
120
+ /** Cap the result, newest first. Used internally by {@link WebhookDeliveryStore.getInstallHealth} to bound its p95 sample; unset means unbounded (the historical behavior). */
121
+ limit?: number;
122
+ }
123
+
124
+ export interface RecordAttemptInput {
125
+ deliveryId: string;
126
+ attemptNo: number;
127
+ startedAt: string;
128
+ durationMs?: number | null;
129
+ httpStatus?: number | null;
130
+ errorClass?: string | null;
131
+ traceId?: string | null;
132
+ }
133
+
134
+ export interface TransitionStateInput {
135
+ deliveryId: string;
136
+ toState: DeliveryState;
137
+ /** Required (and only meaningful) when `toState` is `"retrying"`. */
138
+ nextAttemptAt?: string | null;
139
+ }
140
+
141
+ export interface ScanDueRetriesInput {
142
+ /** Deliveries with `next_attempt_at <= now` are due. */
143
+ now: string;
144
+ limit?: number;
145
+ }
146
+
147
+ export type WebhookStoreErrorCode = "not_found" | "invalid_transition";
148
+
149
+ /** Thrown by every rule violation below — callers branch on `.code`. */
150
+ export class WebhookStoreError extends Error {
151
+ constructor(
152
+ message: string,
153
+ readonly code: WebhookStoreErrorCode,
154
+ ) {
155
+ super(message);
156
+ this.name = "WebhookStoreError";
157
+ }
158
+ }
159
+
160
+ const MAX_RECENT_TRACE_IDS = 10;
161
+
162
+ // How many of an install's most recent deliveries feed the p95 sample in
163
+ // `getInstallHealth` — a bound, not a precise percentile guarantee; recent
164
+ // behavior is what the health view cares about, not all-time history.
165
+ const P95_SAMPLE_SIZE = 50;
166
+
167
+ // A delivery only ever moves forward via `transitionState`; `delivered`/
168
+ // `dead-lettered` are terminal THERE. A manual replay (Chunk E's
169
+ // `requeueForReplay`) is the one deliberate exception: it resets a delivery
170
+ // out of a terminal state back to `pending` on the SAME row, bypassing this
171
+ // table entirely — see that method's own doc comment for why.
172
+ const ALLOWED_TRANSITIONS: Record<DeliveryState, readonly DeliveryState[]> = {
173
+ pending: ["retrying", "delivered", "dead-lettered"],
174
+ retrying: ["retrying", "delivered", "dead-lettered"],
175
+ delivered: [],
176
+ "dead-lettered": [],
177
+ };
178
+
179
+ // Failure-streak thresholds for the `InstallHealth.status` rollup. A DLQ hit
180
+ // always reads as unhealthy, regardless of streak.
181
+ const DEGRADED_FAILURE_STREAK = 1;
182
+ const UNHEALTHY_FAILURE_STREAK = 5;
183
+
184
+ function isSuccessAttempt(httpStatus: number | null | undefined): boolean {
185
+ return httpStatus != null && httpStatus >= 200 && httpStatus < 300;
186
+ }
187
+
188
+ /** Prepend a trace id (if present) and cap the list, newest first. */
189
+ function pushTraceId(existing: string[], traceId: string | null | undefined): string[] {
190
+ if (!traceId) return existing;
191
+ return [traceId, ...existing].slice(0, MAX_RECENT_TRACE_IDS);
192
+ }
193
+
194
+ /** Raw `webhook_events` row shape (snake_case columns). */
195
+ interface EventRow {
196
+ id: string;
197
+ tenant_id: string;
198
+ topic: string;
199
+ dataschema_version: string;
200
+ payload: string;
201
+ created_at: string;
202
+ }
203
+
204
+ /** Raw `webhook_deliveries` row shape (snake_case columns). */
205
+ interface DeliveryRow {
206
+ delivery_id: string;
207
+ event_id: string;
208
+ install_id: string;
209
+ tenant_id: string;
210
+ topic: string;
211
+ state: string;
212
+ attempts: number;
213
+ last_status: number | null;
214
+ last_error_class: string | null;
215
+ last_trace_id: string | null;
216
+ next_attempt_at: string | null;
217
+ created_at: string;
218
+ updated_at: string;
219
+ }
220
+
221
+ /** Raw `webhook_install_health` row shape (snake_case columns). */
222
+ interface HealthRow {
223
+ install_id: string;
224
+ last_success_at: string | null;
225
+ failure_streak: number;
226
+ dlq_count: number;
227
+ last_status: number | null;
228
+ last_error_class: string | null;
229
+ recent_trace_ids: string;
230
+ updated_at: string;
231
+ }
232
+
233
+ function toDomain(row: DeliveryRow): WebhookDelivery {
234
+ return {
235
+ deliveryId: row.delivery_id,
236
+ eventId: row.event_id,
237
+ installId: row.install_id,
238
+ tenantId: row.tenant_id,
239
+ topic: row.topic as WebhookTopic,
240
+ state: row.state as DeliveryState,
241
+ attempts: row.attempts,
242
+ lastStatus: row.last_status,
243
+ lastErrorClass: row.last_error_class,
244
+ lastTraceId: row.last_trace_id,
245
+ nextAttemptAt: row.next_attempt_at,
246
+ createdAt: row.created_at,
247
+ updatedAt: row.updated_at,
248
+ };
249
+ }
250
+
251
+ function toEventDomain(row: EventRow): WebhookEvent {
252
+ return {
253
+ id: row.id,
254
+ tenantId: row.tenant_id,
255
+ topic: row.topic as WebhookTopic,
256
+ dataschemaVersion: row.dataschema_version,
257
+ payload: JSON.parse(row.payload) as unknown,
258
+ createdAt: row.created_at,
259
+ };
260
+ }
261
+
262
+ function healthStatus(row: HealthRow | null): InstallHealth["status"] {
263
+ if (!row) return "healthy";
264
+ if (row.dlq_count > 0 || row.failure_streak >= UNHEALTHY_FAILURE_STREAK) return "unhealthy";
265
+ if (row.failure_streak >= DEGRADED_FAILURE_STREAK) return "degraded";
266
+ return "healthy";
267
+ }
268
+
269
+ function toInstallHealth(row: HealthRow | null): InstallHealth {
270
+ return {
271
+ status: healthStatus(row),
272
+ lastSuccessAt: row?.last_success_at ?? undefined,
273
+ failureStreak: row?.failure_streak ?? 0,
274
+ dlqCount: row?.dlq_count ?? 0,
275
+ lastStatus: row?.last_status ?? undefined,
276
+ lastErrorClass: row?.last_error_class ?? undefined,
277
+ recentTraceIds: row ? (JSON.parse(row.recent_trace_ids) as string[]) : [],
278
+ };
279
+ }
280
+
281
+ export class WebhookDeliveryStore {
282
+ constructor(
283
+ private readonly db: Queryable,
284
+ private readonly now: () => string = () => new Date().toISOString(),
285
+ ) {}
286
+
287
+ /**
288
+ * Write one immutable event, then fan it out to one `pending` delivery per
289
+ * target install. Targeting (which installs get the event) is the caller's
290
+ * job (Chunk D) — this just persists whatever target list it's handed.
291
+ */
292
+ async createEvent(input: CreateEventInput, targets: readonly DeliveryTarget[]): Promise<CreateEventResult> {
293
+ const eventId = input.eventId ?? crypto.randomUUID();
294
+ const now = this.now();
295
+
296
+ await this.db.run(
297
+ `INSERT INTO webhook_events (id, tenant_id, topic, dataschema_version, payload, created_at)
298
+ VALUES (?,?,?,?,?,?)`,
299
+ eventId,
300
+ input.tenantId,
301
+ input.topic,
302
+ input.dataschemaVersion,
303
+ JSON.stringify(input.payload),
304
+ now,
305
+ );
306
+
307
+ const deliveries: WebhookDelivery[] = [];
308
+ for (const target of targets) {
309
+ const deliveryId = target.deliveryId ?? crypto.randomUUID();
310
+ await this.db.run(
311
+ `INSERT INTO webhook_deliveries
312
+ (delivery_id, event_id, install_id, tenant_id, topic, state, attempts, created_at, updated_at)
313
+ VALUES (?,?,?,?,?,'pending',0,?,?)`,
314
+ deliveryId,
315
+ eventId,
316
+ target.installId,
317
+ input.tenantId,
318
+ input.topic,
319
+ now,
320
+ now,
321
+ );
322
+ deliveries.push(await this.getOrThrowDelivery(deliveryId));
323
+ }
324
+
325
+ return {
326
+ event: {
327
+ id: eventId,
328
+ tenantId: input.tenantId,
329
+ topic: input.topic,
330
+ dataschemaVersion: input.dataschemaVersion,
331
+ payload: input.payload,
332
+ createdAt: now,
333
+ },
334
+ deliveries,
335
+ };
336
+ }
337
+
338
+ /** Read one delivery by id, or `null` if it doesn't exist. */
339
+ async getDelivery(deliveryId: string): Promise<WebhookDelivery | null> {
340
+ const row = await this.db.first<DeliveryRow>(
341
+ `SELECT * FROM webhook_deliveries WHERE delivery_id = ?`,
342
+ deliveryId,
343
+ );
344
+ return row ? toDomain(row) : null;
345
+ }
346
+
347
+ /**
348
+ * Read one event by id, or `null` if it doesn't exist. Added for the
349
+ * dispatcher/delivery engine (Chunk C): `attemptDelivery` resolves a
350
+ * delivery's `eventId` via `getDelivery`, then needs this to fetch the
351
+ * `payload`/`dataschemaVersion` that only `webhook_events` (not
352
+ * `webhook_deliveries`) carries, in order to build the CloudEvents
353
+ * envelope's `data`.
354
+ */
355
+ async getEvent(eventId: string): Promise<WebhookEvent | null> {
356
+ const row = await this.db.first<EventRow>(`SELECT * FROM webhook_events WHERE id = ?`, eventId);
357
+ return row ? toEventDomain(row) : null;
358
+ }
359
+
360
+ /** All deliveries for one install (an install only ever sees its own — enforced by the caller), newest first. Optionally capped via `opts.limit`. */
361
+ async listDeliveriesByInstall(
362
+ installId: string,
363
+ opts: ListDeliveriesOptions = {},
364
+ ): Promise<WebhookDelivery[]> {
365
+ const params: unknown[] = [installId];
366
+ let sql = `SELECT * FROM webhook_deliveries WHERE install_id = ?`;
367
+ if (opts.state) {
368
+ sql += ` AND state = ?`;
369
+ params.push(opts.state);
370
+ }
371
+ sql += ` ORDER BY created_at DESC`;
372
+ if (opts.limit != null) {
373
+ sql += ` LIMIT ?`;
374
+ params.push(opts.limit);
375
+ }
376
+ const rows = await this.db.all<DeliveryRow>(sql, ...params);
377
+ return rows.map(toDomain);
378
+ }
379
+
380
+ /**
381
+ * All deliveries for a tenant, across every install (the ops/support admin
382
+ * view — an install only ever sees its OWN deliveries via
383
+ * {@link listDeliveriesByInstall}; this is the tenant-wide superset), newest
384
+ * first. Added for D4 Chunk E's admin route; Chunk A/B/C never call this.
385
+ */
386
+ async listDeliveriesByTenant(
387
+ tenantId: string,
388
+ opts: ListDeliveriesOptions = {},
389
+ ): Promise<WebhookDelivery[]> {
390
+ const rows = opts.state
391
+ ? await this.db.all<DeliveryRow>(
392
+ `SELECT * FROM webhook_deliveries WHERE tenant_id = ? AND state = ? ORDER BY created_at DESC`,
393
+ tenantId,
394
+ opts.state,
395
+ )
396
+ : await this.db.all<DeliveryRow>(
397
+ `SELECT * FROM webhook_deliveries WHERE tenant_id = ? ORDER BY created_at DESC`,
398
+ tenantId,
399
+ );
400
+ return rows.map(toDomain);
401
+ }
402
+
403
+ /**
404
+ * Record the outcome of one HTTP delivery attempt: append it to
405
+ * `webhook_delivery_attempts`, bump the delivery's `attempts`/`last_*`
406
+ * columns, and fold the outcome into the install's health rollup. Does
407
+ * NOT change `state` — see {@link transitionState}.
408
+ */
409
+ async recordAttempt(input: RecordAttemptInput): Promise<WebhookDelivery> {
410
+ const row = await this.mustFindDeliveryRow(input.deliveryId);
411
+ const now = this.now();
412
+
413
+ await this.db.run(
414
+ `INSERT INTO webhook_delivery_attempts
415
+ (id, delivery_id, attempt_no, started_at, duration_ms, http_status, error_class, trace_id)
416
+ VALUES (?,?,?,?,?,?,?,?)`,
417
+ crypto.randomUUID(),
418
+ input.deliveryId,
419
+ input.attemptNo,
420
+ input.startedAt,
421
+ input.durationMs ?? null,
422
+ input.httpStatus ?? null,
423
+ input.errorClass ?? null,
424
+ input.traceId ?? null,
425
+ );
426
+
427
+ await this.db.run(
428
+ `UPDATE webhook_deliveries
429
+ SET attempts = ?, last_status = ?, last_error_class = ?, last_trace_id = ?, updated_at = ?
430
+ WHERE delivery_id = ?`,
431
+ row.attempts + 1,
432
+ input.httpStatus ?? null,
433
+ input.errorClass ?? null,
434
+ input.traceId ?? null,
435
+ now,
436
+ input.deliveryId,
437
+ );
438
+
439
+ await this.foldAttemptIntoHealth(row.install_id, input, now);
440
+
441
+ return this.getOrThrowDelivery(input.deliveryId);
442
+ }
443
+
444
+ /**
445
+ * Move a delivery's `state` forward per `ALLOWED_TRANSITIONS`. The
446
+ * caller (Chunk C's dispatcher) decides WHEN to retry vs. give up; this
447
+ * only records that decision. Dead-lettering bumps the install's
448
+ * `dlqCount` — the one rollup field {@link recordAttempt} doesn't own.
449
+ */
450
+ async transitionState(input: TransitionStateInput): Promise<WebhookDelivery> {
451
+ const row = await this.mustFindDeliveryRow(input.deliveryId);
452
+ const from = row.state as DeliveryState;
453
+ if (!ALLOWED_TRANSITIONS[from].includes(input.toState)) {
454
+ throw new WebhookStoreError(
455
+ `Cannot transition delivery from "${from}" to "${input.toState}".`,
456
+ "invalid_transition",
457
+ );
458
+ }
459
+ if (input.toState === "retrying" && !input.nextAttemptAt) {
460
+ throw new WebhookStoreError(`A transition to "retrying" requires nextAttemptAt.`, "invalid_transition");
461
+ }
462
+
463
+ const now = this.now();
464
+ const nextAttemptAt = input.toState === "retrying" ? (input.nextAttemptAt as string) : null;
465
+ await this.db.run(
466
+ `UPDATE webhook_deliveries SET state = ?, next_attempt_at = ?, updated_at = ? WHERE delivery_id = ?`,
467
+ input.toState,
468
+ nextAttemptAt,
469
+ now,
470
+ input.deliveryId,
471
+ );
472
+
473
+ if (input.toState === "dead-lettered") {
474
+ await this.bumpDlqCount(row.install_id, now);
475
+ }
476
+
477
+ return this.getOrThrowDelivery(input.deliveryId);
478
+ }
479
+
480
+ /**
481
+ * D4 Chunk E's replay support: force a delivery back to `pending` with a
482
+ * fresh attempt budget — from ANY current state, INCLUDING the terminal
483
+ * `delivered`/`dead-lettered` states `transitionState`/`ALLOWED_TRANSITIONS`
484
+ * refuse to leave. This is deliberately a separate write path, not a call
485
+ * through `transitionState`: a replay is an operator/app decision to retry
486
+ * something the normal retry policy already gave up on (or already
487
+ * finished), not a policy-driven transition.
488
+ *
489
+ * The delivery's `deliveryId` is NEVER reassigned — see the header note on
490
+ * why it must stay stable across a replay (a receiver that already acked a
491
+ * `delivered` delivery needs the resend to carry the same wire id to
492
+ * dedupe it). `attempts` resets to 0 (a fresh {@link MAX_DELIVERY_ATTEMPTS}
493
+ * budget, tracked in `dispatcher.ts`) rather than continuing the old count
494
+ * — a replay is a new delivery cycle, not "attempt 7" of the old one.
495
+ * `webhook_delivery_attempts` — the append-only audit trail — is NEVER
496
+ * touched here; only this row's own counters move.
497
+ *
498
+ * A delivery leaving `dead-lettered` is no longer parked in the DLQ, so the
499
+ * install health rollup's `dlqCount` — read here as a CURRENT gauge of
500
+ * still-dead-lettered deliveries, not a lifetime counter — is decremented
501
+ * (floored at 0) to match.
502
+ */
503
+ async requeueForReplay(deliveryId: string): Promise<WebhookDelivery> {
504
+ const row = await this.mustFindDeliveryRow(deliveryId);
505
+ const now = this.now();
506
+
507
+ await this.db.run(
508
+ `UPDATE webhook_deliveries
509
+ SET state = 'pending', attempts = 0, next_attempt_at = ?, updated_at = ?
510
+ WHERE delivery_id = ?`,
511
+ now,
512
+ now,
513
+ deliveryId,
514
+ );
515
+
516
+ if (row.state === "dead-lettered") {
517
+ await this.decrementDlqCount(row.install_id, now);
518
+ }
519
+
520
+ return this.getOrThrowDelivery(deliveryId);
521
+ }
522
+
523
+ /**
524
+ * The `InstallHealth` rollup for one install — a never-attempted install
525
+ * reads as healthy/zeroed. `p95DeliveryMs` is computed here at READ time
526
+ * (not tracked as a running rollup column): no migration, no extra write
527
+ * on the `recordAttempt` hot path, just this one bounded read.
528
+ */
529
+ async getInstallHealth(installId: string): Promise<InstallHealth> {
530
+ const row = await this.db.first<HealthRow>(
531
+ `SELECT * FROM webhook_install_health WHERE install_id = ?`,
532
+ installId,
533
+ );
534
+ const health = toInstallHealth(row);
535
+ const p95DeliveryMs = await this.computeP95DeliveryMs(installId);
536
+ return p95DeliveryMs != null ? { ...health, p95DeliveryMs } : health;
537
+ }
538
+
539
+ /**
540
+ * p95 across `webhook_delivery_attempts.duration_ms` for the install's
541
+ * {@link P95_SAMPLE_SIZE} most recent deliveries — one bounded `IN (...)`
542
+ * read over that fixed set of delivery ids, sorted in JS (the sample is
543
+ * small enough that a DB-side percentile isn't worth the SQL). Nearest-rank
544
+ * method: `ceil(n * 0.95)`th smallest value, 1-indexed. `undefined` when no
545
+ * attempt in the sample has a recorded duration.
546
+ */
547
+ private async computeP95DeliveryMs(installId: string): Promise<number | undefined> {
548
+ const recent = await this.listDeliveriesByInstall(installId, { limit: P95_SAMPLE_SIZE });
549
+ if (recent.length === 0) return undefined;
550
+
551
+ const placeholders = recent.map(() => "?").join(",");
552
+ const rows = await this.db.all<{ duration_ms: number | null }>(
553
+ `SELECT duration_ms FROM webhook_delivery_attempts WHERE delivery_id IN (${placeholders})`,
554
+ ...recent.map((d) => d.deliveryId),
555
+ );
556
+ const durations = rows
557
+ .map((r) => r.duration_ms)
558
+ .filter((d): d is number => d != null)
559
+ .sort((a, b) => a - b);
560
+ if (durations.length === 0) return undefined;
561
+
562
+ const index = Math.min(durations.length - 1, Math.ceil(durations.length * 0.95) - 1);
563
+ return durations[index];
564
+ }
565
+
566
+ /** Deliveries in `retrying` whose `next_attempt_at` is due, earliest first. Uses `idx_webhook_deliveries_due`. */
567
+ async scanDueRetries(params: ScanDueRetriesInput): Promise<WebhookDelivery[]> {
568
+ const limit = params.limit ?? 100;
569
+ const rows = await this.db.all<DeliveryRow>(
570
+ `SELECT * FROM webhook_deliveries
571
+ WHERE state = 'retrying' AND next_attempt_at <= ?
572
+ ORDER BY next_attempt_at ASC
573
+ LIMIT ?`,
574
+ params.now,
575
+ limit,
576
+ );
577
+ return rows.map(toDomain);
578
+ }
579
+
580
+ private async foldAttemptIntoHealth(installId: string, attempt: RecordAttemptInput, now: string): Promise<void> {
581
+ const existing = await this.findHealthRow(installId);
582
+ const success = isSuccessAttempt(attempt.httpStatus);
583
+ const recentTraceIds = pushTraceId(
584
+ existing ? (JSON.parse(existing.recent_trace_ids) as string[]) : [],
585
+ attempt.traceId,
586
+ );
587
+
588
+ await this.writeHealthRow(installId, now, {
589
+ lastSuccessAt: success ? now : existing?.last_success_at ?? null,
590
+ failureStreak: success ? 0 : (existing?.failure_streak ?? 0) + 1,
591
+ dlqCount: existing?.dlq_count ?? 0,
592
+ lastStatus: attempt.httpStatus ?? existing?.last_status ?? null,
593
+ lastErrorClass: success ? null : attempt.errorClass ?? existing?.last_error_class ?? null,
594
+ recentTraceIds,
595
+ });
596
+ }
597
+
598
+ private async bumpDlqCount(installId: string, now: string): Promise<void> {
599
+ const existing = await this.findHealthRow(installId);
600
+ await this.writeHealthRow(installId, now, {
601
+ lastSuccessAt: existing?.last_success_at ?? null,
602
+ failureStreak: existing?.failure_streak ?? 0,
603
+ dlqCount: (existing?.dlq_count ?? 0) + 1,
604
+ lastStatus: existing?.last_status ?? null,
605
+ lastErrorClass: existing?.last_error_class ?? null,
606
+ recentTraceIds: existing ? (JSON.parse(existing.recent_trace_ids) as string[]) : [],
607
+ });
608
+ }
609
+
610
+ /** {@link requeueForReplay}'s counterpart to `bumpDlqCount` — floors at 0; a no-op if the install has no health row yet. */
611
+ private async decrementDlqCount(installId: string, now: string): Promise<void> {
612
+ const existing = await this.findHealthRow(installId);
613
+ if (!existing) return;
614
+ await this.writeHealthRow(installId, now, {
615
+ lastSuccessAt: existing.last_success_at,
616
+ failureStreak: existing.failure_streak,
617
+ dlqCount: Math.max(0, existing.dlq_count - 1),
618
+ lastStatus: existing.last_status,
619
+ lastErrorClass: existing.last_error_class,
620
+ recentTraceIds: JSON.parse(existing.recent_trace_ids) as string[],
621
+ });
622
+ }
623
+
624
+ private async findHealthRow(installId: string): Promise<HealthRow | null> {
625
+ return this.db.first<HealthRow>(`SELECT * FROM webhook_install_health WHERE install_id = ?`, installId);
626
+ }
627
+
628
+ private async writeHealthRow(
629
+ installId: string,
630
+ now: string,
631
+ patch: {
632
+ lastSuccessAt: string | null;
633
+ failureStreak: number;
634
+ dlqCount: number;
635
+ lastStatus: number | null;
636
+ lastErrorClass: string | null;
637
+ recentTraceIds: string[];
638
+ },
639
+ ): Promise<void> {
640
+ await this.db.run(
641
+ `INSERT OR REPLACE INTO webhook_install_health
642
+ (install_id, last_success_at, failure_streak, dlq_count, last_status, last_error_class, recent_trace_ids, updated_at)
643
+ VALUES (?,?,?,?,?,?,?,?)`,
644
+ installId,
645
+ patch.lastSuccessAt,
646
+ patch.failureStreak,
647
+ patch.dlqCount,
648
+ patch.lastStatus,
649
+ patch.lastErrorClass,
650
+ JSON.stringify(patch.recentTraceIds),
651
+ now,
652
+ );
653
+ }
654
+
655
+ /** Fetch a row or throw the generic fail-closed `not_found` — no enumeration detail. */
656
+ private async mustFindDeliveryRow(deliveryId: string): Promise<DeliveryRow> {
657
+ const row = await this.db.first<DeliveryRow>(
658
+ `SELECT * FROM webhook_deliveries WHERE delivery_id = ?`,
659
+ deliveryId,
660
+ );
661
+ if (!row) throw new WebhookStoreError(`Webhook delivery not found.`, "not_found");
662
+ return row;
663
+ }
664
+
665
+ private async getOrThrowDelivery(deliveryId: string): Promise<WebhookDelivery> {
666
+ return toDomain(await this.mustFindDeliveryRow(deliveryId));
667
+ }
668
+ }