@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,1771 @@
|
|
|
1
|
+
const require_keys = require("./keys-FiKpaVHX.cjs");
|
|
2
|
+
const require_core = require("./core-ZGhH6Vs2.cjs");
|
|
3
|
+
const require_contract = require("./contract-Bf1qguwt.cjs");
|
|
4
|
+
const require_dispatcher = require("./dispatcher-B0xTEHt1.cjs");
|
|
5
|
+
let node_fs = require("node:fs");
|
|
6
|
+
let node_url = require("node:url");
|
|
7
|
+
let node_fs_promises = require("node:fs/promises");
|
|
8
|
+
let node_path = require("node:path");
|
|
9
|
+
//#region src/portal.ts
|
|
10
|
+
/**
|
|
11
|
+
* Portal tokens — the credential behind the embeddable consumer portal.
|
|
12
|
+
*
|
|
13
|
+
* A portal token is a compact HMAC-signed grant scoping its bearer to exactly
|
|
14
|
+
* **one application** for a limited time. The host mints tokens server-side
|
|
15
|
+
* with {@link createPortalToken} (the portal secret never reaches a browser)
|
|
16
|
+
* and hands them to its frontend; the panel handler verifies them with
|
|
17
|
+
* {@link verifyPortalToken} and force-scopes authorization to the token's
|
|
18
|
+
* application (see the `portal` panel option).
|
|
19
|
+
*
|
|
20
|
+
* Wire format:
|
|
21
|
+
*
|
|
22
|
+
* ```txt
|
|
23
|
+
* whpt_<base64url(JSON { app, exp })>.<base64url(HMAC-SHA256(secret, payloadPart))>
|
|
24
|
+
* ```
|
|
25
|
+
*
|
|
26
|
+
* Zero dependencies (Web Crypto only). Unlike Standard Webhooks signing
|
|
27
|
+
* secrets, the portal secret is an **arbitrary string** — it is fed to the
|
|
28
|
+
* HMAC as raw UTF-8 bytes, not base64-decoded.
|
|
29
|
+
*
|
|
30
|
+
* @module
|
|
31
|
+
*/
|
|
32
|
+
/** Prefix of every portal token. */
|
|
33
|
+
const PORTAL_TOKEN_PREFIX = "whpt_";
|
|
34
|
+
const DEFAULT_EXPIRES_IN = "7d";
|
|
35
|
+
/**
|
|
36
|
+
* Thrown by {@link verifyPortalToken} for any invalid token — malformed,
|
|
37
|
+
* bad signature, or expired. Maps to HTTP 401 at the API layer.
|
|
38
|
+
*/
|
|
39
|
+
var PortalTokenError = class extends Error {
|
|
40
|
+
constructor(message) {
|
|
41
|
+
super(message);
|
|
42
|
+
this.name = "PortalTokenError";
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
const base64urlEncode = (bytes) => {
|
|
46
|
+
let binary = "";
|
|
47
|
+
for (const b of bytes) binary += String.fromCharCode(b);
|
|
48
|
+
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
|
49
|
+
};
|
|
50
|
+
const base64urlDecode = (value) => {
|
|
51
|
+
const padded = value.replace(/-/g, "+").replace(/_/g, "/");
|
|
52
|
+
const binary = atob(padded + "=".repeat((4 - padded.length % 4) % 4));
|
|
53
|
+
const bytes = new Uint8Array(binary.length);
|
|
54
|
+
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
|
55
|
+
return bytes;
|
|
56
|
+
};
|
|
57
|
+
/** HMAC-SHA256 keyed by the raw UTF-8 bytes of the (arbitrary-string) secret. */
|
|
58
|
+
async function hmac(secret, content) {
|
|
59
|
+
const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(secret), {
|
|
60
|
+
name: "HMAC",
|
|
61
|
+
hash: "SHA-256"
|
|
62
|
+
}, false, ["sign"]);
|
|
63
|
+
const digest = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(content));
|
|
64
|
+
return new Uint8Array(digest);
|
|
65
|
+
}
|
|
66
|
+
/** Constant-time byte comparison (XOR accumulate — no early exit on content). */
|
|
67
|
+
function timingSafeEqual(a, b) {
|
|
68
|
+
if (a.length !== b.length) return false;
|
|
69
|
+
let diff = 0;
|
|
70
|
+
for (let i = 0; i < a.length; i++) diff |= a[i] ^ b[i];
|
|
71
|
+
return diff === 0;
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Mint a portal token granting access to `applicationKey` until `expiresIn`
|
|
75
|
+
* elapses (default 7 days). Call this **server-side** — anyone holding the
|
|
76
|
+
* portal secret can mint tokens for any application.
|
|
77
|
+
*
|
|
78
|
+
* @example
|
|
79
|
+
* ```ts
|
|
80
|
+
* import { createPortalToken } from "@xtandard/webhooks";
|
|
81
|
+
*
|
|
82
|
+
* // In the host app's session-guarded route:
|
|
83
|
+
* const token = await createPortalToken(process.env.PORTAL_SECRET!, "acme", {
|
|
84
|
+
* expiresIn: "1h",
|
|
85
|
+
* });
|
|
86
|
+
* // Hand `token` to the frontend for <WebhooksPortal token={token} />.
|
|
87
|
+
* ```
|
|
88
|
+
*/
|
|
89
|
+
async function createPortalToken(secret, applicationKey, options = {}) {
|
|
90
|
+
const expiresInMs = require_core.durationToMs(options.expiresIn ?? DEFAULT_EXPIRES_IN);
|
|
91
|
+
const claims = {
|
|
92
|
+
app: applicationKey,
|
|
93
|
+
exp: Date.now() + expiresInMs
|
|
94
|
+
};
|
|
95
|
+
const payloadPart = base64urlEncode(new TextEncoder().encode(JSON.stringify(claims)));
|
|
96
|
+
return `${PORTAL_TOKEN_PREFIX}${payloadPart}.${base64urlEncode(await hmac(secret, payloadPart))}`;
|
|
97
|
+
}
|
|
98
|
+
/**
|
|
99
|
+
* Verify a portal token: format, signature (constant time), then expiry.
|
|
100
|
+
* Returns the granted application key on success; throws
|
|
101
|
+
* {@link PortalTokenError} otherwise.
|
|
102
|
+
*
|
|
103
|
+
* @example
|
|
104
|
+
* ```ts
|
|
105
|
+
* import { verifyPortalToken } from "@xtandard/webhooks";
|
|
106
|
+
*
|
|
107
|
+
* const { applicationKey } = await verifyPortalToken(secret, token);
|
|
108
|
+
* ```
|
|
109
|
+
*/
|
|
110
|
+
async function verifyPortalToken(secret, token) {
|
|
111
|
+
if (!token.startsWith("whpt_")) throw new PortalTokenError("Invalid portal token: missing whpt_ prefix");
|
|
112
|
+
const parts = token.slice(5).split(".");
|
|
113
|
+
if (parts.length !== 2 || !parts[0] || !parts[1]) throw new PortalTokenError("Invalid portal token: malformed");
|
|
114
|
+
const [payloadPart, signaturePart] = parts;
|
|
115
|
+
let candidate;
|
|
116
|
+
try {
|
|
117
|
+
candidate = base64urlDecode(signaturePart);
|
|
118
|
+
} catch {
|
|
119
|
+
throw new PortalTokenError("Invalid portal token: malformed signature");
|
|
120
|
+
}
|
|
121
|
+
if (!timingSafeEqual(await hmac(secret, payloadPart), candidate)) throw new PortalTokenError("Invalid portal token: signature mismatch");
|
|
122
|
+
let claims;
|
|
123
|
+
try {
|
|
124
|
+
claims = JSON.parse(new TextDecoder().decode(base64urlDecode(payloadPart)));
|
|
125
|
+
} catch {
|
|
126
|
+
throw new PortalTokenError("Invalid portal token: malformed payload");
|
|
127
|
+
}
|
|
128
|
+
if (typeof claims.app !== "string" || claims.app.length === 0) throw new PortalTokenError("Invalid portal token: missing application");
|
|
129
|
+
if (typeof claims.exp !== "number" || !Number.isFinite(claims.exp)) throw new PortalTokenError("Invalid portal token: missing expiry");
|
|
130
|
+
if (Date.now() >= claims.exp) throw new PortalTokenError("Portal token has expired");
|
|
131
|
+
return { applicationKey: claims.app };
|
|
132
|
+
}
|
|
133
|
+
//#endregion
|
|
134
|
+
//#region src/server/base-path.ts
|
|
135
|
+
/**
|
|
136
|
+
* Base-path helpers. The panel can be mounted under any prefix (`/webhooks`,
|
|
137
|
+
* `/admin/webhooks`, ...). These normalize the configured base path and strip it
|
|
138
|
+
* from incoming request paths so routing is prefix-agnostic.
|
|
139
|
+
*
|
|
140
|
+
* @module
|
|
141
|
+
*/
|
|
142
|
+
/** Normalize a base path to either `""` (root) or `/segment` with no trailing slash. */
|
|
143
|
+
function normalizeBasePath(basePath) {
|
|
144
|
+
if (!basePath || basePath === "/") return "";
|
|
145
|
+
let p = basePath.trim();
|
|
146
|
+
if (!p.startsWith("/")) p = "/" + p;
|
|
147
|
+
if (p.endsWith("/")) p = p.slice(0, -1);
|
|
148
|
+
return p;
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Strip the (normalized) base path from a pathname. Returns a path that always
|
|
152
|
+
* starts with `/`. If the pathname does not start with the base path it is
|
|
153
|
+
* returned unchanged (the host framework may already have stripped the mount).
|
|
154
|
+
*/
|
|
155
|
+
function stripBasePath(pathname, basePath) {
|
|
156
|
+
if (basePath === "") return pathname || "/";
|
|
157
|
+
if (pathname === basePath) return "/";
|
|
158
|
+
if (pathname.startsWith(basePath + "/")) return pathname.slice(basePath.length) || "/";
|
|
159
|
+
return pathname || "/";
|
|
160
|
+
}
|
|
161
|
+
//#endregion
|
|
162
|
+
//#region src/server/cors.ts
|
|
163
|
+
const DEFAULT_METHODS = "GET,HEAD,PUT,POST,DELETE,PATCH,OPTIONS";
|
|
164
|
+
/**
|
|
165
|
+
* Resolve the value for `Access-Control-Allow-Origin` for this request, or
|
|
166
|
+
* `null` when the origin is not allowed (no CORS headers → the browser blocks).
|
|
167
|
+
*/
|
|
168
|
+
function resolveOrigin(cors, requestOrigin) {
|
|
169
|
+
const { origin } = cors;
|
|
170
|
+
if (origin === "*") return cors.credentials ? requestOrigin ?? null : "*";
|
|
171
|
+
if (requestOrigin === null) return null;
|
|
172
|
+
return (typeof origin === "string" ? origin === requestOrigin : Array.isArray(origin) ? origin.includes(requestOrigin) : origin(requestOrigin)) ? requestOrigin : null;
|
|
173
|
+
}
|
|
174
|
+
/** Attach `Access-Control-*` headers to `response` for an allowed cross-origin request. */
|
|
175
|
+
function applyCorsHeaders(request, response, cors) {
|
|
176
|
+
const allowOrigin = resolveOrigin(cors, request.headers.get("origin"));
|
|
177
|
+
if (allowOrigin === null) return response;
|
|
178
|
+
response.headers.set("access-control-allow-origin", allowOrigin);
|
|
179
|
+
if (cors.credentials) response.headers.set("access-control-allow-credentials", "true");
|
|
180
|
+
if (allowOrigin !== "*") response.headers.append("vary", "Origin");
|
|
181
|
+
return response;
|
|
182
|
+
}
|
|
183
|
+
/** Build the `204` response for a CORS preflight (`OPTIONS`). */
|
|
184
|
+
function preflightResponse(request, cors) {
|
|
185
|
+
const response = new Response(null, { status: 204 });
|
|
186
|
+
applyCorsHeaders(request, response, cors);
|
|
187
|
+
response.headers.set("access-control-allow-methods", (cors.methods ?? []).join(",") || DEFAULT_METHODS);
|
|
188
|
+
const requestedHeaders = request.headers.get("access-control-request-headers");
|
|
189
|
+
response.headers.set("access-control-allow-headers", cors.headers?.join(",") || requestedHeaders || "Content-Type, Authorization");
|
|
190
|
+
if (cors.maxAge !== void 0) response.headers.set("access-control-max-age", String(cors.maxAge));
|
|
191
|
+
return response;
|
|
192
|
+
}
|
|
193
|
+
//#endregion
|
|
194
|
+
//#region src/server/openapi.ts
|
|
195
|
+
const schemas = {
|
|
196
|
+
Application: {
|
|
197
|
+
type: "object",
|
|
198
|
+
required: ["key"],
|
|
199
|
+
properties: {
|
|
200
|
+
key: {
|
|
201
|
+
type: "string",
|
|
202
|
+
pattern: "^[a-zA-Z0-9._-]+$"
|
|
203
|
+
},
|
|
204
|
+
name: { type: "string" },
|
|
205
|
+
metadata: {},
|
|
206
|
+
createdAt: {
|
|
207
|
+
type: "string",
|
|
208
|
+
format: "date-time"
|
|
209
|
+
},
|
|
210
|
+
updatedAt: {
|
|
211
|
+
type: "string",
|
|
212
|
+
format: "date-time"
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
EventType: {
|
|
217
|
+
type: "object",
|
|
218
|
+
required: ["name"],
|
|
219
|
+
properties: {
|
|
220
|
+
name: {
|
|
221
|
+
type: "string",
|
|
222
|
+
pattern: "^[a-zA-Z0-9._-]+$"
|
|
223
|
+
},
|
|
224
|
+
description: { type: "string" },
|
|
225
|
+
groupName: { type: "string" },
|
|
226
|
+
schema: { description: "JSON Schema documenting the payload." },
|
|
227
|
+
deprecated: { type: "boolean" },
|
|
228
|
+
createdAt: {
|
|
229
|
+
type: "string",
|
|
230
|
+
format: "date-time"
|
|
231
|
+
},
|
|
232
|
+
updatedAt: {
|
|
233
|
+
type: "string",
|
|
234
|
+
format: "date-time"
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
},
|
|
238
|
+
EndpointSecret: {
|
|
239
|
+
type: "object",
|
|
240
|
+
required: ["secret", "createdAt"],
|
|
241
|
+
properties: {
|
|
242
|
+
secret: {
|
|
243
|
+
type: "string",
|
|
244
|
+
description: "\"whsec_\" + base64 key material."
|
|
245
|
+
},
|
|
246
|
+
createdAt: {
|
|
247
|
+
type: "string",
|
|
248
|
+
format: "date-time"
|
|
249
|
+
},
|
|
250
|
+
expiresAt: {
|
|
251
|
+
type: "string",
|
|
252
|
+
format: "date-time",
|
|
253
|
+
description: "Set on rotation; the secret stops signing after this instant."
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
},
|
|
257
|
+
Endpoint: {
|
|
258
|
+
type: "object",
|
|
259
|
+
required: ["id", "url"],
|
|
260
|
+
description: "Read routes strip `secrets`; only the create/rotate responses and the dedicated /secret route carry them.",
|
|
261
|
+
properties: {
|
|
262
|
+
id: { type: "string" },
|
|
263
|
+
url: { type: "string" },
|
|
264
|
+
description: { type: "string" },
|
|
265
|
+
eventTypes: {
|
|
266
|
+
type: "array",
|
|
267
|
+
items: { type: "string" },
|
|
268
|
+
description: "Subscribed event types; empty/absent = all."
|
|
269
|
+
},
|
|
270
|
+
disabled: { type: "boolean" },
|
|
271
|
+
disabledReason: {
|
|
272
|
+
type: "string",
|
|
273
|
+
enum: ["manual", "auto"]
|
|
274
|
+
},
|
|
275
|
+
headers: {
|
|
276
|
+
type: "object",
|
|
277
|
+
additionalProperties: { type: "string" }
|
|
278
|
+
},
|
|
279
|
+
secrets: {
|
|
280
|
+
type: "array",
|
|
281
|
+
items: { $ref: "#/components/schemas/EndpointSecret" }
|
|
282
|
+
},
|
|
283
|
+
metadata: {},
|
|
284
|
+
createdAt: {
|
|
285
|
+
type: "string",
|
|
286
|
+
format: "date-time"
|
|
287
|
+
},
|
|
288
|
+
updatedAt: {
|
|
289
|
+
type: "string",
|
|
290
|
+
format: "date-time"
|
|
291
|
+
},
|
|
292
|
+
firstFailingAt: {
|
|
293
|
+
type: "string",
|
|
294
|
+
format: "date-time",
|
|
295
|
+
nullable: true
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
Message: {
|
|
300
|
+
type: "object",
|
|
301
|
+
required: [
|
|
302
|
+
"id",
|
|
303
|
+
"eventType",
|
|
304
|
+
"payload",
|
|
305
|
+
"timestamp",
|
|
306
|
+
"createdAt"
|
|
307
|
+
],
|
|
308
|
+
properties: {
|
|
309
|
+
id: {
|
|
310
|
+
type: "string",
|
|
311
|
+
description: "Sent as the webhook-id header."
|
|
312
|
+
},
|
|
313
|
+
eventType: { type: "string" },
|
|
314
|
+
payload: {},
|
|
315
|
+
timestamp: {
|
|
316
|
+
type: "string",
|
|
317
|
+
format: "date-time"
|
|
318
|
+
},
|
|
319
|
+
idempotencyKey: { type: "string" },
|
|
320
|
+
envelope: {
|
|
321
|
+
type: "string",
|
|
322
|
+
description: "The serialized wire envelope."
|
|
323
|
+
},
|
|
324
|
+
createdAt: {
|
|
325
|
+
type: "string",
|
|
326
|
+
format: "date-time"
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
},
|
|
330
|
+
Delivery: {
|
|
331
|
+
type: "object",
|
|
332
|
+
required: [
|
|
333
|
+
"id",
|
|
334
|
+
"applicationKey",
|
|
335
|
+
"messageId",
|
|
336
|
+
"endpointId",
|
|
337
|
+
"status",
|
|
338
|
+
"attemptCount"
|
|
339
|
+
],
|
|
340
|
+
properties: {
|
|
341
|
+
id: { type: "string" },
|
|
342
|
+
applicationKey: { type: "string" },
|
|
343
|
+
messageId: { type: "string" },
|
|
344
|
+
endpointId: { type: "string" },
|
|
345
|
+
status: {
|
|
346
|
+
type: "string",
|
|
347
|
+
enum: [
|
|
348
|
+
"pending",
|
|
349
|
+
"delivering",
|
|
350
|
+
"succeeded",
|
|
351
|
+
"failed"
|
|
352
|
+
]
|
|
353
|
+
},
|
|
354
|
+
attemptCount: { type: "integer" },
|
|
355
|
+
nextAttemptAt: {
|
|
356
|
+
type: "string",
|
|
357
|
+
format: "date-time",
|
|
358
|
+
nullable: true
|
|
359
|
+
},
|
|
360
|
+
leaseUntil: {
|
|
361
|
+
type: "string",
|
|
362
|
+
format: "date-time",
|
|
363
|
+
nullable: true
|
|
364
|
+
},
|
|
365
|
+
createdAt: {
|
|
366
|
+
type: "string",
|
|
367
|
+
format: "date-time"
|
|
368
|
+
},
|
|
369
|
+
updatedAt: {
|
|
370
|
+
type: "string",
|
|
371
|
+
format: "date-time"
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
},
|
|
375
|
+
DeliveryAttempt: {
|
|
376
|
+
type: "object",
|
|
377
|
+
required: [
|
|
378
|
+
"id",
|
|
379
|
+
"deliveryId",
|
|
380
|
+
"attemptNumber",
|
|
381
|
+
"at",
|
|
382
|
+
"durationMs",
|
|
383
|
+
"ok",
|
|
384
|
+
"trigger"
|
|
385
|
+
],
|
|
386
|
+
properties: {
|
|
387
|
+
id: { type: "string" },
|
|
388
|
+
deliveryId: { type: "string" },
|
|
389
|
+
attemptNumber: { type: "integer" },
|
|
390
|
+
at: {
|
|
391
|
+
type: "string",
|
|
392
|
+
format: "date-time"
|
|
393
|
+
},
|
|
394
|
+
durationMs: { type: "number" },
|
|
395
|
+
ok: { type: "boolean" },
|
|
396
|
+
httpStatus: { type: "integer" },
|
|
397
|
+
error: { type: "string" },
|
|
398
|
+
responseBody: { type: "string" },
|
|
399
|
+
trigger: {
|
|
400
|
+
type: "string",
|
|
401
|
+
enum: [
|
|
402
|
+
"schedule",
|
|
403
|
+
"manual",
|
|
404
|
+
"test"
|
|
405
|
+
]
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
PublishResult: {
|
|
410
|
+
type: "object",
|
|
411
|
+
required: [
|
|
412
|
+
"message",
|
|
413
|
+
"deliveries",
|
|
414
|
+
"deduplicated"
|
|
415
|
+
],
|
|
416
|
+
properties: {
|
|
417
|
+
message: { $ref: "#/components/schemas/Message" },
|
|
418
|
+
deliveries: {
|
|
419
|
+
type: "array",
|
|
420
|
+
items: { $ref: "#/components/schemas/Delivery" }
|
|
421
|
+
},
|
|
422
|
+
deduplicated: {
|
|
423
|
+
type: "boolean",
|
|
424
|
+
description: "True when the idempotency key matched an existing message."
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
},
|
|
428
|
+
RecoverResult: {
|
|
429
|
+
type: "object",
|
|
430
|
+
required: ["deliveryIds"],
|
|
431
|
+
properties: { deliveryIds: {
|
|
432
|
+
type: "array",
|
|
433
|
+
items: { type: "string" }
|
|
434
|
+
} }
|
|
435
|
+
},
|
|
436
|
+
AuditEntry: {
|
|
437
|
+
type: "object",
|
|
438
|
+
required: ["action", "at"],
|
|
439
|
+
properties: {
|
|
440
|
+
action: { type: "string" },
|
|
441
|
+
at: {
|
|
442
|
+
type: "string",
|
|
443
|
+
format: "date-time"
|
|
444
|
+
},
|
|
445
|
+
by: {
|
|
446
|
+
type: "object",
|
|
447
|
+
nullable: true
|
|
448
|
+
},
|
|
449
|
+
applicationKey: { type: "string" },
|
|
450
|
+
subjectId: { type: "string" },
|
|
451
|
+
message: { type: "string" }
|
|
452
|
+
}
|
|
453
|
+
},
|
|
454
|
+
Config: {
|
|
455
|
+
type: "object",
|
|
456
|
+
properties: {
|
|
457
|
+
title: { type: "string" },
|
|
458
|
+
basePath: { type: "string" },
|
|
459
|
+
readonly: { type: "boolean" },
|
|
460
|
+
authenticated: { type: "boolean" },
|
|
461
|
+
principal: {
|
|
462
|
+
type: "object",
|
|
463
|
+
nullable: true
|
|
464
|
+
},
|
|
465
|
+
portal: {
|
|
466
|
+
type: "boolean",
|
|
467
|
+
description: "True when the request carried a valid portal token."
|
|
468
|
+
},
|
|
469
|
+
logoUrl: { type: "string" }
|
|
470
|
+
}
|
|
471
|
+
},
|
|
472
|
+
Error: {
|
|
473
|
+
type: "object",
|
|
474
|
+
required: ["error"],
|
|
475
|
+
properties: {
|
|
476
|
+
error: { type: "string" },
|
|
477
|
+
code: { type: "string" },
|
|
478
|
+
errors: {
|
|
479
|
+
type: "array",
|
|
480
|
+
items: { type: "object" }
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
};
|
|
485
|
+
const APP = {
|
|
486
|
+
name: "app",
|
|
487
|
+
in: "path",
|
|
488
|
+
required: true,
|
|
489
|
+
schema: { type: "string" }
|
|
490
|
+
};
|
|
491
|
+
const ID = {
|
|
492
|
+
name: "id",
|
|
493
|
+
in: "path",
|
|
494
|
+
required: true,
|
|
495
|
+
schema: { type: "string" }
|
|
496
|
+
};
|
|
497
|
+
const jsonBody = (schema, required = true) => ({
|
|
498
|
+
required,
|
|
499
|
+
content: { "application/json": { schema } }
|
|
500
|
+
});
|
|
501
|
+
const jsonRes = (desc, schema) => ({
|
|
502
|
+
description: desc,
|
|
503
|
+
content: { "application/json": { schema } }
|
|
504
|
+
});
|
|
505
|
+
const ref = (name) => ({ $ref: `#/components/schemas/${name}` });
|
|
506
|
+
const errorRes = (desc) => jsonRes(desc, ref("Error"));
|
|
507
|
+
const okRef = (name, desc = name) => jsonRes(desc, ref(name));
|
|
508
|
+
const arrayOf = (name) => ({
|
|
509
|
+
type: "array",
|
|
510
|
+
items: ref(name)
|
|
511
|
+
});
|
|
512
|
+
/** Build the OpenAPI 3.1 document for the admin API. Pure — safe to call anywhere. */
|
|
513
|
+
function buildOpenApiDocument(options = {}) {
|
|
514
|
+
const base = options.basePath && options.basePath !== "/" ? options.basePath : "";
|
|
515
|
+
const endpointPath = "/api/applications/{app}/endpoints/{id}";
|
|
516
|
+
return {
|
|
517
|
+
openapi: "3.1.0",
|
|
518
|
+
info: {
|
|
519
|
+
title: options.title ?? "@xtandard/webhooks Admin API",
|
|
520
|
+
version: options.version ?? "0.1.0",
|
|
521
|
+
description: "Admin/control-plane API for @xtandard/webhooks: applications, the global event-type catalog, endpoints (with Standard Webhooks signing secrets), message publishing, and delivery observability. Portal tokens (Authorization: Bearer whpt_…) hit the same surface scoped to one application."
|
|
522
|
+
},
|
|
523
|
+
servers: [{ url: base || "/" }],
|
|
524
|
+
tags: [
|
|
525
|
+
{ name: "meta" },
|
|
526
|
+
{ name: "applications" },
|
|
527
|
+
{ name: "event-types" },
|
|
528
|
+
{ name: "endpoints" },
|
|
529
|
+
{ name: "messages" },
|
|
530
|
+
{ name: "deliveries" },
|
|
531
|
+
{ name: "audit" }
|
|
532
|
+
],
|
|
533
|
+
security: [{ basicAuth: [] }, { bearerAuth: [] }],
|
|
534
|
+
paths: {
|
|
535
|
+
"/config": { get: {
|
|
536
|
+
tags: ["meta"],
|
|
537
|
+
security: [],
|
|
538
|
+
summary: "Bootstrap config (title, basePath, readonly, auth state, portal flag)",
|
|
539
|
+
responses: { "200": okRef("Config") }
|
|
540
|
+
} },
|
|
541
|
+
"/api/openapi.json": { get: {
|
|
542
|
+
tags: ["meta"],
|
|
543
|
+
security: [],
|
|
544
|
+
summary: "This OpenAPI document",
|
|
545
|
+
responses: { "200": jsonRes("OpenAPI 3.1 document", { type: "object" }) }
|
|
546
|
+
} },
|
|
547
|
+
"/api/event-types.json": { get: {
|
|
548
|
+
tags: ["meta"],
|
|
549
|
+
security: [],
|
|
550
|
+
summary: "Public event-type catalog (unauthenticated, CORS-open)",
|
|
551
|
+
responses: { "200": jsonRes("Event types", arrayOf("EventType")) }
|
|
552
|
+
} },
|
|
553
|
+
"/api/applications": {
|
|
554
|
+
get: {
|
|
555
|
+
tags: ["applications"],
|
|
556
|
+
summary: "List applications",
|
|
557
|
+
responses: { "200": jsonRes("Applications", arrayOf("Application")) }
|
|
558
|
+
},
|
|
559
|
+
post: {
|
|
560
|
+
tags: ["applications"],
|
|
561
|
+
summary: "Create an application",
|
|
562
|
+
requestBody: jsonBody(ref("Application")),
|
|
563
|
+
responses: {
|
|
564
|
+
"201": okRef("Application", "Created"),
|
|
565
|
+
"409": errorRes("Key already exists"),
|
|
566
|
+
"422": errorRes("Validation error")
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
},
|
|
570
|
+
"/api/applications/{app}": {
|
|
571
|
+
parameters: [APP],
|
|
572
|
+
get: {
|
|
573
|
+
tags: ["applications"],
|
|
574
|
+
summary: "Get an application",
|
|
575
|
+
responses: {
|
|
576
|
+
"200": okRef("Application"),
|
|
577
|
+
"404": errorRes("Not found")
|
|
578
|
+
}
|
|
579
|
+
},
|
|
580
|
+
put: {
|
|
581
|
+
tags: ["applications"],
|
|
582
|
+
summary: "Update an application",
|
|
583
|
+
requestBody: jsonBody({
|
|
584
|
+
type: "object",
|
|
585
|
+
properties: {
|
|
586
|
+
name: { type: "string" },
|
|
587
|
+
metadata: {}
|
|
588
|
+
}
|
|
589
|
+
}),
|
|
590
|
+
responses: {
|
|
591
|
+
"200": okRef("Application", "Updated"),
|
|
592
|
+
"404": errorRes("Not found")
|
|
593
|
+
}
|
|
594
|
+
},
|
|
595
|
+
delete: {
|
|
596
|
+
tags: ["applications"],
|
|
597
|
+
summary: "Delete an application and everything under it",
|
|
598
|
+
responses: {
|
|
599
|
+
"200": jsonRes("Deleted", { type: "object" }),
|
|
600
|
+
"404": errorRes("Not found")
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
"/api/event-types": {
|
|
605
|
+
get: {
|
|
606
|
+
tags: ["event-types"],
|
|
607
|
+
summary: "List event types",
|
|
608
|
+
responses: { "200": jsonRes("Event types", arrayOf("EventType")) }
|
|
609
|
+
},
|
|
610
|
+
post: {
|
|
611
|
+
tags: ["event-types"],
|
|
612
|
+
summary: "Create (upsert) an event type",
|
|
613
|
+
requestBody: jsonBody(ref("EventType")),
|
|
614
|
+
responses: {
|
|
615
|
+
"201": okRef("EventType", "Created"),
|
|
616
|
+
"422": errorRes("Validation error")
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
"/api/event-types/{name}": {
|
|
621
|
+
parameters: [{
|
|
622
|
+
name: "name",
|
|
623
|
+
in: "path",
|
|
624
|
+
required: true,
|
|
625
|
+
schema: { type: "string" }
|
|
626
|
+
}],
|
|
627
|
+
get: {
|
|
628
|
+
tags: ["event-types"],
|
|
629
|
+
summary: "Get an event type",
|
|
630
|
+
responses: {
|
|
631
|
+
"200": okRef("EventType"),
|
|
632
|
+
"404": errorRes("Not found")
|
|
633
|
+
}
|
|
634
|
+
},
|
|
635
|
+
put: {
|
|
636
|
+
tags: ["event-types"],
|
|
637
|
+
summary: "Update an event type",
|
|
638
|
+
requestBody: jsonBody(ref("EventType")),
|
|
639
|
+
responses: {
|
|
640
|
+
"200": okRef("EventType", "Updated"),
|
|
641
|
+
"422": errorRes("Validation error")
|
|
642
|
+
}
|
|
643
|
+
},
|
|
644
|
+
delete: {
|
|
645
|
+
tags: ["event-types"],
|
|
646
|
+
summary: "Delete an event type (endpoints referencing it stop matching)",
|
|
647
|
+
responses: {
|
|
648
|
+
"200": jsonRes("Deleted", { type: "object" }),
|
|
649
|
+
"404": errorRes("Not found")
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
},
|
|
653
|
+
"/api/applications/{app}/endpoints": {
|
|
654
|
+
parameters: [APP],
|
|
655
|
+
get: {
|
|
656
|
+
tags: ["endpoints"],
|
|
657
|
+
summary: "List endpoints (secrets stripped)",
|
|
658
|
+
responses: { "200": jsonRes("Endpoints", arrayOf("Endpoint")) }
|
|
659
|
+
},
|
|
660
|
+
post: {
|
|
661
|
+
tags: ["endpoints"],
|
|
662
|
+
summary: "Create an endpoint — the response includes the signing secret ONCE",
|
|
663
|
+
requestBody: jsonBody({
|
|
664
|
+
type: "object",
|
|
665
|
+
required: ["url"],
|
|
666
|
+
properties: {
|
|
667
|
+
url: { type: "string" },
|
|
668
|
+
description: { type: "string" },
|
|
669
|
+
eventTypes: {
|
|
670
|
+
type: "array",
|
|
671
|
+
items: { type: "string" }
|
|
672
|
+
},
|
|
673
|
+
headers: {
|
|
674
|
+
type: "object",
|
|
675
|
+
additionalProperties: { type: "string" }
|
|
676
|
+
},
|
|
677
|
+
metadata: {},
|
|
678
|
+
disabled: { type: "boolean" }
|
|
679
|
+
}
|
|
680
|
+
}),
|
|
681
|
+
responses: {
|
|
682
|
+
"201": okRef("Endpoint", "Created (includes secrets)"),
|
|
683
|
+
"422": errorRes("Validation error")
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
},
|
|
687
|
+
[endpointPath]: {
|
|
688
|
+
parameters: [APP, ID],
|
|
689
|
+
get: {
|
|
690
|
+
tags: ["endpoints"],
|
|
691
|
+
summary: "Get an endpoint (secrets stripped)",
|
|
692
|
+
responses: {
|
|
693
|
+
"200": okRef("Endpoint"),
|
|
694
|
+
"404": errorRes("Not found")
|
|
695
|
+
}
|
|
696
|
+
},
|
|
697
|
+
put: {
|
|
698
|
+
tags: ["endpoints"],
|
|
699
|
+
summary: "Update an endpoint",
|
|
700
|
+
requestBody: jsonBody({
|
|
701
|
+
type: "object",
|
|
702
|
+
properties: {
|
|
703
|
+
url: { type: "string" },
|
|
704
|
+
description: { type: "string" },
|
|
705
|
+
eventTypes: {
|
|
706
|
+
type: "array",
|
|
707
|
+
items: { type: "string" }
|
|
708
|
+
},
|
|
709
|
+
headers: {
|
|
710
|
+
type: "object",
|
|
711
|
+
additionalProperties: { type: "string" }
|
|
712
|
+
},
|
|
713
|
+
metadata: {}
|
|
714
|
+
}
|
|
715
|
+
}),
|
|
716
|
+
responses: {
|
|
717
|
+
"200": okRef("Endpoint", "Updated"),
|
|
718
|
+
"404": errorRes("Not found"),
|
|
719
|
+
"422": errorRes("Validation error")
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
delete: {
|
|
723
|
+
tags: ["endpoints"],
|
|
724
|
+
summary: "Delete an endpoint",
|
|
725
|
+
responses: {
|
|
726
|
+
"200": jsonRes("Deleted", { type: "object" }),
|
|
727
|
+
"404": errorRes("Not found")
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
},
|
|
731
|
+
[`${endpointPath}/secret`]: {
|
|
732
|
+
parameters: [APP, ID],
|
|
733
|
+
get: {
|
|
734
|
+
tags: ["endpoints"],
|
|
735
|
+
summary: "Read the endpoint's signing secrets (current first)",
|
|
736
|
+
responses: {
|
|
737
|
+
"200": jsonRes("Secrets", arrayOf("EndpointSecret")),
|
|
738
|
+
"404": errorRes("Not found")
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
},
|
|
742
|
+
[`${endpointPath}/rotate-secret`]: {
|
|
743
|
+
parameters: [APP, ID],
|
|
744
|
+
post: {
|
|
745
|
+
tags: ["endpoints"],
|
|
746
|
+
summary: "Mint a new secret; the previous one keeps signing through the grace window",
|
|
747
|
+
responses: {
|
|
748
|
+
"200": okRef("Endpoint", "Rotated (includes secrets)"),
|
|
749
|
+
"404": errorRes("Not found")
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
},
|
|
753
|
+
[`${endpointPath}/enable`]: {
|
|
754
|
+
parameters: [APP, ID],
|
|
755
|
+
post: {
|
|
756
|
+
tags: ["endpoints"],
|
|
757
|
+
summary: "Enable a disabled endpoint (delivery resumes)",
|
|
758
|
+
responses: {
|
|
759
|
+
"200": okRef("Endpoint", "Enabled"),
|
|
760
|
+
"404": errorRes("Not found")
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
},
|
|
764
|
+
[`${endpointPath}/disable`]: {
|
|
765
|
+
parameters: [APP, ID],
|
|
766
|
+
post: {
|
|
767
|
+
tags: ["endpoints"],
|
|
768
|
+
summary: "Disable an endpoint (pending deliveries are held, not failed)",
|
|
769
|
+
responses: {
|
|
770
|
+
"200": okRef("Endpoint", "Disabled"),
|
|
771
|
+
"404": errorRes("Not found")
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
},
|
|
775
|
+
[`${endpointPath}/test`]: {
|
|
776
|
+
parameters: [APP, ID],
|
|
777
|
+
post: {
|
|
778
|
+
tags: ["endpoints"],
|
|
779
|
+
summary: "Fire a one-off signed example delivery (not retained as a message)",
|
|
780
|
+
requestBody: jsonBody({
|
|
781
|
+
type: "object",
|
|
782
|
+
required: ["eventType"],
|
|
783
|
+
properties: {
|
|
784
|
+
eventType: { type: "string" },
|
|
785
|
+
payload: {}
|
|
786
|
+
}
|
|
787
|
+
}),
|
|
788
|
+
responses: {
|
|
789
|
+
"200": jsonRes("Attempt outcome", { type: "object" }),
|
|
790
|
+
"404": errorRes("Not found")
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
},
|
|
794
|
+
[`${endpointPath}/recover`]: {
|
|
795
|
+
parameters: [APP, ID],
|
|
796
|
+
post: {
|
|
797
|
+
tags: ["deliveries"],
|
|
798
|
+
summary: "Re-queue every failed delivery for this endpoint since a timestamp",
|
|
799
|
+
requestBody: jsonBody({
|
|
800
|
+
type: "object",
|
|
801
|
+
required: ["since"],
|
|
802
|
+
properties: { since: {
|
|
803
|
+
type: "string",
|
|
804
|
+
format: "date-time"
|
|
805
|
+
} }
|
|
806
|
+
}),
|
|
807
|
+
responses: {
|
|
808
|
+
"200": okRef("RecoverResult", "Re-queued delivery ids"),
|
|
809
|
+
"404": errorRes("Not found")
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
},
|
|
813
|
+
"/api/applications/{app}/messages": {
|
|
814
|
+
parameters: [APP],
|
|
815
|
+
get: {
|
|
816
|
+
tags: ["messages"],
|
|
817
|
+
summary: "List messages (newest first)",
|
|
818
|
+
parameters: [
|
|
819
|
+
{
|
|
820
|
+
name: "eventType",
|
|
821
|
+
in: "query",
|
|
822
|
+
required: false,
|
|
823
|
+
schema: { type: "string" }
|
|
824
|
+
},
|
|
825
|
+
{
|
|
826
|
+
name: "limit",
|
|
827
|
+
in: "query",
|
|
828
|
+
required: false,
|
|
829
|
+
schema: { type: "integer" }
|
|
830
|
+
},
|
|
831
|
+
{
|
|
832
|
+
name: "before",
|
|
833
|
+
in: "query",
|
|
834
|
+
required: false,
|
|
835
|
+
schema: { type: "string" }
|
|
836
|
+
}
|
|
837
|
+
],
|
|
838
|
+
responses: { "200": jsonRes("Messages", arrayOf("Message")) }
|
|
839
|
+
},
|
|
840
|
+
post: {
|
|
841
|
+
tags: ["messages"],
|
|
842
|
+
summary: "Publish a message (fan-out to matching endpoints; the ingest route)",
|
|
843
|
+
description: "Honors an `idempotency-key` header OR a body `idempotencyKey` field (the header wins). A deduplicated publish answers 200 with the original message.",
|
|
844
|
+
parameters: [{
|
|
845
|
+
name: "idempotency-key",
|
|
846
|
+
in: "header",
|
|
847
|
+
required: false,
|
|
848
|
+
schema: { type: "string" }
|
|
849
|
+
}],
|
|
850
|
+
requestBody: jsonBody({
|
|
851
|
+
type: "object",
|
|
852
|
+
required: ["eventType", "payload"],
|
|
853
|
+
properties: {
|
|
854
|
+
eventType: { type: "string" },
|
|
855
|
+
payload: {},
|
|
856
|
+
timestamp: {
|
|
857
|
+
type: "string",
|
|
858
|
+
format: "date-time"
|
|
859
|
+
},
|
|
860
|
+
idempotencyKey: { type: "string" }
|
|
861
|
+
}
|
|
862
|
+
}),
|
|
863
|
+
responses: {
|
|
864
|
+
"200": okRef("PublishResult", "Deduplicated (existing message)"),
|
|
865
|
+
"201": okRef("PublishResult", "Published"),
|
|
866
|
+
"409": errorRes("Idempotency key reused with a different payload"),
|
|
867
|
+
"413": errorRes("Payload too large"),
|
|
868
|
+
"422": errorRes("Validation error")
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
},
|
|
872
|
+
"/api/applications/{app}/messages/{id}": {
|
|
873
|
+
parameters: [APP, ID],
|
|
874
|
+
get: {
|
|
875
|
+
tags: ["messages"],
|
|
876
|
+
summary: "Get a message with its deliveries",
|
|
877
|
+
responses: {
|
|
878
|
+
"200": jsonRes("Message + deliveries", {
|
|
879
|
+
allOf: [ref("Message")],
|
|
880
|
+
properties: { deliveries: arrayOf("Delivery") }
|
|
881
|
+
}),
|
|
882
|
+
"404": errorRes("Not found")
|
|
883
|
+
}
|
|
884
|
+
}
|
|
885
|
+
},
|
|
886
|
+
"/api/applications/{app}/deliveries": {
|
|
887
|
+
parameters: [APP],
|
|
888
|
+
get: {
|
|
889
|
+
tags: ["deliveries"],
|
|
890
|
+
summary: "List deliveries (newest first)",
|
|
891
|
+
parameters: [
|
|
892
|
+
{
|
|
893
|
+
name: "status",
|
|
894
|
+
in: "query",
|
|
895
|
+
required: false,
|
|
896
|
+
schema: {
|
|
897
|
+
type: "string",
|
|
898
|
+
enum: [
|
|
899
|
+
"pending",
|
|
900
|
+
"delivering",
|
|
901
|
+
"succeeded",
|
|
902
|
+
"failed"
|
|
903
|
+
]
|
|
904
|
+
}
|
|
905
|
+
},
|
|
906
|
+
{
|
|
907
|
+
name: "endpoint",
|
|
908
|
+
in: "query",
|
|
909
|
+
required: false,
|
|
910
|
+
schema: { type: "string" }
|
|
911
|
+
},
|
|
912
|
+
{
|
|
913
|
+
name: "limit",
|
|
914
|
+
in: "query",
|
|
915
|
+
required: false,
|
|
916
|
+
schema: { type: "integer" }
|
|
917
|
+
},
|
|
918
|
+
{
|
|
919
|
+
name: "before",
|
|
920
|
+
in: "query",
|
|
921
|
+
required: false,
|
|
922
|
+
schema: { type: "string" }
|
|
923
|
+
}
|
|
924
|
+
],
|
|
925
|
+
responses: { "200": jsonRes("Deliveries", arrayOf("Delivery")) }
|
|
926
|
+
}
|
|
927
|
+
},
|
|
928
|
+
"/api/applications/{app}/deliveries/{id}": {
|
|
929
|
+
parameters: [APP, ID],
|
|
930
|
+
get: {
|
|
931
|
+
tags: ["deliveries"],
|
|
932
|
+
summary: "Get a delivery with its attempt timeline",
|
|
933
|
+
responses: {
|
|
934
|
+
"200": jsonRes("Delivery + attempts", {
|
|
935
|
+
allOf: [ref("Delivery")],
|
|
936
|
+
properties: { attempts: arrayOf("DeliveryAttempt") }
|
|
937
|
+
}),
|
|
938
|
+
"404": errorRes("Not found")
|
|
939
|
+
}
|
|
940
|
+
}
|
|
941
|
+
},
|
|
942
|
+
"/api/applications/{app}/deliveries/{id}/retry": {
|
|
943
|
+
parameters: [APP, ID],
|
|
944
|
+
post: {
|
|
945
|
+
tags: ["deliveries"],
|
|
946
|
+
summary: "Re-queue a dead-lettered delivery",
|
|
947
|
+
responses: {
|
|
948
|
+
"200": okRef("Delivery", "Re-queued"),
|
|
949
|
+
"404": errorRes("Not found"),
|
|
950
|
+
"422": errorRes("Delivery is not in the failed state")
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
},
|
|
954
|
+
"/api/applications/{app}/audit": {
|
|
955
|
+
parameters: [APP],
|
|
956
|
+
get: {
|
|
957
|
+
tags: ["audit"],
|
|
958
|
+
summary: "List audit entries (newest first)",
|
|
959
|
+
responses: { "200": jsonRes("Audit", arrayOf("AuditEntry")) }
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
},
|
|
963
|
+
components: {
|
|
964
|
+
schemas,
|
|
965
|
+
securitySchemes: {
|
|
966
|
+
basicAuth: {
|
|
967
|
+
type: "http",
|
|
968
|
+
scheme: "basic"
|
|
969
|
+
},
|
|
970
|
+
bearerAuth: {
|
|
971
|
+
type: "http",
|
|
972
|
+
scheme: "bearer",
|
|
973
|
+
description: "Portal tokens (whpt_…) or host-issued bearer credentials."
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
//#endregion
|
|
980
|
+
//#region src/server/render-index-html.ts
|
|
981
|
+
/**
|
|
982
|
+
* Renders the SPA `index.html`: injects a `<base>` tag so relative asset URLs
|
|
983
|
+
* resolve under any mount path, and a bootstrap `window.__WEBHOOKS_CONFIG__`
|
|
984
|
+
* blob. Falls back to a minimal built-in page when the UI bundle is absent
|
|
985
|
+
* (e.g. before `bun run build:ui`, or in headless tests).
|
|
986
|
+
*
|
|
987
|
+
* @module
|
|
988
|
+
*/
|
|
989
|
+
const escapeJson = (value) => JSON.stringify(value).replace(/</g, "\\u003c").replace(/>/g, "\\u003e");
|
|
990
|
+
/** Build the `<base href>` value (always ends in `/`). */
|
|
991
|
+
function baseHref(basePath) {
|
|
992
|
+
return basePath === "" ? "/" : `${basePath}/`;
|
|
993
|
+
}
|
|
994
|
+
function injectInto(html, config) {
|
|
995
|
+
const tags = `<base href="${baseHref(config.basePath)}"><script>window.__WEBHOOKS_CONFIG__=${escapeJson(config)}<\/script>`;
|
|
996
|
+
if (html.includes("<head>")) return html.replace("<head>", `<head>${tags}`);
|
|
997
|
+
return tags + html;
|
|
998
|
+
}
|
|
999
|
+
/** Minimal page served when no built UI is present. */
|
|
1000
|
+
function fallbackHtml(config) {
|
|
1001
|
+
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><base href="${baseHref(config.basePath)}"><title>${config.title}</title><script>window.__WEBHOOKS_CONFIG__=${escapeJson(config)}<\/script></head><body style="font-family:system-ui;margin:0;padding:3rem;background:#0a0a0a;color:#e5e5e5"><h1 style="margin:0 0 .5rem">${config.title}</h1><p style="color:#a3a3a3">The admin UI bundle is not built. Run <code style="background:#1a1a1a;padding:2px 6px;border-radius:4px">bun run build:ui</code>. The JSON API at <code style="background:#1a1a1a;padding:2px 6px;border-radius:4px">api/</code> is fully available.</p></body></html>`;
|
|
1002
|
+
}
|
|
1003
|
+
/** Read, inject into, and return the SPA index HTML (or a fallback). */
|
|
1004
|
+
async function renderIndexHtml(uiDir, config) {
|
|
1005
|
+
try {
|
|
1006
|
+
return injectInto(await (0, node_fs_promises.readFile)((0, node_path.join)(uiDir, "index.html"), "utf8"), config);
|
|
1007
|
+
} catch {
|
|
1008
|
+
return fallbackHtml(config);
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
//#endregion
|
|
1012
|
+
//#region src/server/routes.ts
|
|
1013
|
+
/**
|
|
1014
|
+
* The default portal grant: manage own endpoints (including secrets), inspect
|
|
1015
|
+
* own messages/deliveries, retry, and read the event-type catalog.
|
|
1016
|
+
*/
|
|
1017
|
+
const DEFAULT_PORTAL_ACTIONS = [
|
|
1018
|
+
"endpoint:read",
|
|
1019
|
+
"endpoint:create",
|
|
1020
|
+
"endpoint:update",
|
|
1021
|
+
"endpoint:delete",
|
|
1022
|
+
"endpoint:rotate-secret",
|
|
1023
|
+
"endpoint:read-secret",
|
|
1024
|
+
"message:read",
|
|
1025
|
+
"delivery:read",
|
|
1026
|
+
"delivery:retry",
|
|
1027
|
+
"event-type:read"
|
|
1028
|
+
];
|
|
1029
|
+
const json = (data, status = 200) => new Response(JSON.stringify(data), {
|
|
1030
|
+
status,
|
|
1031
|
+
headers: { "content-type": "application/json; charset=utf-8" }
|
|
1032
|
+
});
|
|
1033
|
+
const error = (status, message, extra) => json({
|
|
1034
|
+
error: message,
|
|
1035
|
+
...extra
|
|
1036
|
+
}, status);
|
|
1037
|
+
/** Match `pattern` (with `:name` segments) against `path`. */
|
|
1038
|
+
function match(pattern, path) {
|
|
1039
|
+
const pp = pattern.split("/").filter(Boolean);
|
|
1040
|
+
const ap = path.split("/").filter(Boolean);
|
|
1041
|
+
if (pp.length !== ap.length) return null;
|
|
1042
|
+
const params = {};
|
|
1043
|
+
for (let i = 0; i < pp.length; i++) {
|
|
1044
|
+
const seg = pp[i];
|
|
1045
|
+
const val = ap[i];
|
|
1046
|
+
if (seg.startsWith(":")) params[seg.slice(1)] = decodeURIComponent(val);
|
|
1047
|
+
else if (seg !== val) return null;
|
|
1048
|
+
}
|
|
1049
|
+
return { params };
|
|
1050
|
+
}
|
|
1051
|
+
/**
|
|
1052
|
+
* An endpoint as served by read routes: secrets stripped. The secret is
|
|
1053
|
+
* returned exactly once at mint time (create / rotate); afterwards it is only
|
|
1054
|
+
* reachable through the dedicated `/secret` route gated by
|
|
1055
|
+
* `endpoint:read-secret`.
|
|
1056
|
+
*/
|
|
1057
|
+
function withoutSecrets(endpoint) {
|
|
1058
|
+
const { secrets: _secrets, ...rest } = endpoint;
|
|
1059
|
+
return rest;
|
|
1060
|
+
}
|
|
1061
|
+
/** Build the audit {@link Actor} for a principal. */
|
|
1062
|
+
function actorFor(principal) {
|
|
1063
|
+
return {
|
|
1064
|
+
id: principal.id,
|
|
1065
|
+
...principal.email !== void 0 ? { email: principal.email } : {},
|
|
1066
|
+
...principal.name !== void 0 ? { name: principal.name } : {}
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
/** Parse a positive-integer query param, or `undefined`. */
|
|
1070
|
+
function intParam(value) {
|
|
1071
|
+
if (value === null) return void 0;
|
|
1072
|
+
const n = Number(value);
|
|
1073
|
+
return Number.isFinite(n) && n > 0 ? Math.floor(n) : void 0;
|
|
1074
|
+
}
|
|
1075
|
+
/**
|
|
1076
|
+
* Handle an API request. `path` is already base-path-stripped. Returns a
|
|
1077
|
+
* `Response` for API/config routes, or `null` if the path is not an API route.
|
|
1078
|
+
*/
|
|
1079
|
+
async function handleApiRequest(request, path, ctx) {
|
|
1080
|
+
if (!(path === "/config" || path === "/api/config" || path === "/openapi.json" || path.startsWith("/api/"))) return null;
|
|
1081
|
+
const method = request.method.toUpperCase();
|
|
1082
|
+
if (path === "/api/openapi.json" || path === "/openapi.json") return json(buildOpenApiDocument({
|
|
1083
|
+
basePath: ctx.basePath,
|
|
1084
|
+
title: ctx.title
|
|
1085
|
+
}));
|
|
1086
|
+
if (path === "/api/event-types.json") return new Response(JSON.stringify(await ctx.core.listEventTypes()), {
|
|
1087
|
+
status: 200,
|
|
1088
|
+
headers: {
|
|
1089
|
+
"content-type": "application/json; charset=utf-8",
|
|
1090
|
+
"access-control-allow-origin": "*"
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
let principal = null;
|
|
1094
|
+
let portalScope = null;
|
|
1095
|
+
const bearer = request.headers.get("authorization");
|
|
1096
|
+
if (ctx.portal && bearer?.startsWith(`Bearer whpt_`)) try {
|
|
1097
|
+
const { applicationKey } = await verifyPortalToken(ctx.portal.secret, bearer.slice(7));
|
|
1098
|
+
principal = {
|
|
1099
|
+
id: `portal:${applicationKey}`,
|
|
1100
|
+
metadata: {
|
|
1101
|
+
portal: true,
|
|
1102
|
+
applicationKey
|
|
1103
|
+
}
|
|
1104
|
+
};
|
|
1105
|
+
portalScope = {
|
|
1106
|
+
applicationKey,
|
|
1107
|
+
allow: new Set(ctx.portal.allow ?? DEFAULT_PORTAL_ACTIONS)
|
|
1108
|
+
};
|
|
1109
|
+
} catch (err) {
|
|
1110
|
+
return mapError(err);
|
|
1111
|
+
}
|
|
1112
|
+
else try {
|
|
1113
|
+
principal = await ctx.auth.authenticate(request);
|
|
1114
|
+
} catch (err) {
|
|
1115
|
+
return mapError(err);
|
|
1116
|
+
}
|
|
1117
|
+
if (path === "/config" || path === "/api/config") return json({
|
|
1118
|
+
title: ctx.title,
|
|
1119
|
+
basePath: ctx.basePath,
|
|
1120
|
+
readonly: ctx.readonly,
|
|
1121
|
+
authenticated: principal !== null,
|
|
1122
|
+
principal: principal ? {
|
|
1123
|
+
id: principal.id,
|
|
1124
|
+
email: principal.email,
|
|
1125
|
+
name: principal.name,
|
|
1126
|
+
roles: principal.roles
|
|
1127
|
+
} : null,
|
|
1128
|
+
portal: portalScope !== null,
|
|
1129
|
+
logoUrl: ctx.logoUrl
|
|
1130
|
+
});
|
|
1131
|
+
if (principal === null) return ctx.auth.challenge?.(request) ?? error(401, "Unauthorized");
|
|
1132
|
+
const authorize = async (action, resource) => {
|
|
1133
|
+
if (portalScope) {
|
|
1134
|
+
if (!portalScope.allow.has(action)) return error(403, "Forbidden", { action });
|
|
1135
|
+
if (resource.type === "event-type") {
|
|
1136
|
+
if (action !== "event-type:read") return error(403, "Forbidden", { action });
|
|
1137
|
+
return null;
|
|
1138
|
+
}
|
|
1139
|
+
if (resource.applicationKey !== portalScope.applicationKey) return error(403, "Forbidden", { action });
|
|
1140
|
+
return null;
|
|
1141
|
+
}
|
|
1142
|
+
return await ctx.authorization.authorize({
|
|
1143
|
+
principal,
|
|
1144
|
+
action,
|
|
1145
|
+
resource,
|
|
1146
|
+
request
|
|
1147
|
+
}) ? null : error(403, "Forbidden", { action });
|
|
1148
|
+
};
|
|
1149
|
+
const actor = actorFor(principal);
|
|
1150
|
+
const body = async () => await request.json();
|
|
1151
|
+
try {
|
|
1152
|
+
if (path === "/api/applications") {
|
|
1153
|
+
if (method === "GET") {
|
|
1154
|
+
const denied = await authorize("application:read", {
|
|
1155
|
+
type: "application",
|
|
1156
|
+
applicationKey: "*"
|
|
1157
|
+
});
|
|
1158
|
+
if (denied) return denied;
|
|
1159
|
+
return json(await ctx.core.listApplications());
|
|
1160
|
+
}
|
|
1161
|
+
if (method === "POST") {
|
|
1162
|
+
const input = await body();
|
|
1163
|
+
const denied = await authorize("application:create", {
|
|
1164
|
+
type: "application",
|
|
1165
|
+
applicationKey: input.key
|
|
1166
|
+
});
|
|
1167
|
+
if (denied) return denied;
|
|
1168
|
+
return json(await ctx.core.createApplication(input, { actor }), 201);
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
let m = match("/api/applications/:app", path);
|
|
1172
|
+
if (m) {
|
|
1173
|
+
const app = m.params.app;
|
|
1174
|
+
const resource = {
|
|
1175
|
+
type: "application",
|
|
1176
|
+
applicationKey: app
|
|
1177
|
+
};
|
|
1178
|
+
if (method === "GET") {
|
|
1179
|
+
const denied = await authorize("application:read", resource);
|
|
1180
|
+
if (denied) return denied;
|
|
1181
|
+
const application = await ctx.core.getApplication(app);
|
|
1182
|
+
return application ? json(application) : error(404, `application "${app}" not found`);
|
|
1183
|
+
}
|
|
1184
|
+
if (method === "PUT") {
|
|
1185
|
+
const patch = await body();
|
|
1186
|
+
const denied = await authorize("application:update", resource);
|
|
1187
|
+
if (denied) return denied;
|
|
1188
|
+
return json(await ctx.core.updateApplication(app, patch, { actor }));
|
|
1189
|
+
}
|
|
1190
|
+
if (method === "DELETE") {
|
|
1191
|
+
const denied = await authorize("application:delete", resource);
|
|
1192
|
+
if (denied) return denied;
|
|
1193
|
+
await ctx.core.deleteApplication(app, { actor });
|
|
1194
|
+
return json({ ok: true });
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
if (path === "/api/event-types") {
|
|
1198
|
+
if (method === "GET") {
|
|
1199
|
+
const denied = await authorize("event-type:read", {
|
|
1200
|
+
type: "event-type",
|
|
1201
|
+
name: "*"
|
|
1202
|
+
});
|
|
1203
|
+
if (denied) return denied;
|
|
1204
|
+
return json(await ctx.core.listEventTypes());
|
|
1205
|
+
}
|
|
1206
|
+
if (method === "POST") {
|
|
1207
|
+
const input = await body();
|
|
1208
|
+
const denied = await authorize("event-type:create", {
|
|
1209
|
+
type: "event-type",
|
|
1210
|
+
name: input.name
|
|
1211
|
+
});
|
|
1212
|
+
if (denied) return denied;
|
|
1213
|
+
return json(await ctx.core.upsertEventType(input, { actor }), 201);
|
|
1214
|
+
}
|
|
1215
|
+
}
|
|
1216
|
+
m = match("/api/event-types/:name", path);
|
|
1217
|
+
if (m) {
|
|
1218
|
+
const name = m.params.name;
|
|
1219
|
+
const resource = {
|
|
1220
|
+
type: "event-type",
|
|
1221
|
+
name
|
|
1222
|
+
};
|
|
1223
|
+
if (method === "GET") {
|
|
1224
|
+
const denied = await authorize("event-type:read", resource);
|
|
1225
|
+
if (denied) return denied;
|
|
1226
|
+
const eventType = await ctx.core.getEventType(name);
|
|
1227
|
+
return eventType ? json(eventType) : error(404, `event type "${name}" not found`);
|
|
1228
|
+
}
|
|
1229
|
+
if (method === "PUT") {
|
|
1230
|
+
const input = await body();
|
|
1231
|
+
const denied = await authorize("event-type:update", resource);
|
|
1232
|
+
if (denied) return denied;
|
|
1233
|
+
return json(await ctx.core.upsertEventType({
|
|
1234
|
+
...input,
|
|
1235
|
+
name
|
|
1236
|
+
}, { actor }));
|
|
1237
|
+
}
|
|
1238
|
+
if (method === "DELETE") {
|
|
1239
|
+
const denied = await authorize("event-type:delete", resource);
|
|
1240
|
+
if (denied) return denied;
|
|
1241
|
+
await ctx.core.deleteEventType(name, { actor });
|
|
1242
|
+
return json({ ok: true });
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
m = match("/api/applications/:app/endpoints", path);
|
|
1246
|
+
if (m) {
|
|
1247
|
+
const app = m.params.app;
|
|
1248
|
+
if (method === "GET") {
|
|
1249
|
+
const denied = await authorize("endpoint:read", {
|
|
1250
|
+
type: "endpoint",
|
|
1251
|
+
applicationKey: app,
|
|
1252
|
+
endpointId: "*"
|
|
1253
|
+
});
|
|
1254
|
+
if (denied) return denied;
|
|
1255
|
+
return json((await ctx.core.listEndpoints(app)).map(withoutSecrets));
|
|
1256
|
+
}
|
|
1257
|
+
if (method === "POST") {
|
|
1258
|
+
const input = await body();
|
|
1259
|
+
const denied = await authorize("endpoint:create", {
|
|
1260
|
+
type: "endpoint",
|
|
1261
|
+
applicationKey: app,
|
|
1262
|
+
endpointId: "*"
|
|
1263
|
+
});
|
|
1264
|
+
if (denied) return denied;
|
|
1265
|
+
return json(await ctx.core.createEndpoint(app, input, { actor }), 201);
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
m = match("/api/applications/:app/endpoints/:id", path);
|
|
1269
|
+
if (m) {
|
|
1270
|
+
const app = m.params.app;
|
|
1271
|
+
const id = m.params.id;
|
|
1272
|
+
const resource = {
|
|
1273
|
+
type: "endpoint",
|
|
1274
|
+
applicationKey: app,
|
|
1275
|
+
endpointId: id
|
|
1276
|
+
};
|
|
1277
|
+
if (method === "GET") {
|
|
1278
|
+
const denied = await authorize("endpoint:read", resource);
|
|
1279
|
+
if (denied) return denied;
|
|
1280
|
+
const endpoint = await ctx.core.getEndpoint(app, id);
|
|
1281
|
+
return endpoint ? json(withoutSecrets(endpoint)) : error(404, `endpoint "${id}" not found`);
|
|
1282
|
+
}
|
|
1283
|
+
if (method === "PUT") {
|
|
1284
|
+
const patch = await body();
|
|
1285
|
+
const denied = await authorize("endpoint:update", resource);
|
|
1286
|
+
if (denied) return denied;
|
|
1287
|
+
return json(withoutSecrets(await ctx.core.updateEndpoint(app, id, patch, { actor })));
|
|
1288
|
+
}
|
|
1289
|
+
if (method === "DELETE") {
|
|
1290
|
+
const denied = await authorize("endpoint:delete", resource);
|
|
1291
|
+
if (denied) return denied;
|
|
1292
|
+
await ctx.core.deleteEndpoint(app, id, { actor });
|
|
1293
|
+
return json({ ok: true });
|
|
1294
|
+
}
|
|
1295
|
+
}
|
|
1296
|
+
m = match("/api/applications/:app/endpoints/:id/secret", path);
|
|
1297
|
+
if (m && method === "GET") {
|
|
1298
|
+
const app = m.params.app;
|
|
1299
|
+
const id = m.params.id;
|
|
1300
|
+
const denied = await authorize("endpoint:read-secret", {
|
|
1301
|
+
type: "endpoint",
|
|
1302
|
+
applicationKey: app,
|
|
1303
|
+
endpointId: id
|
|
1304
|
+
});
|
|
1305
|
+
if (denied) return denied;
|
|
1306
|
+
return json(await ctx.core.getSecrets(app, id));
|
|
1307
|
+
}
|
|
1308
|
+
m = match("/api/applications/:app/endpoints/:id/rotate-secret", path);
|
|
1309
|
+
if (m && method === "POST") {
|
|
1310
|
+
const app = m.params.app;
|
|
1311
|
+
const id = m.params.id;
|
|
1312
|
+
const denied = await authorize("endpoint:rotate-secret", {
|
|
1313
|
+
type: "endpoint",
|
|
1314
|
+
applicationKey: app,
|
|
1315
|
+
endpointId: id
|
|
1316
|
+
});
|
|
1317
|
+
if (denied) return denied;
|
|
1318
|
+
return json(await ctx.core.rotateSecret(app, id, { actor }));
|
|
1319
|
+
}
|
|
1320
|
+
m = match("/api/applications/:app/endpoints/:id/enable", path);
|
|
1321
|
+
if (m && method === "POST") {
|
|
1322
|
+
const app = m.params.app;
|
|
1323
|
+
const id = m.params.id;
|
|
1324
|
+
const denied = await authorize("endpoint:update", {
|
|
1325
|
+
type: "endpoint",
|
|
1326
|
+
applicationKey: app,
|
|
1327
|
+
endpointId: id
|
|
1328
|
+
});
|
|
1329
|
+
if (denied) return denied;
|
|
1330
|
+
return json(withoutSecrets(await ctx.core.enableEndpoint(app, id, { actor })));
|
|
1331
|
+
}
|
|
1332
|
+
m = match("/api/applications/:app/endpoints/:id/disable", path);
|
|
1333
|
+
if (m && method === "POST") {
|
|
1334
|
+
const app = m.params.app;
|
|
1335
|
+
const id = m.params.id;
|
|
1336
|
+
const denied = await authorize("endpoint:update", {
|
|
1337
|
+
type: "endpoint",
|
|
1338
|
+
applicationKey: app,
|
|
1339
|
+
endpointId: id
|
|
1340
|
+
});
|
|
1341
|
+
if (denied) return denied;
|
|
1342
|
+
return json(withoutSecrets(await ctx.core.disableEndpoint(app, id, { actor })));
|
|
1343
|
+
}
|
|
1344
|
+
m = match("/api/applications/:app/endpoints/:id/test", path);
|
|
1345
|
+
if (m && method === "POST") {
|
|
1346
|
+
const app = m.params.app;
|
|
1347
|
+
const id = m.params.id;
|
|
1348
|
+
const denied = await authorize("endpoint:update", {
|
|
1349
|
+
type: "endpoint",
|
|
1350
|
+
applicationKey: app,
|
|
1351
|
+
endpointId: id
|
|
1352
|
+
});
|
|
1353
|
+
if (denied) return denied;
|
|
1354
|
+
const input = await body();
|
|
1355
|
+
return json(await ctx.core.sendExample(app, id, input, { actor }));
|
|
1356
|
+
}
|
|
1357
|
+
m = match("/api/applications/:app/endpoints/:id/recover", path);
|
|
1358
|
+
if (m && method === "POST") {
|
|
1359
|
+
const app = m.params.app;
|
|
1360
|
+
const id = m.params.id;
|
|
1361
|
+
const denied = await authorize("delivery:retry", {
|
|
1362
|
+
type: "delivery",
|
|
1363
|
+
applicationKey: app
|
|
1364
|
+
});
|
|
1365
|
+
if (denied) return denied;
|
|
1366
|
+
const input = await body();
|
|
1367
|
+
return json(await ctx.core.recoverEndpoint(app, id, input, { actor }));
|
|
1368
|
+
}
|
|
1369
|
+
m = match("/api/applications/:app/messages", path);
|
|
1370
|
+
if (m) {
|
|
1371
|
+
const app = m.params.app;
|
|
1372
|
+
if (method === "GET") {
|
|
1373
|
+
const denied = await authorize("message:read", {
|
|
1374
|
+
type: "message",
|
|
1375
|
+
applicationKey: app
|
|
1376
|
+
});
|
|
1377
|
+
if (denied) return denied;
|
|
1378
|
+
const url = new URL(request.url);
|
|
1379
|
+
const eventType = url.searchParams.get("eventType") ?? void 0;
|
|
1380
|
+
const before = url.searchParams.get("before") ?? void 0;
|
|
1381
|
+
const limit = intParam(url.searchParams.get("limit"));
|
|
1382
|
+
return json(await ctx.core.listMessages(app, {
|
|
1383
|
+
...eventType !== void 0 ? { eventType } : {},
|
|
1384
|
+
...before !== void 0 ? { before } : {},
|
|
1385
|
+
...limit !== void 0 ? { limit } : {}
|
|
1386
|
+
}));
|
|
1387
|
+
}
|
|
1388
|
+
if (method === "POST") {
|
|
1389
|
+
const input = await body();
|
|
1390
|
+
const denied = await authorize("message:publish", {
|
|
1391
|
+
type: "message",
|
|
1392
|
+
applicationKey: app
|
|
1393
|
+
});
|
|
1394
|
+
if (denied) return denied;
|
|
1395
|
+
const idempotencyKey = request.headers.get("idempotency-key") ?? input.idempotencyKey;
|
|
1396
|
+
const result = await ctx.core.publish(app, {
|
|
1397
|
+
eventType: input.eventType,
|
|
1398
|
+
payload: input.payload,
|
|
1399
|
+
...input.timestamp !== void 0 ? { timestamp: input.timestamp } : {},
|
|
1400
|
+
...idempotencyKey !== void 0 ? { idempotencyKey } : {}
|
|
1401
|
+
}, { actor });
|
|
1402
|
+
return json(result, result.deduplicated ? 200 : 201);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
m = match("/api/applications/:app/messages/:id", path);
|
|
1406
|
+
if (m && method === "GET") {
|
|
1407
|
+
const app = m.params.app;
|
|
1408
|
+
const id = m.params.id;
|
|
1409
|
+
const denied = await authorize("message:read", {
|
|
1410
|
+
type: "message",
|
|
1411
|
+
applicationKey: app,
|
|
1412
|
+
messageId: id
|
|
1413
|
+
});
|
|
1414
|
+
if (denied) return denied;
|
|
1415
|
+
const message = await ctx.core.getMessage(app, id);
|
|
1416
|
+
if (!message) return error(404, `message "${id}" not found`);
|
|
1417
|
+
const deliveries = await ctx.core.listDeliveries(app, { messageId: id });
|
|
1418
|
+
return json({
|
|
1419
|
+
...message,
|
|
1420
|
+
deliveries
|
|
1421
|
+
});
|
|
1422
|
+
}
|
|
1423
|
+
m = match("/api/applications/:app/deliveries", path);
|
|
1424
|
+
if (m && method === "GET") {
|
|
1425
|
+
const app = m.params.app;
|
|
1426
|
+
const denied = await authorize("delivery:read", {
|
|
1427
|
+
type: "delivery",
|
|
1428
|
+
applicationKey: app
|
|
1429
|
+
});
|
|
1430
|
+
if (denied) return denied;
|
|
1431
|
+
const url = new URL(request.url);
|
|
1432
|
+
const status = url.searchParams.get("status") ?? void 0;
|
|
1433
|
+
const endpointId = url.searchParams.get("endpoint") ?? void 0;
|
|
1434
|
+
const before = url.searchParams.get("before") ?? void 0;
|
|
1435
|
+
const limit = intParam(url.searchParams.get("limit"));
|
|
1436
|
+
return json(await ctx.core.listDeliveries(app, {
|
|
1437
|
+
...status !== void 0 ? { status } : {},
|
|
1438
|
+
...endpointId !== void 0 ? { endpointId } : {},
|
|
1439
|
+
...before !== void 0 ? { before } : {},
|
|
1440
|
+
...limit !== void 0 ? { limit } : {}
|
|
1441
|
+
}));
|
|
1442
|
+
}
|
|
1443
|
+
m = match("/api/applications/:app/deliveries/:id", path);
|
|
1444
|
+
if (m && method === "GET") {
|
|
1445
|
+
const app = m.params.app;
|
|
1446
|
+
const id = m.params.id;
|
|
1447
|
+
const denied = await authorize("delivery:read", {
|
|
1448
|
+
type: "delivery",
|
|
1449
|
+
applicationKey: app,
|
|
1450
|
+
deliveryId: id
|
|
1451
|
+
});
|
|
1452
|
+
if (denied) return denied;
|
|
1453
|
+
const found = await ctx.core.getDelivery(app, id);
|
|
1454
|
+
if (!found) return error(404, `delivery "${id}" not found`);
|
|
1455
|
+
return json({
|
|
1456
|
+
...found.delivery,
|
|
1457
|
+
attempts: found.attempts
|
|
1458
|
+
});
|
|
1459
|
+
}
|
|
1460
|
+
m = match("/api/applications/:app/deliveries/:id/request", path);
|
|
1461
|
+
if (m && method === "GET") {
|
|
1462
|
+
const app = m.params.app;
|
|
1463
|
+
const id = m.params.id;
|
|
1464
|
+
const denied = await authorize("delivery:read", {
|
|
1465
|
+
type: "delivery",
|
|
1466
|
+
applicationKey: app,
|
|
1467
|
+
deliveryId: id
|
|
1468
|
+
});
|
|
1469
|
+
if (denied) return denied;
|
|
1470
|
+
const preview = await ctx.core.previewDeliveryRequest(app, id);
|
|
1471
|
+
if (!preview) return error(404, `delivery "${id}" not found (or its endpoint was deleted)`);
|
|
1472
|
+
return json(preview);
|
|
1473
|
+
}
|
|
1474
|
+
m = match("/api/applications/:app/deliveries/:id/retry", path);
|
|
1475
|
+
if (m && method === "POST") {
|
|
1476
|
+
const app = m.params.app;
|
|
1477
|
+
const id = m.params.id;
|
|
1478
|
+
const denied = await authorize("delivery:retry", {
|
|
1479
|
+
type: "delivery",
|
|
1480
|
+
applicationKey: app,
|
|
1481
|
+
deliveryId: id
|
|
1482
|
+
});
|
|
1483
|
+
if (denied) return denied;
|
|
1484
|
+
return json(await ctx.core.retryDelivery(app, id, { actor }));
|
|
1485
|
+
}
|
|
1486
|
+
m = match("/api/applications/:app/audit", path);
|
|
1487
|
+
if (m && method === "GET") {
|
|
1488
|
+
const app = m.params.app;
|
|
1489
|
+
const denied = await authorize("audit:read", {
|
|
1490
|
+
type: "audit",
|
|
1491
|
+
applicationKey: app
|
|
1492
|
+
});
|
|
1493
|
+
if (denied) return denied;
|
|
1494
|
+
return json(await ctx.core.listAudit(app));
|
|
1495
|
+
}
|
|
1496
|
+
return error(404, "Not found");
|
|
1497
|
+
} catch (err) {
|
|
1498
|
+
return mapError(err);
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
/**
|
|
1502
|
+
* Cross-bundle error detection: `instanceof` first, `err.name` fallback. An
|
|
1503
|
+
* error thrown from a separate subpath bundle carries its own copy of the
|
|
1504
|
+
* class, so `instanceof` alone would mis-map it to 500.
|
|
1505
|
+
*/
|
|
1506
|
+
function isNamed(err, ctor, name) {
|
|
1507
|
+
return err instanceof ctor || err instanceof Error && err.name === name;
|
|
1508
|
+
}
|
|
1509
|
+
/**
|
|
1510
|
+
* A hook denial, detected by `name` rather than `instanceof`: a hook thrown from
|
|
1511
|
+
* a separate subpath bundle carries its own copy of the `HookDeniedError` class,
|
|
1512
|
+
* so `instanceof` would miss it and mis-map the denial to 500. Returns the
|
|
1513
|
+
* status to respond with, or `null` if not a denial.
|
|
1514
|
+
*/
|
|
1515
|
+
function hookDeniedStatus(err) {
|
|
1516
|
+
if (err instanceof require_core.HookDeniedError) return err.status;
|
|
1517
|
+
if (err instanceof Error && err.name === "HookDeniedError") {
|
|
1518
|
+
const status = err.status;
|
|
1519
|
+
return typeof status === "number" ? status : 403;
|
|
1520
|
+
}
|
|
1521
|
+
return null;
|
|
1522
|
+
}
|
|
1523
|
+
/** Map domain errors to HTTP responses. */
|
|
1524
|
+
function mapError(err) {
|
|
1525
|
+
if (isNamed(err, require_core.ReadonlyError, "ReadonlyError")) return error(403, err.message, { code: "READONLY" });
|
|
1526
|
+
const denied = hookDeniedStatus(err);
|
|
1527
|
+
if (denied !== null) return error(denied, err.message, { code: "HOOK_DENIED" });
|
|
1528
|
+
if (isNamed(err, PortalTokenError, "PortalTokenError")) return error(401, err.message, { code: "PORTAL_TOKEN" });
|
|
1529
|
+
if (isNamed(err, require_core.NotFoundError, "NotFoundError")) return error(404, err.message);
|
|
1530
|
+
if (isNamed(err, require_core.IdempotencyConflictError, "IdempotencyConflictError")) return error(409, err.message, { code: "IDEMPOTENCY_CONFLICT" });
|
|
1531
|
+
if (isNamed(err, require_core.ConflictError, "ConflictError")) return error(409, err.message, { code: "CONFLICT" });
|
|
1532
|
+
if (isNamed(err, require_core.PayloadTooLargeError, "PayloadTooLargeError")) return error(413, err.message, { code: "PAYLOAD_TOO_LARGE" });
|
|
1533
|
+
if (isNamed(err, require_core.ValidationError, "ValidationError")) {
|
|
1534
|
+
const errors = err.errors;
|
|
1535
|
+
return error(422, err.message, {
|
|
1536
|
+
code: "VALIDATION",
|
|
1537
|
+
errors: Array.isArray(errors) ? errors : []
|
|
1538
|
+
});
|
|
1539
|
+
}
|
|
1540
|
+
if (err instanceof SyntaxError) return error(400, "Invalid JSON body");
|
|
1541
|
+
return error(500, err instanceof Error ? err.message : "Internal error");
|
|
1542
|
+
}
|
|
1543
|
+
//#endregion
|
|
1544
|
+
//#region src/server/static-assets.ts
|
|
1545
|
+
/**
|
|
1546
|
+
* Serves the bundled SPA's static assets from the UI output directory. Maps file
|
|
1547
|
+
* extensions to content types and guards against path traversal.
|
|
1548
|
+
*
|
|
1549
|
+
* @module
|
|
1550
|
+
*/
|
|
1551
|
+
const CONTENT_TYPES = {
|
|
1552
|
+
".html": "text/html; charset=utf-8",
|
|
1553
|
+
".js": "text/javascript; charset=utf-8",
|
|
1554
|
+
".mjs": "text/javascript; charset=utf-8",
|
|
1555
|
+
".css": "text/css; charset=utf-8",
|
|
1556
|
+
".json": "application/json; charset=utf-8",
|
|
1557
|
+
".svg": "image/svg+xml",
|
|
1558
|
+
".png": "image/png",
|
|
1559
|
+
".jpg": "image/jpeg",
|
|
1560
|
+
".jpeg": "image/jpeg",
|
|
1561
|
+
".gif": "image/gif",
|
|
1562
|
+
".webp": "image/webp",
|
|
1563
|
+
".ico": "image/x-icon",
|
|
1564
|
+
".woff": "font/woff",
|
|
1565
|
+
".woff2": "font/woff2",
|
|
1566
|
+
".ttf": "font/ttf",
|
|
1567
|
+
".map": "application/json; charset=utf-8",
|
|
1568
|
+
".txt": "text/plain; charset=utf-8"
|
|
1569
|
+
};
|
|
1570
|
+
function contentType(path) {
|
|
1571
|
+
const dot = path.lastIndexOf(".");
|
|
1572
|
+
return CONTENT_TYPES[dot >= 0 ? path.slice(dot).toLowerCase() : ""] ?? "application/octet-stream";
|
|
1573
|
+
}
|
|
1574
|
+
/** True if the path has a file extension (so a miss should 404 rather than fall through to the SPA). */
|
|
1575
|
+
function looksLikeAsset(path) {
|
|
1576
|
+
return (path.split("/").pop() ?? "").includes(".");
|
|
1577
|
+
}
|
|
1578
|
+
/**
|
|
1579
|
+
* Try to serve `path` (base-path-stripped, leading `/`) as a static file from
|
|
1580
|
+
* `uiDir`. Returns a `Response`, or `null` if the file does not exist.
|
|
1581
|
+
*/
|
|
1582
|
+
async function serveStaticAsset(uiDir, path) {
|
|
1583
|
+
const rel = (0, node_path.normalize)(path).replace(/^(\.\.(\/|\\|$))+/, "");
|
|
1584
|
+
const full = (0, node_path.join)(uiDir, rel);
|
|
1585
|
+
if (!full.startsWith((0, node_path.normalize)(uiDir))) return null;
|
|
1586
|
+
try {
|
|
1587
|
+
const data = await (0, node_fs_promises.readFile)(full);
|
|
1588
|
+
return new Response(new Uint8Array(data), {
|
|
1589
|
+
status: 200,
|
|
1590
|
+
headers: {
|
|
1591
|
+
"content-type": contentType(full),
|
|
1592
|
+
"cache-control": rel.includes("/assets/") ? "public, max-age=31536000, immutable" : "no-cache"
|
|
1593
|
+
}
|
|
1594
|
+
});
|
|
1595
|
+
} catch {
|
|
1596
|
+
return null;
|
|
1597
|
+
}
|
|
1598
|
+
}
|
|
1599
|
+
//#endregion
|
|
1600
|
+
//#region src/server/create-fetch-handler.ts
|
|
1601
|
+
/**
|
|
1602
|
+
* The web-standard fetch handler at the heart of every framework adapter and the
|
|
1603
|
+
* standalone app. Composes auth/authorization (plus portal-token scoping), the
|
|
1604
|
+
* JSON admin API, static-asset serving, and SPA fallback into a single
|
|
1605
|
+
* `(request: Request) => Promise<Response>` — and, by default, starts the
|
|
1606
|
+
* in-process delivery dispatcher so mounting the panel is all it takes to
|
|
1607
|
+
* deliver webhooks.
|
|
1608
|
+
*
|
|
1609
|
+
* @module
|
|
1610
|
+
*/
|
|
1611
|
+
var create_fetch_handler_exports = /* @__PURE__ */ require_keys.__exportAll({ createFetchHandler: () => createFetchHandler });
|
|
1612
|
+
const defaultAuth = { authenticate: async () => ({ id: "anonymous" }) };
|
|
1613
|
+
const defaultAuthorization = { authorize: async () => true };
|
|
1614
|
+
/**
|
|
1615
|
+
* Locate the built admin SPA (`dist/ui`). When this module runs compiled (the
|
|
1616
|
+
* normal npm-consumer case) it lives in `dist/` and the bundle is the sibling
|
|
1617
|
+
* `./ui`. When it runs from TypeScript source (examples / dev against a
|
|
1618
|
+
* `file:`-linked checkout, where the runtime executes `src/server/*.ts`
|
|
1619
|
+
* directly), the bundle is instead at `<repo>/dist/ui` — i.e. `../../dist/ui`
|
|
1620
|
+
* relative to `src/server/`. Try the candidates and return the first that
|
|
1621
|
+
* exists, falling back to the compiled-layout path so the "build the UI" hint
|
|
1622
|
+
* still fires when nothing is built yet.
|
|
1623
|
+
*/
|
|
1624
|
+
function defaultUiDir() {
|
|
1625
|
+
try {
|
|
1626
|
+
const candidates = [new URL("./ui", require("url").pathToFileURL(__filename).href), new URL("../../dist/ui", require("url").pathToFileURL(__filename).href)].map((u) => (0, node_url.fileURLToPath)(u));
|
|
1627
|
+
return candidates.find((p) => (0, node_fs.existsSync)(p)) ?? candidates[0];
|
|
1628
|
+
} catch {
|
|
1629
|
+
return "./ui";
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
/**
|
|
1633
|
+
* Build the panel fetch handler.
|
|
1634
|
+
*
|
|
1635
|
+
* @example
|
|
1636
|
+
* ```ts
|
|
1637
|
+
* import { createFetchHandler } from "@xtandard/webhooks";
|
|
1638
|
+
* import { createFileStorage } from "@xtandard/webhooks/storage/file";
|
|
1639
|
+
*
|
|
1640
|
+
* const storage = createFileStorage({ dir: "./data/webhooks" });
|
|
1641
|
+
* const { fetch, core } = createFetchHandler({
|
|
1642
|
+
* storage,
|
|
1643
|
+
* basePath: "/webhooks",
|
|
1644
|
+
* title: "Acme Webhooks",
|
|
1645
|
+
* });
|
|
1646
|
+
*
|
|
1647
|
+
* Bun.serve({ port: 3000, fetch });
|
|
1648
|
+
* // Elsewhere in the app:
|
|
1649
|
+
* await core.publish("acme", { eventType: "invoice.paid", payload: { id: "inv_1" } });
|
|
1650
|
+
* ```
|
|
1651
|
+
*/
|
|
1652
|
+
function createFetchHandler(options) {
|
|
1653
|
+
const basePath = normalizeBasePath(options.basePath);
|
|
1654
|
+
const readonly = options.readonly ?? false;
|
|
1655
|
+
const title = options.title ?? "@xtandard/webhooks";
|
|
1656
|
+
const uiDir = options.uiDir ?? defaultUiDir();
|
|
1657
|
+
const dispatcherOptions = options.dispatcher === false ? void 0 : options.dispatcher;
|
|
1658
|
+
const core = options.core ?? require_core.createWebhooksCore({
|
|
1659
|
+
storage: options.storage,
|
|
1660
|
+
...options.queueStorage ? { queueStorage: options.queueStorage } : {},
|
|
1661
|
+
readonly,
|
|
1662
|
+
...options.hooks !== void 0 ? { hooks: options.hooks } : {},
|
|
1663
|
+
...options.onHookError ? { onHookError: options.onHookError } : {},
|
|
1664
|
+
...options.retention ? { retention: options.retention } : {},
|
|
1665
|
+
...options.onDelivery ? { onDelivery: options.onDelivery } : {},
|
|
1666
|
+
...options.onDeliveryError ? { onDeliveryError: options.onDeliveryError } : {},
|
|
1667
|
+
...dispatcherOptions ? { dispatcher: dispatcherOptions } : {}
|
|
1668
|
+
});
|
|
1669
|
+
let dispatcher = null;
|
|
1670
|
+
if (options.dispatcher !== false) {
|
|
1671
|
+
const queue = options.queueStorage ?? options.storage;
|
|
1672
|
+
if (queue && !require_contract.hasDeliveryQueue(queue) && !require_contract.isCompareAndSwap(queue)) console.warn("[@xtandard/webhooks] The panel started an in-process dispatcher over storage without atomic claiming (no claimDue/compareAndSwap). If you run more than one instance, deliveries WILL be sent multiple times — set `dispatcher: false` on all but one instance, or use redis/memory storage. See docs/DELIVERY.md.");
|
|
1673
|
+
dispatcher = require_dispatcher.createDispatcher(core, dispatcherOptions ?? {});
|
|
1674
|
+
dispatcher.start();
|
|
1675
|
+
}
|
|
1676
|
+
const apiCtx = {
|
|
1677
|
+
core,
|
|
1678
|
+
auth: options.auth ?? defaultAuth,
|
|
1679
|
+
authorization: options.authorization ?? defaultAuthorization,
|
|
1680
|
+
title,
|
|
1681
|
+
readonly,
|
|
1682
|
+
basePath,
|
|
1683
|
+
...options.logoUrl !== void 0 ? { logoUrl: options.logoUrl } : {},
|
|
1684
|
+
...options.portal ? { portal: options.portal } : {}
|
|
1685
|
+
};
|
|
1686
|
+
const cors = options.cors;
|
|
1687
|
+
async function fetch(request) {
|
|
1688
|
+
if (cors && request.method === "OPTIONS") return preflightResponse(request, cors);
|
|
1689
|
+
const response = await respond(request);
|
|
1690
|
+
return cors ? applyCorsHeaders(request, response, cors) : response;
|
|
1691
|
+
}
|
|
1692
|
+
async function respond(request) {
|
|
1693
|
+
const path = stripBasePath(new URL(request.url).pathname, basePath);
|
|
1694
|
+
const api = await handleApiRequest(request, path, apiCtx);
|
|
1695
|
+
if (api) return api;
|
|
1696
|
+
if (request.method !== "GET" && request.method !== "HEAD") return new Response("Method Not Allowed", { status: 405 });
|
|
1697
|
+
const asset = await serveStaticAsset(uiDir, path);
|
|
1698
|
+
if (asset) return asset;
|
|
1699
|
+
if (path !== "/" && looksLikeAsset(path)) return new Response("Not Found", { status: 404 });
|
|
1700
|
+
const html = await renderIndexHtml(uiDir, {
|
|
1701
|
+
title,
|
|
1702
|
+
basePath,
|
|
1703
|
+
readonly,
|
|
1704
|
+
...options.logoUrl !== void 0 ? { logoUrl: options.logoUrl } : {}
|
|
1705
|
+
});
|
|
1706
|
+
return new Response(html, {
|
|
1707
|
+
status: 200,
|
|
1708
|
+
headers: { "content-type": "text/html; charset=utf-8" }
|
|
1709
|
+
});
|
|
1710
|
+
}
|
|
1711
|
+
return {
|
|
1712
|
+
fetch,
|
|
1713
|
+
core,
|
|
1714
|
+
dispatcher,
|
|
1715
|
+
openapi: () => buildOpenApiDocument({
|
|
1716
|
+
basePath,
|
|
1717
|
+
title
|
|
1718
|
+
})
|
|
1719
|
+
};
|
|
1720
|
+
}
|
|
1721
|
+
//#endregion
|
|
1722
|
+
Object.defineProperty(exports, "DEFAULT_PORTAL_ACTIONS", {
|
|
1723
|
+
enumerable: true,
|
|
1724
|
+
get: function() {
|
|
1725
|
+
return DEFAULT_PORTAL_ACTIONS;
|
|
1726
|
+
}
|
|
1727
|
+
});
|
|
1728
|
+
Object.defineProperty(exports, "PORTAL_TOKEN_PREFIX", {
|
|
1729
|
+
enumerable: true,
|
|
1730
|
+
get: function() {
|
|
1731
|
+
return PORTAL_TOKEN_PREFIX;
|
|
1732
|
+
}
|
|
1733
|
+
});
|
|
1734
|
+
Object.defineProperty(exports, "PortalTokenError", {
|
|
1735
|
+
enumerable: true,
|
|
1736
|
+
get: function() {
|
|
1737
|
+
return PortalTokenError;
|
|
1738
|
+
}
|
|
1739
|
+
});
|
|
1740
|
+
Object.defineProperty(exports, "buildOpenApiDocument", {
|
|
1741
|
+
enumerable: true,
|
|
1742
|
+
get: function() {
|
|
1743
|
+
return buildOpenApiDocument;
|
|
1744
|
+
}
|
|
1745
|
+
});
|
|
1746
|
+
Object.defineProperty(exports, "createFetchHandler", {
|
|
1747
|
+
enumerable: true,
|
|
1748
|
+
get: function() {
|
|
1749
|
+
return createFetchHandler;
|
|
1750
|
+
}
|
|
1751
|
+
});
|
|
1752
|
+
Object.defineProperty(exports, "createPortalToken", {
|
|
1753
|
+
enumerable: true,
|
|
1754
|
+
get: function() {
|
|
1755
|
+
return createPortalToken;
|
|
1756
|
+
}
|
|
1757
|
+
});
|
|
1758
|
+
Object.defineProperty(exports, "create_fetch_handler_exports", {
|
|
1759
|
+
enumerable: true,
|
|
1760
|
+
get: function() {
|
|
1761
|
+
return create_fetch_handler_exports;
|
|
1762
|
+
}
|
|
1763
|
+
});
|
|
1764
|
+
Object.defineProperty(exports, "verifyPortalToken", {
|
|
1765
|
+
enumerable: true,
|
|
1766
|
+
get: function() {
|
|
1767
|
+
return verifyPortalToken;
|
|
1768
|
+
}
|
|
1769
|
+
});
|
|
1770
|
+
|
|
1771
|
+
//# sourceMappingURL=create-fetch-handler-CmooujQo.cjs.map
|