@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.
- package/LICENSE +21 -0
- package/README.md +315 -0
- package/bin/xtandard-webhooks.mjs +3 -0
- package/dist/basic-BIW3Rvuz.cjs +199 -0
- package/dist/basic-BIW3Rvuz.cjs.map +1 -0
- package/dist/basic-DKk0Xfuu.mjs +176 -0
- package/dist/basic-DKk0Xfuu.mjs.map +1 -0
- package/dist/chunk-D7D4PA-g.mjs +13 -0
- package/dist/cli.cjs +655 -0
- package/dist/cli.cjs.map +1 -0
- package/dist/cli.d.cts +42 -0
- package/dist/cli.d.mts +42 -0
- package/dist/cli.mjs +653 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/contract-8h-Azxa5.d.cts +71 -0
- package/dist/contract-9XpcwcCn.mjs +22 -0
- package/dist/contract-9XpcwcCn.mjs.map +1 -0
- package/dist/contract-B2d5dNU3.cjs +33 -0
- package/dist/contract-B2d5dNU3.cjs.map +1 -0
- package/dist/contract-BEhDcd_5.mjs +28 -0
- package/dist/contract-BEhDcd_5.mjs.map +1 -0
- package/dist/contract-Bf1qguwt.cjs +57 -0
- package/dist/contract-Bf1qguwt.cjs.map +1 -0
- package/dist/contract-Bnb3fgRJ.d.cts +177 -0
- package/dist/contract-C2r2Xzwp.d.mts +46 -0
- package/dist/contract-CiPskNvS.d.cts +46 -0
- package/dist/contract-DhQ4JjGG.d.mts +71 -0
- package/dist/contract-T1kcZNdG.d.mts +177 -0
- package/dist/contract-lETlIuXo.d.cts +30 -0
- package/dist/contract-lETlIuXo.d.mts +30 -0
- package/dist/core-CMpnmI5Q.mjs +1605 -0
- package/dist/core-CMpnmI5Q.mjs.map +1 -0
- package/dist/core-DT4ppWh8.d.mts +502 -0
- package/dist/core-KJawHjFF.d.cts +502 -0
- package/dist/core-ZGhH6Vs2.cjs +1790 -0
- package/dist/core-ZGhH6Vs2.cjs.map +1 -0
- package/dist/core.cjs +8 -0
- package/dist/core.d.cts +2 -0
- package/dist/core.d.mts +2 -0
- package/dist/core.mjs +2 -0
- package/dist/create-fetch-handler-BIdk9P30.mjs +1724 -0
- package/dist/create-fetch-handler-BIdk9P30.mjs.map +1 -0
- package/dist/create-fetch-handler-CmooujQo.cjs +1771 -0
- package/dist/create-fetch-handler-CmooujQo.cjs.map +1 -0
- package/dist/create-fetch-handler-Dlkhustu.d.cts +162 -0
- package/dist/create-fetch-handler-jy3hy5nZ.d.mts +162 -0
- package/dist/dispatcher-B0xTEHt1.cjs +212 -0
- package/dist/dispatcher-B0xTEHt1.cjs.map +1 -0
- package/dist/dispatcher-Coubwrka.mjs +196 -0
- package/dist/dispatcher-Coubwrka.mjs.map +1 -0
- package/dist/entry-auth-basic.cjs +5 -0
- package/dist/entry-auth-basic.d.cts +83 -0
- package/dist/entry-auth-basic.d.mts +83 -0
- package/dist/entry-auth-basic.mjs +2 -0
- package/dist/entry-auth-delegated.cjs +28 -0
- package/dist/entry-auth-delegated.cjs.map +1 -0
- package/dist/entry-auth-delegated.d.cts +36 -0
- package/dist/entry-auth-delegated.d.mts +36 -0
- package/dist/entry-auth-delegated.mjs +27 -0
- package/dist/entry-auth-delegated.mjs.map +1 -0
- package/dist/entry-auth-none.cjs +4 -0
- package/dist/entry-auth-none.d.cts +25 -0
- package/dist/entry-auth-none.d.mts +25 -0
- package/dist/entry-auth-none.mjs +2 -0
- package/dist/entry-authorization-delegated.cjs +27 -0
- package/dist/entry-authorization-delegated.cjs.map +1 -0
- package/dist/entry-authorization-delegated.d.cts +31 -0
- package/dist/entry-authorization-delegated.d.mts +31 -0
- package/dist/entry-authorization-delegated.mjs +26 -0
- package/dist/entry-authorization-delegated.mjs.map +1 -0
- package/dist/entry-authorization-none.cjs +3 -0
- package/dist/entry-authorization-none.d.cts +18 -0
- package/dist/entry-authorization-none.d.mts +18 -0
- package/dist/entry-authorization-none.mjs +2 -0
- package/dist/entry-authorization-roles.cjs +6 -0
- package/dist/entry-authorization-roles.d.cts +65 -0
- package/dist/entry-authorization-roles.d.mts +65 -0
- package/dist/entry-authorization-roles.mjs +2 -0
- package/dist/entry-bun.cjs +24 -0
- package/dist/entry-bun.cjs.map +1 -0
- package/dist/entry-bun.d.cts +8 -0
- package/dist/entry-bun.d.mts +8 -0
- package/dist/entry-bun.mjs +23 -0
- package/dist/entry-bun.mjs.map +1 -0
- package/dist/entry-drizzle-mysql.cjs +20 -0
- package/dist/entry-drizzle-mysql.cjs.map +1 -0
- package/dist/entry-drizzle-mysql.d.cts +27 -0
- package/dist/entry-drizzle-mysql.d.mts +27 -0
- package/dist/entry-drizzle-mysql.mjs +19 -0
- package/dist/entry-drizzle-mysql.mjs.map +1 -0
- package/dist/entry-drizzle-pg.cjs +21 -0
- package/dist/entry-drizzle-pg.cjs.map +1 -0
- package/dist/entry-drizzle-pg.d.cts +26 -0
- package/dist/entry-drizzle-pg.d.mts +26 -0
- package/dist/entry-drizzle-pg.mjs +20 -0
- package/dist/entry-drizzle-pg.mjs.map +1 -0
- package/dist/entry-drizzle-sqlite.cjs +21 -0
- package/dist/entry-drizzle-sqlite.cjs.map +1 -0
- package/dist/entry-drizzle-sqlite.d.cts +23 -0
- package/dist/entry-drizzle-sqlite.d.mts +23 -0
- package/dist/entry-drizzle-sqlite.mjs +20 -0
- package/dist/entry-drizzle-sqlite.mjs.map +1 -0
- package/dist/entry-elysia.cjs +125 -0
- package/dist/entry-elysia.cjs.map +1 -0
- package/dist/entry-elysia.d.cts +1017 -0
- package/dist/entry-elysia.d.mts +1017 -0
- package/dist/entry-elysia.mjs +123 -0
- package/dist/entry-elysia.mjs.map +1 -0
- package/dist/entry-express.cjs +57 -0
- package/dist/entry-express.cjs.map +1 -0
- package/dist/entry-express.d.cts +15 -0
- package/dist/entry-express.d.mts +15 -0
- package/dist/entry-express.mjs +56 -0
- package/dist/entry-express.mjs.map +1 -0
- package/dist/entry-hono.cjs +35 -0
- package/dist/entry-hono.cjs.map +1 -0
- package/dist/entry-hono.d.cts +16 -0
- package/dist/entry-hono.d.mts +16 -0
- package/dist/entry-hono.mjs +34 -0
- package/dist/entry-hono.mjs.map +1 -0
- package/dist/entry-hooks-log.cjs +22 -0
- package/dist/entry-hooks-log.cjs.map +1 -0
- package/dist/entry-hooks-log.d.cts +23 -0
- package/dist/entry-hooks-log.d.mts +23 -0
- package/dist/entry-hooks-log.mjs +21 -0
- package/dist/entry-hooks-log.mjs.map +1 -0
- package/dist/entry-storage-cloudflare-kv.cjs +47 -0
- package/dist/entry-storage-cloudflare-kv.cjs.map +1 -0
- package/dist/entry-storage-cloudflare-kv.d.cts +42 -0
- package/dist/entry-storage-cloudflare-kv.d.mts +42 -0
- package/dist/entry-storage-cloudflare-kv.mjs +46 -0
- package/dist/entry-storage-cloudflare-kv.mjs.map +1 -0
- package/dist/entry-storage-drizzle.cjs +78 -0
- package/dist/entry-storage-drizzle.cjs.map +1 -0
- package/dist/entry-storage-drizzle.d.cts +30 -0
- package/dist/entry-storage-drizzle.d.mts +30 -0
- package/dist/entry-storage-drizzle.mjs +77 -0
- package/dist/entry-storage-drizzle.mjs.map +1 -0
- package/dist/entry-storage-file.cjs +4 -0
- package/dist/entry-storage-file.d.cts +30 -0
- package/dist/entry-storage-file.d.mts +30 -0
- package/dist/entry-storage-file.mjs +2 -0
- package/dist/entry-storage-libsql.cjs +3 -0
- package/dist/entry-storage-libsql.d.cts +48 -0
- package/dist/entry-storage-libsql.d.mts +48 -0
- package/dist/entry-storage-libsql.mjs +2 -0
- package/dist/entry-storage-memory.cjs +3 -0
- package/dist/entry-storage-memory.d.cts +2 -0
- package/dist/entry-storage-memory.d.mts +2 -0
- package/dist/entry-storage-memory.mjs +2 -0
- package/dist/entry-storage-mongodb.cjs +3 -0
- package/dist/entry-storage-mongodb.d.cts +55 -0
- package/dist/entry-storage-mongodb.d.mts +55 -0
- package/dist/entry-storage-mongodb.mjs +2 -0
- package/dist/entry-storage-postgres.cjs +3 -0
- package/dist/entry-storage-postgres.d.cts +62 -0
- package/dist/entry-storage-postgres.d.mts +62 -0
- package/dist/entry-storage-postgres.mjs +2 -0
- package/dist/entry-storage-redis.cjs +4 -0
- package/dist/entry-storage-redis.d.cts +77 -0
- package/dist/entry-storage-redis.d.mts +77 -0
- package/dist/entry-storage-redis.mjs +2 -0
- package/dist/entry-storage-sqlite.cjs +3 -0
- package/dist/entry-storage-sqlite.d.cts +36 -0
- package/dist/entry-storage-sqlite.d.mts +36 -0
- package/dist/entry-storage-sqlite.mjs +2 -0
- package/dist/entry-storage-unstorage.cjs +42 -0
- package/dist/entry-storage-unstorage.cjs.map +1 -0
- package/dist/entry-storage-unstorage.d.cts +29 -0
- package/dist/entry-storage-unstorage.d.mts +29 -0
- package/dist/entry-storage-unstorage.mjs +41 -0
- package/dist/entry-storage-unstorage.mjs.map +1 -0
- package/dist/file-COBYZA4Q.cjs +148 -0
- package/dist/file-COBYZA4Q.cjs.map +1 -0
- package/dist/file-fi02eFHk.mjs +131 -0
- package/dist/file-fi02eFHk.mjs.map +1 -0
- package/dist/index.cjs +123 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +368 -0
- package/dist/index.d.mts +366 -0
- package/dist/index.mjs +61 -0
- package/dist/index.mjs.map +1 -0
- package/dist/keys-Byyj4quQ.mjs +111 -0
- package/dist/keys-Byyj4quQ.mjs.map +1 -0
- package/dist/keys-FiKpaVHX.cjs +302 -0
- package/dist/keys-FiKpaVHX.cjs.map +1 -0
- package/dist/libsql-bpVi0bXN.mjs +113 -0
- package/dist/libsql-bpVi0bXN.mjs.map +1 -0
- package/dist/libsql-pPJEo1e4.cjs +124 -0
- package/dist/libsql-pPJEo1e4.cjs.map +1 -0
- package/dist/memory-8Ef-PL5a.cjs +137 -0
- package/dist/memory-8Ef-PL5a.cjs.map +1 -0
- package/dist/memory-BMsSSwqn.mjs +127 -0
- package/dist/memory-BMsSSwqn.mjs.map +1 -0
- package/dist/memory-FnMJWCmB.d.cts +28 -0
- package/dist/memory-qIvANEs_.d.mts +28 -0
- package/dist/mongodb-Cy8yo0uk.cjs +108 -0
- package/dist/mongodb-Cy8yo0uk.cjs.map +1 -0
- package/dist/mongodb-Ddaq9mml.mjs +97 -0
- package/dist/mongodb-Ddaq9mml.mjs.map +1 -0
- package/dist/none-BnZtaGNJ.mjs +23 -0
- package/dist/none-BnZtaGNJ.mjs.map +1 -0
- package/dist/none-CAsxCOWN.cjs +49 -0
- package/dist/none-CAsxCOWN.cjs.map +1 -0
- package/dist/none-CZVrfnmF.cjs +33 -0
- package/dist/none-CZVrfnmF.cjs.map +1 -0
- package/dist/none-GhVIoh_s.mjs +33 -0
- package/dist/none-GhVIoh_s.mjs.map +1 -0
- package/dist/postgres-C8WbchFa.cjs +134 -0
- package/dist/postgres-C8WbchFa.cjs.map +1 -0
- package/dist/postgres-c3pAhmhr.mjs +123 -0
- package/dist/postgres-c3pAhmhr.mjs.map +1 -0
- package/dist/react.css +1 -0
- package/dist/react.js +31465 -0
- package/dist/receiver.cjs +43 -0
- package/dist/receiver.cjs.map +1 -0
- package/dist/receiver.d.cts +36 -0
- package/dist/receiver.d.mts +36 -0
- package/dist/receiver.mjs +40 -0
- package/dist/receiver.mjs.map +1 -0
- package/dist/redis-CFJkuSgB.cjs +270 -0
- package/dist/redis-CFJkuSgB.cjs.map +1 -0
- package/dist/redis-CvLi0KF7.mjs +254 -0
- package/dist/redis-CvLi0KF7.mjs.map +1 -0
- package/dist/roles-D0G9XqBq.cjs +128 -0
- package/dist/roles-D0G9XqBq.cjs.map +1 -0
- package/dist/roles-vp361lTk.mjs +99 -0
- package/dist/roles-vp361lTk.mjs.map +1 -0
- package/dist/schema-mo__wv4P.d.cts +233 -0
- package/dist/schema-mo__wv4P.d.mts +233 -0
- package/dist/schema.cjs +13 -0
- package/dist/schema.cjs.map +1 -0
- package/dist/schema.d.cts +2 -0
- package/dist/schema.d.mts +2 -0
- package/dist/schema.mjs +11 -0
- package/dist/schema.mjs.map +1 -0
- package/dist/signing.cjs +162 -0
- package/dist/signing.cjs.map +1 -0
- package/dist/signing.d.cts +73 -0
- package/dist/signing.d.mts +73 -0
- package/dist/signing.mjs +156 -0
- package/dist/signing.mjs.map +1 -0
- package/dist/sqlite-Cmqnrjes.mjs +67 -0
- package/dist/sqlite-Cmqnrjes.mjs.map +1 -0
- package/dist/sqlite-Dcufk0x3.cjs +78 -0
- package/dist/sqlite-Dcufk0x3.cjs.map +1 -0
- package/dist/table-Ce3Tzwqs.d.cts +11 -0
- package/dist/table-Ce3Tzwqs.d.mts +11 -0
- package/dist/testing.cjs +134 -0
- package/dist/testing.cjs.map +1 -0
- package/dist/testing.d.cts +80 -0
- package/dist/testing.d.mts +80 -0
- package/dist/testing.mjs +131 -0
- package/dist/testing.mjs.map +1 -0
- package/dist/types-react/react.d.ts +98 -0
- package/dist/types-react/schema.d.ts +229 -0
- package/dist/types-react/ui/App.d.ts +22 -0
- package/dist/types-react/ui/api.d.ts +97 -0
- package/dist/types-react/ui/components/JsonCodeEditor.d.ts +12 -0
- package/dist/types-react/ui/components/ThemeToggle.d.ts +2 -0
- package/dist/types-react/ui/components/Toast.d.ts +16 -0
- package/dist/types-react/ui/components/primitives.d.ts +50 -0
- package/dist/types-react/ui/components/ui-bits.d.ts +22 -0
- package/dist/types-react/ui/components/webhook-bits.d.ts +51 -0
- package/dist/types-react/ui/lib/format.d.ts +39 -0
- package/dist/types-react/ui/lib/nav-guard.d.ts +20 -0
- package/dist/types-react/ui/lib/utils.d.ts +3 -0
- package/dist/types-react/ui/theme.d.ts +12 -0
- package/dist/types-react/ui/types.d.ts +80 -0
- package/dist/types-react/ui/views/AuditView.d.ts +6 -0
- package/dist/types-react/ui/views/DeliveriesView.d.ts +12 -0
- package/dist/types-react/ui/views/EndpointsView.d.ts +11 -0
- package/dist/types-react/ui/views/EventTypesView.d.ts +11 -0
- package/dist/types-react/ui/views/MessagesView.d.ts +10 -0
- package/dist/types-react/ui/views/OverviewView.d.ts +12 -0
- package/dist/ui/assets/index-B0eoQX2U.css +1 -0
- package/dist/ui/assets/index-S5t_CLOe.js +209 -0
- package/dist/ui/index.html +14 -0
- 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
|
+
[](https://github.com/xantiagoma/xtandard-webhooks/actions/workflows/ci.yml)
|
|
10
|
+
[](https://www.npmjs.com/package/@xtandard/webhooks)
|
|
11
|
+
[](./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
|
+
|  |  |
|
|
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,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"}
|