@xtandard/webhooks 0.1.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 (279) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +315 -0
  3. package/bin/xtandard-webhooks.mjs +3 -0
  4. package/dist/basic-BIW3Rvuz.cjs +199 -0
  5. package/dist/basic-BIW3Rvuz.cjs.map +1 -0
  6. package/dist/basic-DKk0Xfuu.mjs +176 -0
  7. package/dist/basic-DKk0Xfuu.mjs.map +1 -0
  8. package/dist/chunk-D7D4PA-g.mjs +13 -0
  9. package/dist/cli.cjs +655 -0
  10. package/dist/cli.cjs.map +1 -0
  11. package/dist/cli.d.cts +42 -0
  12. package/dist/cli.d.mts +42 -0
  13. package/dist/cli.mjs +653 -0
  14. package/dist/cli.mjs.map +1 -0
  15. package/dist/contract-8h-Azxa5.d.cts +71 -0
  16. package/dist/contract-9XpcwcCn.mjs +22 -0
  17. package/dist/contract-9XpcwcCn.mjs.map +1 -0
  18. package/dist/contract-B2d5dNU3.cjs +33 -0
  19. package/dist/contract-B2d5dNU3.cjs.map +1 -0
  20. package/dist/contract-BEhDcd_5.mjs +28 -0
  21. package/dist/contract-BEhDcd_5.mjs.map +1 -0
  22. package/dist/contract-Bf1qguwt.cjs +57 -0
  23. package/dist/contract-Bf1qguwt.cjs.map +1 -0
  24. package/dist/contract-Bnb3fgRJ.d.cts +177 -0
  25. package/dist/contract-C2r2Xzwp.d.mts +46 -0
  26. package/dist/contract-CiPskNvS.d.cts +46 -0
  27. package/dist/contract-DhQ4JjGG.d.mts +71 -0
  28. package/dist/contract-T1kcZNdG.d.mts +177 -0
  29. package/dist/contract-lETlIuXo.d.cts +30 -0
  30. package/dist/contract-lETlIuXo.d.mts +30 -0
  31. package/dist/core-CMpnmI5Q.mjs +1605 -0
  32. package/dist/core-CMpnmI5Q.mjs.map +1 -0
  33. package/dist/core-DT4ppWh8.d.mts +502 -0
  34. package/dist/core-KJawHjFF.d.cts +502 -0
  35. package/dist/core-ZGhH6Vs2.cjs +1790 -0
  36. package/dist/core-ZGhH6Vs2.cjs.map +1 -0
  37. package/dist/core.cjs +8 -0
  38. package/dist/core.d.cts +2 -0
  39. package/dist/core.d.mts +2 -0
  40. package/dist/core.mjs +2 -0
  41. package/dist/create-fetch-handler-BIdk9P30.mjs +1724 -0
  42. package/dist/create-fetch-handler-BIdk9P30.mjs.map +1 -0
  43. package/dist/create-fetch-handler-CmooujQo.cjs +1771 -0
  44. package/dist/create-fetch-handler-CmooujQo.cjs.map +1 -0
  45. package/dist/create-fetch-handler-Dlkhustu.d.cts +162 -0
  46. package/dist/create-fetch-handler-jy3hy5nZ.d.mts +162 -0
  47. package/dist/dispatcher-B0xTEHt1.cjs +212 -0
  48. package/dist/dispatcher-B0xTEHt1.cjs.map +1 -0
  49. package/dist/dispatcher-Coubwrka.mjs +196 -0
  50. package/dist/dispatcher-Coubwrka.mjs.map +1 -0
  51. package/dist/entry-auth-basic.cjs +5 -0
  52. package/dist/entry-auth-basic.d.cts +83 -0
  53. package/dist/entry-auth-basic.d.mts +83 -0
  54. package/dist/entry-auth-basic.mjs +2 -0
  55. package/dist/entry-auth-delegated.cjs +28 -0
  56. package/dist/entry-auth-delegated.cjs.map +1 -0
  57. package/dist/entry-auth-delegated.d.cts +36 -0
  58. package/dist/entry-auth-delegated.d.mts +36 -0
  59. package/dist/entry-auth-delegated.mjs +27 -0
  60. package/dist/entry-auth-delegated.mjs.map +1 -0
  61. package/dist/entry-auth-none.cjs +4 -0
  62. package/dist/entry-auth-none.d.cts +25 -0
  63. package/dist/entry-auth-none.d.mts +25 -0
  64. package/dist/entry-auth-none.mjs +2 -0
  65. package/dist/entry-authorization-delegated.cjs +27 -0
  66. package/dist/entry-authorization-delegated.cjs.map +1 -0
  67. package/dist/entry-authorization-delegated.d.cts +31 -0
  68. package/dist/entry-authorization-delegated.d.mts +31 -0
  69. package/dist/entry-authorization-delegated.mjs +26 -0
  70. package/dist/entry-authorization-delegated.mjs.map +1 -0
  71. package/dist/entry-authorization-none.cjs +3 -0
  72. package/dist/entry-authorization-none.d.cts +18 -0
  73. package/dist/entry-authorization-none.d.mts +18 -0
  74. package/dist/entry-authorization-none.mjs +2 -0
  75. package/dist/entry-authorization-roles.cjs +6 -0
  76. package/dist/entry-authorization-roles.d.cts +65 -0
  77. package/dist/entry-authorization-roles.d.mts +65 -0
  78. package/dist/entry-authorization-roles.mjs +2 -0
  79. package/dist/entry-bun.cjs +24 -0
  80. package/dist/entry-bun.cjs.map +1 -0
  81. package/dist/entry-bun.d.cts +8 -0
  82. package/dist/entry-bun.d.mts +8 -0
  83. package/dist/entry-bun.mjs +23 -0
  84. package/dist/entry-bun.mjs.map +1 -0
  85. package/dist/entry-drizzle-mysql.cjs +20 -0
  86. package/dist/entry-drizzle-mysql.cjs.map +1 -0
  87. package/dist/entry-drizzle-mysql.d.cts +27 -0
  88. package/dist/entry-drizzle-mysql.d.mts +27 -0
  89. package/dist/entry-drizzle-mysql.mjs +19 -0
  90. package/dist/entry-drizzle-mysql.mjs.map +1 -0
  91. package/dist/entry-drizzle-pg.cjs +21 -0
  92. package/dist/entry-drizzle-pg.cjs.map +1 -0
  93. package/dist/entry-drizzle-pg.d.cts +26 -0
  94. package/dist/entry-drizzle-pg.d.mts +26 -0
  95. package/dist/entry-drizzle-pg.mjs +20 -0
  96. package/dist/entry-drizzle-pg.mjs.map +1 -0
  97. package/dist/entry-drizzle-sqlite.cjs +21 -0
  98. package/dist/entry-drizzle-sqlite.cjs.map +1 -0
  99. package/dist/entry-drizzle-sqlite.d.cts +23 -0
  100. package/dist/entry-drizzle-sqlite.d.mts +23 -0
  101. package/dist/entry-drizzle-sqlite.mjs +20 -0
  102. package/dist/entry-drizzle-sqlite.mjs.map +1 -0
  103. package/dist/entry-elysia.cjs +125 -0
  104. package/dist/entry-elysia.cjs.map +1 -0
  105. package/dist/entry-elysia.d.cts +1017 -0
  106. package/dist/entry-elysia.d.mts +1017 -0
  107. package/dist/entry-elysia.mjs +123 -0
  108. package/dist/entry-elysia.mjs.map +1 -0
  109. package/dist/entry-express.cjs +57 -0
  110. package/dist/entry-express.cjs.map +1 -0
  111. package/dist/entry-express.d.cts +15 -0
  112. package/dist/entry-express.d.mts +15 -0
  113. package/dist/entry-express.mjs +56 -0
  114. package/dist/entry-express.mjs.map +1 -0
  115. package/dist/entry-hono.cjs +35 -0
  116. package/dist/entry-hono.cjs.map +1 -0
  117. package/dist/entry-hono.d.cts +16 -0
  118. package/dist/entry-hono.d.mts +16 -0
  119. package/dist/entry-hono.mjs +34 -0
  120. package/dist/entry-hono.mjs.map +1 -0
  121. package/dist/entry-hooks-log.cjs +22 -0
  122. package/dist/entry-hooks-log.cjs.map +1 -0
  123. package/dist/entry-hooks-log.d.cts +23 -0
  124. package/dist/entry-hooks-log.d.mts +23 -0
  125. package/dist/entry-hooks-log.mjs +21 -0
  126. package/dist/entry-hooks-log.mjs.map +1 -0
  127. package/dist/entry-storage-cloudflare-kv.cjs +47 -0
  128. package/dist/entry-storage-cloudflare-kv.cjs.map +1 -0
  129. package/dist/entry-storage-cloudflare-kv.d.cts +42 -0
  130. package/dist/entry-storage-cloudflare-kv.d.mts +42 -0
  131. package/dist/entry-storage-cloudflare-kv.mjs +46 -0
  132. package/dist/entry-storage-cloudflare-kv.mjs.map +1 -0
  133. package/dist/entry-storage-drizzle.cjs +78 -0
  134. package/dist/entry-storage-drizzle.cjs.map +1 -0
  135. package/dist/entry-storage-drizzle.d.cts +30 -0
  136. package/dist/entry-storage-drizzle.d.mts +30 -0
  137. package/dist/entry-storage-drizzle.mjs +77 -0
  138. package/dist/entry-storage-drizzle.mjs.map +1 -0
  139. package/dist/entry-storage-file.cjs +4 -0
  140. package/dist/entry-storage-file.d.cts +30 -0
  141. package/dist/entry-storage-file.d.mts +30 -0
  142. package/dist/entry-storage-file.mjs +2 -0
  143. package/dist/entry-storage-libsql.cjs +3 -0
  144. package/dist/entry-storage-libsql.d.cts +48 -0
  145. package/dist/entry-storage-libsql.d.mts +48 -0
  146. package/dist/entry-storage-libsql.mjs +2 -0
  147. package/dist/entry-storage-memory.cjs +3 -0
  148. package/dist/entry-storage-memory.d.cts +2 -0
  149. package/dist/entry-storage-memory.d.mts +2 -0
  150. package/dist/entry-storage-memory.mjs +2 -0
  151. package/dist/entry-storage-mongodb.cjs +3 -0
  152. package/dist/entry-storage-mongodb.d.cts +55 -0
  153. package/dist/entry-storage-mongodb.d.mts +55 -0
  154. package/dist/entry-storage-mongodb.mjs +2 -0
  155. package/dist/entry-storage-postgres.cjs +3 -0
  156. package/dist/entry-storage-postgres.d.cts +62 -0
  157. package/dist/entry-storage-postgres.d.mts +62 -0
  158. package/dist/entry-storage-postgres.mjs +2 -0
  159. package/dist/entry-storage-redis.cjs +4 -0
  160. package/dist/entry-storage-redis.d.cts +77 -0
  161. package/dist/entry-storage-redis.d.mts +77 -0
  162. package/dist/entry-storage-redis.mjs +2 -0
  163. package/dist/entry-storage-sqlite.cjs +3 -0
  164. package/dist/entry-storage-sqlite.d.cts +36 -0
  165. package/dist/entry-storage-sqlite.d.mts +36 -0
  166. package/dist/entry-storage-sqlite.mjs +2 -0
  167. package/dist/entry-storage-unstorage.cjs +42 -0
  168. package/dist/entry-storage-unstorage.cjs.map +1 -0
  169. package/dist/entry-storage-unstorage.d.cts +29 -0
  170. package/dist/entry-storage-unstorage.d.mts +29 -0
  171. package/dist/entry-storage-unstorage.mjs +41 -0
  172. package/dist/entry-storage-unstorage.mjs.map +1 -0
  173. package/dist/file-COBYZA4Q.cjs +148 -0
  174. package/dist/file-COBYZA4Q.cjs.map +1 -0
  175. package/dist/file-fi02eFHk.mjs +131 -0
  176. package/dist/file-fi02eFHk.mjs.map +1 -0
  177. package/dist/index.cjs +123 -0
  178. package/dist/index.cjs.map +1 -0
  179. package/dist/index.d.cts +368 -0
  180. package/dist/index.d.mts +366 -0
  181. package/dist/index.mjs +61 -0
  182. package/dist/index.mjs.map +1 -0
  183. package/dist/keys-Byyj4quQ.mjs +111 -0
  184. package/dist/keys-Byyj4quQ.mjs.map +1 -0
  185. package/dist/keys-FiKpaVHX.cjs +302 -0
  186. package/dist/keys-FiKpaVHX.cjs.map +1 -0
  187. package/dist/libsql-bpVi0bXN.mjs +113 -0
  188. package/dist/libsql-bpVi0bXN.mjs.map +1 -0
  189. package/dist/libsql-pPJEo1e4.cjs +124 -0
  190. package/dist/libsql-pPJEo1e4.cjs.map +1 -0
  191. package/dist/memory-8Ef-PL5a.cjs +137 -0
  192. package/dist/memory-8Ef-PL5a.cjs.map +1 -0
  193. package/dist/memory-BMsSSwqn.mjs +127 -0
  194. package/dist/memory-BMsSSwqn.mjs.map +1 -0
  195. package/dist/memory-FnMJWCmB.d.cts +28 -0
  196. package/dist/memory-qIvANEs_.d.mts +28 -0
  197. package/dist/mongodb-Cy8yo0uk.cjs +108 -0
  198. package/dist/mongodb-Cy8yo0uk.cjs.map +1 -0
  199. package/dist/mongodb-Ddaq9mml.mjs +97 -0
  200. package/dist/mongodb-Ddaq9mml.mjs.map +1 -0
  201. package/dist/none-BnZtaGNJ.mjs +23 -0
  202. package/dist/none-BnZtaGNJ.mjs.map +1 -0
  203. package/dist/none-CAsxCOWN.cjs +49 -0
  204. package/dist/none-CAsxCOWN.cjs.map +1 -0
  205. package/dist/none-CZVrfnmF.cjs +33 -0
  206. package/dist/none-CZVrfnmF.cjs.map +1 -0
  207. package/dist/none-GhVIoh_s.mjs +33 -0
  208. package/dist/none-GhVIoh_s.mjs.map +1 -0
  209. package/dist/postgres-C8WbchFa.cjs +134 -0
  210. package/dist/postgres-C8WbchFa.cjs.map +1 -0
  211. package/dist/postgres-c3pAhmhr.mjs +123 -0
  212. package/dist/postgres-c3pAhmhr.mjs.map +1 -0
  213. package/dist/react.css +1 -0
  214. package/dist/react.js +31465 -0
  215. package/dist/receiver.cjs +43 -0
  216. package/dist/receiver.cjs.map +1 -0
  217. package/dist/receiver.d.cts +36 -0
  218. package/dist/receiver.d.mts +36 -0
  219. package/dist/receiver.mjs +40 -0
  220. package/dist/receiver.mjs.map +1 -0
  221. package/dist/redis-CFJkuSgB.cjs +270 -0
  222. package/dist/redis-CFJkuSgB.cjs.map +1 -0
  223. package/dist/redis-CvLi0KF7.mjs +254 -0
  224. package/dist/redis-CvLi0KF7.mjs.map +1 -0
  225. package/dist/roles-D0G9XqBq.cjs +128 -0
  226. package/dist/roles-D0G9XqBq.cjs.map +1 -0
  227. package/dist/roles-vp361lTk.mjs +99 -0
  228. package/dist/roles-vp361lTk.mjs.map +1 -0
  229. package/dist/schema-mo__wv4P.d.cts +233 -0
  230. package/dist/schema-mo__wv4P.d.mts +233 -0
  231. package/dist/schema.cjs +13 -0
  232. package/dist/schema.cjs.map +1 -0
  233. package/dist/schema.d.cts +2 -0
  234. package/dist/schema.d.mts +2 -0
  235. package/dist/schema.mjs +11 -0
  236. package/dist/schema.mjs.map +1 -0
  237. package/dist/signing.cjs +162 -0
  238. package/dist/signing.cjs.map +1 -0
  239. package/dist/signing.d.cts +73 -0
  240. package/dist/signing.d.mts +73 -0
  241. package/dist/signing.mjs +156 -0
  242. package/dist/signing.mjs.map +1 -0
  243. package/dist/sqlite-Cmqnrjes.mjs +67 -0
  244. package/dist/sqlite-Cmqnrjes.mjs.map +1 -0
  245. package/dist/sqlite-Dcufk0x3.cjs +78 -0
  246. package/dist/sqlite-Dcufk0x3.cjs.map +1 -0
  247. package/dist/table-Ce3Tzwqs.d.cts +11 -0
  248. package/dist/table-Ce3Tzwqs.d.mts +11 -0
  249. package/dist/testing.cjs +134 -0
  250. package/dist/testing.cjs.map +1 -0
  251. package/dist/testing.d.cts +80 -0
  252. package/dist/testing.d.mts +80 -0
  253. package/dist/testing.mjs +131 -0
  254. package/dist/testing.mjs.map +1 -0
  255. package/dist/types-react/react.d.ts +98 -0
  256. package/dist/types-react/schema.d.ts +229 -0
  257. package/dist/types-react/ui/App.d.ts +22 -0
  258. package/dist/types-react/ui/api.d.ts +97 -0
  259. package/dist/types-react/ui/components/JsonCodeEditor.d.ts +12 -0
  260. package/dist/types-react/ui/components/ThemeToggle.d.ts +2 -0
  261. package/dist/types-react/ui/components/Toast.d.ts +16 -0
  262. package/dist/types-react/ui/components/primitives.d.ts +50 -0
  263. package/dist/types-react/ui/components/ui-bits.d.ts +22 -0
  264. package/dist/types-react/ui/components/webhook-bits.d.ts +51 -0
  265. package/dist/types-react/ui/lib/format.d.ts +39 -0
  266. package/dist/types-react/ui/lib/nav-guard.d.ts +20 -0
  267. package/dist/types-react/ui/lib/utils.d.ts +3 -0
  268. package/dist/types-react/ui/theme.d.ts +12 -0
  269. package/dist/types-react/ui/types.d.ts +80 -0
  270. package/dist/types-react/ui/views/AuditView.d.ts +6 -0
  271. package/dist/types-react/ui/views/DeliveriesView.d.ts +12 -0
  272. package/dist/types-react/ui/views/EndpointsView.d.ts +11 -0
  273. package/dist/types-react/ui/views/EventTypesView.d.ts +11 -0
  274. package/dist/types-react/ui/views/MessagesView.d.ts +10 -0
  275. package/dist/types-react/ui/views/OverviewView.d.ts +12 -0
  276. package/dist/ui/assets/index-B0eoQX2U.css +1 -0
  277. package/dist/ui/assets/index-S5t_CLOe.js +209 -0
  278. package/dist/ui/index.html +14 -0
  279. package/package.json +487 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Santiago Montoya
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,315 @@
1
+ <div align="center">
2
+
3
+ # @xtandard/webhooks
4
+
5
+ **Self-hosted, embeddable, [Standard Webhooks](https://www.standardwebhooks.com)-compliant outbound-webhooks control plane.**
6
+
7
+ Mount it inside your existing app, point it at the database you already run, and ship signed events with retries, dead-lettering, and a customer-facing portal — no per-message SaaS pricing, no separate service to operate.
8
+
9
+ [![CI](https://github.com/xantiagoma/xtandard-webhooks/actions/workflows/ci.yml/badge.svg)](https://github.com/xantiagoma/xtandard-webhooks/actions/workflows/ci.yml)
10
+ [![npm](https://img.shields.io/npm/v/%40xtandard%2Fwebhooks)](https://www.npmjs.com/package/@xtandard/webhooks)
11
+ [![license](https://img.shields.io/badge/license-MIT-blue)](./LICENSE)
12
+
13
+ <img src="docs/assets/deliveries.png" alt="Deliveries view: attempt timeline with retries and dead-letters" width="800" />
14
+
15
+ </div>
16
+
17
+ > `publish()` never blocks on a customer's server. Your app publishes; the dispatcher inside your process (or a dedicated worker running the same library) delivers; your customers self-serve through an embeddable portal.
18
+
19
+ ## Contents
20
+
21
+ - [Why another webhooks tool?](#why-another-webhooks-tool)
22
+ - [How it works — the two planes](#how-it-works--the-two-planes)
23
+ - [Quickstart](#quickstart)
24
+ - [Send your first webhook](#send-your-first-webhook)
25
+ - [Verify on the receiving side](#verify-on-the-receiving-side)
26
+ - [The delivery model](#the-delivery-model)
27
+ - [The consumer portal](#the-consumer-portal)
28
+ - [Storage backends](#storage-backends)
29
+ - [Subpath exports](#subpath-exports)
30
+ - [Examples](#examples)
31
+ - [CLI](#cli)
32
+ - [Documentation](#documentation)
33
+ - [Project status](#project-status)
34
+
35
+ ## Why another webhooks tool?
36
+
37
+ Webhook delivery is a library concern, not a service you rent or a second deployment you babysit.
38
+
39
+ - **Svix** is excellent — and a SaaS (or a heavyweight self-hosted server with its own Postgres/Redis to operate). This is `bun add` + the DB you already have.
40
+ - **Hookdeck / Convoy** are services to operate. Same objection.
41
+ - **Hand-rolled** senders reimplement signing, retries, and endpoint management — usually badly, always repeatedly. This packages the 20% everyone needs: applications, event types, endpoints, signed delivery, exponential retries, dead-letters, replay, observability, and a portal.
42
+
43
+ And because the wire contract is **Standard Webhooks**, your receivers verify with the official `standardwebhooks` libraries in Python, Go, Ruby, Java, Rust, PHP, or the zero-dependency `@xtandard/webhooks/receiver` — nothing bespoke on their side.
44
+
45
+ Sibling project: [`@xtandard/flags`](https://github.com/xantiagoma/xtandard-flags) — same architecture, same design system, for feature flags.
46
+
47
+ ## How it works — the two planes
48
+
49
+ ```mermaid
50
+ flowchart LR
51
+ subgraph host["your app process"]
52
+ A[app code] -- "publish()" --> C[core]
53
+ UI[admin UI / portal] --> C
54
+ D[dispatcher] --> C
55
+ end
56
+ C <--> S[(your database)]
57
+ D -- "signed POSTs, retries" --> E1[customer endpoint]
58
+ D --> E2[customer endpoint]
59
+ ```
60
+
61
+ - **Control plane** — CRUD on applications, event types, endpoints; browsing messages/deliveries; replay. Hook-guarded, audited, auth'd.
62
+ - **Delivery plane** — `publish()` is one message write + fan-out (no HTTP, never throws because an endpoint is down). The in-process dispatcher owns all network I/O and retries; leases make it crash-safe and multi-instance-safe. At-least-once semantics; receivers dedupe on `webhook-id`.
63
+
64
+ Kill the process mid-retry-schedule and restart it: pending deliveries resume. The admin UI can be completely unmounted and delivery still works.
65
+
66
+ ## Quickstart
67
+
68
+ <details>
69
+ <summary><b>Standalone (Docker)</b></summary>
70
+
71
+ ```sh
72
+ docker run -p 3000:3000 -e STORAGE_DRIVER=memory ghcr.io/xantiagoma/xtandard-webhooks
73
+ # open http://localhost:3000
74
+ ```
75
+
76
+ </details>
77
+
78
+ <details>
79
+ <summary><b>CLI (npx / bunx)</b></summary>
80
+
81
+ ```sh
82
+ bunx @xtandard/webhooks serve # STORAGE_DRIVER=file by default → ./.webhooks
83
+ ```
84
+
85
+ </details>
86
+
87
+ <details>
88
+ <summary><b>Elysia</b></summary>
89
+
90
+ ```ts
91
+ import { Elysia } from "elysia";
92
+ import { webhooksPanel } from "@xtandard/webhooks/elysia";
93
+ import { createMemoryStorage } from "@xtandard/webhooks/storage/memory";
94
+
95
+ const webhooks = webhooksPanel({ storage: createMemoryStorage() });
96
+ new Elysia().mount("/webhooks", webhooks.fetch).listen(3000);
97
+ ```
98
+
99
+ </details>
100
+
101
+ <details>
102
+ <summary><b>Hono</b></summary>
103
+
104
+ ```ts
105
+ import { Hono } from "hono";
106
+ import { webhooksPanel } from "@xtandard/webhooks/hono";
107
+ import { createMemoryStorage } from "@xtandard/webhooks/storage/memory";
108
+
109
+ const app = new Hono();
110
+ app.route("/webhooks", webhooksPanel({ storage: createMemoryStorage() }));
111
+ ```
112
+
113
+ </details>
114
+
115
+ <details>
116
+ <summary><b>Express</b></summary>
117
+
118
+ ```ts
119
+ import express from "express";
120
+ import { webhooksPanel } from "@xtandard/webhooks/express";
121
+ import { createMemoryStorage } from "@xtandard/webhooks/storage/memory";
122
+
123
+ const app = express();
124
+ app.use("/webhooks", webhooksPanel({ storage: createMemoryStorage() })); // before body-parser
125
+ app.listen(3000);
126
+ ```
127
+
128
+ </details>
129
+
130
+ <details>
131
+ <summary><b>Bun</b></summary>
132
+
133
+ ```ts
134
+ import { webhooksPanel } from "@xtandard/webhooks/bun";
135
+ import { createMemoryStorage } from "@xtandard/webhooks/storage/memory";
136
+
137
+ const webhooks = webhooksPanel({ storage: createMemoryStorage(), basePath: "/webhooks" });
138
+ Bun.serve({ port: 3000, fetch: webhooks.fetch });
139
+ ```
140
+
141
+ </details>
142
+
143
+ Mounting the panel starts an in-process dispatcher by default (`dispatcher: false` for split-worker deployments).
144
+
145
+ ## Send your first webhook
146
+
147
+ ```ts
148
+ const { core } = webhooks;
149
+
150
+ await core.createApplication({ key: "acme" }); // your customer
151
+ await core.upsertEventType({ name: "invoice.paid" }); // the catalog
152
+ await core.createEndpoint("acme", {
153
+ url: "https://api.acme-customer.com/webhooks", // their receiver
154
+ eventTypes: ["invoice.paid"],
155
+ });
156
+
157
+ // The hot path — in the handler where the thing actually happens:
158
+ await core.publish("acme", {
159
+ eventType: "invoice.paid",
160
+ payload: { invoiceId: "inv_123", amount: 4200 },
161
+ idempotencyKey: `invoice-paid-inv_123`, // safe to call twice
162
+ });
163
+ ```
164
+
165
+ ## Verify on the receiving side
166
+
167
+ TypeScript (this package, zero deps, any WinterCG runtime):
168
+
169
+ ```ts
170
+ import { verifyWebhook } from "@xtandard/webhooks/receiver";
171
+
172
+ export default async function handler(request: Request) {
173
+ const event = await verifyWebhook(request, process.env.WEBHOOK_SECRET!); // throws if invalid
174
+ // event.type === "invoice.paid", event.data === { invoiceId, amount }
175
+ return new Response("ok");
176
+ }
177
+ ```
178
+
179
+ Python / Go / anything — the **official** Standard Webhooks libraries verify deliveries from this package unmodified:
180
+
181
+ ```python
182
+ from standardwebhooks.webhooks import Webhook
183
+ payload = Webhook(secret).verify(body, headers) # raises on failure
184
+ ```
185
+
186
+ `examples/receivers/` runs FastAPI + Go receivers against a live dispatcher as the interop proof. Details: [docs/SIGNING.md](docs/SIGNING.md).
187
+
188
+ ## The delivery model
189
+
190
+ ```txt
191
+ attempt: #1 #2 #3 #4 #5 #6 #7
192
+ delay: 0s ───► 5s ───► 5m ───► 30m ───► 2h ───► 5h ───► 10h ──► dead-letter
193
+ (±10% jitter; fully configurable)
194
+ ```
195
+
196
+ - Failures walk the schedule; exhaustion **dead-letters** (never silently drops) — visible in the UI with per-attempt HTTP detail, replayable one-at-a-time or in bulk (`recover` an endpoint since a timestamp).
197
+ - Endpoints failing every attempt for 5 consecutive days auto-disable (configurable); disabled endpoints hold deliveries and resume on enable.
198
+ - Every attempt hits the fire-and-forget `onDelivery` sink (metrics); terminal transitions fire `after` hooks (`delivery.succeeded` / `delivery.exhausted` — the offload point).
199
+ - Secret rotation keeps the old secret verifying for a 24h grace window, with both signatures in the header.
200
+
201
+ Details: [docs/DELIVERY.md](docs/DELIVERY.md).
202
+
203
+ ## The consumer portal
204
+
205
+ The Svix "App Portal" experience, self-hosted: your customers manage their own endpoints and inspect their own deliveries inside your product, scoped by a signed token — no sessions, no proxy routes.
206
+
207
+ ```tsx
208
+ // Your server: mint a token after your own auth
209
+ const token = await createPortalToken(process.env.PORTAL_SECRET!, customer.appKey);
210
+
211
+ // Your React frontend:
212
+ import { WebhooksPortal } from "@xtandard/webhooks/react";
213
+ import "@xtandard/webhooks/react/styles.css";
214
+
215
+ <WebhooksPortal baseUrl="/webhooks" token={token} />;
216
+ ```
217
+
218
+ Cross-application access is denied by construction — the host's authorization is never consulted for portal principals. Details: [docs/PORTAL.md](docs/PORTAL.md).
219
+
220
+ ## Storage backends
221
+
222
+ Point it at what you already run — the whole system sits on a four-method KV contract ([ADR 0005](docs/ADR/0005-storage-kv-contract-due-index.md)):
223
+
224
+ | Backend | Subpath | Notes |
225
+ | -------------- | ----------------------------------------------- | --------------------------------------------------------------------------- |
226
+ | Memory | `storage/memory` | dev/tests/demo; every capability |
227
+ | File | `storage/file` | zero-dep persistence |
228
+ | Redis | `storage/redis` | native queue claiming (sorted set + Lua), pub/sub watch, JSON codec variant |
229
+ | Postgres | `storage/postgres` | jsonb KV, lazy DDL |
230
+ | Drizzle | `storage/drizzle` + `drizzle/{pg,mysql,sqlite}` | your ORM, your migrations |
231
+ | MongoDB | `storage/mongodb` | |
232
+ | SQLite (Bun) | `storage/sqlite` | `bun:sqlite` |
233
+ | libSQL / Turso | `storage/libsql` | |
234
+ | unstorage | `storage/unstorage` | 20+ drivers via unjs |
235
+ | Cloudflare KV | `storage/cloudflare-kv` | dispatch via cron trigger |
236
+
237
+ Split planes: control data in Postgres, delivery queue in Redis — `queueStorage`. Details: [docs/STORAGE.md](docs/STORAGE.md).
238
+
239
+ ## Subpath exports
240
+
241
+ | Import | What you get |
242
+ | ---------------------------------------- | --------------------------------------------------------------------------- |
243
+ | `@xtandard/webhooks` | core, dispatcher, panel handler, signing, portal tokens, hooks contract |
244
+ | `…/receiver` | `verifyWebhook` — zero-dep verification of **any** Standard Webhooks sender |
245
+ | `…/signing` | low-level sign/verify primitives |
246
+ | `…/schema` | types only |
247
+ | `…/testing` | test core + verifying local receiver + drain helper |
248
+ | `…/storage/*`, `…/drizzle/*` | storage adapters (optional peers) |
249
+ | `…/auth/{none,basic,delegated}` | authentication providers |
250
+ | `…/authorization/{none,roles,delegated}` | authorization providers |
251
+ | `…/hooks/log` | reference logging hook |
252
+ | `…/{elysia,hono,express,bun}` | framework adapters |
253
+ | `…/react`, `…/react/styles.css` | `<WebhooksDashboard>` + `<WebhooksPortal>` embeds |
254
+
255
+ ## Examples
256
+
257
+ `bun run demo` boots a seeded playground on http://localhost:7789 — two applications, a grouped event catalog, healthy/flaky/dead endpoints producing real live attempt history, dead-letters to replay.
258
+
259
+ | Example | Shows |
260
+ | ------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- |
261
+ | [`elysia`](examples/elysia) / [`hono`](examples/hono) / [`express`](examples/express) | panel + publish-on-user-action loop per framework |
262
+ | [`full-loop`](examples/full-loop) | sender + verifying receiver in one command; watch retries live |
263
+ | [`portal-embed`](examples/portal-embed) | `<WebhooksPortal>` inside a host React app |
264
+ | [`auth`](examples/auth) | none/basic/delegated + roles + portal side-by-side |
265
+ | [`receivers`](examples/receivers) | **polyglot proof**: official Python + Go libs verifying our deliveries |
266
+ | [`storage-drivers`](examples/storage-drivers) | one contract, every backend |
267
+ | [`postgres-redis`](examples/postgres-redis) | split planes: control in Postgres, queue in Redis |
268
+ | [`split-worker`](examples/split-worker) | web publishes, worker dispatches |
269
+ | [`standalone-docker`](examples/standalone-docker) | compose file for the image |
270
+
271
+ ## Screenshots
272
+
273
+ | | |
274
+ | ------------------------------------- | --------------------------------------- |
275
+ | ![Overview](docs/assets/overview.png) | ![Endpoints](docs/assets/endpoints.png) |
276
+
277
+ ## CLI
278
+
279
+ ```txt
280
+ xtandard-webhooks serve # panel + dispatcher from env
281
+ xtandard-webhooks dispatch # dispatcher only (split worker)
282
+ xtandard-webhooks init # create an app + example event type
283
+ xtandard-webhooks list-apps
284
+ xtandard-webhooks list-endpoints --app acme
285
+ xtandard-webhooks publish --app acme --type invoice.paid --data '{"n":1}'
286
+ xtandard-webhooks retry --app acme --delivery dlv_…
287
+ xtandard-webhooks verify --secret whsec_… --payload-file body.json --headers-file headers.json
288
+ xtandard-webhooks listen --port 4000 --secret whsec_… # local inspecting receiver
289
+ xtandard-webhooks sign --secret whsec_… --data '{"n":1}' --url http://localhost:4000
290
+ ```
291
+
292
+ Env contract (storage drivers, auth, retention, `RETRY_SCHEDULE`, …): [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md).
293
+
294
+ ## Testing webhooks
295
+
296
+ Self-hosted equivalents of webhook.site / Svix Play / the Standard Webhooks simulator — no hosting, no accounts:
297
+
298
+ - **`xtandard-webhooks listen`** — a local inspecting receiver: point an endpoint at `http://localhost:4000` and every incoming webhook is pretty-printed; with `--secret` it verifies the signature (VERIFIED/FAILED) and answers 401 on failure so senders exercise their retry path.
299
+ - **`xtandard-webhooks sign`** — the signature playground: builds a fully signed request from a secret + payload, printing the headers/body and a ready-to-run `curl` (with `--url`).
300
+ - **Panel "Request" inspector** — on any delivery's detail, see the exact signed request it sends (method, URL, all headers including `webhook-signature`, body) with a Copy-as-curl button.
301
+ - **`@xtandard/webhooks/testing`** — programmatic: `createTestReceiver` (a verifying local receiver) + `drainDeliveries` for deterministic tests.
302
+
303
+ See [docs/TESTING.md](docs/TESTING.md).
304
+
305
+ ## Documentation
306
+
307
+ [Getting started](docs/GETTING_STARTED.md) · [Architecture](docs/ARCHITECTURE.md) · [Delivery](docs/DELIVERY.md) · [Signing](docs/SIGNING.md) · [Storage](docs/STORAGE.md) · [Portal](docs/PORTAL.md) · [Auth](docs/AUTH.md) · [Authorization](docs/AUTHORIZATION.md) · [Hooks](docs/HOOKS.md) · [UI](docs/UI.md) · [Adapters](docs/ADAPTERS.md) · [Deployment](docs/DEPLOYMENT.md) · [Testing](docs/TESTING.md) · [Releases](docs/RELEASES.md) · [ADRs](docs/ADR)
308
+
309
+ ## Project status
310
+
311
+ ZeroVer (`0.x`) and pre-first-publish: the surface is stabilizing, minor versions may break, and feedback shapes it — issues welcome. Anti-bloat is a core value: when a feature is composable from an existing primitive in ten lines, we document the recipe instead of shipping the subsystem.
312
+
313
+ ## License
314
+
315
+ [MIT](./LICENSE)
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { run } from "../dist/cli.mjs";
3
+ run(process.argv.slice(2)).then((code) => process.exit(code));
@@ -0,0 +1,199 @@
1
+ const require_keys = require("./keys-FiKpaVHX.cjs");
2
+ let node_crypto = require("node:crypto");
3
+ //#region src/auth/basic.ts
4
+ /**
5
+ * HTTP Basic authentication {@link AuthProvider}.
6
+ *
7
+ * Parses the `Authorization: Basic <base64>` header, looks the username up in a
8
+ * configured user list, and verifies the supplied password using one of three
9
+ * credential modes (in order of preference):
10
+ *
11
+ * 1. A custom `passwordVerifier(username, password)` callback — for delegating
12
+ * to your own user store.
13
+ * 2. A `passwordHash` produced by {@link hashPassword} — scrypt with a random
14
+ * salt, verified in constant time. **Recommended for production.**
15
+ * 3. A plain `password` field — **development only**. Never ship real
16
+ * credentials as plaintext; they are compared in constant time but stored as
17
+ * cleartext in your config.
18
+ *
19
+ * On success the matched user becomes a {@link Principal}; on any failure
20
+ * (missing/malformed header, unknown user, bad password) `authenticate` returns
21
+ * `null`. {@link AuthProvider.challenge} emits a `401` with a
22
+ * `WWW-Authenticate: Basic realm="…"` header so browsers prompt for credentials.
23
+ *
24
+ * Password hashing uses Node's `node:crypto` `scrypt`, which is available in
25
+ * both Node and Bun — no Bun-only APIs and no extra dependencies.
26
+ *
27
+ * @module
28
+ */
29
+ var basic_exports = /* @__PURE__ */ require_keys.__exportAll({
30
+ basicAuth: () => basicAuth,
31
+ hashPassword: () => hashPassword,
32
+ verifyPassword: () => verifyPassword
33
+ });
34
+ /** scrypt key length (bytes) used by {@link hashPassword}. */
35
+ const KEY_LENGTH = 64;
36
+ /** Salt length (bytes) used by {@link hashPassword}. */
37
+ const SALT_LENGTH = 16;
38
+ /** Prefix identifying a {@link hashPassword} digest. */
39
+ const SCRYPT_PREFIX = "scrypt";
40
+ const scryptAsync = (password, salt, keylen) => new Promise((resolve, reject) => {
41
+ (0, node_crypto.scrypt)(password, salt, keylen, (err, derivedKey) => {
42
+ if (err) reject(err);
43
+ else resolve(derivedKey);
44
+ });
45
+ });
46
+ /** Lowercase hex encoding of a buffer. */
47
+ const toHex = (buf) => buf.toString("hex");
48
+ /**
49
+ * Hash a password with scrypt and a fresh random salt.
50
+ *
51
+ * The returned string is self-describing and safe to store in config or a
52
+ * database: `scrypt$<saltHex>$<hashHex>`. Pass it back to
53
+ * {@link verifyPassword} (or supply it as a user's `passwordHash`) to check a
54
+ * candidate password.
55
+ *
56
+ * @example
57
+ * ```ts
58
+ * const stored = await hashPassword("correct horse battery staple");
59
+ * // → "scrypt$<32 hex chars>$<128 hex chars>"
60
+ * ```
61
+ */
62
+ async function hashPassword(password) {
63
+ const salt = (0, node_crypto.randomBytes)(SALT_LENGTH);
64
+ const derived = await scryptAsync(password, salt, KEY_LENGTH);
65
+ return `${SCRYPT_PREFIX}$${toHex(salt)}$${toHex(derived)}`;
66
+ }
67
+ /**
68
+ * Verify a candidate `password` against a digest produced by
69
+ * {@link hashPassword}.
70
+ *
71
+ * Re-derives the scrypt hash using the stored salt and compares it to the stored
72
+ * hash with `crypto.timingSafeEqual` (constant time). Returns `false` — rather
73
+ * than throwing — for malformed or unrecognized digests.
74
+ */
75
+ async function verifyPassword(password, stored) {
76
+ const parts = stored.split("$");
77
+ if (parts.length !== 3 || parts[0] !== SCRYPT_PREFIX) return false;
78
+ const saltHex = parts[1];
79
+ const hashHex = parts[2];
80
+ if (!saltHex || !hashHex) return false;
81
+ let salt;
82
+ let expected;
83
+ try {
84
+ salt = Buffer.from(saltHex, "hex");
85
+ expected = Buffer.from(hashHex, "hex");
86
+ } catch {
87
+ return false;
88
+ }
89
+ if (salt.length === 0 || expected.length === 0) return false;
90
+ const derived = await scryptAsync(password, salt, expected.length);
91
+ if (derived.length !== expected.length) return false;
92
+ return (0, node_crypto.timingSafeEqual)(derived, expected);
93
+ }
94
+ /** Constant-time string comparison that does not leak length via early exit. */
95
+ function constantTimeEquals(a, b) {
96
+ const bufA = Buffer.from(a, "utf8");
97
+ const bufB = Buffer.from(b, "utf8");
98
+ if (bufA.length !== bufB.length) {
99
+ (0, node_crypto.timingSafeEqual)(bufA, bufA);
100
+ return false;
101
+ }
102
+ return (0, node_crypto.timingSafeEqual)(bufA, bufB);
103
+ }
104
+ /** Decode the `Authorization: Basic <base64>` header, or `null` if absent/malformed. */
105
+ function parseBasicHeader(request) {
106
+ const header = request.headers.get("authorization");
107
+ if (!header) return null;
108
+ const [scheme, encoded] = header.split(" ", 2);
109
+ if (!scheme || scheme.toLowerCase() !== "basic" || !encoded) return null;
110
+ let decoded;
111
+ try {
112
+ decoded = Buffer.from(encoded, "base64").toString("utf8");
113
+ } catch {
114
+ return null;
115
+ }
116
+ const sep = decoded.indexOf(":");
117
+ if (sep < 0) return null;
118
+ return {
119
+ username: decoded.slice(0, sep),
120
+ password: decoded.slice(sep + 1)
121
+ };
122
+ }
123
+ /** Build the {@link Principal} for a successfully authenticated user. */
124
+ function principalFor(user) {
125
+ return {
126
+ id: user.id ?? user.username,
127
+ name: user.username,
128
+ ...user.email !== void 0 ? { email: user.email } : {},
129
+ ...user.roles !== void 0 ? { roles: user.roles } : {}
130
+ };
131
+ }
132
+ /**
133
+ * Create an HTTP Basic {@link AuthProvider}.
134
+ *
135
+ * @example
136
+ * ```ts
137
+ * import { basicAuth, hashPassword } from "@xtandard/webhooks/auth/basic";
138
+ *
139
+ * const auth = basicAuth({
140
+ * realm: "Webhooks Admin",
141
+ * users: [
142
+ * { username: "admin", passwordHash: await hashPassword("s3cret"), roles: ["admin"] },
143
+ * ],
144
+ * });
145
+ * ```
146
+ */
147
+ function basicAuth(options) {
148
+ const realm = options.realm ?? "xtandard-webhooks";
149
+ const usersByName = /* @__PURE__ */ new Map();
150
+ for (const user of options.users) usersByName.set(user.username, user);
151
+ return {
152
+ async authenticate(request) {
153
+ const creds = parseBasicHeader(request);
154
+ if (!creds) return null;
155
+ const user = usersByName.get(creds.username);
156
+ if (!user) {
157
+ if (options.passwordVerifier) await options.passwordVerifier(creds.username, creds.password);
158
+ return null;
159
+ }
160
+ if (options.passwordVerifier) return await options.passwordVerifier(creds.username, creds.password) ? principalFor(user) : null;
161
+ if (user.passwordHash !== void 0) return await verifyPassword(creds.password, user.passwordHash) ? principalFor(user) : null;
162
+ if (user.password !== void 0) return constantTimeEquals(creds.password, user.password) ? principalFor(user) : null;
163
+ return null;
164
+ },
165
+ challenge(_request) {
166
+ return new Response("Unauthorized", {
167
+ status: 401,
168
+ headers: { "WWW-Authenticate": `Basic realm="${realm}"` }
169
+ });
170
+ }
171
+ };
172
+ }
173
+ //#endregion
174
+ Object.defineProperty(exports, "basicAuth", {
175
+ enumerable: true,
176
+ get: function() {
177
+ return basicAuth;
178
+ }
179
+ });
180
+ Object.defineProperty(exports, "basic_exports", {
181
+ enumerable: true,
182
+ get: function() {
183
+ return basic_exports;
184
+ }
185
+ });
186
+ Object.defineProperty(exports, "hashPassword", {
187
+ enumerable: true,
188
+ get: function() {
189
+ return hashPassword;
190
+ }
191
+ });
192
+ Object.defineProperty(exports, "verifyPassword", {
193
+ enumerable: true,
194
+ get: function() {
195
+ return verifyPassword;
196
+ }
197
+ });
198
+
199
+ //# sourceMappingURL=basic-BIW3Rvuz.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"basic-BIW3Rvuz.cjs","names":[],"sources":["../src/auth/basic.ts"],"sourcesContent":["/**\n * HTTP Basic authentication {@link AuthProvider}.\n *\n * Parses the `Authorization: Basic <base64>` header, looks the username up in a\n * configured user list, and verifies the supplied password using one of three\n * credential modes (in order of preference):\n *\n * 1. A custom `passwordVerifier(username, password)` callback — for delegating\n * to your own user store.\n * 2. A `passwordHash` produced by {@link hashPassword} — scrypt with a random\n * salt, verified in constant time. **Recommended for production.**\n * 3. A plain `password` field — **development only**. Never ship real\n * credentials as plaintext; they are compared in constant time but stored as\n * cleartext in your config.\n *\n * On success the matched user becomes a {@link Principal}; on any failure\n * (missing/malformed header, unknown user, bad password) `authenticate` returns\n * `null`. {@link AuthProvider.challenge} emits a `401` with a\n * `WWW-Authenticate: Basic realm=\"…\"` header so browsers prompt for credentials.\n *\n * Password hashing uses Node's `node:crypto` `scrypt`, which is available in\n * both Node and Bun — no Bun-only APIs and no extra dependencies.\n *\n * @module\n */\n\nimport { randomBytes, scrypt as scryptCb, timingSafeEqual } from \"node:crypto\";\nimport type { AuthProvider, Principal } from \"./contract.ts\";\n\n/** scrypt key length (bytes) used by {@link hashPassword}. */\nconst KEY_LENGTH = 64;\n/** Salt length (bytes) used by {@link hashPassword}. */\nconst SALT_LENGTH = 16;\n/** Prefix identifying a {@link hashPassword} digest. */\nconst SCRYPT_PREFIX = \"scrypt\";\n\nconst scryptAsync = (password: string, salt: Buffer, keylen: number): Promise<Buffer> =>\n new Promise((resolve, reject) => {\n scryptCb(password, salt, keylen, (err, derivedKey) => {\n if (err) reject(err);\n else resolve(derivedKey);\n });\n });\n\n/** Lowercase hex encoding of a buffer. */\nconst toHex = (buf: Buffer): string => buf.toString(\"hex\");\n\n/**\n * Hash a password with scrypt and a fresh random salt.\n *\n * The returned string is self-describing and safe to store in config or a\n * database: `scrypt$<saltHex>$<hashHex>`. Pass it back to\n * {@link verifyPassword} (or supply it as a user's `passwordHash`) to check a\n * candidate password.\n *\n * @example\n * ```ts\n * const stored = await hashPassword(\"correct horse battery staple\");\n * // → \"scrypt$<32 hex chars>$<128 hex chars>\"\n * ```\n */\nexport async function hashPassword(password: string): Promise<string> {\n const salt = randomBytes(SALT_LENGTH);\n const derived = await scryptAsync(password, salt, KEY_LENGTH);\n return `${SCRYPT_PREFIX}$${toHex(salt)}$${toHex(derived)}`;\n}\n\n/**\n * Verify a candidate `password` against a digest produced by\n * {@link hashPassword}.\n *\n * Re-derives the scrypt hash using the stored salt and compares it to the stored\n * hash with `crypto.timingSafeEqual` (constant time). Returns `false` — rather\n * than throwing — for malformed or unrecognized digests.\n */\nexport async function verifyPassword(password: string, stored: string): Promise<boolean> {\n const parts = stored.split(\"$\");\n if (parts.length !== 3 || parts[0] !== SCRYPT_PREFIX) return false;\n const saltHex = parts[1];\n const hashHex = parts[2];\n if (!saltHex || !hashHex) return false;\n\n let salt: Buffer;\n let expected: Buffer;\n try {\n salt = Buffer.from(saltHex, \"hex\");\n expected = Buffer.from(hashHex, \"hex\");\n } catch {\n return false;\n }\n if (salt.length === 0 || expected.length === 0) return false;\n\n const derived = await scryptAsync(password, salt, expected.length);\n if (derived.length !== expected.length) return false;\n return timingSafeEqual(derived, expected);\n}\n\n/** A configured user for {@link basicAuth}. */\nexport interface BasicAuthUser {\n /** The login name, matched against the Basic credentials. */\n username: string;\n /**\n * A digest produced by {@link hashPassword} (`scrypt$<salt>$<hash>`).\n * Preferred for production.\n */\n passwordHash?: string;\n /**\n * A plaintext password. **Development only** — prefer `passwordHash`. Compared\n * in constant time but stored as cleartext in your config.\n */\n password?: string;\n /** Roles attached to the resulting {@link Principal}. */\n roles?: string[];\n /** Email attached to the resulting {@link Principal}. */\n email?: string;\n /** Principal id. Defaults to {@link BasicAuthUser.username} when omitted. */\n id?: string;\n}\n\n/** Options for {@link basicAuth}. */\nexport interface BasicAuthOptions {\n /** The known users. */\n users: BasicAuthUser[];\n /**\n * Realm advertised in the `WWW-Authenticate` header on challenge.\n * @default \"xtandard-webhooks\"\n */\n realm?: string;\n /**\n * Custom verifier. When supplied it takes precedence over `passwordHash` and\n * `password` for matched users — delegate to your own credential store and\n * return `true` to accept.\n */\n passwordVerifier?: (username: string, password: string) => Promise<boolean> | boolean;\n}\n\n/** Constant-time string comparison that does not leak length via early exit. */\nfunction constantTimeEquals(a: string, b: string): boolean {\n const bufA = Buffer.from(a, \"utf8\");\n const bufB = Buffer.from(b, \"utf8\");\n // timingSafeEqual requires equal lengths; hash to a fixed width first so the\n // comparison time does not depend on the inputs.\n if (bufA.length !== bufB.length) {\n // Still perform a comparison to keep timing uniform, then fail.\n timingSafeEqual(bufA, bufA);\n return false;\n }\n return timingSafeEqual(bufA, bufB);\n}\n\n/** Result of parsing an `Authorization: Basic` header. */\ninterface BasicCredentials {\n username: string;\n password: string;\n}\n\n/** Decode the `Authorization: Basic <base64>` header, or `null` if absent/malformed. */\nfunction parseBasicHeader(request: Request): BasicCredentials | null {\n const header = request.headers.get(\"authorization\");\n if (!header) return null;\n const [scheme, encoded] = header.split(\" \", 2);\n if (!scheme || scheme.toLowerCase() !== \"basic\" || !encoded) return null;\n\n let decoded: string;\n try {\n decoded = Buffer.from(encoded, \"base64\").toString(\"utf8\");\n } catch {\n return null;\n }\n const sep = decoded.indexOf(\":\");\n if (sep < 0) return null;\n return { username: decoded.slice(0, sep), password: decoded.slice(sep + 1) };\n}\n\n/** Build the {@link Principal} for a successfully authenticated user. */\nfunction principalFor(user: BasicAuthUser): Principal {\n return {\n id: user.id ?? user.username,\n name: user.username,\n ...(user.email !== undefined ? { email: user.email } : {}),\n ...(user.roles !== undefined ? { roles: user.roles } : {}),\n };\n}\n\n/**\n * Create an HTTP Basic {@link AuthProvider}.\n *\n * @example\n * ```ts\n * import { basicAuth, hashPassword } from \"@xtandard/webhooks/auth/basic\";\n *\n * const auth = basicAuth({\n * realm: \"Webhooks Admin\",\n * users: [\n * { username: \"admin\", passwordHash: await hashPassword(\"s3cret\"), roles: [\"admin\"] },\n * ],\n * });\n * ```\n */\nexport function basicAuth(options: BasicAuthOptions): AuthProvider {\n const realm = options.realm ?? \"xtandard-webhooks\";\n const usersByName = new Map<string, BasicAuthUser>();\n for (const user of options.users) usersByName.set(user.username, user);\n\n return {\n async authenticate(request: Request): Promise<Principal | null> {\n const creds = parseBasicHeader(request);\n if (!creds) return null;\n\n const user = usersByName.get(creds.username);\n if (!user) {\n // Run a dummy verification to keep timing roughly uniform for unknown\n // users versus known users.\n if (options.passwordVerifier) {\n await options.passwordVerifier(creds.username, creds.password);\n }\n return null;\n }\n\n // (a) custom verifier wins.\n if (options.passwordVerifier) {\n const ok = await options.passwordVerifier(creds.username, creds.password);\n return ok ? principalFor(user) : null;\n }\n\n // (b) scrypt hash.\n if (user.passwordHash !== undefined) {\n const ok = await verifyPassword(creds.password, user.passwordHash);\n return ok ? principalFor(user) : null;\n }\n\n // (c) dev-only plaintext.\n if (user.password !== undefined) {\n const ok = constantTimeEquals(creds.password, user.password);\n return ok ? principalFor(user) : null;\n }\n\n return null;\n },\n\n challenge(_request: Request): Response {\n return new Response(\"Unauthorized\", {\n status: 401,\n headers: { \"WWW-Authenticate\": `Basic realm=\"${realm}\"` },\n });\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,MAAM,aAAa;;AAEnB,MAAM,cAAc;;AAEpB,MAAM,gBAAgB;AAEtB,MAAM,eAAe,UAAkB,MAAc,WACnD,IAAI,SAAS,SAAS,WAAW;CAC/B,CAAA,GAAA,YAAA,QAAS,UAAU,MAAM,SAAS,KAAK,eAAe;EACpD,IAAI,KAAK,OAAO,GAAG;OACd,QAAQ,UAAU;CACzB,CAAC;AACH,CAAC;;AAGH,MAAM,SAAS,QAAwB,IAAI,SAAS,KAAK;;;;;;;;;;;;;;;AAgBzD,eAAsB,aAAa,UAAmC;CACpE,MAAM,QAAA,GAAA,YAAA,aAAmB,WAAW;CACpC,MAAM,UAAU,MAAM,YAAY,UAAU,MAAM,UAAU;CAC5D,OAAO,GAAG,cAAc,GAAG,MAAM,IAAI,EAAE,GAAG,MAAM,OAAO;AACzD;;;;;;;;;AAUA,eAAsB,eAAe,UAAkB,QAAkC;CACvF,MAAM,QAAQ,OAAO,MAAM,GAAG;CAC9B,IAAI,MAAM,WAAW,KAAK,MAAM,OAAO,eAAe,OAAO;CAC7D,MAAM,UAAU,MAAM;CACtB,MAAM,UAAU,MAAM;CACtB,IAAI,CAAC,WAAW,CAAC,SAAS,OAAO;CAEjC,IAAI;CACJ,IAAI;CACJ,IAAI;EACF,OAAO,OAAO,KAAK,SAAS,KAAK;EACjC,WAAW,OAAO,KAAK,SAAS,KAAK;CACvC,QAAQ;EACN,OAAO;CACT;CACA,IAAI,KAAK,WAAW,KAAK,SAAS,WAAW,GAAG,OAAO;CAEvD,MAAM,UAAU,MAAM,YAAY,UAAU,MAAM,SAAS,MAAM;CACjE,IAAI,QAAQ,WAAW,SAAS,QAAQ,OAAO;CAC/C,QAAA,GAAA,YAAA,iBAAuB,SAAS,QAAQ;AAC1C;;AA0CA,SAAS,mBAAmB,GAAW,GAAoB;CACzD,MAAM,OAAO,OAAO,KAAK,GAAG,MAAM;CAClC,MAAM,OAAO,OAAO,KAAK,GAAG,MAAM;CAGlC,IAAI,KAAK,WAAW,KAAK,QAAQ;EAE/B,CAAA,GAAA,YAAA,iBAAgB,MAAM,IAAI;EAC1B,OAAO;CACT;CACA,QAAA,GAAA,YAAA,iBAAuB,MAAM,IAAI;AACnC;;AASA,SAAS,iBAAiB,SAA2C;CACnE,MAAM,SAAS,QAAQ,QAAQ,IAAI,eAAe;CAClD,IAAI,CAAC,QAAQ,OAAO;CACpB,MAAM,CAAC,QAAQ,WAAW,OAAO,MAAM,KAAK,CAAC;CAC7C,IAAI,CAAC,UAAU,OAAO,YAAY,MAAM,WAAW,CAAC,SAAS,OAAO;CAEpE,IAAI;CACJ,IAAI;EACF,UAAU,OAAO,KAAK,SAAS,QAAQ,EAAE,SAAS,MAAM;CAC1D,QAAQ;EACN,OAAO;CACT;CACA,MAAM,MAAM,QAAQ,QAAQ,GAAG;CAC/B,IAAI,MAAM,GAAG,OAAO;CACpB,OAAO;EAAE,UAAU,QAAQ,MAAM,GAAG,GAAG;EAAG,UAAU,QAAQ,MAAM,MAAM,CAAC;CAAE;AAC7E;;AAGA,SAAS,aAAa,MAAgC;CACpD,OAAO;EACL,IAAI,KAAK,MAAM,KAAK;EACpB,MAAM,KAAK;EACX,GAAI,KAAK,UAAU,KAAA,IAAY,EAAE,OAAO,KAAK,MAAM,IAAI,CAAC;EACxD,GAAI,KAAK,UAAU,KAAA,IAAY,EAAE,OAAO,KAAK,MAAM,IAAI,CAAC;CAC1D;AACF;;;;;;;;;;;;;;;;AAiBA,SAAgB,UAAU,SAAyC;CACjE,MAAM,QAAQ,QAAQ,SAAS;CAC/B,MAAM,8BAAc,IAAI,IAA2B;CACnD,KAAK,MAAM,QAAQ,QAAQ,OAAO,YAAY,IAAI,KAAK,UAAU,IAAI;CAErE,OAAO;EACL,MAAM,aAAa,SAA6C;GAC9D,MAAM,QAAQ,iBAAiB,OAAO;GACtC,IAAI,CAAC,OAAO,OAAO;GAEnB,MAAM,OAAO,YAAY,IAAI,MAAM,QAAQ;GAC3C,IAAI,CAAC,MAAM;IAGT,IAAI,QAAQ,kBACV,MAAM,QAAQ,iBAAiB,MAAM,UAAU,MAAM,QAAQ;IAE/D,OAAO;GACT;GAGA,IAAI,QAAQ,kBAEV,OAAO,MADU,QAAQ,iBAAiB,MAAM,UAAU,MAAM,QAAQ,IAC5D,aAAa,IAAI,IAAI;GAInC,IAAI,KAAK,iBAAiB,KAAA,GAExB,OAAO,MADU,eAAe,MAAM,UAAU,KAAK,YAAY,IACrD,aAAa,IAAI,IAAI;GAInC,IAAI,KAAK,aAAa,KAAA,GAEpB,OADW,mBAAmB,MAAM,UAAU,KAAK,QAC3C,IAAI,aAAa,IAAI,IAAI;GAGnC,OAAO;EACT;EAEA,UAAU,UAA6B;GACrC,OAAO,IAAI,SAAS,gBAAgB;IAClC,QAAQ;IACR,SAAS,EAAE,oBAAoB,gBAAgB,MAAM,GAAG;GAC1D,CAAC;EACH;CACF;AACF"}