@xtandard/webhooks 0.1.1 → 0.1.3
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/README.md +1 -0
- package/dist/cli.cjs +4 -4
- package/dist/cli.mjs +4 -4
- package/dist/{core-CF8U0hgi.cjs → core-DkIXozBY.cjs} +2 -2
- package/dist/{core-CF8U0hgi.cjs.map → core-DkIXozBY.cjs.map} +1 -1
- package/dist/{core-C9mPi4iw.mjs → core-tmmtadF1.mjs} +2 -2
- package/dist/{core-C9mPi4iw.mjs.map → core-tmmtadF1.mjs.map} +1 -1
- package/dist/core.cjs +1 -1
- package/dist/core.mjs +1 -1
- package/dist/{create-fetch-handler-BILaZ2Z7.cjs → create-fetch-handler-B5WBbf8x.cjs} +3 -3
- package/dist/{create-fetch-handler-BILaZ2Z7.cjs.map → create-fetch-handler-B5WBbf8x.cjs.map} +1 -1
- package/dist/{create-fetch-handler-D0F3cjoq.mjs → create-fetch-handler-DZohI08M.mjs} +3 -3
- package/dist/{create-fetch-handler-D0F3cjoq.mjs.map → create-fetch-handler-DZohI08M.mjs.map} +1 -1
- package/dist/{dispatcher-DOOCJJxM.cjs → dispatcher-Bn4O874S.cjs} +3 -3
- package/dist/{dispatcher-DOOCJJxM.cjs.map → dispatcher-Bn4O874S.cjs.map} +1 -1
- package/dist/{dispatcher-DuBrQL46.mjs → dispatcher-CQcoIsS9.mjs} +3 -3
- package/dist/{dispatcher-DuBrQL46.mjs.map → dispatcher-CQcoIsS9.mjs.map} +1 -1
- package/dist/elysia-shared-CgTU3yPa.mjs +47 -0
- package/dist/elysia-shared-CgTU3yPa.mjs.map +1 -0
- package/dist/elysia-shared-Cop1-ie0.cjs +58 -0
- package/dist/elysia-shared-Cop1-ie0.cjs.map +1 -0
- package/dist/elysia-shared-CvSsqMbR.d.mts +19 -0
- package/dist/elysia-shared-DSSrfuoF.d.cts +19 -0
- package/dist/entry-bun.cjs +1 -1
- package/dist/entry-bun.mjs +1 -1
- package/dist/entry-elysia.cjs +5 -25
- package/dist/entry-elysia.cjs.map +1 -1
- package/dist/entry-elysia.d.cts +5 -15
- package/dist/entry-elysia.d.mts +5 -15
- package/dist/entry-elysia.mjs +4 -24
- package/dist/entry-elysia.mjs.map +1 -1
- package/dist/entry-elysia2.cjs +95 -0
- package/dist/entry-elysia2.cjs.map +1 -0
- package/dist/entry-elysia2.d.cts +1273 -0
- package/dist/entry-elysia2.d.mts +1273 -0
- package/dist/entry-elysia2.mjs +93 -0
- package/dist/entry-elysia2.mjs.map +1 -0
- package/dist/entry-express.cjs +1 -1
- package/dist/entry-express.mjs +1 -1
- package/dist/entry-hono.cjs +1 -1
- package/dist/entry-hono.mjs +1 -1
- package/dist/index.cjs +3 -3
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +3 -3
- package/dist/react.css +1 -1
- package/dist/react.js +2522 -2506
- package/dist/testing.cjs +2 -2
- package/dist/testing.mjs +2 -2
- package/dist/types-elysia2/adapters/elysia-shared.d.ts +34 -0
- package/dist/types-elysia2/adapters/elysia2.d.ts +820 -0
- package/dist/types-elysia2/auth/contract.d.ts +26 -0
- package/dist/types-elysia2/authorization/contract.d.ts +50 -0
- package/dist/types-elysia2/canonical.d.ts +27 -0
- package/dist/types-elysia2/core.d.ts +344 -0
- package/dist/types-elysia2/deliver.d.ts +79 -0
- package/dist/types-elysia2/delivery-sink.d.ts +57 -0
- package/dist/types-elysia2/dispatcher.d.ts +80 -0
- package/dist/types-elysia2/duration.d.ts +26 -0
- package/dist/types-elysia2/entry-elysia2.d.ts +8 -0
- package/dist/types-elysia2/hooks/contract.d.ts +203 -0
- package/dist/types-elysia2/id.d.ts +24 -0
- package/dist/types-elysia2/keys.d.ts +98 -0
- package/dist/types-elysia2/portal.d.ts +69 -0
- package/dist/types-elysia2/schema.d.ts +229 -0
- package/dist/types-elysia2/server/base-path.d.ts +15 -0
- package/dist/types-elysia2/server/cors.d.ts +36 -0
- package/dist/types-elysia2/server/create-fetch-handler.d.ts +121 -0
- package/dist/types-elysia2/server/openapi.d.ts +25 -0
- package/dist/types-elysia2/server/render-index-html.d.ts +18 -0
- package/dist/types-elysia2/server/routes.d.ts +52 -0
- package/dist/types-elysia2/server/static-assets.d.ts +13 -0
- package/dist/types-elysia2/signing.d.ts +88 -0
- package/dist/types-elysia2/storage/contract.d.ts +75 -0
- package/dist/types-elysia2/validation.d.ts +82 -0
- package/dist/types-elysia2/version.d.ts +8 -0
- package/dist/types-react/ui/lib/portal-container.d.ts +24 -0
- package/dist/ui/assets/{index-S5t_CLOe.js → index-BgyX_njf.js} +44 -44
- package/dist/ui/assets/index-mJCJq_EB.css +1 -0
- package/dist/ui/index.html +2 -2
- package/package.json +23 -6
- package/dist/ui/assets/index-B0eoQX2U.css +0 -1
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authentication contract. Answers: "Who is this request from?"
|
|
3
|
+
*
|
|
4
|
+
* `@xtandard/webhooks` ships `none`/`basic`/`delegated` implementations, but any
|
|
5
|
+
* object satisfying {@link AuthProvider} works — bring your own.
|
|
6
|
+
*
|
|
7
|
+
* @module
|
|
8
|
+
*/
|
|
9
|
+
/** An authenticated identity. */
|
|
10
|
+
export interface Principal {
|
|
11
|
+
id: string;
|
|
12
|
+
email?: string;
|
|
13
|
+
name?: string;
|
|
14
|
+
roles?: string[];
|
|
15
|
+
metadata?: unknown;
|
|
16
|
+
}
|
|
17
|
+
/** Authenticates an incoming web-standard {@link Request}. */
|
|
18
|
+
export interface AuthProvider {
|
|
19
|
+
/** Resolve the principal, or `null` if the request is unauthenticated. */
|
|
20
|
+
authenticate(request: Request): Promise<Principal | null>;
|
|
21
|
+
/**
|
|
22
|
+
* Optional: build a `Response` that prompts for credentials (e.g. a 401 with a
|
|
23
|
+
* `WWW-Authenticate` header). When omitted, the server returns a plain 401.
|
|
24
|
+
*/
|
|
25
|
+
challenge?(request: Request): Response | undefined;
|
|
26
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Authorization contract. Answers: "Can this principal perform this action on
|
|
3
|
+
* this resource?"
|
|
4
|
+
*
|
|
5
|
+
* Every mutating admin API route consults the {@link AuthorizationProvider}.
|
|
6
|
+
* Ships `none`/`roles`/`delegated` implementations.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
import type { Principal } from "../auth/contract.ts";
|
|
11
|
+
/** The full set of authorizable actions. */
|
|
12
|
+
export type WebhooksAction = "application:read" | "application:create" | "application:update" | "application:delete" | "event-type:read" | "event-type:create" | "event-type:update" | "event-type:delete" | "endpoint:read" | "endpoint:create" | "endpoint:update" | "endpoint:delete" | "endpoint:rotate-secret" | "endpoint:read-secret" | "message:read" | "message:publish" | "delivery:read" | "delivery:retry" | "audit:read";
|
|
13
|
+
/** The resource an action targets. */
|
|
14
|
+
export type WebhooksResource = {
|
|
15
|
+
type: "application";
|
|
16
|
+
applicationKey: string;
|
|
17
|
+
} | {
|
|
18
|
+
type: "event-type";
|
|
19
|
+
name: string;
|
|
20
|
+
} | {
|
|
21
|
+
type: "endpoint";
|
|
22
|
+
applicationKey: string;
|
|
23
|
+
endpointId: string;
|
|
24
|
+
} | {
|
|
25
|
+
type: "message";
|
|
26
|
+
applicationKey: string;
|
|
27
|
+
messageId?: string;
|
|
28
|
+
} | {
|
|
29
|
+
type: "delivery";
|
|
30
|
+
applicationKey: string;
|
|
31
|
+
deliveryId?: string;
|
|
32
|
+
} | {
|
|
33
|
+
type: "audit";
|
|
34
|
+
applicationKey?: string;
|
|
35
|
+
};
|
|
36
|
+
/** Input passed to {@link AuthorizationProvider.authorize}. */
|
|
37
|
+
export interface AuthorizeInput {
|
|
38
|
+
principal: Principal | null;
|
|
39
|
+
action: WebhooksAction;
|
|
40
|
+
resource: WebhooksResource;
|
|
41
|
+
request: Request;
|
|
42
|
+
}
|
|
43
|
+
/** Decides whether an action is permitted. */
|
|
44
|
+
export interface AuthorizationProvider {
|
|
45
|
+
authorize(input: AuthorizeInput): Promise<boolean>;
|
|
46
|
+
}
|
|
47
|
+
/** Actions that mutate state — blocked in readonly mode. */
|
|
48
|
+
export declare const MUTATING_ACTIONS: ReadonlySet<WebhooksAction>;
|
|
49
|
+
/** True if the action mutates state. */
|
|
50
|
+
export declare const isMutatingAction: (action: WebhooksAction) => boolean;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Canonical JSON serialization: object keys sorted recursively, so two values
|
|
3
|
+
* that are structurally equal produce byte-identical output regardless of key
|
|
4
|
+
* insertion order.
|
|
5
|
+
*
|
|
6
|
+
* Used for the idempotency comparison in {@link ./core.WebhooksCore.publish}:
|
|
7
|
+
* some control-plane stores canonicalize object key order on round-trip
|
|
8
|
+
* (Postgres `jsonb`, some BSON paths), so a stored payload read back may have a
|
|
9
|
+
* different key order than the freshly-published one. An order-sensitive
|
|
10
|
+
* comparison would then flag an identical re-publish as an
|
|
11
|
+
* `IdempotencyConflictError`. Comparing canonical forms fixes it uniformly,
|
|
12
|
+
* independent of the storage adapter.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
import type { JsonValue } from "./schema.ts";
|
|
17
|
+
/**
|
|
18
|
+
* Serialize a JSON value with object keys sorted lexicographically at every
|
|
19
|
+
* depth. Arrays keep their order (order is semantic for arrays); `undefined`
|
|
20
|
+
* inside objects is dropped exactly as `JSON.stringify` would.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* canonicalStringify({ b: 1, a: 2 }) === canonicalStringify({ a: 2, b: 1 }); // true
|
|
25
|
+
* ```
|
|
26
|
+
*/
|
|
27
|
+
export declare function canonicalStringify(value: JsonValue): string;
|
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Admin + publish core — the operations layer the API, CLI, and dispatcher sit
|
|
3
|
+
* on top of.
|
|
4
|
+
*
|
|
5
|
+
* Owns the split between the **control plane** (CRUD on applications / event
|
|
6
|
+
* types / endpoints, browsing messages and deliveries, replay — rare,
|
|
7
|
+
* human-driven, hook-guarded, audited) and the **delivery plane** (`publish()`
|
|
8
|
+
* + the dispatcher's claim/record internals — the hot path). `publish()` never
|
|
9
|
+
* performs an HTTP call and never throws because an endpoint is down: it
|
|
10
|
+
* persists one message and fans out one pending delivery per matching enabled
|
|
11
|
+
* endpoint; the dispatcher owns all network I/O.
|
|
12
|
+
*
|
|
13
|
+
* Storage can be split: control data lives in `storage`, while deliveries +
|
|
14
|
+
* the due index live in `queueStorage` (defaults to `storage`) — e.g. control
|
|
15
|
+
* in Postgres, queue in Redis.
|
|
16
|
+
*
|
|
17
|
+
* @module
|
|
18
|
+
*/
|
|
19
|
+
import { type AttemptOutcome, type SignedRequest } from "./deliver.ts";
|
|
20
|
+
import { type DeliveryErrorReporter, type DeliveryListener } from "./delivery-sink.ts";
|
|
21
|
+
import type { DispatcherOptions } from "./dispatcher.ts";
|
|
22
|
+
import type { Actor, Application, AuditEntry, Delivery, DeliveryAttempt, Endpoint, EventType, JsonValue, Message, WebhookDuration } from "./schema.ts";
|
|
23
|
+
import { type WebhooksStorage } from "./storage/contract.ts";
|
|
24
|
+
import type { HookErrorReporter, WebhooksHooks, WebhooksHooksInput } from "./hooks/contract.ts";
|
|
25
|
+
/** Thrown by mutating operations when the core is in readonly mode. */
|
|
26
|
+
export declare class ReadonlyError extends Error {
|
|
27
|
+
constructor(operation: string);
|
|
28
|
+
}
|
|
29
|
+
/** Thrown when a referenced application/event type/endpoint/delivery does not exist. */
|
|
30
|
+
export declare class NotFoundError extends Error {
|
|
31
|
+
constructor(message: string);
|
|
32
|
+
}
|
|
33
|
+
/** Thrown when creating an entity whose key already exists. Maps to HTTP 409. */
|
|
34
|
+
export declare class ConflictError extends Error {
|
|
35
|
+
constructor(message: string);
|
|
36
|
+
}
|
|
37
|
+
/** Thrown by {@link WebhooksCore.publish} when the payload exceeds the limit. Maps to 413. */
|
|
38
|
+
export declare class PayloadTooLargeError extends Error {
|
|
39
|
+
constructor(size: number, limit: number);
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Thrown by {@link WebhooksCore.publish} when an idempotency key is reused with
|
|
43
|
+
* a **different** payload (same key + same payload returns the original
|
|
44
|
+
* message instead). Maps to HTTP 409.
|
|
45
|
+
*/
|
|
46
|
+
export declare class IdempotencyConflictError extends Error {
|
|
47
|
+
constructor(key: string);
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* One retention rule set. When both fields are set, an item is **kept if either
|
|
51
|
+
* rule keeps it** (union of keeps — the same semantics as backup tools): pruned
|
|
52
|
+
* only when it is outside the `keepLast` most recent AND older than `maxAge`.
|
|
53
|
+
*/
|
|
54
|
+
export interface RetentionRule {
|
|
55
|
+
/** Keep at most the N most recent items. */
|
|
56
|
+
keepLast?: number;
|
|
57
|
+
/** Keep items newer than this age (relative to prune time). */
|
|
58
|
+
maxAge?: WebhookDuration;
|
|
59
|
+
}
|
|
60
|
+
/** Retention policy for {@link WebhooksCoreOptions.retention}. */
|
|
61
|
+
export interface RetentionOptions {
|
|
62
|
+
/**
|
|
63
|
+
* Prune old messages, cascading their deliveries and attempts. Messages with
|
|
64
|
+
* a non-terminal delivery are never pruned (the dispatcher still needs them).
|
|
65
|
+
*/
|
|
66
|
+
messages?: RetentionRule;
|
|
67
|
+
/** Prune old audit entries. */
|
|
68
|
+
audit?: RetentionRule;
|
|
69
|
+
}
|
|
70
|
+
/** Options for {@link createWebhooksCore}. */
|
|
71
|
+
export interface WebhooksCoreOptions {
|
|
72
|
+
/** Control-plane store: applications, event types, endpoints, messages, audit. */
|
|
73
|
+
storage: WebhooksStorage;
|
|
74
|
+
/**
|
|
75
|
+
* Store for deliveries, attempts, and the due index. Defaults to `storage`.
|
|
76
|
+
* Splitting lets control data live in Postgres while the queue lives in Redis.
|
|
77
|
+
*/
|
|
78
|
+
queueStorage?: WebhooksStorage;
|
|
79
|
+
/** When true, all mutating operations throw {@link ReadonlyError}. */
|
|
80
|
+
readonly?: boolean;
|
|
81
|
+
/**
|
|
82
|
+
* Control-plane hooks fired around admin mutations. Pass one hook or an array.
|
|
83
|
+
* `before` hooks run sequentially and may **throw to deny**; `after` hooks run
|
|
84
|
+
* post-commit for side effects and never fail the operation.
|
|
85
|
+
*/
|
|
86
|
+
hooks?: WebhooksHooksInput;
|
|
87
|
+
/** Reporter invoked when an `after` hook throws. Defaults to `console.warn`. */
|
|
88
|
+
onHookError?: HookErrorReporter;
|
|
89
|
+
/** Fire-and-forget sink invoked for **every** delivery attempt (metrics tap). */
|
|
90
|
+
onDelivery?: DeliveryListener;
|
|
91
|
+
/** Reporter invoked when the `onDelivery` sink throws/rejects. */
|
|
92
|
+
onDeliveryError?: DeliveryErrorReporter;
|
|
93
|
+
/**
|
|
94
|
+
* Retention policy. Message pruning cascades deliveries/attempts and is
|
|
95
|
+
* non-vetoable; removed payloads are surfaced to `after` hooks
|
|
96
|
+
* (`message.pruned`, `audit.pruned`) so they can be offloaded first. Pruning
|
|
97
|
+
* runs opportunistically after publishes (off the hot path, fire-and-forget)
|
|
98
|
+
* and on demand via {@link WebhooksCore.prune}.
|
|
99
|
+
*/
|
|
100
|
+
retention?: RetentionOptions;
|
|
101
|
+
/**
|
|
102
|
+
* Dispatcher configuration, carried here so panels/CLI construct dispatchers
|
|
103
|
+
* consistently. The core itself never starts one — creation is the
|
|
104
|
+
* panel/CLI's job (or yours, via `createDispatcher(core, core.options.dispatcher)`).
|
|
105
|
+
*/
|
|
106
|
+
dispatcher?: DispatcherOptions;
|
|
107
|
+
/** Grace window during which a rotated-out secret keeps signing. Default `"24h"`. */
|
|
108
|
+
secretRotationGrace?: WebhookDuration;
|
|
109
|
+
/** Allow `http:` endpoint URLs beyond localhost (dev only; default `false`). */
|
|
110
|
+
allowInsecureUrls?: boolean;
|
|
111
|
+
/** Extra endpoint-URL gate; return `false` to reject (SSRF denylist etc.). */
|
|
112
|
+
urlPolicy?: (url: string) => boolean;
|
|
113
|
+
/** Max serialized payload size accepted by publish. Default `262_144` bytes. */
|
|
114
|
+
payloadLimitBytes?: number;
|
|
115
|
+
/**
|
|
116
|
+
* Whether {@link WebhooksCore.publish} requires the event type to exist in
|
|
117
|
+
* the catalog. Default `true` — a typo'd event type name would otherwise
|
|
118
|
+
* silently deliver to nobody (endpoints subscribe by exact name).
|
|
119
|
+
*/
|
|
120
|
+
requireKnownEventTypes?: boolean;
|
|
121
|
+
/** Injectable clock (unix millis) for tests. Default `Date.now`. */
|
|
122
|
+
now?: () => number;
|
|
123
|
+
}
|
|
124
|
+
/** Per-call actor attribution for audited mutations. */
|
|
125
|
+
export interface ActorOptions {
|
|
126
|
+
actor?: Actor | null;
|
|
127
|
+
}
|
|
128
|
+
/** Input to {@link WebhooksCore.publish}. */
|
|
129
|
+
export interface PublishInput {
|
|
130
|
+
eventType: string;
|
|
131
|
+
payload: JsonValue;
|
|
132
|
+
/** ISO-8601 event-occurred time. Defaults to publish time. */
|
|
133
|
+
timestamp?: string;
|
|
134
|
+
/** Dedupe key: same key + same payload within retention returns the existing message. */
|
|
135
|
+
idempotencyKey?: string;
|
|
136
|
+
}
|
|
137
|
+
/** Result of {@link WebhooksCore.publish}. */
|
|
138
|
+
export interface PublishResult {
|
|
139
|
+
message: Message;
|
|
140
|
+
deliveries: Delivery[];
|
|
141
|
+
/** True when an idempotency key matched and the existing message was returned. */
|
|
142
|
+
deduplicated: boolean;
|
|
143
|
+
}
|
|
144
|
+
/** Pagination + filters for {@link WebhooksCore.listMessages}. */
|
|
145
|
+
export interface ListMessagesOptions {
|
|
146
|
+
/** Page size. Default 50, max 200. */
|
|
147
|
+
limit?: number;
|
|
148
|
+
/** Cursor: return items strictly older than the message with this id. */
|
|
149
|
+
before?: string;
|
|
150
|
+
eventType?: string;
|
|
151
|
+
}
|
|
152
|
+
/** Pagination + filters for {@link WebhooksCore.listDeliveries}. */
|
|
153
|
+
export interface ListDeliveriesOptions {
|
|
154
|
+
status?: Delivery["status"];
|
|
155
|
+
endpointId?: string;
|
|
156
|
+
messageId?: string;
|
|
157
|
+
/** Page size. Default 50, max 200. */
|
|
158
|
+
limit?: number;
|
|
159
|
+
/** Cursor: return items strictly older than the delivery with this id. */
|
|
160
|
+
before?: string;
|
|
161
|
+
}
|
|
162
|
+
/** Input to the dispatcher-facing {@link WebhooksCore.claimDueDeliveries}. */
|
|
163
|
+
export interface ClaimInput {
|
|
164
|
+
limit: number;
|
|
165
|
+
leaseMs: number;
|
|
166
|
+
}
|
|
167
|
+
/** Input to the dispatcher-facing {@link WebhooksCore.recordAttempt}. */
|
|
168
|
+
export interface RecordAttemptInput {
|
|
169
|
+
delivery: Delivery;
|
|
170
|
+
outcome: AttemptOutcome;
|
|
171
|
+
trigger: DeliveryAttempt["trigger"];
|
|
172
|
+
/**
|
|
173
|
+
* When the outcome failed: the next attempt time (dispatcher computes the
|
|
174
|
+
* schedule + jitter), or `null` when the schedule is exhausted (dead-letter).
|
|
175
|
+
* Ignored for successful outcomes.
|
|
176
|
+
*/
|
|
177
|
+
nextAttemptAt?: string | null;
|
|
178
|
+
/** The message's event type (for the sink event); loaded by the dispatcher anyway. */
|
|
179
|
+
eventType: string;
|
|
180
|
+
}
|
|
181
|
+
/** Result of {@link WebhooksCore.sendExample}. */
|
|
182
|
+
export interface SendExampleResult {
|
|
183
|
+
outcome: AttemptOutcome;
|
|
184
|
+
/** The envelope body that was sent. */
|
|
185
|
+
body: string;
|
|
186
|
+
/** The synthetic message id used as `webhook-id` (not retained). */
|
|
187
|
+
messageId: string;
|
|
188
|
+
}
|
|
189
|
+
/** Result of {@link WebhooksCore.recoverEndpoint}. */
|
|
190
|
+
export interface RecoverResult {
|
|
191
|
+
/** Ids of the failed deliveries that were re-queued. */
|
|
192
|
+
deliveryIds: string[];
|
|
193
|
+
}
|
|
194
|
+
/** The admin + publish core surface. */
|
|
195
|
+
export interface WebhooksCore {
|
|
196
|
+
readonly options: {
|
|
197
|
+
storage: WebhooksStorage;
|
|
198
|
+
queueStorage: WebhooksStorage;
|
|
199
|
+
readonly: boolean;
|
|
200
|
+
/** Normalized hooks (always an array). */
|
|
201
|
+
hooks: WebhooksHooks[];
|
|
202
|
+
retention?: RetentionOptions;
|
|
203
|
+
dispatcher?: DispatcherOptions;
|
|
204
|
+
secretRotationGrace: WebhookDuration;
|
|
205
|
+
allowInsecureUrls: boolean;
|
|
206
|
+
urlPolicy?: (url: string) => boolean;
|
|
207
|
+
payloadLimitBytes: number;
|
|
208
|
+
requireKnownEventTypes: boolean;
|
|
209
|
+
onDelivery?: DeliveryListener;
|
|
210
|
+
onDeliveryError?: DeliveryErrorReporter;
|
|
211
|
+
now: () => number;
|
|
212
|
+
};
|
|
213
|
+
listApplications(): Promise<Application[]>;
|
|
214
|
+
createApplication(input: {
|
|
215
|
+
key: string;
|
|
216
|
+
name?: string;
|
|
217
|
+
metadata?: JsonValue;
|
|
218
|
+
}, options?: ActorOptions): Promise<Application>;
|
|
219
|
+
getApplication(applicationKey: string): Promise<Application | null>;
|
|
220
|
+
updateApplication(applicationKey: string, patch: {
|
|
221
|
+
name?: string;
|
|
222
|
+
metadata?: JsonValue;
|
|
223
|
+
}, options?: ActorOptions): Promise<Application>;
|
|
224
|
+
/** Deletes the application and **everything** under it (endpoints, messages, deliveries). */
|
|
225
|
+
deleteApplication(applicationKey: string, options?: ActorOptions): Promise<void>;
|
|
226
|
+
listEventTypes(): Promise<EventType[]>;
|
|
227
|
+
getEventType(name: string): Promise<EventType | null>;
|
|
228
|
+
upsertEventType(input: EventType, options?: ActorOptions): Promise<EventType>;
|
|
229
|
+
/**
|
|
230
|
+
* Delete an event type. Endpoints referencing it keep their subscription
|
|
231
|
+
* entry and simply stop matching new publishes of that name.
|
|
232
|
+
*/
|
|
233
|
+
deleteEventType(name: string, options?: ActorOptions): Promise<void>;
|
|
234
|
+
listEndpoints(applicationKey: string): Promise<Endpoint[]>;
|
|
235
|
+
getEndpoint(applicationKey: string, endpointId: string): Promise<Endpoint | null>;
|
|
236
|
+
/** Creates the endpoint and generates its first signing secret. */
|
|
237
|
+
createEndpoint(applicationKey: string, input: {
|
|
238
|
+
url: string;
|
|
239
|
+
description?: string;
|
|
240
|
+
eventTypes?: string[];
|
|
241
|
+
headers?: Record<string, string>;
|
|
242
|
+
metadata?: JsonValue;
|
|
243
|
+
disabled?: boolean;
|
|
244
|
+
}, options?: ActorOptions): Promise<Endpoint>;
|
|
245
|
+
updateEndpoint(applicationKey: string, endpointId: string, patch: {
|
|
246
|
+
url?: string;
|
|
247
|
+
description?: string;
|
|
248
|
+
eventTypes?: string[];
|
|
249
|
+
headers?: Record<string, string>;
|
|
250
|
+
metadata?: JsonValue;
|
|
251
|
+
}, options?: ActorOptions): Promise<Endpoint>;
|
|
252
|
+
deleteEndpoint(applicationKey: string, endpointId: string, options?: ActorOptions): Promise<void>;
|
|
253
|
+
/**
|
|
254
|
+
* Mint a new current secret; the previous one keeps verifying until
|
|
255
|
+
* `secretRotationGrace` elapses. Expired grace secrets are pruned lazily.
|
|
256
|
+
*/
|
|
257
|
+
rotateSecret(applicationKey: string, endpointId: string, options?: ActorOptions): Promise<Endpoint>;
|
|
258
|
+
/** The endpoint's secrets (current first). Gate behind `endpoint:read-secret`. */
|
|
259
|
+
getSecrets(applicationKey: string, endpointId: string): Promise<Endpoint["secrets"]>;
|
|
260
|
+
enableEndpoint(applicationKey: string, endpointId: string, options?: ActorOptions): Promise<Endpoint>;
|
|
261
|
+
disableEndpoint(applicationKey: string, endpointId: string, options?: ActorOptions): Promise<Endpoint>;
|
|
262
|
+
/**
|
|
263
|
+
* Persist a message and fan out one pending delivery per matching enabled
|
|
264
|
+
* endpoint. Never performs HTTP; never throws because an endpoint is down.
|
|
265
|
+
*/
|
|
266
|
+
publish(applicationKey: string, input: PublishInput, options?: ActorOptions): Promise<PublishResult>;
|
|
267
|
+
listMessages(applicationKey: string, options?: ListMessagesOptions): Promise<Message[]>;
|
|
268
|
+
getMessage(applicationKey: string, messageId: string): Promise<Message | null>;
|
|
269
|
+
listDeliveries(applicationKey: string, options?: ListDeliveriesOptions): Promise<Delivery[]>;
|
|
270
|
+
getDelivery(applicationKey: string, deliveryId: string): Promise<{
|
|
271
|
+
delivery: Delivery;
|
|
272
|
+
attempts: DeliveryAttempt[];
|
|
273
|
+
} | null>;
|
|
274
|
+
/**
|
|
275
|
+
* The exact signed HTTP request this delivery would send **right now** —
|
|
276
|
+
* method, URL, all headers (including the computed `webhook-signature`), and
|
|
277
|
+
* body. The inspector view: shows what a receiver gets, without sending.
|
|
278
|
+
* Returns `null` if the delivery or its endpoint no longer exists. The
|
|
279
|
+
* `webhook-timestamp`/signature reflect the current time (they change per
|
|
280
|
+
* attempt by design); the id and body are stable.
|
|
281
|
+
*/
|
|
282
|
+
previewDeliveryRequest(applicationKey: string, deliveryId: string): Promise<SignedRequest | null>;
|
|
283
|
+
/** Re-queue a dead-lettered delivery (`failed` → `pending`, due immediately). */
|
|
284
|
+
retryDelivery(applicationKey: string, deliveryId: string, options?: ActorOptions): Promise<Delivery>;
|
|
285
|
+
/** Re-queue every failed delivery for an endpoint created at/after `since`. */
|
|
286
|
+
recoverEndpoint(applicationKey: string, endpointId: string, input: {
|
|
287
|
+
since: string;
|
|
288
|
+
}, options?: ActorOptions): Promise<RecoverResult>;
|
|
289
|
+
/**
|
|
290
|
+
* Fire a one-off signed test delivery at an endpoint, synchronously, through
|
|
291
|
+
* the same wire path as real deliveries (`trigger: "test"`). The synthetic
|
|
292
|
+
* message is not retained.
|
|
293
|
+
*/
|
|
294
|
+
sendExample(applicationKey: string, endpointId: string, input: {
|
|
295
|
+
eventType: string;
|
|
296
|
+
payload?: JsonValue;
|
|
297
|
+
}, options?: ActorOptions): Promise<SendExampleResult>;
|
|
298
|
+
/** Newest-first. With `applicationKey`: that app's log; without: global + all apps. */
|
|
299
|
+
listAudit(applicationKey?: string): Promise<AuditEntry[]>;
|
|
300
|
+
/** Run retention now (also runs opportunistically after publishes). */
|
|
301
|
+
prune(): Promise<void>;
|
|
302
|
+
/**
|
|
303
|
+
* Claim up to `limit` due deliveries for exclusive processing. Uses the
|
|
304
|
+
* storage's native `claimDue` when available; otherwise scans the due index
|
|
305
|
+
* with compare-and-swap claiming (plain read-modify-write when the storage
|
|
306
|
+
* has no CAS — single-dispatcher assumption, see docs/DELIVERY.md).
|
|
307
|
+
*/
|
|
308
|
+
claimDueDeliveries(input: ClaimInput): Promise<Delivery[]>;
|
|
309
|
+
/** Record one attempt's outcome and advance the delivery's state machine. */
|
|
310
|
+
recordAttempt(input: RecordAttemptInput): Promise<Delivery>;
|
|
311
|
+
/**
|
|
312
|
+
* Update the endpoint's failure streak after an attempt (any success clears
|
|
313
|
+
* it) and auto-disable once the streak exceeds `failingForDays`. Returns the
|
|
314
|
+
* endpoint when it was auto-disabled by this call.
|
|
315
|
+
*/
|
|
316
|
+
noteEndpointOutcome(applicationKey: string, endpointId: string, ok: boolean, policy?: {
|
|
317
|
+
failingForDays?: number;
|
|
318
|
+
} | false): Promise<Endpoint | null>;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Construct the core over the configured storage.
|
|
322
|
+
*
|
|
323
|
+
* @example
|
|
324
|
+
* ```ts
|
|
325
|
+
* import { createWebhooksCore } from "@xtandard/webhooks";
|
|
326
|
+
* import { createMemoryStorage } from "@xtandard/webhooks/storage/memory";
|
|
327
|
+
*
|
|
328
|
+
* const core = createWebhooksCore({ storage: createMemoryStorage() });
|
|
329
|
+
*
|
|
330
|
+
* await core.createApplication({ key: "acme" });
|
|
331
|
+
* await core.upsertEventType({ name: "invoice.paid" });
|
|
332
|
+
* const endpoint = await core.createEndpoint("acme", {
|
|
333
|
+
* url: "https://api.acme-customer.com/webhooks",
|
|
334
|
+
* eventTypes: ["invoice.paid"],
|
|
335
|
+
* });
|
|
336
|
+
*
|
|
337
|
+
* // The hot path — call this from your app code:
|
|
338
|
+
* await core.publish("acme", {
|
|
339
|
+
* eventType: "invoice.paid",
|
|
340
|
+
* payload: { invoiceId: "inv_1", amount: 4200 },
|
|
341
|
+
* });
|
|
342
|
+
* ```
|
|
343
|
+
*/
|
|
344
|
+
export declare function createWebhooksCore(options: WebhooksCoreOptions): WebhooksCore;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The single-attempt HTTP delivery primitive: build the Standard Webhooks
|
|
3
|
+
* headers, POST the envelope, capture the outcome. Owns no retry/queue logic —
|
|
4
|
+
* the dispatcher schedules attempts and `core.sendExample` fires one-off test
|
|
5
|
+
* sends through the same code path so test deliveries are wire-identical to
|
|
6
|
+
* real ones.
|
|
7
|
+
*
|
|
8
|
+
* @module
|
|
9
|
+
*/
|
|
10
|
+
import type { Endpoint } from "./schema.ts";
|
|
11
|
+
/** Default cap on how many response-body characters are kept per attempt. */
|
|
12
|
+
export declare const DEFAULT_RESPONSE_BODY_LIMIT = 4096;
|
|
13
|
+
/** Default per-attempt timeout. */
|
|
14
|
+
export declare const DEFAULT_ATTEMPT_TIMEOUT_MS = 20000;
|
|
15
|
+
/** Input to {@link attemptDelivery}. */
|
|
16
|
+
export interface AttemptDeliveryInput {
|
|
17
|
+
endpoint: Endpoint;
|
|
18
|
+
/** The `webhook-id` header — the **message** id (stable across retries). */
|
|
19
|
+
messageId: string;
|
|
20
|
+
/** The serialized envelope body, exactly as stored at publish time. */
|
|
21
|
+
body: string;
|
|
22
|
+
/** Per-attempt timeout (AbortController). Default 20s. */
|
|
23
|
+
timeoutMs?: number;
|
|
24
|
+
/** Cap on stored response-body characters. Default 4096. */
|
|
25
|
+
responseBodyLimit?: number;
|
|
26
|
+
/** `user-agent` header value. */
|
|
27
|
+
userAgent?: string;
|
|
28
|
+
/** Injectable fetch (tests, instrumentation). Default: global fetch. */
|
|
29
|
+
fetch?: typeof fetch;
|
|
30
|
+
/** Unix millis of the attempt; defaults to `Date.now()`. */
|
|
31
|
+
nowMs?: number;
|
|
32
|
+
}
|
|
33
|
+
/** The observed outcome of one HTTP delivery attempt. */
|
|
34
|
+
export interface AttemptOutcome {
|
|
35
|
+
ok: boolean;
|
|
36
|
+
httpStatus?: number;
|
|
37
|
+
/** Truncated error message on network error/timeout. */
|
|
38
|
+
error?: string;
|
|
39
|
+
/** Truncated response body. */
|
|
40
|
+
responseBody?: string;
|
|
41
|
+
durationMs: number;
|
|
42
|
+
/** ISO-8601 timestamp of the attempt. */
|
|
43
|
+
at: string;
|
|
44
|
+
}
|
|
45
|
+
/** The exact HTTP request a delivery attempt sends — the inspector's view. */
|
|
46
|
+
export interface SignedRequest {
|
|
47
|
+
method: "POST";
|
|
48
|
+
url: string;
|
|
49
|
+
headers: Record<string, string>;
|
|
50
|
+
body: string;
|
|
51
|
+
}
|
|
52
|
+
/** Input to {@link buildSignedRequest}. */
|
|
53
|
+
export interface BuildSignedRequestInput {
|
|
54
|
+
endpoint: Endpoint;
|
|
55
|
+
/** `webhook-id` — the message id (stable across retries). */
|
|
56
|
+
messageId: string;
|
|
57
|
+
/** The serialized envelope body. */
|
|
58
|
+
body: string;
|
|
59
|
+
/** `user-agent` header value. */
|
|
60
|
+
userAgent?: string;
|
|
61
|
+
/** Unix millis; defaults to `Date.now()`. Determines `webhook-timestamp`. */
|
|
62
|
+
nowMs?: number;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Build the exact signed request an attempt would POST — Standard Webhooks
|
|
66
|
+
* headers, all unexpired secrets signing — **without sending it**. Shared by
|
|
67
|
+
* {@link attemptDelivery} and the panel's request inspector so what you preview
|
|
68
|
+
* is byte-for-byte what a receiver gets.
|
|
69
|
+
*/
|
|
70
|
+
export declare function buildSignedRequest(input: BuildSignedRequestInput): Promise<SignedRequest>;
|
|
71
|
+
/** The endpoint's currently-signing secrets: the active one + unexpired rotation grace. */
|
|
72
|
+
export declare function activeSecrets(endpoint: Endpoint, nowMs: number): string[];
|
|
73
|
+
/**
|
|
74
|
+
* Perform one signed POST to `endpoint.url`. Never throws for remote-party
|
|
75
|
+
* failures — network errors, timeouts, and non-2xx responses all come back as
|
|
76
|
+
* a failed {@link AttemptOutcome}. (It does throw on programmer error, e.g. an
|
|
77
|
+
* endpoint with no unexpired secret.)
|
|
78
|
+
*/
|
|
79
|
+
export declare function attemptDelivery(input: AttemptDeliveryInput): Promise<AttemptOutcome>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Delivery sink — a **delivery-plane** observation tap, fired once per attempt.
|
|
3
|
+
* Deliberately separate from the admin-plane {@link ./hooks/contract.WebhooksHooks}
|
|
4
|
+
* (`before`/`after` around control-plane mutations):
|
|
5
|
+
*
|
|
6
|
+
* - **Different plane.** Admin hooks live around rare, human-driven mutations
|
|
7
|
+
* (endpoints, event types, replay). Attempts happen inside the dispatcher,
|
|
8
|
+
* potentially thousands per minute.
|
|
9
|
+
* - **Hot path.** The sink is **fire-and-forget**: invoked *after* the attempt
|
|
10
|
+
* is recorded, never awaited, and its errors never propagate into the
|
|
11
|
+
* delivery loop.
|
|
12
|
+
*
|
|
13
|
+
* It exists to feed metrics/observability pipelines — Prometheus counters,
|
|
14
|
+
* per-endpoint success dashboards, usage-based stale-endpoint detection —
|
|
15
|
+
* without a stats engine. The `after` hooks only see **terminal** transitions
|
|
16
|
+
* (`delivery.succeeded` / `delivery.exhausted`); the sink sees every attempt.
|
|
17
|
+
*
|
|
18
|
+
* @module
|
|
19
|
+
*/
|
|
20
|
+
import type { AttemptTrigger } from "./schema.ts";
|
|
21
|
+
/** One recorded delivery attempt, delivered to a {@link DeliveryListener}. */
|
|
22
|
+
export interface DeliveryEvent {
|
|
23
|
+
applicationKey: string;
|
|
24
|
+
endpointId: string;
|
|
25
|
+
messageId: string;
|
|
26
|
+
deliveryId: string;
|
|
27
|
+
/** The message's event type name. */
|
|
28
|
+
eventType: string;
|
|
29
|
+
/** 1-based attempt ordinal within the delivery. */
|
|
30
|
+
attemptNumber: number;
|
|
31
|
+
/** True when the receiver answered 2xx. */
|
|
32
|
+
ok: boolean;
|
|
33
|
+
/** True when this attempt drove the delivery to a terminal state. */
|
|
34
|
+
terminal: boolean;
|
|
35
|
+
/** HTTP status code; absent on network error/timeout. */
|
|
36
|
+
httpStatus?: number;
|
|
37
|
+
/** Wall-clock duration of the HTTP round trip. */
|
|
38
|
+
durationMs: number;
|
|
39
|
+
/** What initiated the attempt. */
|
|
40
|
+
trigger: AttemptTrigger;
|
|
41
|
+
/** ISO-8601 timestamp of the attempt. */
|
|
42
|
+
at: string;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A fire-and-forget observer of delivery attempts. Return value (including a
|
|
46
|
+
* Promise) is ignored by the caller; throwing / rejecting never affects the
|
|
47
|
+
* delivery — failures are routed to the configured error reporter.
|
|
48
|
+
*/
|
|
49
|
+
export type DeliveryListener = (event: DeliveryEvent) => void | Promise<void>;
|
|
50
|
+
/** Reports an error thrown/rejected by a {@link DeliveryListener}. */
|
|
51
|
+
export type DeliveryErrorReporter = (error: unknown, event: DeliveryEvent) => void;
|
|
52
|
+
/**
|
|
53
|
+
* Invoke `listener` safely off the delivery path: synchronous throws are caught
|
|
54
|
+
* and a returned Promise's rejection is handled, so a broken sink can never
|
|
55
|
+
* fail (or slow, since it is not awaited) a delivery.
|
|
56
|
+
*/
|
|
57
|
+
export declare function emitDelivery(listener: DeliveryListener, event: DeliveryEvent, onError?: DeliveryErrorReporter): void;
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The delivery engine. Polls the due index, claims deliveries (lease-based,
|
|
3
|
+
* multi-instance safe when storage supports CAS or the `deliveryQueue`
|
|
4
|
+
* capability), performs the signed HTTP attempts, and drives each delivery's
|
|
5
|
+
* state machine through the retry schedule into success or dead-letter.
|
|
6
|
+
*
|
|
7
|
+
* Runs in-process: the panel starts one by default, a split worker runs one
|
|
8
|
+
* via the CLI (`xtandard-webhooks dispatch`), and tests drive {@link Dispatcher.tick}
|
|
9
|
+
* manually. All timers are `unref()`ed so a dispatcher never keeps a process
|
|
10
|
+
* alive on its own. Semantics are **at-least-once**: a crashed process loses
|
|
11
|
+
* nothing (leases expire, the next tick reclaims); receivers dedupe on
|
|
12
|
+
* `webhook-id`.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
import type { WebhooksCore } from "./core.ts";
|
|
17
|
+
import type { WebhookDuration } from "./schema.ts";
|
|
18
|
+
/** Options for {@link createDispatcher} (also accepted on panels and the core). */
|
|
19
|
+
export interface DispatcherOptions {
|
|
20
|
+
/** How often to poll for due deliveries. Default `1000`. */
|
|
21
|
+
pollIntervalMs?: number;
|
|
22
|
+
/** Max deliveries claimed per tick. Default `20`. */
|
|
23
|
+
batchSize?: number;
|
|
24
|
+
/** Max in-flight HTTP attempts. Default `8`. */
|
|
25
|
+
concurrency?: number;
|
|
26
|
+
/** Per-attempt timeout (AbortController). Default `20_000`. */
|
|
27
|
+
timeoutMs?: number;
|
|
28
|
+
/** Claim lease duration; an expired lease makes a claim reclaimable. Default `60_000`. */
|
|
29
|
+
leaseMs?: number;
|
|
30
|
+
/**
|
|
31
|
+
* Delay before attempt N+1 after attempt N fails (index 0 = the initial
|
|
32
|
+
* attempt's delay). Exhausting the schedule dead-letters the delivery.
|
|
33
|
+
* Default `["0s", "5s", "5m", "30m", "2h", "5h", "10h"]` (Svix-compatible).
|
|
34
|
+
*/
|
|
35
|
+
retrySchedule?: WebhookDuration[];
|
|
36
|
+
/**
|
|
37
|
+
* Auto-disable endpoints whose every attempt has failed for this many
|
|
38
|
+
* consecutive days. `false` disables the policy. Default `{ failingForDays: 5 }`.
|
|
39
|
+
*/
|
|
40
|
+
autoDisable?: {
|
|
41
|
+
failingForDays?: number;
|
|
42
|
+
} | false;
|
|
43
|
+
/** Cap on stored response-body characters per attempt. Default `4096`. */
|
|
44
|
+
responseBodyLimit?: number;
|
|
45
|
+
/** Injectable fetch (tests, instrumentation). Default: global fetch. */
|
|
46
|
+
fetch?: typeof fetch;
|
|
47
|
+
/** `user-agent` header. Default `"xtandard-webhooks/<version>"`. */
|
|
48
|
+
userAgent?: string;
|
|
49
|
+
}
|
|
50
|
+
/** The delivery engine handle. */
|
|
51
|
+
export interface Dispatcher {
|
|
52
|
+
/** Begin polling. Idempotent. */
|
|
53
|
+
start(): void;
|
|
54
|
+
/** Stop polling and wait for in-flight attempts to finish. */
|
|
55
|
+
stop(): Promise<void>;
|
|
56
|
+
/**
|
|
57
|
+
* Run one manual pass: claim due deliveries, attempt them, record outcomes.
|
|
58
|
+
* Returns the number of attempts made — the unit-test surface (tests never
|
|
59
|
+
* assert on timers).
|
|
60
|
+
*/
|
|
61
|
+
tick(): Promise<number>;
|
|
62
|
+
readonly running: boolean;
|
|
63
|
+
}
|
|
64
|
+
/** The default retry schedule (Svix-compatible). */
|
|
65
|
+
export declare const DEFAULT_RETRY_SCHEDULE: WebhookDuration[];
|
|
66
|
+
/**
|
|
67
|
+
* Create a dispatcher over a core. Not started — call
|
|
68
|
+
* {@link Dispatcher.start}, or drive {@link Dispatcher.tick} manually.
|
|
69
|
+
*
|
|
70
|
+
* @example
|
|
71
|
+
* ```ts
|
|
72
|
+
* import { createWebhooksCore, createDispatcher } from "@xtandard/webhooks";
|
|
73
|
+
* import { createMemoryStorage } from "@xtandard/webhooks/storage/memory";
|
|
74
|
+
*
|
|
75
|
+
* const core = createWebhooksCore({ storage: createMemoryStorage() });
|
|
76
|
+
* const dispatcher = createDispatcher(core);
|
|
77
|
+
* dispatcher.start();
|
|
78
|
+
* ```
|
|
79
|
+
*/
|
|
80
|
+
export declare function createDispatcher(core: WebhooksCore, options?: DispatcherOptions): Dispatcher;
|