@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/dist/signing.cjs
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
+
//#region src/signing.ts
|
|
3
|
+
/** Prefix of every symmetric Standard Webhooks secret. */
|
|
4
|
+
const SECRET_PREFIX = "whsec_";
|
|
5
|
+
/** Prefix of every symmetric (v1) signature entry. */
|
|
6
|
+
const SIGNATURE_VERSION = "v1";
|
|
7
|
+
const MIN_SECRET_BYTES = 24;
|
|
8
|
+
const MAX_SECRET_BYTES = 64;
|
|
9
|
+
/** Thrown by {@link verify} / receiver helpers when a webhook fails verification. */
|
|
10
|
+
var WebhookVerificationError = class extends Error {
|
|
11
|
+
constructor(message) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "WebhookVerificationError";
|
|
14
|
+
}
|
|
15
|
+
};
|
|
16
|
+
function base64Encode(bytes) {
|
|
17
|
+
let binary = "";
|
|
18
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
19
|
+
return btoa(binary);
|
|
20
|
+
}
|
|
21
|
+
function base64Decode(value) {
|
|
22
|
+
const binary = atob(value);
|
|
23
|
+
const bytes = new Uint8Array(binary.length);
|
|
24
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
25
|
+
return bytes;
|
|
26
|
+
}
|
|
27
|
+
/** Decode `whsec_…` into raw key bytes, enforcing the spec's 24–64 byte range. */
|
|
28
|
+
function decodeSecret(secret) {
|
|
29
|
+
const body = secret.startsWith("whsec_") ? secret.slice(6) : secret;
|
|
30
|
+
let bytes;
|
|
31
|
+
try {
|
|
32
|
+
bytes = base64Decode(body);
|
|
33
|
+
} catch {
|
|
34
|
+
throw new WebhookVerificationError("Invalid secret: not base64");
|
|
35
|
+
}
|
|
36
|
+
if (bytes.length < MIN_SECRET_BYTES || bytes.length > MAX_SECRET_BYTES) throw new WebhookVerificationError(`Invalid secret: decoded length ${bytes.length} outside ${MIN_SECRET_BYTES}–${MAX_SECRET_BYTES} bytes`);
|
|
37
|
+
return bytes;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Generate a new signing secret: `whsec_` + base64 of 24 crypto-random bytes.
|
|
41
|
+
*
|
|
42
|
+
* @example
|
|
43
|
+
* ```ts
|
|
44
|
+
* import { generateSecret } from "@xtandard/webhooks/signing";
|
|
45
|
+
*
|
|
46
|
+
* const secret = generateSecret(); // "whsec_…"
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
function generateSecret() {
|
|
50
|
+
const bytes = new Uint8Array(MIN_SECRET_BYTES);
|
|
51
|
+
crypto.getRandomValues(bytes);
|
|
52
|
+
return SECRET_PREFIX + base64Encode(bytes);
|
|
53
|
+
}
|
|
54
|
+
async function hmacSha256(keyBytes, content) {
|
|
55
|
+
const key = await crypto.subtle.importKey("raw", keyBytes, {
|
|
56
|
+
name: "HMAC",
|
|
57
|
+
hash: "SHA-256"
|
|
58
|
+
}, false, ["sign"]);
|
|
59
|
+
const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(content));
|
|
60
|
+
return new Uint8Array(digest);
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Sign one delivery attempt with one secret: `v1,` + base64(HMAC-SHA256 over
|
|
64
|
+
* `${id}.${timestamp}.${body}`). `timestamp` is unix **seconds**.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* ```ts
|
|
68
|
+
* import { sign } from "@xtandard/webhooks/signing";
|
|
69
|
+
*
|
|
70
|
+
* const signature = await sign(secret, "msg_…", 1720000000, body);
|
|
71
|
+
* // "v1,K5oZ…"
|
|
72
|
+
* ```
|
|
73
|
+
*/
|
|
74
|
+
async function sign(secret, id, timestamp, body) {
|
|
75
|
+
return `${SIGNATURE_VERSION},${base64Encode(await hmacSha256(decodeSecret(secret), `${id}.${timestamp}.${body}`))}`;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Build the full `webhook-signature` header value: one signature per secret,
|
|
79
|
+
* space-separated (multiple entries appear during secret rotation).
|
|
80
|
+
*/
|
|
81
|
+
async function signatureHeader(secrets, id, timestamp, body) {
|
|
82
|
+
return (await Promise.all(secrets.map((s) => sign(s, id, timestamp, body)))).join(" ");
|
|
83
|
+
}
|
|
84
|
+
/** Constant-time byte comparison (XOR accumulate — no early exit on content). */
|
|
85
|
+
function timingSafeEqual(a, b) {
|
|
86
|
+
if (a.length !== b.length) return false;
|
|
87
|
+
let diff = 0;
|
|
88
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
89
|
+
return diff === 0;
|
|
90
|
+
}
|
|
91
|
+
/** Case-insensitive header lookup in a plain record. */
|
|
92
|
+
function headerLookup(headers, name) {
|
|
93
|
+
const direct = headers[name];
|
|
94
|
+
if (direct !== void 0) return direct;
|
|
95
|
+
const lower = name.toLowerCase();
|
|
96
|
+
for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === lower) return value;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Verify an incoming Standard Webhooks request (from **any** compliant sender,
|
|
100
|
+
* not just this package). Checks the timestamp tolerance, then compares every
|
|
101
|
+
* `v1,` signature in the header against every candidate secret in constant
|
|
102
|
+
* time. Returns the parsed envelope on success; throws
|
|
103
|
+
* {@link WebhookVerificationError} otherwise.
|
|
104
|
+
*
|
|
105
|
+
* @example
|
|
106
|
+
* ```ts
|
|
107
|
+
* import { verify } from "@xtandard/webhooks/signing";
|
|
108
|
+
*
|
|
109
|
+
* const envelope = await verify({
|
|
110
|
+
* payload: rawBody,
|
|
111
|
+
* headers: { "webhook-id": id, "webhook-timestamp": ts, "webhook-signature": sig },
|
|
112
|
+
* secret: "whsec_…",
|
|
113
|
+
* });
|
|
114
|
+
* ```
|
|
115
|
+
*/
|
|
116
|
+
async function verify(input) {
|
|
117
|
+
const id = headerLookup(input.headers, "webhook-id");
|
|
118
|
+
const timestampRaw = headerLookup(input.headers, "webhook-timestamp");
|
|
119
|
+
const signatureHeaderValue = headerLookup(input.headers, "webhook-signature");
|
|
120
|
+
if (!id) throw new WebhookVerificationError("Missing webhook-id header");
|
|
121
|
+
if (!timestampRaw) throw new WebhookVerificationError("Missing webhook-timestamp header");
|
|
122
|
+
if (!signatureHeaderValue) throw new WebhookVerificationError("Missing webhook-signature header");
|
|
123
|
+
const timestamp = Number(timestampRaw);
|
|
124
|
+
if (!Number.isFinite(timestamp) || !/^\d+$/.test(timestampRaw.trim())) throw new WebhookVerificationError(`Invalid webhook-timestamp "${timestampRaw}"`);
|
|
125
|
+
const tolerance = input.toleranceSeconds ?? 300;
|
|
126
|
+
const now = input.now ?? Math.floor(Date.now() / 1e3);
|
|
127
|
+
if (timestamp < now - tolerance) throw new WebhookVerificationError("webhook-timestamp is too old");
|
|
128
|
+
if (timestamp > now + tolerance) throw new WebhookVerificationError("webhook-timestamp is in the future");
|
|
129
|
+
const candidates = signatureHeaderValue.split(" ").map((entry) => entry.trim()).filter((entry) => entry.startsWith(`${SIGNATURE_VERSION},`)).map((entry) => entry.slice(3));
|
|
130
|
+
if (candidates.length === 0) throw new WebhookVerificationError("No v1 signature found in webhook-signature header");
|
|
131
|
+
const secrets = Array.isArray(input.secret) ? input.secret : [input.secret];
|
|
132
|
+
if (secrets.length === 0) throw new WebhookVerificationError("No secret provided");
|
|
133
|
+
const content = `${id}.${timestampRaw.trim()}.${input.payload}`;
|
|
134
|
+
let matched = false;
|
|
135
|
+
for (const secret of secrets) {
|
|
136
|
+
const expected = await hmacSha256(decodeSecret(secret), content);
|
|
137
|
+
for (const candidate of candidates) {
|
|
138
|
+
let candidateBytes;
|
|
139
|
+
try {
|
|
140
|
+
candidateBytes = base64Decode(candidate);
|
|
141
|
+
} catch {
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (timingSafeEqual(expected, candidateBytes)) matched = true;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
if (!matched) throw new WebhookVerificationError("No matching signature");
|
|
148
|
+
try {
|
|
149
|
+
return JSON.parse(input.payload);
|
|
150
|
+
} catch {
|
|
151
|
+
throw new WebhookVerificationError("Payload is not valid JSON");
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
//#endregion
|
|
155
|
+
exports.SECRET_PREFIX = SECRET_PREFIX;
|
|
156
|
+
exports.WebhookVerificationError = WebhookVerificationError;
|
|
157
|
+
exports.generateSecret = generateSecret;
|
|
158
|
+
exports.sign = sign;
|
|
159
|
+
exports.signatureHeader = signatureHeader;
|
|
160
|
+
exports.verify = verify;
|
|
161
|
+
|
|
162
|
+
//# sourceMappingURL=signing.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signing.cjs","names":[],"sources":["../src/signing.ts"],"sourcesContent":["/**\n * Standard Webhooks symmetric (v1) signing and verification.\n *\n * Pure and zero-dependency (Web Crypto only) — safe on the request path and in\n * any WinterCG runtime. Shared by the sender (the dispatcher signs outgoing\n * deliveries) and the receiver subpath (`@xtandard/webhooks/receiver`), and\n * compatible with any Standard Webhooks implementation, including Svix.\n *\n * The wire contract (https://www.standardwebhooks.com):\n *\n * - secrets are `whsec_` + base64-encoded key material (24–64 bytes decoded)\n * - the signed content is `${id}.${timestamp}.${body}`\n * - the signature is `v1,` + base64(HMAC-SHA256(key, signedContent))\n * - `webhook-signature` may carry several space-separated signatures (secret\n * rotation); a receiver accepts if **any** matches\n * - `webhook-timestamp` (unix seconds) must be within tolerance, both ways\n *\n * @module\n */\n\nimport type { WebhookEnvelope } from \"./schema.ts\";\n\nexport type { WebhookEnvelope };\n\n/** Prefix of every symmetric Standard Webhooks secret. */\nexport const SECRET_PREFIX = \"whsec_\";\n\n/** Prefix of every symmetric (v1) signature entry. */\nconst SIGNATURE_VERSION = \"v1\";\n\nconst MIN_SECRET_BYTES = 24;\nconst MAX_SECRET_BYTES = 64;\n\n/** Thrown by {@link verify} / receiver helpers when a webhook fails verification. */\nexport class WebhookVerificationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"WebhookVerificationError\";\n }\n}\n\nfunction base64Encode(bytes: Uint8Array): string {\n let binary = \"\";\n for (const b of bytes) binary += String.fromCharCode(b);\n return btoa(binary);\n}\n\nfunction base64Decode(value: string): Uint8Array {\n const binary = atob(value);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);\n return bytes;\n}\n\n/** Decode `whsec_…` into raw key bytes, enforcing the spec's 24–64 byte range. */\nfunction decodeSecret(secret: string): Uint8Array {\n const body = secret.startsWith(SECRET_PREFIX) ? secret.slice(SECRET_PREFIX.length) : secret;\n let bytes: Uint8Array;\n try {\n bytes = base64Decode(body);\n } catch {\n throw new WebhookVerificationError(\"Invalid secret: not base64\");\n }\n if (bytes.length < MIN_SECRET_BYTES || bytes.length > MAX_SECRET_BYTES) {\n throw new WebhookVerificationError(\n `Invalid secret: decoded length ${bytes.length} outside ${MIN_SECRET_BYTES}–${MAX_SECRET_BYTES} bytes`,\n );\n }\n return bytes;\n}\n\n/**\n * Generate a new signing secret: `whsec_` + base64 of 24 crypto-random bytes.\n *\n * @example\n * ```ts\n * import { generateSecret } from \"@xtandard/webhooks/signing\";\n *\n * const secret = generateSecret(); // \"whsec_…\"\n * ```\n */\nexport function generateSecret(): string {\n const bytes = new Uint8Array(MIN_SECRET_BYTES);\n crypto.getRandomValues(bytes);\n return SECRET_PREFIX + base64Encode(bytes);\n}\n\nasync function hmacSha256(keyBytes: Uint8Array, content: string): Promise<Uint8Array> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n keyBytes as BufferSource,\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const digest = await crypto.subtle.sign(\"HMAC\", key, new TextEncoder().encode(content));\n return new Uint8Array(digest);\n}\n\n/**\n * Sign one delivery attempt with one secret: `v1,` + base64(HMAC-SHA256 over\n * `${id}.${timestamp}.${body}`). `timestamp` is unix **seconds**.\n *\n * @example\n * ```ts\n * import { sign } from \"@xtandard/webhooks/signing\";\n *\n * const signature = await sign(secret, \"msg_…\", 1720000000, body);\n * // \"v1,K5oZ…\"\n * ```\n */\nexport async function sign(\n secret: string,\n id: string,\n timestamp: number,\n body: string,\n): Promise<string> {\n const digest = await hmacSha256(decodeSecret(secret), `${id}.${timestamp}.${body}`);\n return `${SIGNATURE_VERSION},${base64Encode(digest)}`;\n}\n\n/**\n * Build the full `webhook-signature` header value: one signature per secret,\n * space-separated (multiple entries appear during secret rotation).\n */\nexport async function signatureHeader(\n secrets: string[],\n id: string,\n timestamp: number,\n body: string,\n): Promise<string> {\n const signatures = await Promise.all(secrets.map((s) => sign(s, id, timestamp, body)));\n return signatures.join(\" \");\n}\n\n/** Constant-time byte comparison (XOR accumulate — no early exit on content). */\nfunction timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {\n if (a.length !== b.length) return false;\n let diff = 0;\n for (let i = 0; i < a.length; i++) diff |= (a[i] as number) ^ (b[i] as number);\n return diff === 0;\n}\n\n/** Case-insensitive header lookup in a plain record. */\nfunction headerLookup(headers: Record<string, string>, name: string): string | undefined {\n const direct = headers[name];\n if (direct !== undefined) return direct;\n const lower = name.toLowerCase();\n for (const [key, value] of Object.entries(headers)) {\n if (key.toLowerCase() === lower) return value;\n }\n return undefined;\n}\n\n/** Input to {@link verify}. */\nexport interface VerifyInput {\n /** The raw request body, **exactly** as received (no re-serialization). */\n payload: string;\n /** Request headers; name lookup is case-insensitive. */\n headers: Record<string, string>;\n /** Candidate secret(s) — pass several during rotation; any match passes. */\n secret: string | string[];\n /** Allowed clock skew in seconds, both past and future. Default `300`. */\n toleranceSeconds?: number;\n /** Current unix time in seconds — injectable for tests. */\n now?: number;\n}\n\n/**\n * Verify an incoming Standard Webhooks request (from **any** compliant sender,\n * not just this package). Checks the timestamp tolerance, then compares every\n * `v1,` signature in the header against every candidate secret in constant\n * time. Returns the parsed envelope on success; throws\n * {@link WebhookVerificationError} otherwise.\n *\n * @example\n * ```ts\n * import { verify } from \"@xtandard/webhooks/signing\";\n *\n * const envelope = await verify({\n * payload: rawBody,\n * headers: { \"webhook-id\": id, \"webhook-timestamp\": ts, \"webhook-signature\": sig },\n * secret: \"whsec_…\",\n * });\n * ```\n */\nexport async function verify(input: VerifyInput): Promise<WebhookEnvelope> {\n const id = headerLookup(input.headers, \"webhook-id\");\n const timestampRaw = headerLookup(input.headers, \"webhook-timestamp\");\n const signatureHeaderValue = headerLookup(input.headers, \"webhook-signature\");\n if (!id) throw new WebhookVerificationError(\"Missing webhook-id header\");\n if (!timestampRaw) throw new WebhookVerificationError(\"Missing webhook-timestamp header\");\n if (!signatureHeaderValue) throw new WebhookVerificationError(\"Missing webhook-signature header\");\n\n const timestamp = Number(timestampRaw);\n if (!Number.isFinite(timestamp) || !/^\\d+$/.test(timestampRaw.trim())) {\n throw new WebhookVerificationError(`Invalid webhook-timestamp \"${timestampRaw}\"`);\n }\n const tolerance = input.toleranceSeconds ?? 300;\n const now = input.now ?? Math.floor(Date.now() / 1000);\n if (timestamp < now - tolerance) {\n throw new WebhookVerificationError(\"webhook-timestamp is too old\");\n }\n if (timestamp > now + tolerance) {\n throw new WebhookVerificationError(\"webhook-timestamp is in the future\");\n }\n\n // Only v1 (symmetric) entries participate; other versions are skipped.\n const candidates = signatureHeaderValue\n .split(\" \")\n .map((entry) => entry.trim())\n .filter((entry) => entry.startsWith(`${SIGNATURE_VERSION},`))\n .map((entry) => entry.slice(SIGNATURE_VERSION.length + 1));\n if (candidates.length === 0) {\n throw new WebhookVerificationError(\"No v1 signature found in webhook-signature header\");\n }\n\n const secrets = Array.isArray(input.secret) ? input.secret : [input.secret];\n if (secrets.length === 0) throw new WebhookVerificationError(\"No secret provided\");\n\n // Sign over the timestamp EXACTLY as it arrived on the wire (already\n // validated as digits above), not the JS-parsed number — re-stringifying\n // through Number() can lose precision or reformat and would reject a\n // legitimately-signed webhook whose value doesn't round-trip.\n const content = `${id}.${timestampRaw.trim()}.${input.payload}`;\n let matched = false;\n for (const secret of secrets) {\n const expected = await hmacSha256(decodeSecret(secret), content);\n for (const candidate of candidates) {\n let candidateBytes: Uint8Array;\n try {\n candidateBytes = base64Decode(candidate);\n } catch {\n continue; // malformed entry — try the rest\n }\n if (timingSafeEqual(expected, candidateBytes)) matched = true;\n }\n }\n if (!matched) throw new WebhookVerificationError(\"No matching signature\");\n\n try {\n return JSON.parse(input.payload) as WebhookEnvelope;\n } catch {\n throw new WebhookVerificationError(\"Payload is not valid JSON\");\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { _ as WebhookEnvelope } from "./schema-mo__wv4P.cjs";
|
|
2
|
+
|
|
3
|
+
//#region src/signing.d.ts
|
|
4
|
+
/** Prefix of every symmetric Standard Webhooks secret. */
|
|
5
|
+
declare const SECRET_PREFIX = "whsec_";
|
|
6
|
+
/** Thrown by {@link verify} / receiver helpers when a webhook fails verification. */
|
|
7
|
+
declare class WebhookVerificationError extends Error {
|
|
8
|
+
constructor(message: string);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Generate a new signing secret: `whsec_` + base64 of 24 crypto-random bytes.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { generateSecret } from "@xtandard/webhooks/signing";
|
|
16
|
+
*
|
|
17
|
+
* const secret = generateSecret(); // "whsec_…"
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
declare function generateSecret(): string;
|
|
21
|
+
/**
|
|
22
|
+
* Sign one delivery attempt with one secret: `v1,` + base64(HMAC-SHA256 over
|
|
23
|
+
* `${id}.${timestamp}.${body}`). `timestamp` is unix **seconds**.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { sign } from "@xtandard/webhooks/signing";
|
|
28
|
+
*
|
|
29
|
+
* const signature = await sign(secret, "msg_…", 1720000000, body);
|
|
30
|
+
* // "v1,K5oZ…"
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
declare function sign(secret: string, id: string, timestamp: number, body: string): Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* Build the full `webhook-signature` header value: one signature per secret,
|
|
36
|
+
* space-separated (multiple entries appear during secret rotation).
|
|
37
|
+
*/
|
|
38
|
+
declare function signatureHeader(secrets: string[], id: string, timestamp: number, body: string): Promise<string>;
|
|
39
|
+
/** Input to {@link verify}. */
|
|
40
|
+
interface VerifyInput {
|
|
41
|
+
/** The raw request body, **exactly** as received (no re-serialization). */
|
|
42
|
+
payload: string;
|
|
43
|
+
/** Request headers; name lookup is case-insensitive. */
|
|
44
|
+
headers: Record<string, string>;
|
|
45
|
+
/** Candidate secret(s) — pass several during rotation; any match passes. */
|
|
46
|
+
secret: string | string[];
|
|
47
|
+
/** Allowed clock skew in seconds, both past and future. Default `300`. */
|
|
48
|
+
toleranceSeconds?: number;
|
|
49
|
+
/** Current unix time in seconds — injectable for tests. */
|
|
50
|
+
now?: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Verify an incoming Standard Webhooks request (from **any** compliant sender,
|
|
54
|
+
* not just this package). Checks the timestamp tolerance, then compares every
|
|
55
|
+
* `v1,` signature in the header against every candidate secret in constant
|
|
56
|
+
* time. Returns the parsed envelope on success; throws
|
|
57
|
+
* {@link WebhookVerificationError} otherwise.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* import { verify } from "@xtandard/webhooks/signing";
|
|
62
|
+
*
|
|
63
|
+
* const envelope = await verify({
|
|
64
|
+
* payload: rawBody,
|
|
65
|
+
* headers: { "webhook-id": id, "webhook-timestamp": ts, "webhook-signature": sig },
|
|
66
|
+
* secret: "whsec_…",
|
|
67
|
+
* });
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
declare function verify(input: VerifyInput): Promise<WebhookEnvelope>;
|
|
71
|
+
//#endregion
|
|
72
|
+
export { SECRET_PREFIX, VerifyInput, type WebhookEnvelope, WebhookVerificationError, generateSecret, sign, signatureHeader, verify };
|
|
73
|
+
//# sourceMappingURL=signing.d.cts.map
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { _ as WebhookEnvelope } from "./schema-mo__wv4P.mjs";
|
|
2
|
+
|
|
3
|
+
//#region src/signing.d.ts
|
|
4
|
+
/** Prefix of every symmetric Standard Webhooks secret. */
|
|
5
|
+
declare const SECRET_PREFIX = "whsec_";
|
|
6
|
+
/** Thrown by {@link verify} / receiver helpers when a webhook fails verification. */
|
|
7
|
+
declare class WebhookVerificationError extends Error {
|
|
8
|
+
constructor(message: string);
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Generate a new signing secret: `whsec_` + base64 of 24 crypto-random bytes.
|
|
12
|
+
*
|
|
13
|
+
* @example
|
|
14
|
+
* ```ts
|
|
15
|
+
* import { generateSecret } from "@xtandard/webhooks/signing";
|
|
16
|
+
*
|
|
17
|
+
* const secret = generateSecret(); // "whsec_…"
|
|
18
|
+
* ```
|
|
19
|
+
*/
|
|
20
|
+
declare function generateSecret(): string;
|
|
21
|
+
/**
|
|
22
|
+
* Sign one delivery attempt with one secret: `v1,` + base64(HMAC-SHA256 over
|
|
23
|
+
* `${id}.${timestamp}.${body}`). `timestamp` is unix **seconds**.
|
|
24
|
+
*
|
|
25
|
+
* @example
|
|
26
|
+
* ```ts
|
|
27
|
+
* import { sign } from "@xtandard/webhooks/signing";
|
|
28
|
+
*
|
|
29
|
+
* const signature = await sign(secret, "msg_…", 1720000000, body);
|
|
30
|
+
* // "v1,K5oZ…"
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
declare function sign(secret: string, id: string, timestamp: number, body: string): Promise<string>;
|
|
34
|
+
/**
|
|
35
|
+
* Build the full `webhook-signature` header value: one signature per secret,
|
|
36
|
+
* space-separated (multiple entries appear during secret rotation).
|
|
37
|
+
*/
|
|
38
|
+
declare function signatureHeader(secrets: string[], id: string, timestamp: number, body: string): Promise<string>;
|
|
39
|
+
/** Input to {@link verify}. */
|
|
40
|
+
interface VerifyInput {
|
|
41
|
+
/** The raw request body, **exactly** as received (no re-serialization). */
|
|
42
|
+
payload: string;
|
|
43
|
+
/** Request headers; name lookup is case-insensitive. */
|
|
44
|
+
headers: Record<string, string>;
|
|
45
|
+
/** Candidate secret(s) — pass several during rotation; any match passes. */
|
|
46
|
+
secret: string | string[];
|
|
47
|
+
/** Allowed clock skew in seconds, both past and future. Default `300`. */
|
|
48
|
+
toleranceSeconds?: number;
|
|
49
|
+
/** Current unix time in seconds — injectable for tests. */
|
|
50
|
+
now?: number;
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Verify an incoming Standard Webhooks request (from **any** compliant sender,
|
|
54
|
+
* not just this package). Checks the timestamp tolerance, then compares every
|
|
55
|
+
* `v1,` signature in the header against every candidate secret in constant
|
|
56
|
+
* time. Returns the parsed envelope on success; throws
|
|
57
|
+
* {@link WebhookVerificationError} otherwise.
|
|
58
|
+
*
|
|
59
|
+
* @example
|
|
60
|
+
* ```ts
|
|
61
|
+
* import { verify } from "@xtandard/webhooks/signing";
|
|
62
|
+
*
|
|
63
|
+
* const envelope = await verify({
|
|
64
|
+
* payload: rawBody,
|
|
65
|
+
* headers: { "webhook-id": id, "webhook-timestamp": ts, "webhook-signature": sig },
|
|
66
|
+
* secret: "whsec_…",
|
|
67
|
+
* });
|
|
68
|
+
* ```
|
|
69
|
+
*/
|
|
70
|
+
declare function verify(input: VerifyInput): Promise<WebhookEnvelope>;
|
|
71
|
+
//#endregion
|
|
72
|
+
export { SECRET_PREFIX, VerifyInput, type WebhookEnvelope, WebhookVerificationError, generateSecret, sign, signatureHeader, verify };
|
|
73
|
+
//# sourceMappingURL=signing.d.mts.map
|
package/dist/signing.mjs
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
//#region src/signing.ts
|
|
2
|
+
/** Prefix of every symmetric Standard Webhooks secret. */
|
|
3
|
+
const SECRET_PREFIX = "whsec_";
|
|
4
|
+
/** Prefix of every symmetric (v1) signature entry. */
|
|
5
|
+
const SIGNATURE_VERSION = "v1";
|
|
6
|
+
const MIN_SECRET_BYTES = 24;
|
|
7
|
+
const MAX_SECRET_BYTES = 64;
|
|
8
|
+
/** Thrown by {@link verify} / receiver helpers when a webhook fails verification. */
|
|
9
|
+
var WebhookVerificationError = class extends Error {
|
|
10
|
+
constructor(message) {
|
|
11
|
+
super(message);
|
|
12
|
+
this.name = "WebhookVerificationError";
|
|
13
|
+
}
|
|
14
|
+
};
|
|
15
|
+
function base64Encode(bytes) {
|
|
16
|
+
let binary = "";
|
|
17
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
18
|
+
return btoa(binary);
|
|
19
|
+
}
|
|
20
|
+
function base64Decode(value) {
|
|
21
|
+
const binary = atob(value);
|
|
22
|
+
const bytes = new Uint8Array(binary.length);
|
|
23
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
24
|
+
return bytes;
|
|
25
|
+
}
|
|
26
|
+
/** Decode `whsec_…` into raw key bytes, enforcing the spec's 24–64 byte range. */
|
|
27
|
+
function decodeSecret(secret) {
|
|
28
|
+
const body = secret.startsWith("whsec_") ? secret.slice(6) : secret;
|
|
29
|
+
let bytes;
|
|
30
|
+
try {
|
|
31
|
+
bytes = base64Decode(body);
|
|
32
|
+
} catch {
|
|
33
|
+
throw new WebhookVerificationError("Invalid secret: not base64");
|
|
34
|
+
}
|
|
35
|
+
if (bytes.length < MIN_SECRET_BYTES || bytes.length > MAX_SECRET_BYTES) throw new WebhookVerificationError(`Invalid secret: decoded length ${bytes.length} outside ${MIN_SECRET_BYTES}–${MAX_SECRET_BYTES} bytes`);
|
|
36
|
+
return bytes;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Generate a new signing secret: `whsec_` + base64 of 24 crypto-random bytes.
|
|
40
|
+
*
|
|
41
|
+
* @example
|
|
42
|
+
* ```ts
|
|
43
|
+
* import { generateSecret } from "@xtandard/webhooks/signing";
|
|
44
|
+
*
|
|
45
|
+
* const secret = generateSecret(); // "whsec_…"
|
|
46
|
+
* ```
|
|
47
|
+
*/
|
|
48
|
+
function generateSecret() {
|
|
49
|
+
const bytes = new Uint8Array(MIN_SECRET_BYTES);
|
|
50
|
+
crypto.getRandomValues(bytes);
|
|
51
|
+
return SECRET_PREFIX + base64Encode(bytes);
|
|
52
|
+
}
|
|
53
|
+
async function hmacSha256(keyBytes, content) {
|
|
54
|
+
const key = await crypto.subtle.importKey("raw", keyBytes, {
|
|
55
|
+
name: "HMAC",
|
|
56
|
+
hash: "SHA-256"
|
|
57
|
+
}, false, ["sign"]);
|
|
58
|
+
const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(content));
|
|
59
|
+
return new Uint8Array(digest);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Sign one delivery attempt with one secret: `v1,` + base64(HMAC-SHA256 over
|
|
63
|
+
* `${id}.${timestamp}.${body}`). `timestamp` is unix **seconds**.
|
|
64
|
+
*
|
|
65
|
+
* @example
|
|
66
|
+
* ```ts
|
|
67
|
+
* import { sign } from "@xtandard/webhooks/signing";
|
|
68
|
+
*
|
|
69
|
+
* const signature = await sign(secret, "msg_…", 1720000000, body);
|
|
70
|
+
* // "v1,K5oZ…"
|
|
71
|
+
* ```
|
|
72
|
+
*/
|
|
73
|
+
async function sign(secret, id, timestamp, body) {
|
|
74
|
+
return `${SIGNATURE_VERSION},${base64Encode(await hmacSha256(decodeSecret(secret), `${id}.${timestamp}.${body}`))}`;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Build the full `webhook-signature` header value: one signature per secret,
|
|
78
|
+
* space-separated (multiple entries appear during secret rotation).
|
|
79
|
+
*/
|
|
80
|
+
async function signatureHeader(secrets, id, timestamp, body) {
|
|
81
|
+
return (await Promise.all(secrets.map((s) => sign(s, id, timestamp, body)))).join(" ");
|
|
82
|
+
}
|
|
83
|
+
/** Constant-time byte comparison (XOR accumulate — no early exit on content). */
|
|
84
|
+
function timingSafeEqual(a, b) {
|
|
85
|
+
if (a.length !== b.length) return false;
|
|
86
|
+
let diff = 0;
|
|
87
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
88
|
+
return diff === 0;
|
|
89
|
+
}
|
|
90
|
+
/** Case-insensitive header lookup in a plain record. */
|
|
91
|
+
function headerLookup(headers, name) {
|
|
92
|
+
const direct = headers[name];
|
|
93
|
+
if (direct !== void 0) return direct;
|
|
94
|
+
const lower = name.toLowerCase();
|
|
95
|
+
for (const [key, value] of Object.entries(headers)) if (key.toLowerCase() === lower) return value;
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* Verify an incoming Standard Webhooks request (from **any** compliant sender,
|
|
99
|
+
* not just this package). Checks the timestamp tolerance, then compares every
|
|
100
|
+
* `v1,` signature in the header against every candidate secret in constant
|
|
101
|
+
* time. Returns the parsed envelope on success; throws
|
|
102
|
+
* {@link WebhookVerificationError} otherwise.
|
|
103
|
+
*
|
|
104
|
+
* @example
|
|
105
|
+
* ```ts
|
|
106
|
+
* import { verify } from "@xtandard/webhooks/signing";
|
|
107
|
+
*
|
|
108
|
+
* const envelope = await verify({
|
|
109
|
+
* payload: rawBody,
|
|
110
|
+
* headers: { "webhook-id": id, "webhook-timestamp": ts, "webhook-signature": sig },
|
|
111
|
+
* secret: "whsec_…",
|
|
112
|
+
* });
|
|
113
|
+
* ```
|
|
114
|
+
*/
|
|
115
|
+
async function verify(input) {
|
|
116
|
+
const id = headerLookup(input.headers, "webhook-id");
|
|
117
|
+
const timestampRaw = headerLookup(input.headers, "webhook-timestamp");
|
|
118
|
+
const signatureHeaderValue = headerLookup(input.headers, "webhook-signature");
|
|
119
|
+
if (!id) throw new WebhookVerificationError("Missing webhook-id header");
|
|
120
|
+
if (!timestampRaw) throw new WebhookVerificationError("Missing webhook-timestamp header");
|
|
121
|
+
if (!signatureHeaderValue) throw new WebhookVerificationError("Missing webhook-signature header");
|
|
122
|
+
const timestamp = Number(timestampRaw);
|
|
123
|
+
if (!Number.isFinite(timestamp) || !/^\d+$/.test(timestampRaw.trim())) throw new WebhookVerificationError(`Invalid webhook-timestamp "${timestampRaw}"`);
|
|
124
|
+
const tolerance = input.toleranceSeconds ?? 300;
|
|
125
|
+
const now = input.now ?? Math.floor(Date.now() / 1e3);
|
|
126
|
+
if (timestamp < now - tolerance) throw new WebhookVerificationError("webhook-timestamp is too old");
|
|
127
|
+
if (timestamp > now + tolerance) throw new WebhookVerificationError("webhook-timestamp is in the future");
|
|
128
|
+
const candidates = signatureHeaderValue.split(" ").map((entry) => entry.trim()).filter((entry) => entry.startsWith(`${SIGNATURE_VERSION},`)).map((entry) => entry.slice(3));
|
|
129
|
+
if (candidates.length === 0) throw new WebhookVerificationError("No v1 signature found in webhook-signature header");
|
|
130
|
+
const secrets = Array.isArray(input.secret) ? input.secret : [input.secret];
|
|
131
|
+
if (secrets.length === 0) throw new WebhookVerificationError("No secret provided");
|
|
132
|
+
const content = `${id}.${timestampRaw.trim()}.${input.payload}`;
|
|
133
|
+
let matched = false;
|
|
134
|
+
for (const secret of secrets) {
|
|
135
|
+
const expected = await hmacSha256(decodeSecret(secret), content);
|
|
136
|
+
for (const candidate of candidates) {
|
|
137
|
+
let candidateBytes;
|
|
138
|
+
try {
|
|
139
|
+
candidateBytes = base64Decode(candidate);
|
|
140
|
+
} catch {
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
if (timingSafeEqual(expected, candidateBytes)) matched = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (!matched) throw new WebhookVerificationError("No matching signature");
|
|
147
|
+
try {
|
|
148
|
+
return JSON.parse(input.payload);
|
|
149
|
+
} catch {
|
|
150
|
+
throw new WebhookVerificationError("Payload is not valid JSON");
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
//#endregion
|
|
154
|
+
export { SECRET_PREFIX, WebhookVerificationError, generateSecret, sign, signatureHeader, verify };
|
|
155
|
+
|
|
156
|
+
//# sourceMappingURL=signing.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"signing.mjs","names":[],"sources":["../src/signing.ts"],"sourcesContent":["/**\n * Standard Webhooks symmetric (v1) signing and verification.\n *\n * Pure and zero-dependency (Web Crypto only) — safe on the request path and in\n * any WinterCG runtime. Shared by the sender (the dispatcher signs outgoing\n * deliveries) and the receiver subpath (`@xtandard/webhooks/receiver`), and\n * compatible with any Standard Webhooks implementation, including Svix.\n *\n * The wire contract (https://www.standardwebhooks.com):\n *\n * - secrets are `whsec_` + base64-encoded key material (24–64 bytes decoded)\n * - the signed content is `${id}.${timestamp}.${body}`\n * - the signature is `v1,` + base64(HMAC-SHA256(key, signedContent))\n * - `webhook-signature` may carry several space-separated signatures (secret\n * rotation); a receiver accepts if **any** matches\n * - `webhook-timestamp` (unix seconds) must be within tolerance, both ways\n *\n * @module\n */\n\nimport type { WebhookEnvelope } from \"./schema.ts\";\n\nexport type { WebhookEnvelope };\n\n/** Prefix of every symmetric Standard Webhooks secret. */\nexport const SECRET_PREFIX = \"whsec_\";\n\n/** Prefix of every symmetric (v1) signature entry. */\nconst SIGNATURE_VERSION = \"v1\";\n\nconst MIN_SECRET_BYTES = 24;\nconst MAX_SECRET_BYTES = 64;\n\n/** Thrown by {@link verify} / receiver helpers when a webhook fails verification. */\nexport class WebhookVerificationError extends Error {\n constructor(message: string) {\n super(message);\n this.name = \"WebhookVerificationError\";\n }\n}\n\nfunction base64Encode(bytes: Uint8Array): string {\n let binary = \"\";\n for (const b of bytes) binary += String.fromCharCode(b);\n return btoa(binary);\n}\n\nfunction base64Decode(value: string): Uint8Array {\n const binary = atob(value);\n const bytes = new Uint8Array(binary.length);\n for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);\n return bytes;\n}\n\n/** Decode `whsec_…` into raw key bytes, enforcing the spec's 24–64 byte range. */\nfunction decodeSecret(secret: string): Uint8Array {\n const body = secret.startsWith(SECRET_PREFIX) ? secret.slice(SECRET_PREFIX.length) : secret;\n let bytes: Uint8Array;\n try {\n bytes = base64Decode(body);\n } catch {\n throw new WebhookVerificationError(\"Invalid secret: not base64\");\n }\n if (bytes.length < MIN_SECRET_BYTES || bytes.length > MAX_SECRET_BYTES) {\n throw new WebhookVerificationError(\n `Invalid secret: decoded length ${bytes.length} outside ${MIN_SECRET_BYTES}–${MAX_SECRET_BYTES} bytes`,\n );\n }\n return bytes;\n}\n\n/**\n * Generate a new signing secret: `whsec_` + base64 of 24 crypto-random bytes.\n *\n * @example\n * ```ts\n * import { generateSecret } from \"@xtandard/webhooks/signing\";\n *\n * const secret = generateSecret(); // \"whsec_…\"\n * ```\n */\nexport function generateSecret(): string {\n const bytes = new Uint8Array(MIN_SECRET_BYTES);\n crypto.getRandomValues(bytes);\n return SECRET_PREFIX + base64Encode(bytes);\n}\n\nasync function hmacSha256(keyBytes: Uint8Array, content: string): Promise<Uint8Array> {\n const key = await crypto.subtle.importKey(\n \"raw\",\n keyBytes as BufferSource,\n { name: \"HMAC\", hash: \"SHA-256\" },\n false,\n [\"sign\"],\n );\n const digest = await crypto.subtle.sign(\"HMAC\", key, new TextEncoder().encode(content));\n return new Uint8Array(digest);\n}\n\n/**\n * Sign one delivery attempt with one secret: `v1,` + base64(HMAC-SHA256 over\n * `${id}.${timestamp}.${body}`). `timestamp` is unix **seconds**.\n *\n * @example\n * ```ts\n * import { sign } from \"@xtandard/webhooks/signing\";\n *\n * const signature = await sign(secret, \"msg_…\", 1720000000, body);\n * // \"v1,K5oZ…\"\n * ```\n */\nexport async function sign(\n secret: string,\n id: string,\n timestamp: number,\n body: string,\n): Promise<string> {\n const digest = await hmacSha256(decodeSecret(secret), `${id}.${timestamp}.${body}`);\n return `${SIGNATURE_VERSION},${base64Encode(digest)}`;\n}\n\n/**\n * Build the full `webhook-signature` header value: one signature per secret,\n * space-separated (multiple entries appear during secret rotation).\n */\nexport async function signatureHeader(\n secrets: string[],\n id: string,\n timestamp: number,\n body: string,\n): Promise<string> {\n const signatures = await Promise.all(secrets.map((s) => sign(s, id, timestamp, body)));\n return signatures.join(\" \");\n}\n\n/** Constant-time byte comparison (XOR accumulate — no early exit on content). */\nfunction timingSafeEqual(a: Uint8Array, b: Uint8Array): boolean {\n if (a.length !== b.length) return false;\n let diff = 0;\n for (let i = 0; i < a.length; i++) diff |= (a[i] as number) ^ (b[i] as number);\n return diff === 0;\n}\n\n/** Case-insensitive header lookup in a plain record. */\nfunction headerLookup(headers: Record<string, string>, name: string): string | undefined {\n const direct = headers[name];\n if (direct !== undefined) return direct;\n const lower = name.toLowerCase();\n for (const [key, value] of Object.entries(headers)) {\n if (key.toLowerCase() === lower) return value;\n }\n return undefined;\n}\n\n/** Input to {@link verify}. */\nexport interface VerifyInput {\n /** The raw request body, **exactly** as received (no re-serialization). */\n payload: string;\n /** Request headers; name lookup is case-insensitive. */\n headers: Record<string, string>;\n /** Candidate secret(s) — pass several during rotation; any match passes. */\n secret: string | string[];\n /** Allowed clock skew in seconds, both past and future. Default `300`. */\n toleranceSeconds?: number;\n /** Current unix time in seconds — injectable for tests. */\n now?: number;\n}\n\n/**\n * Verify an incoming Standard Webhooks request (from **any** compliant sender,\n * not just this package). Checks the timestamp tolerance, then compares every\n * `v1,` signature in the header against every candidate secret in constant\n * time. Returns the parsed envelope on success; throws\n * {@link WebhookVerificationError} otherwise.\n *\n * @example\n * ```ts\n * import { verify } from \"@xtandard/webhooks/signing\";\n *\n * const envelope = await verify({\n * payload: rawBody,\n * headers: { \"webhook-id\": id, \"webhook-timestamp\": ts, \"webhook-signature\": sig },\n * secret: \"whsec_…\",\n * });\n * ```\n */\nexport async function verify(input: VerifyInput): Promise<WebhookEnvelope> {\n const id = headerLookup(input.headers, \"webhook-id\");\n const timestampRaw = headerLookup(input.headers, \"webhook-timestamp\");\n const signatureHeaderValue = headerLookup(input.headers, \"webhook-signature\");\n if (!id) throw new WebhookVerificationError(\"Missing webhook-id header\");\n if (!timestampRaw) throw new WebhookVerificationError(\"Missing webhook-timestamp header\");\n if (!signatureHeaderValue) throw new WebhookVerificationError(\"Missing webhook-signature header\");\n\n const timestamp = Number(timestampRaw);\n if (!Number.isFinite(timestamp) || !/^\\d+$/.test(timestampRaw.trim())) {\n throw new WebhookVerificationError(`Invalid webhook-timestamp \"${timestampRaw}\"`);\n }\n const tolerance = input.toleranceSeconds ?? 300;\n const now = input.now ?? Math.floor(Date.now() / 1000);\n if (timestamp < now - tolerance) {\n throw new WebhookVerificationError(\"webhook-timestamp is too old\");\n }\n if (timestamp > now + tolerance) {\n throw new WebhookVerificationError(\"webhook-timestamp is in the future\");\n }\n\n // Only v1 (symmetric) entries participate; other versions are skipped.\n const candidates = signatureHeaderValue\n .split(\" \")\n .map((entry) => entry.trim())\n .filter((entry) => entry.startsWith(`${SIGNATURE_VERSION},`))\n .map((entry) => entry.slice(SIGNATURE_VERSION.length + 1));\n if (candidates.length === 0) {\n throw new WebhookVerificationError(\"No v1 signature found in webhook-signature header\");\n }\n\n const secrets = Array.isArray(input.secret) ? input.secret : [input.secret];\n if (secrets.length === 0) throw new WebhookVerificationError(\"No secret provided\");\n\n // Sign over the timestamp EXACTLY as it arrived on the wire (already\n // validated as digits above), not the JS-parsed number — re-stringifying\n // through Number() can lose precision or reformat and would reject a\n // legitimately-signed webhook whose value doesn't round-trip.\n const content = `${id}.${timestampRaw.trim()}.${input.payload}`;\n let matched = false;\n for (const secret of secrets) {\n const expected = await hmacSha256(decodeSecret(secret), content);\n for (const candidate of candidates) {\n let candidateBytes: Uint8Array;\n try {\n candidateBytes = base64Decode(candidate);\n } catch {\n continue; // malformed entry — try the rest\n }\n if (timingSafeEqual(expected, candidateBytes)) matched = true;\n }\n }\n if (!matched) throw new WebhookVerificationError(\"No matching signature\");\n\n try {\n return JSON.parse(input.payload) as WebhookEnvelope;\n } catch {\n throw new WebhookVerificationError(\"Payload is not valid JSON\");\n }\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
//#region src/storage/sqlite.ts
|
|
4
|
+
/**
|
|
5
|
+
* SQLite storage adapter built on `bun:sqlite` — **Bun only**, zero npm deps.
|
|
6
|
+
* Ideal for single-node deployments and local/dev persistence: durable, fast, and
|
|
7
|
+
* file-backed (or in-memory). For multi-node runtimes prefer Redis/Postgres.
|
|
8
|
+
*
|
|
9
|
+
* `bun:sqlite` is marked external at build time, so this module only resolves
|
|
10
|
+
* under the Bun runtime. Importing it under Node will throw — by design.
|
|
11
|
+
*
|
|
12
|
+
* ```ts
|
|
13
|
+
* import { createSqliteStorage } from "@xtandard/webhooks/storage/sqlite";
|
|
14
|
+
* const storage = createSqliteStorage({ path: "./webhooks.sqlite" });
|
|
15
|
+
* ```
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
var sqlite_exports = /* @__PURE__ */ __exportAll({ createSqliteStorage: () => createSqliteStorage });
|
|
20
|
+
const escapeLike = (prefix) => prefix.replace(/[\\%_]/g, (c) => `\\${c}`);
|
|
21
|
+
/**
|
|
22
|
+
* Create a SQLite-backed storage. Requires the Bun runtime (`bun:sqlite`).
|
|
23
|
+
*
|
|
24
|
+
* @example
|
|
25
|
+
* ```ts
|
|
26
|
+
* import { createSqliteStorage } from "@xtandard/webhooks/storage/sqlite";
|
|
27
|
+
*
|
|
28
|
+
* // File-backed (persists across restarts):
|
|
29
|
+
* const storage = createSqliteStorage({ path: "./webhooks.sqlite" });
|
|
30
|
+
*
|
|
31
|
+
* // In-memory (reset each run, useful for tests):
|
|
32
|
+
* // const storage = createSqliteStorage({ path: ":memory:" });
|
|
33
|
+
* ```
|
|
34
|
+
*/
|
|
35
|
+
function createSqliteStorage(options = {}) {
|
|
36
|
+
const table = options.table ?? "xtandard_webhooks";
|
|
37
|
+
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) throw new Error(`Invalid table name: ${JSON.stringify(table)}`);
|
|
38
|
+
const ownsDb = !options.database;
|
|
39
|
+
const db = options.database ?? new Database(options.path ?? ":memory:");
|
|
40
|
+
db.run(`CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);
|
|
41
|
+
const selectStmt = db.query(`SELECT value FROM ${table} WHERE key = ?`);
|
|
42
|
+
const upsertStmt = db.query(`INSERT INTO ${table} (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`);
|
|
43
|
+
const deleteStmt = db.query(`DELETE FROM ${table} WHERE key = ?`);
|
|
44
|
+
const keysStmt = db.query(`SELECT key FROM ${table} WHERE key LIKE ? ESCAPE '\\'`);
|
|
45
|
+
return {
|
|
46
|
+
async getItem(key) {
|
|
47
|
+
const row = selectStmt.get(key);
|
|
48
|
+
return row ? JSON.parse(row.value) : null;
|
|
49
|
+
},
|
|
50
|
+
async setItem(key, value) {
|
|
51
|
+
upsertStmt.run(key, JSON.stringify(value));
|
|
52
|
+
},
|
|
53
|
+
async removeItem(key) {
|
|
54
|
+
deleteStmt.run(key);
|
|
55
|
+
},
|
|
56
|
+
async getKeys(prefix) {
|
|
57
|
+
return keysStmt.all(`${escapeLike(prefix)}%`).map((r) => r.key);
|
|
58
|
+
},
|
|
59
|
+
close() {
|
|
60
|
+
if (ownsDb) db.close();
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
export { sqlite_exports as n, createSqliteStorage as t };
|
|
66
|
+
|
|
67
|
+
//# sourceMappingURL=sqlite-Cmqnrjes.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"sqlite-Cmqnrjes.mjs","names":[],"sources":["../src/storage/sqlite.ts"],"sourcesContent":["/**\n * SQLite storage adapter built on `bun:sqlite` — **Bun only**, zero npm deps.\n * Ideal for single-node deployments and local/dev persistence: durable, fast, and\n * file-backed (or in-memory). For multi-node runtimes prefer Redis/Postgres.\n *\n * `bun:sqlite` is marked external at build time, so this module only resolves\n * under the Bun runtime. Importing it under Node will throw — by design.\n *\n * ```ts\n * import { createSqliteStorage } from \"@xtandard/webhooks/storage/sqlite\";\n * const storage = createSqliteStorage({ path: \"./webhooks.sqlite\" });\n * ```\n *\n * @module\n */\n\nimport { Database } from \"bun:sqlite\";\nimport type { WebhooksStorage } from \"./contract.ts\";\n\n/** Options for {@link createSqliteStorage}. */\nexport interface SqliteStorageOptions {\n /** File path for the database. Default `\":memory:\"`. Ignored when `database` is given. */\n path?: string;\n /** An existing `bun:sqlite` Database instance to use instead of opening one. */\n database?: Database;\n /** Table name (default `\"xtandard_webhooks\"`). Validated as a safe identifier. */\n table?: string;\n}\n\n/** A {@link WebhooksStorage} backed by SQLite, plus `close()`. */\nexport interface SqliteWebhooksStorage extends WebhooksStorage {\n /** Close the database if this adapter opened it; no-op for a borrowed instance. */\n close(): void;\n}\n\nconst escapeLike = (prefix: string): string => prefix.replace(/[\\\\%_]/g, (c) => `\\\\${c}`);\n\n/**\n * Create a SQLite-backed storage. Requires the Bun runtime (`bun:sqlite`).\n *\n * @example\n * ```ts\n * import { createSqliteStorage } from \"@xtandard/webhooks/storage/sqlite\";\n *\n * // File-backed (persists across restarts):\n * const storage = createSqliteStorage({ path: \"./webhooks.sqlite\" });\n *\n * // In-memory (reset each run, useful for tests):\n * // const storage = createSqliteStorage({ path: \":memory:\" });\n * ```\n */\nexport function createSqliteStorage(options: SqliteStorageOptions = {}): SqliteWebhooksStorage {\n const table = options.table ?? \"xtandard_webhooks\";\n if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) {\n throw new Error(`Invalid table name: ${JSON.stringify(table)}`);\n }\n\n const ownsDb = !options.database;\n const db = options.database ?? new Database(options.path ?? \":memory:\");\n db.run(`CREATE TABLE IF NOT EXISTS ${table} (key TEXT PRIMARY KEY, value TEXT NOT NULL)`);\n\n const selectStmt = db.query(`SELECT value FROM ${table} WHERE key = ?`);\n const upsertStmt = db.query(\n `INSERT INTO ${table} (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`,\n );\n const deleteStmt = db.query(`DELETE FROM ${table} WHERE key = ?`);\n const keysStmt = db.query(`SELECT key FROM ${table} WHERE key LIKE ? ESCAPE '\\\\'`);\n\n return {\n async getItem<T>(key: string): Promise<T | null> {\n const row = selectStmt.get(key) as { value: string } | null;\n return row ? (JSON.parse(row.value) as T) : null;\n },\n async setItem<T>(key: string, value: T): Promise<void> {\n upsertStmt.run(key, JSON.stringify(value));\n },\n async removeItem(key: string): Promise<void> {\n deleteStmt.run(key);\n },\n async getKeys(prefix: string): Promise<string[]> {\n const rows = keysStmt.all(`${escapeLike(prefix)}%`) as Array<{ key: string }>;\n return rows.map((r) => r.key);\n },\n close(): void {\n if (ownsDb) db.close();\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAmCA,MAAM,cAAc,WAA2B,OAAO,QAAQ,YAAY,MAAM,KAAK,GAAG;;;;;;;;;;;;;;;AAgBxF,SAAgB,oBAAoB,UAAgC,CAAC,GAA0B;CAC7F,MAAM,QAAQ,QAAQ,SAAS;CAC/B,IAAI,CAAC,2BAA2B,KAAK,KAAK,GACxC,MAAM,IAAI,MAAM,uBAAuB,KAAK,UAAU,KAAK,GAAG;CAGhE,MAAM,SAAS,CAAC,QAAQ;CACxB,MAAM,KAAK,QAAQ,YAAY,IAAI,SAAS,QAAQ,QAAQ,UAAU;CACtE,GAAG,IAAI,8BAA8B,MAAM,6CAA6C;CAExF,MAAM,aAAa,GAAG,MAAM,qBAAqB,MAAM,eAAe;CACtE,MAAM,aAAa,GAAG,MACpB,eAAe,MAAM,kFACvB;CACA,MAAM,aAAa,GAAG,MAAM,eAAe,MAAM,eAAe;CAChE,MAAM,WAAW,GAAG,MAAM,mBAAmB,MAAM,8BAA8B;CAEjF,OAAO;EACL,MAAM,QAAW,KAAgC;GAC/C,MAAM,MAAM,WAAW,IAAI,GAAG;GAC9B,OAAO,MAAO,KAAK,MAAM,IAAI,KAAK,IAAU;EAC9C;EACA,MAAM,QAAW,KAAa,OAAyB;GACrD,WAAW,IAAI,KAAK,KAAK,UAAU,KAAK,CAAC;EAC3C;EACA,MAAM,WAAW,KAA4B;GAC3C,WAAW,IAAI,GAAG;EACpB;EACA,MAAM,QAAQ,QAAmC;GAE/C,OADa,SAAS,IAAI,GAAG,WAAW,MAAM,EAAE,EACtC,EAAE,KAAK,MAAM,EAAE,GAAG;EAC9B;EACA,QAAc;GACZ,IAAI,QAAQ,GAAG,MAAM;EACvB;CACF;AACF"}
|