@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
|
@@ -0,0 +1,254 @@
|
|
|
1
|
+
import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
|
|
2
|
+
import { T as parseDueKey, m as dueKey, p as deliveryKey } from "./keys-Byyj4quQ.mjs";
|
|
3
|
+
import { a as requirePeer } from "./contract-BEhDcd_5.mjs";
|
|
4
|
+
//#region src/storage/redis.ts
|
|
5
|
+
var redis_exports = /* @__PURE__ */ __exportAll({
|
|
6
|
+
createRedisJSONStorage: () => createRedisJSONStorage,
|
|
7
|
+
createRedisStorage: () => createRedisStorage
|
|
8
|
+
});
|
|
9
|
+
/** Plain strings containing JSON — works on any Redis. */
|
|
10
|
+
const stringCodec = {
|
|
11
|
+
async get(c, key) {
|
|
12
|
+
const raw = await c.get(key);
|
|
13
|
+
return raw === null ? null : JSON.parse(raw);
|
|
14
|
+
},
|
|
15
|
+
async set(c, key, value) {
|
|
16
|
+
await c.set(key, JSON.stringify(value));
|
|
17
|
+
}
|
|
18
|
+
};
|
|
19
|
+
/** Native RedisJSON documents — requires the JSON module on the server. */
|
|
20
|
+
const jsonCodec = {
|
|
21
|
+
async get(c, key) {
|
|
22
|
+
return await requireJson(c).get(key) ?? null;
|
|
23
|
+
},
|
|
24
|
+
async set(c, key, value) {
|
|
25
|
+
await requireJson(c).set(key, "$", value);
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
function requireJson(c) {
|
|
29
|
+
if (!c.json) throw new Error("createRedisJSONStorage: the client exposes no `json` commands — use node-redis (the `redis` package) v4+, and a server with the RedisJSON module (Redis 8 / Redis Stack).");
|
|
30
|
+
return c.json;
|
|
31
|
+
}
|
|
32
|
+
/** Matches the due-index keys the adapter mirrors into the due sorted set. */
|
|
33
|
+
const DUE_KEY_RE = new RegExp(`^whk/[^/]+/due/`);
|
|
34
|
+
/**
|
|
35
|
+
* Atomically pop up to `ARGV[2]` members due at or before `ARGV[1]` by
|
|
36
|
+
* repositioning them at `ARGV[3]` (the lease expiry). Repositioning — instead of
|
|
37
|
+
* removing — means a claimer that crashes right after this script re-surfaces
|
|
38
|
+
* its members automatically when the lease expires.
|
|
39
|
+
*/
|
|
40
|
+
const CLAIM_SCRIPT = `
|
|
41
|
+
local members = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))
|
|
42
|
+
for i = 1, #members do
|
|
43
|
+
redis.call('ZADD', KEYS[1], ARGV[3], members[i])
|
|
44
|
+
end
|
|
45
|
+
return members`;
|
|
46
|
+
/**
|
|
47
|
+
* Create a Redis-backed {@link RedisWebhooksStorage}. Connection is lazy: the
|
|
48
|
+
* client is created/connected on the first storage operation and reused
|
|
49
|
+
* thereafter, guarded by a single connection promise so concurrent calls connect
|
|
50
|
+
* once.
|
|
51
|
+
*
|
|
52
|
+
* @example
|
|
53
|
+
* ```ts
|
|
54
|
+
* import { createRedisStorage } from "@xtandard/webhooks/storage/redis";
|
|
55
|
+
*
|
|
56
|
+
* const storage = createRedisStorage({
|
|
57
|
+
* url: process.env.REDIS_URL ?? "redis://localhost:6379",
|
|
58
|
+
* prefix: "myapp:webhooks",
|
|
59
|
+
* onError: (err) => console.error("[webhooks/redis]", err),
|
|
60
|
+
* });
|
|
61
|
+
*
|
|
62
|
+
* // Disconnect when the process exits:
|
|
63
|
+
* // process.on("SIGTERM", () => storage.close());
|
|
64
|
+
* ```
|
|
65
|
+
*/
|
|
66
|
+
function createRedisStorage(options) {
|
|
67
|
+
return buildRedisStorage(options, stringCodec);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Create a Redis-backed {@link RedisWebhooksStorage} that stores values as native
|
|
71
|
+
* **RedisJSON** documents (`JSON.SET`/`JSON.GET`) instead of strings — making
|
|
72
|
+
* the stored records queryable with JSONPath (`JSON.GET key $.status`) and
|
|
73
|
+
* indexable with RediSearch, while the webhooks system behaves identically.
|
|
74
|
+
*
|
|
75
|
+
* Requires the JSON module on the server (built into Redis 8; Redis Stack; or
|
|
76
|
+
* `redisjson` loaded). Same options and semantics as {@link createRedisStorage},
|
|
77
|
+
* including lazy connection, `SCAN`-based `getKeys`, keyspace-notification
|
|
78
|
+
* `watch`, and the native due-queue `claimDue`. **Do not point it at keys
|
|
79
|
+
* written by `createRedisStorage`** (or vice versa) — the underlying types
|
|
80
|
+
* differ and Redis answers `WRONGTYPE`.
|
|
81
|
+
*
|
|
82
|
+
* @example
|
|
83
|
+
* ```ts
|
|
84
|
+
* import { createRedisJSONStorage } from "@xtandard/webhooks/storage/redis";
|
|
85
|
+
*
|
|
86
|
+
* const storage = createRedisJSONStorage({
|
|
87
|
+
* url: process.env.REDIS_URL ?? "redis://localhost:6379",
|
|
88
|
+
* prefix: "myapp:webhooks",
|
|
89
|
+
* });
|
|
90
|
+
* // Then, in redis-cli: JSON.GET myapp:webhooks:whk/acme/deliveries/dlv_1 $.status
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
function createRedisJSONStorage(options) {
|
|
94
|
+
return buildRedisStorage(options, jsonCodec);
|
|
95
|
+
}
|
|
96
|
+
/** Shared implementation — connection, prefixing, SCAN, watch, due queue, close. */
|
|
97
|
+
function buildRedisStorage(options, codec) {
|
|
98
|
+
const { url, prefix } = options;
|
|
99
|
+
const fullPrefix = prefix ? `${prefix}:` : "";
|
|
100
|
+
const ownsClient = !options.client;
|
|
101
|
+
let client = options.client;
|
|
102
|
+
let connecting;
|
|
103
|
+
const attachErrorHandler = (c) => {
|
|
104
|
+
c.on?.("error", (err) => options.onError?.(err));
|
|
105
|
+
};
|
|
106
|
+
if (client) attachErrorHandler(client);
|
|
107
|
+
/** Resolve a connected client, creating/connecting on first use. */
|
|
108
|
+
async function getClient() {
|
|
109
|
+
if (client?.isOpen) return client;
|
|
110
|
+
connecting ??= (async () => {
|
|
111
|
+
if (!client) {
|
|
112
|
+
let createClient;
|
|
113
|
+
try {
|
|
114
|
+
({createClient} = await import("redis"));
|
|
115
|
+
} catch {
|
|
116
|
+
requirePeer("redis", "storage/redis");
|
|
117
|
+
}
|
|
118
|
+
client = createClient({
|
|
119
|
+
url,
|
|
120
|
+
disableOfflineQueue: true,
|
|
121
|
+
socket: { reconnectStrategy: (retries) => Math.min(retries * 100, 3e3) }
|
|
122
|
+
});
|
|
123
|
+
attachErrorHandler(client);
|
|
124
|
+
}
|
|
125
|
+
if (!client.isOpen) await client.connect();
|
|
126
|
+
return client;
|
|
127
|
+
})();
|
|
128
|
+
try {
|
|
129
|
+
return await connecting;
|
|
130
|
+
} finally {
|
|
131
|
+
connecting = void 0;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/** Prepend the namespace to a caller key. */
|
|
135
|
+
const toRedisKey = (key) => `${fullPrefix}${key}`;
|
|
136
|
+
/** Strip the namespace off a Redis key, yielding the caller key. */
|
|
137
|
+
const fromRedisKey = (key) => fullPrefix && key.startsWith(fullPrefix) ? key.slice(fullPrefix.length) : key;
|
|
138
|
+
/**
|
|
139
|
+
* The due-index sorted set (members are caller due keys; the zset key itself
|
|
140
|
+
* carries the namespace). `:`-separated so it never collides with `whk/…`.
|
|
141
|
+
*/
|
|
142
|
+
const dueZsetKey = toRedisKey(`whk:due`);
|
|
143
|
+
return {
|
|
144
|
+
async getItem(key) {
|
|
145
|
+
const c = await getClient();
|
|
146
|
+
return await codec.get(c, toRedisKey(key)) ?? null;
|
|
147
|
+
},
|
|
148
|
+
async setItem(key, value) {
|
|
149
|
+
const c = await getClient();
|
|
150
|
+
await codec.set(c, toRedisKey(key), value);
|
|
151
|
+
if (DUE_KEY_RE.test(key)) {
|
|
152
|
+
const parsed = parseDueKey(key);
|
|
153
|
+
if (parsed) await c.zAdd(dueZsetKey, {
|
|
154
|
+
score: parsed.dueAtMillis,
|
|
155
|
+
value: key
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
},
|
|
159
|
+
async removeItem(key) {
|
|
160
|
+
const c = await getClient();
|
|
161
|
+
await c.del(toRedisKey(key));
|
|
162
|
+
if (DUE_KEY_RE.test(key)) await c.zRem(dueZsetKey, key);
|
|
163
|
+
},
|
|
164
|
+
async getKeys(prefix) {
|
|
165
|
+
const c = await getClient();
|
|
166
|
+
const match = `${toRedisKey(prefix)}*`;
|
|
167
|
+
const out = [];
|
|
168
|
+
for await (const entry of c.scanIterator({
|
|
169
|
+
MATCH: match,
|
|
170
|
+
COUNT: 100
|
|
171
|
+
})) if (Array.isArray(entry)) for (const k of entry) out.push(fromRedisKey(k));
|
|
172
|
+
else out.push(fromRedisKey(entry));
|
|
173
|
+
return out;
|
|
174
|
+
},
|
|
175
|
+
async claimDue(input) {
|
|
176
|
+
const c = await getClient();
|
|
177
|
+
const nowMillis = Date.parse(input.now);
|
|
178
|
+
const leaseExpiryMillis = nowMillis + input.leaseMs;
|
|
179
|
+
const members = await c.eval(CLAIM_SCRIPT, {
|
|
180
|
+
keys: [dueZsetKey],
|
|
181
|
+
arguments: [
|
|
182
|
+
String(nowMillis),
|
|
183
|
+
String(input.limit),
|
|
184
|
+
String(leaseExpiryMillis)
|
|
185
|
+
]
|
|
186
|
+
});
|
|
187
|
+
const claimed = [];
|
|
188
|
+
for (const member of members ?? []) {
|
|
189
|
+
if (!parseDueKey(member)) {
|
|
190
|
+
await c.zRem(dueZsetKey, member);
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
const entry = await codec.get(c, toRedisKey(member));
|
|
194
|
+
if (!entry) {
|
|
195
|
+
await c.zRem(dueZsetKey, member);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
const dKey = deliveryKey(entry.app, entry.deliveryId);
|
|
199
|
+
const delivery = await codec.get(c, toRedisKey(dKey));
|
|
200
|
+
if (!delivery || delivery.status === "succeeded" || delivery.status === "failed") {
|
|
201
|
+
await c.del(toRedisKey(member));
|
|
202
|
+
await c.zRem(dueZsetKey, member);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const leaseExpired = delivery.status === "delivering" && (!delivery.leaseUntil || Date.parse(delivery.leaseUntil) <= nowMillis);
|
|
206
|
+
if (delivery.status !== "pending" && !leaseExpired) continue;
|
|
207
|
+
const leaseUntil = new Date(leaseExpiryMillis).toISOString();
|
|
208
|
+
const next = {
|
|
209
|
+
...delivery,
|
|
210
|
+
status: "delivering",
|
|
211
|
+
leaseUntil,
|
|
212
|
+
updatedAt: input.now
|
|
213
|
+
};
|
|
214
|
+
await codec.set(c, toRedisKey(dKey), next);
|
|
215
|
+
const nextDueKey = dueKey(entry.app, leaseExpiryMillis, entry.deliveryId);
|
|
216
|
+
await c.del(toRedisKey(member));
|
|
217
|
+
await c.zRem(dueZsetKey, member);
|
|
218
|
+
await codec.set(c, toRedisKey(nextDueKey), entry);
|
|
219
|
+
await c.zAdd(dueZsetKey, {
|
|
220
|
+
score: leaseExpiryMillis,
|
|
221
|
+
value: nextDueKey
|
|
222
|
+
});
|
|
223
|
+
claimed.push(next);
|
|
224
|
+
}
|
|
225
|
+
return claimed;
|
|
226
|
+
},
|
|
227
|
+
async watch(prefix, callback) {
|
|
228
|
+
const subscriber = (await getClient()).duplicate();
|
|
229
|
+
attachErrorHandler(subscriber);
|
|
230
|
+
await subscriber.connect();
|
|
231
|
+
const pattern = `__keyspace@*__:${toRedisKey(prefix)}*`;
|
|
232
|
+
await subscriber.pSubscribe(pattern, (event, channel) => {
|
|
233
|
+
const idx = channel.indexOf("__:");
|
|
234
|
+
if (idx === -1) return;
|
|
235
|
+
const key = fromRedisKey(channel.slice(idx + 3));
|
|
236
|
+
if (!key.startsWith(prefix)) return;
|
|
237
|
+
callback({
|
|
238
|
+
type: event === "del" || event === "expired" ? "remove" : "update",
|
|
239
|
+
key
|
|
240
|
+
});
|
|
241
|
+
});
|
|
242
|
+
return () => {
|
|
243
|
+
subscriber.disconnect();
|
|
244
|
+
};
|
|
245
|
+
},
|
|
246
|
+
async close() {
|
|
247
|
+
if (ownsClient && client?.isOpen) await client.quit();
|
|
248
|
+
}
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
//#endregion
|
|
252
|
+
export { createRedisStorage as n, redis_exports as r, createRedisJSONStorage as t };
|
|
253
|
+
|
|
254
|
+
//# sourceMappingURL=redis-CvLi0KF7.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"redis-CvLi0KF7.mjs","names":[],"sources":["../src/storage/redis.ts"],"sourcesContent":["/**\n * Redis storage adapters built on the [`redis`](https://github.com/redis/node-redis)\n * package (node-redis v4/v5), an optional peer dependency. You can either pass a\n * pre-connected `client` or a `url` to connect lazily on first use. An optional\n * `prefix` is prepended (with a `:` separator) to every key so multiple\n * deployments can share a Redis instance without collisions; the prefix is\n * stripped from keys returned by {@link RedisWebhooksStorage.getKeys}.\n *\n * Two variants over the same plumbing, differing only in the value encoding:\n *\n * - {@link createRedisStorage} — values are plain **strings** containing JSON\n * (`SET`/`GET`). Works on any Redis; the default choice.\n * - {@link createRedisJSONStorage} — values are native **RedisJSON** documents\n * (`JSON.SET`/`JSON.GET`), so the stored data is queryable with JSONPath and\n * indexable with RediSearch. Requires the JSON module (Redis 8 / Redis Stack).\n * Do NOT point both variants at the same keys — the types are incompatible\n * (`WRONGTYPE`).\n *\n * `getKeys` uses a non-blocking `SCAN` cursor (never the blocking `KEYS`\n * command). `watch` is implemented with Redis keyspace notifications; it\n * requires the server to be configured with `notify-keyspace-events` covering\n * generic + string + expiry events (e.g. `KEA` — whose `A` class also covers the\n * module events that RedisJSON writes emit, e.g. `json.set`).\n *\n * ## Native delivery queue (`claimDue`)\n *\n * Both variants implement {@link DeliveryQueueStorage} natively: a sorted set at\n * `<prefix>whk:due` (score = due-time millis, member = the `whk/{app}/due/…`\n * key) is maintained alongside every `setItem`/`removeItem` that touches a\n * due-index key, keeping the zset and the plain keys consistent (so the generic\n * scan path still works). `claimDue` runs a small Lua script that atomically\n * pops due members up to `limit` by **repositioning** them at the lease expiry —\n * exclusivity between concurrent claimers is guaranteed by the script, and a\n * crashed claimer's members re-surface when the lease expires. The per-delivery\n * claim (verify claimable, write the lease, move the due key) then happens in\n * JS; under a race a claimer may return fewer than `limit`, never a duplicate.\n *\n * @module\n */\n\nimport type { RedisClientType } from \"redis\";\nimport { deliveryKey, dueKey, parseDueKey, ROOT, type DueEntry } from \"../keys.ts\";\nimport type { Delivery } from \"../schema.ts\";\nimport { requirePeer } from \"./contract.ts\";\nimport type {\n DeliveryQueueStorage,\n StorageChangeEvent,\n WatchableWebhooksStorage,\n} from \"./contract.ts\";\n\n/** Options for {@link createRedisStorage}. */\nexport interface RedisStorageOptions {\n /** Connection URL (e.g. `redis://localhost:6379`). Used when no `client` is given. */\n url?: string;\n /** A pre-constructed (optionally pre-connected) node-redis client. */\n client?: RedisClientType;\n /** Optional key namespace prepended to every key, joined with `:`. */\n prefix?: string;\n /**\n * Called on client `error` events (connection drops, reconnect failures).\n * A handler is always attached internally so a downed Redis never crashes the\n * process via an unhandled `error` event — this just lets you observe/log them.\n */\n onError?: (error: unknown) => void;\n}\n\n/**\n * A {@link WatchableWebhooksStorage} + {@link DeliveryQueueStorage} backed by\n * Redis, plus a `close()` method that disconnects the client — but only the one\n * this adapter created. A client you passed in is left for you to manage.\n */\nexport interface RedisWebhooksStorage extends WatchableWebhooksStorage, DeliveryQueueStorage {\n /** Disconnect the underlying client if this adapter created it. No-op otherwise. */\n close(): Promise<void>;\n}\n\n/** Minimal structural view of the node-redis client surface this adapter uses. */\ninterface RedisLike {\n isOpen?: boolean;\n on?(event: string, listener: (...args: unknown[]) => void): unknown;\n connect(): Promise<unknown>;\n quit(): Promise<unknown>;\n get(key: string): Promise<string | null>;\n set(key: string, value: string): Promise<unknown>;\n del(key: string): Promise<unknown>;\n zAdd(key: string, members: { score: number; value: string }): Promise<unknown>;\n zRem(key: string, member: string): Promise<unknown>;\n eval(script: string, options?: { keys?: string[]; arguments?: string[] }): Promise<unknown>;\n /** RedisJSON module commands (bundled in node-redis; the SERVER needs the module). */\n json?: {\n get(key: string): Promise<unknown>;\n set(key: string, path: string, value: unknown): Promise<unknown>;\n };\n scanIterator(options?: { MATCH?: string; COUNT?: number }): AsyncIterable<string | string[]>;\n duplicate(): RedisLike;\n pSubscribe(\n pattern: string,\n listener: (message: string, channel: string) => void,\n ): Promise<unknown>;\n disconnect(): Promise<unknown>;\n}\n\n/** How a variant reads/writes values (the only place the two adapters differ). */\ninterface RedisValueCodec {\n get(c: RedisLike, key: string): Promise<unknown>;\n set(c: RedisLike, key: string, value: unknown): Promise<void>;\n}\n\n/** Plain strings containing JSON — works on any Redis. */\nconst stringCodec: RedisValueCodec = {\n async get(c, key) {\n const raw = await c.get(key);\n return raw === null ? null : (JSON.parse(raw) as unknown);\n },\n async set(c, key, value) {\n await c.set(key, JSON.stringify(value));\n },\n};\n\n/** Native RedisJSON documents — requires the JSON module on the server. */\nconst jsonCodec: RedisValueCodec = {\n async get(c, key) {\n const value = await requireJson(c).get(key);\n return value ?? null;\n },\n async set(c, key, value) {\n await requireJson(c).set(key, \"$\", value);\n },\n};\n\nfunction requireJson(c: RedisLike): NonNullable<RedisLike[\"json\"]> {\n if (!c.json) {\n throw new Error(\n \"createRedisJSONStorage: the client exposes no `json` commands — use node-redis \" +\n \"(the `redis` package) v4+, and a server with the RedisJSON module (Redis 8 / Redis Stack).\",\n );\n }\n return c.json;\n}\n\n/** Matches the due-index keys the adapter mirrors into the due sorted set. */\nconst DUE_KEY_RE = new RegExp(`^${ROOT}/[^/]+/due/`);\n\n/**\n * Atomically pop up to `ARGV[2]` members due at or before `ARGV[1]` by\n * repositioning them at `ARGV[3]` (the lease expiry). Repositioning — instead of\n * removing — means a claimer that crashes right after this script re-surfaces\n * its members automatically when the lease expires.\n */\nconst CLAIM_SCRIPT = `\nlocal members = redis.call('ZRANGEBYSCORE', KEYS[1], 0, ARGV[1], 'LIMIT', 0, tonumber(ARGV[2]))\nfor i = 1, #members do\n redis.call('ZADD', KEYS[1], ARGV[3], members[i])\nend\nreturn members`;\n\n/**\n * Create a Redis-backed {@link RedisWebhooksStorage}. Connection is lazy: the\n * client is created/connected on the first storage operation and reused\n * thereafter, guarded by a single connection promise so concurrent calls connect\n * once.\n *\n * @example\n * ```ts\n * import { createRedisStorage } from \"@xtandard/webhooks/storage/redis\";\n *\n * const storage = createRedisStorage({\n * url: process.env.REDIS_URL ?? \"redis://localhost:6379\",\n * prefix: \"myapp:webhooks\",\n * onError: (err) => console.error(\"[webhooks/redis]\", err),\n * });\n *\n * // Disconnect when the process exits:\n * // process.on(\"SIGTERM\", () => storage.close());\n * ```\n */\nexport function createRedisStorage(options: RedisStorageOptions): RedisWebhooksStorage {\n return buildRedisStorage(options, stringCodec);\n}\n\n/**\n * Create a Redis-backed {@link RedisWebhooksStorage} that stores values as native\n * **RedisJSON** documents (`JSON.SET`/`JSON.GET`) instead of strings — making\n * the stored records queryable with JSONPath (`JSON.GET key $.status`) and\n * indexable with RediSearch, while the webhooks system behaves identically.\n *\n * Requires the JSON module on the server (built into Redis 8; Redis Stack; or\n * `redisjson` loaded). Same options and semantics as {@link createRedisStorage},\n * including lazy connection, `SCAN`-based `getKeys`, keyspace-notification\n * `watch`, and the native due-queue `claimDue`. **Do not point it at keys\n * written by `createRedisStorage`** (or vice versa) — the underlying types\n * differ and Redis answers `WRONGTYPE`.\n *\n * @example\n * ```ts\n * import { createRedisJSONStorage } from \"@xtandard/webhooks/storage/redis\";\n *\n * const storage = createRedisJSONStorage({\n * url: process.env.REDIS_URL ?? \"redis://localhost:6379\",\n * prefix: \"myapp:webhooks\",\n * });\n * // Then, in redis-cli: JSON.GET myapp:webhooks:whk/acme/deliveries/dlv_1 $.status\n * ```\n */\nexport function createRedisJSONStorage(options: RedisStorageOptions): RedisWebhooksStorage {\n return buildRedisStorage(options, jsonCodec);\n}\n\n/** Shared implementation — connection, prefixing, SCAN, watch, due queue, close. */\nfunction buildRedisStorage(\n options: RedisStorageOptions,\n codec: RedisValueCodec,\n): RedisWebhooksStorage {\n const { url, prefix } = options;\n const fullPrefix = prefix ? `${prefix}:` : \"\";\n const ownsClient = !options.client;\n\n let client: RedisLike | undefined = options.client as RedisLike | undefined;\n let connecting: Promise<RedisLike> | undefined;\n\n // node-redis emits `error` on connection drops and reconnect attempts. Without a\n // listener Node treats it as an unhandled error and crashes the process — which\n // would defeat the whole \"storage can be down\" promise. Always attach one.\n const attachErrorHandler = (c: RedisLike): void => {\n c.on?.(\"error\", (err: unknown) => options.onError?.(err));\n };\n if (client) attachErrorHandler(client);\n\n /** Resolve a connected client, creating/connecting on first use. */\n async function getClient(): Promise<RedisLike> {\n if (client?.isOpen) return client;\n connecting ??= (async () => {\n if (!client) {\n let createClient: (opts: Record<string, unknown>) => RedisLike;\n try {\n ({ createClient } = (await import(\"redis\")) as unknown as {\n createClient: (opts: Record<string, unknown>) => RedisLike;\n });\n } catch {\n requirePeer(\"redis\", \"storage/redis\");\n }\n // disableOfflineQueue: commands reject immediately when the socket is down\n // (instead of queueing forever) so a dispatcher tick fails fast and the\n // next tick retries. The reconnect strategy keeps trying with a capped\n // backoff so it recovers automatically.\n client = createClient({\n url,\n disableOfflineQueue: true,\n socket: { reconnectStrategy: (retries: number) => Math.min(retries * 100, 3000) },\n });\n attachErrorHandler(client);\n }\n if (!client.isOpen) await client.connect();\n return client;\n })();\n try {\n return await connecting;\n } finally {\n connecting = undefined;\n }\n }\n\n /** Prepend the namespace to a caller key. */\n const toRedisKey = (key: string): string => `${fullPrefix}${key}`;\n /** Strip the namespace off a Redis key, yielding the caller key. */\n const fromRedisKey = (key: string): string =>\n fullPrefix && key.startsWith(fullPrefix) ? key.slice(fullPrefix.length) : key;\n\n /**\n * The due-index sorted set (members are caller due keys; the zset key itself\n * carries the namespace). `:`-separated so it never collides with `whk/…`.\n */\n const dueZsetKey = toRedisKey(`${ROOT}:due`);\n\n return {\n async getItem<T>(key: string): Promise<T | null> {\n const c = await getClient();\n return ((await codec.get(c, toRedisKey(key))) as T | null) ?? null;\n },\n\n async setItem<T>(key: string, value: T): Promise<void> {\n const c = await getClient();\n await codec.set(c, toRedisKey(key), value);\n // Mirror due-index keys into the due zset so claimDue can pop by score.\n if (DUE_KEY_RE.test(key)) {\n const parsed = parseDueKey(key);\n if (parsed) await c.zAdd(dueZsetKey, { score: parsed.dueAtMillis, value: key });\n }\n },\n\n async removeItem(key: string): Promise<void> {\n const c = await getClient();\n await c.del(toRedisKey(key));\n if (DUE_KEY_RE.test(key)) await c.zRem(dueZsetKey, key);\n },\n\n async getKeys(prefix: string): Promise<string[]> {\n const c = await getClient();\n const match = `${toRedisKey(prefix)}*`;\n const out: string[] = [];\n for await (const entry of c.scanIterator({ MATCH: match, COUNT: 100 })) {\n // node-redis v4 yields one key per iteration; v5 may yield batches.\n if (Array.isArray(entry)) {\n for (const k of entry) out.push(fromRedisKey(k));\n } else {\n out.push(fromRedisKey(entry));\n }\n }\n return out;\n },\n\n async claimDue(input: { now: string; limit: number; leaseMs: number }): Promise<Delivery[]> {\n const c = await getClient();\n const nowMillis = Date.parse(input.now);\n const leaseExpiryMillis = nowMillis + input.leaseMs;\n // Atomic pop: reposition due members at the lease expiry so no concurrent\n // claimer can see them, while a crashed claimer's members re-surface.\n const members = (await c.eval(CLAIM_SCRIPT, {\n keys: [dueZsetKey],\n arguments: [String(nowMillis), String(input.limit), String(leaseExpiryMillis)],\n })) as string[] | null;\n\n const claimed: Delivery[] = [];\n for (const member of members ?? []) {\n const parsed = parseDueKey(member);\n if (!parsed) {\n await c.zRem(dueZsetKey, member); // unparseable member — sweep from the zset\n continue;\n }\n const entry = (await codec.get(c, toRedisKey(member))) as DueEntry | null;\n if (!entry) {\n await c.zRem(dueZsetKey, member); // stale zset member with no backing key\n continue;\n }\n const dKey = deliveryKey(entry.app, entry.deliveryId);\n const delivery = (await codec.get(c, toRedisKey(dKey))) as Delivery | null;\n // Orphaned or already-terminal entries are garbage — sweep them.\n if (!delivery || delivery.status === \"succeeded\" || delivery.status === \"failed\") {\n await c.del(toRedisKey(member));\n await c.zRem(dueZsetKey, member);\n continue;\n }\n // Claimable = pending, or delivering with an expired lease. A delivery\n // with an active lease was claimed elsewhere; its member is already\n // repositioned near the lease expiry, so just skip it.\n const leaseExpired =\n delivery.status === \"delivering\" &&\n (!delivery.leaseUntil || Date.parse(delivery.leaseUntil) <= nowMillis);\n if (delivery.status !== \"pending\" && !leaseExpired) continue;\n\n const leaseUntil = new Date(leaseExpiryMillis).toISOString();\n const next: Delivery = {\n ...delivery,\n status: \"delivering\",\n leaseUntil,\n updatedAt: input.now,\n };\n await codec.set(c, toRedisKey(dKey), next);\n // Move the due entry (plain key + zset member) to the lease-expiry\n // position so a crashed claimer's work re-surfaces automatically.\n const nextDueKey = dueKey(entry.app, leaseExpiryMillis, entry.deliveryId);\n await c.del(toRedisKey(member));\n await c.zRem(dueZsetKey, member);\n await codec.set(c, toRedisKey(nextDueKey), entry);\n await c.zAdd(dueZsetKey, { score: leaseExpiryMillis, value: nextDueKey });\n claimed.push(next);\n }\n return claimed;\n },\n\n async watch(\n prefix: string,\n callback: (event: StorageChangeEvent) => void,\n ): Promise<() => void> {\n // Keyspace notifications publish to `__keyspace@<db>__:<key>`; subscribe to\n // all key events under our namespaced prefix and translate them.\n const c = await getClient();\n const subscriber = c.duplicate();\n attachErrorHandler(subscriber);\n await subscriber.connect();\n const pattern = `__keyspace@*__:${toRedisKey(prefix)}*`;\n await subscriber.pSubscribe(pattern, (event: string, channel: string) => {\n const idx = channel.indexOf(\"__:\");\n if (idx === -1) return;\n const redisKey = channel.slice(idx + 3);\n const key = fromRedisKey(redisKey);\n if (!key.startsWith(prefix)) return;\n const type: StorageChangeEvent[\"type\"] =\n event === \"del\" || event === \"expired\" ? \"remove\" : \"update\";\n callback({ type, key });\n });\n return () => {\n void subscriber.disconnect();\n };\n },\n\n async close(): Promise<void> {\n if (ownsClient && client?.isOpen) await client.quit();\n },\n } satisfies RedisWebhooksStorage;\n}\n"],"mappings":"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"}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
const require_keys = require("./keys-FiKpaVHX.cjs");
|
|
2
|
+
const require_contract = require("./contract-B2d5dNU3.cjs");
|
|
3
|
+
//#region src/authorization/roles.ts
|
|
4
|
+
var roles_exports = /* @__PURE__ */ require_keys.__exportAll({
|
|
5
|
+
ALL_ACTIONS: () => ALL_ACTIONS,
|
|
6
|
+
DEFAULT_ROLE_POLICY: () => DEFAULT_ROLE_POLICY,
|
|
7
|
+
READ_ACTIONS: () => READ_ACTIONS,
|
|
8
|
+
rolesAuthorization: () => rolesAuthorization
|
|
9
|
+
});
|
|
10
|
+
/** Every action in the system, used to expand non-wildcard "all actions" presets. */
|
|
11
|
+
const ALL_ACTIONS = [
|
|
12
|
+
"application:read",
|
|
13
|
+
"application:create",
|
|
14
|
+
"application:update",
|
|
15
|
+
"application:delete",
|
|
16
|
+
"event-type:read",
|
|
17
|
+
"event-type:create",
|
|
18
|
+
"event-type:update",
|
|
19
|
+
"event-type:delete",
|
|
20
|
+
"endpoint:read",
|
|
21
|
+
"endpoint:create",
|
|
22
|
+
"endpoint:update",
|
|
23
|
+
"endpoint:delete",
|
|
24
|
+
"endpoint:rotate-secret",
|
|
25
|
+
"endpoint:read-secret",
|
|
26
|
+
"message:read",
|
|
27
|
+
"message:publish",
|
|
28
|
+
"delivery:read",
|
|
29
|
+
"delivery:retry",
|
|
30
|
+
"audit:read"
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* Every read-only action (the `*:read` subset of {@link ALL_ACTIONS}).
|
|
34
|
+
* `endpoint:read-secret` is deliberately excluded — secret access is sensitive
|
|
35
|
+
* and must be granted explicitly.
|
|
36
|
+
*/
|
|
37
|
+
const READ_ACTIONS = ALL_ACTIONS.filter((a) => a.endsWith(":read"));
|
|
38
|
+
/**
|
|
39
|
+
* The default role policy applied when {@link RolesAuthorizationOptions.policy}
|
|
40
|
+
* is omitted.
|
|
41
|
+
*
|
|
42
|
+
* - `admin` — `"*"`, every action.
|
|
43
|
+
* - `editor` — every action (explicit list, equivalent to `admin` here).
|
|
44
|
+
* - `viewer` — every `*:read` action only.
|
|
45
|
+
*/
|
|
46
|
+
const DEFAULT_ROLE_POLICY = {
|
|
47
|
+
admin: "*",
|
|
48
|
+
editor: [...ALL_ACTIONS],
|
|
49
|
+
viewer: [...READ_ACTIONS]
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Create a role-based {@link AuthorizationProvider}.
|
|
53
|
+
*
|
|
54
|
+
* Decision order:
|
|
55
|
+
* 1. If `readonly` and the action mutates → **deny**.
|
|
56
|
+
* 2. If the principal is `null` → **deny**.
|
|
57
|
+
* 3. If any of the principal's roles grants the action (via `"*"` or an explicit
|
|
58
|
+
* list) → **allow**; otherwise **deny**.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* import { rolesAuthorization } from "@xtandard/webhooks/authorization/roles";
|
|
63
|
+
*
|
|
64
|
+
* // Built-in admin/editor/viewer policy:
|
|
65
|
+
* const authz = rolesAuthorization();
|
|
66
|
+
*
|
|
67
|
+
* // Custom policy:
|
|
68
|
+
* const custom = rolesAuthorization({
|
|
69
|
+
* policy: {
|
|
70
|
+
* ops: ["delivery:read", "delivery:retry", "endpoint:read"],
|
|
71
|
+
* auditor: ["audit:read"],
|
|
72
|
+
* },
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
function rolesAuthorization(options = {}) {
|
|
77
|
+
const policy = options.policy ?? DEFAULT_ROLE_POLICY;
|
|
78
|
+
const readonly = options.readonly ?? false;
|
|
79
|
+
const grants = /* @__PURE__ */ new Map();
|
|
80
|
+
for (const [role, actions] of Object.entries(policy)) grants.set(role, actions === "*" ? null : new Set(actions));
|
|
81
|
+
return { async authorize(input) {
|
|
82
|
+
if (readonly && require_contract.isMutatingAction(input.action)) return false;
|
|
83
|
+
const principal = input.principal;
|
|
84
|
+
if (!principal) return false;
|
|
85
|
+
const roles = principal.roles;
|
|
86
|
+
if (!roles || roles.length === 0) return false;
|
|
87
|
+
for (const role of roles) {
|
|
88
|
+
if (!grants.has(role)) continue;
|
|
89
|
+
const granted = grants.get(role);
|
|
90
|
+
if (granted === null) return true;
|
|
91
|
+
if (granted?.has(input.action)) return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
} };
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
Object.defineProperty(exports, "ALL_ACTIONS", {
|
|
98
|
+
enumerable: true,
|
|
99
|
+
get: function() {
|
|
100
|
+
return ALL_ACTIONS;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
Object.defineProperty(exports, "DEFAULT_ROLE_POLICY", {
|
|
104
|
+
enumerable: true,
|
|
105
|
+
get: function() {
|
|
106
|
+
return DEFAULT_ROLE_POLICY;
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
Object.defineProperty(exports, "READ_ACTIONS", {
|
|
110
|
+
enumerable: true,
|
|
111
|
+
get: function() {
|
|
112
|
+
return READ_ACTIONS;
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
Object.defineProperty(exports, "rolesAuthorization", {
|
|
116
|
+
enumerable: true,
|
|
117
|
+
get: function() {
|
|
118
|
+
return rolesAuthorization;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
Object.defineProperty(exports, "roles_exports", {
|
|
122
|
+
enumerable: true,
|
|
123
|
+
get: function() {
|
|
124
|
+
return roles_exports;
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
//# sourceMappingURL=roles-D0G9XqBq.cjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"roles-D0G9XqBq.cjs","names":["isMutatingAction"],"sources":["../src/authorization/roles.ts"],"sourcesContent":["/**\n * Role-based {@link AuthorizationProvider}. Maps each role name to the set of\n * {@link WebhooksAction}s it grants, then allows an action when *any* of the\n * principal's roles grants it.\n *\n * The policy is a flat `Record<roleName, WebhooksAction[] | \"*\">` — `\"*\"` is a\n * wildcard granting every action. When no `policy` is supplied, the built-in\n * {@link DEFAULT_ROLE_POLICY} applies (`admin`/`editor`/`viewer`).\n *\n * A `readonly` switch denies every {@link isMutatingAction mutating} action\n * regardless of role — handy for read-only mirrors or \"break glass\" lockdowns.\n *\n * @module\n */\n\nimport type { AuthorizationProvider, AuthorizeInput, WebhooksAction } from \"./contract.ts\";\nimport { isMutatingAction } from \"./contract.ts\";\n\n/** Every action in the system, used to expand non-wildcard \"all actions\" presets. */\nexport const ALL_ACTIONS: readonly WebhooksAction[] = [\n \"application:read\",\n \"application:create\",\n \"application:update\",\n \"application:delete\",\n \"event-type:read\",\n \"event-type:create\",\n \"event-type:update\",\n \"event-type:delete\",\n \"endpoint:read\",\n \"endpoint:create\",\n \"endpoint:update\",\n \"endpoint:delete\",\n \"endpoint:rotate-secret\",\n \"endpoint:read-secret\",\n \"message:read\",\n \"message:publish\",\n \"delivery:read\",\n \"delivery:retry\",\n \"audit:read\",\n];\n\n/**\n * Every read-only action (the `*:read` subset of {@link ALL_ACTIONS}).\n * `endpoint:read-secret` is deliberately excluded — secret access is sensitive\n * and must be granted explicitly.\n */\nexport const READ_ACTIONS: readonly WebhooksAction[] = ALL_ACTIONS.filter((a) =>\n a.endsWith(\":read\"),\n);\n\n/** A role policy: each role maps to an explicit action list or the `\"*\"` wildcard. */\nexport type RolePolicy = Record<string, WebhooksAction[] | \"*\">;\n\n/**\n * The default role policy applied when {@link RolesAuthorizationOptions.policy}\n * is omitted.\n *\n * - `admin` — `\"*\"`, every action.\n * - `editor` — every action (explicit list, equivalent to `admin` here).\n * - `viewer` — every `*:read` action only.\n */\nexport const DEFAULT_ROLE_POLICY: RolePolicy = {\n admin: \"*\",\n editor: [...ALL_ACTIONS],\n viewer: [...READ_ACTIONS],\n};\n\n/** Options for {@link rolesAuthorization}. */\nexport interface RolesAuthorizationOptions {\n /**\n * Role → granted actions. `\"*\"` grants everything. Defaults to\n * {@link DEFAULT_ROLE_POLICY} when omitted.\n */\n policy?: RolePolicy;\n /**\n * When `true`, every {@link isMutatingAction mutating} action is denied\n * regardless of the principal's roles.\n * @default false\n */\n readonly?: boolean;\n}\n\n/**\n * Create a role-based {@link AuthorizationProvider}.\n *\n * Decision order:\n * 1. If `readonly` and the action mutates → **deny**.\n * 2. If the principal is `null` → **deny**.\n * 3. If any of the principal's roles grants the action (via `\"*\"` or an explicit\n * list) → **allow**; otherwise **deny**.\n *\n * @example\n * ```ts\n * import { rolesAuthorization } from \"@xtandard/webhooks/authorization/roles\";\n *\n * // Built-in admin/editor/viewer policy:\n * const authz = rolesAuthorization();\n *\n * // Custom policy:\n * const custom = rolesAuthorization({\n * policy: {\n * ops: [\"delivery:read\", \"delivery:retry\", \"endpoint:read\"],\n * auditor: [\"audit:read\"],\n * },\n * });\n * ```\n */\nexport function rolesAuthorization(options: RolesAuthorizationOptions = {}): AuthorizationProvider {\n const policy = options.policy ?? DEFAULT_ROLE_POLICY;\n const readonly = options.readonly ?? false;\n\n // Pre-compute each role's granted action set for O(1) lookups. A role mapped\n // to \"*\" is recorded as `null` (wildcard).\n const grants = new Map<string, Set<WebhooksAction> | null>();\n for (const [role, actions] of Object.entries(policy)) {\n grants.set(role, actions === \"*\" ? null : new Set(actions));\n }\n\n return {\n async authorize(input: AuthorizeInput): Promise<boolean> {\n if (readonly && isMutatingAction(input.action)) return false;\n\n const principal = input.principal;\n if (!principal) return false;\n\n const roles = principal.roles;\n if (!roles || roles.length === 0) return false;\n\n for (const role of roles) {\n if (!grants.has(role)) continue;\n const granted = grants.get(role);\n if (granted === null) return true; // wildcard\n if (granted?.has(input.action)) return true;\n }\n return false;\n },\n };\n}\n"],"mappings":";;;;;;;;;;AAmBA,MAAa,cAAyC;CACpD;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;AAOA,MAAa,eAA0C,YAAY,QAAQ,MACzE,EAAE,SAAS,OAAO,CACpB;;;;;;;;;AAaA,MAAa,sBAAkC;CAC7C,OAAO;CACP,QAAQ,CAAC,GAAG,WAAW;CACvB,QAAQ,CAAC,GAAG,YAAY;AAC1B;;;;;;;;;;;;;;;;;;;;;;;;;;AA0CA,SAAgB,mBAAmB,UAAqC,CAAC,GAA0B;CACjG,MAAM,SAAS,QAAQ,UAAU;CACjC,MAAM,WAAW,QAAQ,YAAY;CAIrC,MAAM,yBAAS,IAAI,IAAwC;CAC3D,KAAK,MAAM,CAAC,MAAM,YAAY,OAAO,QAAQ,MAAM,GACjD,OAAO,IAAI,MAAM,YAAY,MAAM,OAAO,IAAI,IAAI,OAAO,CAAC;CAG5D,OAAO,EACL,MAAM,UAAU,OAAyC;EACvD,IAAI,YAAYA,iBAAAA,iBAAiB,MAAM,MAAM,GAAG,OAAO;EAEvD,MAAM,YAAY,MAAM;EACxB,IAAI,CAAC,WAAW,OAAO;EAEvB,MAAM,QAAQ,UAAU;EACxB,IAAI,CAAC,SAAS,MAAM,WAAW,GAAG,OAAO;EAEzC,KAAK,MAAM,QAAQ,OAAO;GACxB,IAAI,CAAC,OAAO,IAAI,IAAI,GAAG;GACvB,MAAM,UAAU,OAAO,IAAI,IAAI;GAC/B,IAAI,YAAY,MAAM,OAAO;GAC7B,IAAI,SAAS,IAAI,MAAM,MAAM,GAAG,OAAO;EACzC;EACA,OAAO;CACT,EACF;AACF"}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { t as __exportAll } from "./chunk-D7D4PA-g.mjs";
|
|
2
|
+
import { n as isMutatingAction } from "./contract-9XpcwcCn.mjs";
|
|
3
|
+
//#region src/authorization/roles.ts
|
|
4
|
+
var roles_exports = /* @__PURE__ */ __exportAll({
|
|
5
|
+
ALL_ACTIONS: () => ALL_ACTIONS,
|
|
6
|
+
DEFAULT_ROLE_POLICY: () => DEFAULT_ROLE_POLICY,
|
|
7
|
+
READ_ACTIONS: () => READ_ACTIONS,
|
|
8
|
+
rolesAuthorization: () => rolesAuthorization
|
|
9
|
+
});
|
|
10
|
+
/** Every action in the system, used to expand non-wildcard "all actions" presets. */
|
|
11
|
+
const ALL_ACTIONS = [
|
|
12
|
+
"application:read",
|
|
13
|
+
"application:create",
|
|
14
|
+
"application:update",
|
|
15
|
+
"application:delete",
|
|
16
|
+
"event-type:read",
|
|
17
|
+
"event-type:create",
|
|
18
|
+
"event-type:update",
|
|
19
|
+
"event-type:delete",
|
|
20
|
+
"endpoint:read",
|
|
21
|
+
"endpoint:create",
|
|
22
|
+
"endpoint:update",
|
|
23
|
+
"endpoint:delete",
|
|
24
|
+
"endpoint:rotate-secret",
|
|
25
|
+
"endpoint:read-secret",
|
|
26
|
+
"message:read",
|
|
27
|
+
"message:publish",
|
|
28
|
+
"delivery:read",
|
|
29
|
+
"delivery:retry",
|
|
30
|
+
"audit:read"
|
|
31
|
+
];
|
|
32
|
+
/**
|
|
33
|
+
* Every read-only action (the `*:read` subset of {@link ALL_ACTIONS}).
|
|
34
|
+
* `endpoint:read-secret` is deliberately excluded — secret access is sensitive
|
|
35
|
+
* and must be granted explicitly.
|
|
36
|
+
*/
|
|
37
|
+
const READ_ACTIONS = ALL_ACTIONS.filter((a) => a.endsWith(":read"));
|
|
38
|
+
/**
|
|
39
|
+
* The default role policy applied when {@link RolesAuthorizationOptions.policy}
|
|
40
|
+
* is omitted.
|
|
41
|
+
*
|
|
42
|
+
* - `admin` — `"*"`, every action.
|
|
43
|
+
* - `editor` — every action (explicit list, equivalent to `admin` here).
|
|
44
|
+
* - `viewer` — every `*:read` action only.
|
|
45
|
+
*/
|
|
46
|
+
const DEFAULT_ROLE_POLICY = {
|
|
47
|
+
admin: "*",
|
|
48
|
+
editor: [...ALL_ACTIONS],
|
|
49
|
+
viewer: [...READ_ACTIONS]
|
|
50
|
+
};
|
|
51
|
+
/**
|
|
52
|
+
* Create a role-based {@link AuthorizationProvider}.
|
|
53
|
+
*
|
|
54
|
+
* Decision order:
|
|
55
|
+
* 1. If `readonly` and the action mutates → **deny**.
|
|
56
|
+
* 2. If the principal is `null` → **deny**.
|
|
57
|
+
* 3. If any of the principal's roles grants the action (via `"*"` or an explicit
|
|
58
|
+
* list) → **allow**; otherwise **deny**.
|
|
59
|
+
*
|
|
60
|
+
* @example
|
|
61
|
+
* ```ts
|
|
62
|
+
* import { rolesAuthorization } from "@xtandard/webhooks/authorization/roles";
|
|
63
|
+
*
|
|
64
|
+
* // Built-in admin/editor/viewer policy:
|
|
65
|
+
* const authz = rolesAuthorization();
|
|
66
|
+
*
|
|
67
|
+
* // Custom policy:
|
|
68
|
+
* const custom = rolesAuthorization({
|
|
69
|
+
* policy: {
|
|
70
|
+
* ops: ["delivery:read", "delivery:retry", "endpoint:read"],
|
|
71
|
+
* auditor: ["audit:read"],
|
|
72
|
+
* },
|
|
73
|
+
* });
|
|
74
|
+
* ```
|
|
75
|
+
*/
|
|
76
|
+
function rolesAuthorization(options = {}) {
|
|
77
|
+
const policy = options.policy ?? DEFAULT_ROLE_POLICY;
|
|
78
|
+
const readonly = options.readonly ?? false;
|
|
79
|
+
const grants = /* @__PURE__ */ new Map();
|
|
80
|
+
for (const [role, actions] of Object.entries(policy)) grants.set(role, actions === "*" ? null : new Set(actions));
|
|
81
|
+
return { async authorize(input) {
|
|
82
|
+
if (readonly && isMutatingAction(input.action)) return false;
|
|
83
|
+
const principal = input.principal;
|
|
84
|
+
if (!principal) return false;
|
|
85
|
+
const roles = principal.roles;
|
|
86
|
+
if (!roles || roles.length === 0) return false;
|
|
87
|
+
for (const role of roles) {
|
|
88
|
+
if (!grants.has(role)) continue;
|
|
89
|
+
const granted = grants.get(role);
|
|
90
|
+
if (granted === null) return true;
|
|
91
|
+
if (granted?.has(input.action)) return true;
|
|
92
|
+
}
|
|
93
|
+
return false;
|
|
94
|
+
} };
|
|
95
|
+
}
|
|
96
|
+
//#endregion
|
|
97
|
+
export { roles_exports as a, rolesAuthorization as i, DEFAULT_ROLE_POLICY as n, READ_ACTIONS as r, ALL_ACTIONS as t };
|
|
98
|
+
|
|
99
|
+
//# sourceMappingURL=roles-vp361lTk.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"roles-vp361lTk.mjs","names":[],"sources":["../src/authorization/roles.ts"],"sourcesContent":["/**\n * Role-based {@link AuthorizationProvider}. Maps each role name to the set of\n * {@link WebhooksAction}s it grants, then allows an action when *any* of the\n * principal's roles grants it.\n *\n * The policy is a flat `Record<roleName, WebhooksAction[] | \"*\">` — `\"*\"` is a\n * wildcard granting every action. When no `policy` is supplied, the built-in\n * {@link DEFAULT_ROLE_POLICY} applies (`admin`/`editor`/`viewer`).\n *\n * A `readonly` switch denies every {@link isMutatingAction mutating} action\n * regardless of role — handy for read-only mirrors or \"break glass\" lockdowns.\n *\n * @module\n */\n\nimport type { AuthorizationProvider, AuthorizeInput, WebhooksAction } from \"./contract.ts\";\nimport { isMutatingAction } from \"./contract.ts\";\n\n/** Every action in the system, used to expand non-wildcard \"all actions\" presets. */\nexport const ALL_ACTIONS: readonly WebhooksAction[] = [\n \"application:read\",\n \"application:create\",\n \"application:update\",\n \"application:delete\",\n \"event-type:read\",\n \"event-type:create\",\n \"event-type:update\",\n \"event-type:delete\",\n \"endpoint:read\",\n \"endpoint:create\",\n \"endpoint:update\",\n \"endpoint:delete\",\n \"endpoint:rotate-secret\",\n \"endpoint:read-secret\",\n \"message:read\",\n \"message:publish\",\n \"delivery:read\",\n \"delivery:retry\",\n \"audit:read\",\n];\n\n/**\n * Every read-only action (the `*:read` subset of {@link ALL_ACTIONS}).\n * `endpoint:read-secret` is deliberately excluded — secret access is sensitive\n * and must be granted explicitly.\n */\nexport const READ_ACTIONS: readonly WebhooksAction[] = ALL_ACTIONS.filter((a) =>\n a.endsWith(\":read\"),\n);\n\n/** A role policy: each role maps to an explicit action list or the `\"*\"` wildcard. */\nexport type RolePolicy = Record<string, WebhooksAction[] | \"*\">;\n\n/**\n * The default role policy applied when {@link RolesAuthorizationOptions.policy}\n * is omitted.\n *\n * - `admin` — `\"*\"`, every action.\n * - `editor` — every action (explicit list, equivalent to `admin` here).\n * - `viewer` — every `*:read` action only.\n */\nexport const DEFAULT_ROLE_POLICY: RolePolicy = {\n admin: \"*\",\n editor: [...ALL_ACTIONS],\n viewer: [...READ_ACTIONS],\n};\n\n/** Options for {@link rolesAuthorization}. */\nexport interface RolesAuthorizationOptions {\n /**\n * Role → granted actions. `\"*\"` grants everything. Defaults to\n * {@link DEFAULT_ROLE_POLICY} when omitted.\n */\n policy?: RolePolicy;\n /**\n * When `true`, every {@link isMutatingAction mutating} action is denied\n * regardless of the principal's roles.\n * @default false\n */\n readonly?: boolean;\n}\n\n/**\n * Create a role-based {@link AuthorizationProvider}.\n *\n * Decision order:\n * 1. If `readonly` and the action mutates → **deny**.\n * 2. If the principal is `null` → **deny**.\n * 3. If any of the principal's roles grants the action (via `\"*\"` or an explicit\n * list) → **allow**; otherwise **deny**.\n *\n * @example\n * ```ts\n * import { rolesAuthorization } from \"@xtandard/webhooks/authorization/roles\";\n *\n * // Built-in admin/editor/viewer policy:\n * const authz = rolesAuthorization();\n *\n * // Custom policy:\n * const custom = rolesAuthorization({\n * policy: {\n * ops: [\"delivery:read\", \"delivery:retry\", \"endpoint:read\"],\n * auditor: [\"audit:read\"],\n * },\n * });\n * ```\n */\nexport function rolesAuthorization(options: RolesAuthorizationOptions = {}): AuthorizationProvider {\n const policy = options.policy ?? DEFAULT_ROLE_POLICY;\n const readonly = options.readonly ?? false;\n\n // Pre-compute each role's granted action set for O(1) lookups. A role mapped\n // to \"*\" is recorded as `null` (wildcard).\n const grants = new Map<string, Set<WebhooksAction> | null>();\n for (const [role, actions] of Object.entries(policy)) {\n grants.set(role, actions === \"*\" ? null : new Set(actions));\n }\n\n return {\n async authorize(input: AuthorizeInput): Promise<boolean> {\n if (readonly && isMutatingAction(input.action)) return false;\n\n const principal = input.principal;\n if (!principal) return false;\n\n const roles = principal.roles;\n if (!roles || roles.length === 0) return false;\n\n for (const role of roles) {\n if (!grants.has(role)) continue;\n const granted = grants.get(role);\n if (granted === null) return true; // wildcard\n if (granted?.has(input.action)) return true;\n }\n return false;\n },\n };\n}\n"],"mappings":";;;;;;;;;;AAmBA,MAAa,cAAyC;CACpD;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;;;;;AAOA,MAAa,eAA0C,YAAY,QAAQ,MACzE,EAAE,SAAS,OAAO,CACpB;;;;;;;;;AAaA,MAAa,sBAAkC;CAC7C,OAAO;CACP,QAAQ,CAAC,GAAG,WAAW;CACvB,QAAQ,CAAC,GAAG,YAAY;AAC1B;;;;;;;;;;;;;;;;;;;;;;;;;;AA0CA,SAAgB,mBAAmB,UAAqC,CAAC,GAA0B;CACjG,MAAM,SAAS,QAAQ,UAAU;CACjC,MAAM,WAAW,QAAQ,YAAY;CAIrC,MAAM,yBAAS,IAAI,IAAwC;CAC3D,KAAK,MAAM,CAAC,MAAM,YAAY,OAAO,QAAQ,MAAM,GACjD,OAAO,IAAI,MAAM,YAAY,MAAM,OAAO,IAAI,IAAI,OAAO,CAAC;CAG5D,OAAO,EACL,MAAM,UAAU,OAAyC;EACvD,IAAI,YAAY,iBAAiB,MAAM,MAAM,GAAG,OAAO;EAEvD,MAAM,YAAY,MAAM;EACxB,IAAI,CAAC,WAAW,OAAO;EAEvB,MAAM,QAAQ,UAAU;EACxB,IAAI,CAAC,SAAS,MAAM,WAAW,GAAG,OAAO;EAEzC,KAAK,MAAM,QAAQ,OAAO;GACxB,IAAI,CAAC,OAAO,IAAI,IAAI,GAAG;GACvB,MAAM,UAAU,OAAO,IAAI,IAAI;GAC/B,IAAI,YAAY,MAAM,OAAO;GAC7B,IAAI,SAAS,IAAI,MAAM,MAAM,GAAG,OAAO;EACzC;EACA,OAAO;CACT,EACF;AACF"}
|