@voltro/plugin-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.
@@ -0,0 +1,849 @@
1
+ import { AppContext } from '@voltro/runtime';
2
+ import { Context } from 'effect';
3
+ import { DataStore } from '@voltro/database';
4
+ import { Effect } from 'effect';
5
+ import { Schema } from 'effect';
6
+ import { TableLike } from '@voltro/database';
7
+ import { Workflow } from '@effect/workflow';
8
+ import { WorkflowEngine } from '@effect/workflow/WorkflowEngine';
9
+ import { WorkflowInstance } from '@effect/workflow/WorkflowEngine';
10
+
11
+ /**
12
+ * Acquire one slot in EVERY scope for the window containing `now`
13
+ * (all-or-nothing: a partial acquisition is refunded before deferring,
14
+ * so a delivery blocked by the event-global limit doesn't waste its
15
+ * target's budget).
16
+ *
17
+ * Returns `{ acquired: true }` when the delivery may POST now, or
18
+ * `{ acquired: false, retryInMs }` where `retryInMs` is the time until
19
+ * the next window opens.
20
+ */
21
+ export declare const acquireRateSlots: (store: DataStore, scopes: ReadonlyArray<RateScope>, now: number, windowMs?: number) => Promise<RateAcquireResult>;
22
+
23
+ /** What `recordDeliveryOutcome` did — surfaced so the workflow can log
24
+ * the auto-disable transition. */
25
+ export declare interface AutoDisableOutcome {
26
+ /** The streak count AFTER this outcome was recorded. */
27
+ readonly consecutiveFailures: number;
28
+ /** True when THIS call flipped the target to `active=false`. */
29
+ readonly autoDisabled: boolean;
30
+ }
31
+
32
+ export declare const buildDeliverWebhookExecute: (ctx: AppContext, options?: DeliverWorkflowOptions) => (input: DeliverInput, _executionId: string) => Effect.Effect<{
33
+ finalStatus: "failed";
34
+ attempts: number;
35
+ } | {
36
+ finalStatus: "deferred";
37
+ attempts: number;
38
+ } | {
39
+ finalStatus: "succeeded";
40
+ attempts: number;
41
+ }, never, WorkflowEngine | WorkflowInstance>;
42
+
43
+ /**
44
+ * Build a `WebhooksServiceShape` that uses the in-process store
45
+ * directly + an injected `trigger` for delivery. The `trigger` is
46
+ * the seam where the framework wires the actual @effect/workflow
47
+ * runner (stage 4). For tests and the very first dev-mode loop we
48
+ * accept a simple async function that resolves when the workflow
49
+ * has been kicked off (NOT when it completes).
50
+ */
51
+ export declare const buildWebhooksService: (ctx: AppContext, trigger: (input: {
52
+ readonly deliveryId: string;
53
+ readonly targetId: string;
54
+ readonly event: string;
55
+ readonly eventId: string;
56
+ readonly payloadJson: string;
57
+ readonly attemptEpoch?: number;
58
+ }) => Promise<void>, options?: WebhooksServiceOptions) => WebhooksServiceShape;
59
+
60
+ export declare const compareVersions: (targetVersion: number, eventVersion: number) => VersionState;
61
+
62
+ /** Consume ONE slot from the (key, bucket) window. Returns `true`
63
+ * when a slot was atomically claimed, `false` when the window is
64
+ * full. Throws only on pathological contention (transient).
65
+ *
66
+ * Ensure-then-CAS: the window row is created at `count: 0` via
67
+ * `insertIgnore` over the deterministic PK (so the create race is
68
+ * harmless — every racer converges on ONE row), and every slot claim
69
+ * is a conditional increment (`count = read` in the WHERE). With a
70
+ * deterministic key the billing-style "my minted id came back"
71
+ * ownership check can't disambiguate concurrent creators, so the
72
+ * insert path never claims a slot directly. */
73
+ export declare const consumeRateSlot: (store: DataStore, scope: RateScope, bucket: number) => Promise<boolean>;
74
+
75
+ export declare interface CustomSignatureScheme {
76
+ readonly _tag: 'custom';
77
+ readonly header: string;
78
+ readonly sign: (rawBody: Uint8Array, secret: string) => string;
79
+ readonly verify: (rawBody: Uint8Array, secret: string, signatureHeader: string) => boolean;
80
+ }
81
+
82
+ /** Default URL path for an incoming webhook when the descriptor
83
+ * doesn't override. Stage 4's discovery walker uses this to
84
+ * register routes. */
85
+ export declare const defaultIncomingPath: (webhookId: string) => `/${string}`;
86
+
87
+ /** Generic HMAC-SHA-256 — sensible default for new outgoing webhooks. */
88
+ export declare const defaultOutgoingSignature: () => HmacSignatureScheme;
89
+
90
+ /** Sensible default for new outgoing subscriptions: exponential, 8
91
+ * attempts, 5s → 1h. Roughly: 5s, 10s, 20s, 40s, 80s, 160s, 320s,
92
+ * 640s. Total ~17 minutes before giving up. */
93
+ export declare const defaultRetryPolicy: () => RetryPolicy;
94
+
95
+ export declare const defineIncomingWebhook: <Body>(spec: Omit<IncomingWebhookDescriptor<Body>, "_tag">) => IncomingWebhookDescriptor<Body>;
96
+
97
+ export declare const defineOutgoingEvent: <Payload>(spec: Omit<OutgoingEventDescriptor<Payload>, "_tag">) => OutgoingEventDescriptor<Payload>;
98
+
99
+ export declare const defineWebhookProvider: (spec: Omit<WebhookProviderDescriptor, "_tag">) => WebhookProviderDescriptor;
100
+
101
+ /**
102
+ * Build the workflow body. `ctx` carries the per-request store +
103
+ * runtime services — passed in by the framework when it registers
104
+ * the workflow's `.toLayer(...)`.
105
+ *
106
+ * The body's structure is intentionally LINEAR — one for-loop with
107
+ * activities marking each checkpoint. Activity caching means a
108
+ * crash mid-loop resumes at the next un-cached activity.
109
+ */
110
+ declare interface DeliverInput {
111
+ readonly deliveryId: string;
112
+ readonly targetId: string;
113
+ readonly event: string;
114
+ readonly eventId: string;
115
+ readonly payloadJson: string;
116
+ readonly attemptEpoch?: number;
117
+ }
118
+
119
+ export declare const deliverWebhookWorkflow: Workflow.Workflow<"voltro.deliverWebhook", Workflow.AnyStructSchema | Schema.Struct<Schema.Struct.Fields>, Schema.Struct<{
120
+ finalStatus: Schema.Literal<["succeeded", "failed", "deferred"]>;
121
+ attempts: typeof Schema.Number;
122
+ }>, typeof Schema.Never>;
123
+
124
+ declare interface DeliverWorkflowOptions {
125
+ /** Discovered `defineOutgoingEvent` descriptors — the workflow reads
126
+ * the emitted event's `globalRateLimit` from here. Absent events
127
+ * simply have no global limit. */
128
+ readonly events?: ReadonlyArray<OutgoingEventDescriptor<unknown>>;
129
+ /** The fixed window backing "per minute" — injectable for tests
130
+ * (production uses the 60s default). */
131
+ readonly rateWindowMs?: number;
132
+ }
133
+
134
+ /** Compact duration literal — parsed without a deps-pulling library.
135
+ * Covers ms / s / m / h / d. Default seconds (no suffix). */
136
+ export declare type DurationLiteral = `${number}${'ms' | 's' | 'm' | 'h' | 'd'}` | `${number}`;
137
+
138
+ export declare interface EmitResult {
139
+ /** Stable id matching the row(s) the deliverWebhook workflow
140
+ * writes to `_voltro_webhook_deliveries`. Use as the foreign
141
+ * key when joining a domain event to its webhook deliveries. */
142
+ readonly eventId: string;
143
+ /** Per-target deliveries. Empty when no target matched the
144
+ * filter. `'dispatched'` — a delivery workflow run was kicked
145
+ * off; `'queued'` — the target is paused, so the delivery was
146
+ * written as a `status='pending'` row that `resumeTarget`
147
+ * flushes. */
148
+ readonly deliveries: ReadonlyArray<{
149
+ readonly targetId: string;
150
+ readonly deliveryId: string;
151
+ readonly status: 'dispatched' | 'queued';
152
+ }>;
153
+ }
154
+
155
+ export declare interface EncodedPayload {
156
+ readonly contentType: string;
157
+ readonly bytes: Uint8Array;
158
+ }
159
+
160
+ /** Re-encode the JSON-stringified payload into the target's wire
161
+ * format. Throws `WebhookPayloadUnrepresentable` when the shape can't
162
+ * be honestly represented in the requested format. */
163
+ export declare const encodePayload: (format: WireFormat, payloadJson: string) => EncodedPayload;
164
+
165
+ /** Aggressive policy — retry every 30s for an hour. Use for a
166
+ * downstream that's expected to come back quickly. */
167
+ export declare const fastRetryPolicy: () => RetryPolicy;
168
+
169
+ export declare const generateSecret: () => string;
170
+
171
+ export declare const getIdempotencyCache: () => IdempotencyCache;
172
+
173
+ /** GitHub-style: `X-Hub-Signature-256: sha256=hex` (no timestamp). */
174
+ export declare const githubSignature: () => HmacSignatureScheme;
175
+
176
+ export declare type HmacAlgorithm = 'hmacSha256' | 'hmacSha1';
177
+
178
+ export declare interface HmacSignatureScheme {
179
+ readonly _tag: 'hmac';
180
+ readonly algorithm: HmacAlgorithm;
181
+ /** Header the signature is written to / read from. Stripe uses
182
+ * `Stripe-Signature`, GitHub uses `X-Hub-Signature-256`, generic
183
+ * apps use `X-Webhook-Signature`. */
184
+ readonly header: string;
185
+ /** When `true`, the signed payload is `<timestamp>.<rawBody>` and
186
+ * the header carries both (`t=...,v1=...`). Recipients reject
187
+ * signatures whose `t` is older than `replayWindowSeconds`. */
188
+ readonly includeTimestamp: boolean;
189
+ /** Window during which a signed payload is replay-safe. Older
190
+ * signatures get rejected. Default 5 minutes. Only relevant when
191
+ * `includeTimestamp: true`. */
192
+ readonly replayWindowSeconds?: number;
193
+ /** Encoding the signature is rendered in. `hex` is most common;
194
+ * Slack uses `hex` prefixed with `v0=`. */
195
+ readonly encoding?: 'hex' | 'base64';
196
+ /** Prefix written before the hex/base64 signature in the header
197
+ * value. Slack: `v0=`. GitHub: `sha256=`. Default empty. */
198
+ readonly versionPrefix?: string;
199
+ /** Optional secret-rotation hint: a SECOND secret accepted during
200
+ * verification but never used for outgoing signing. Lets you
201
+ * rotate the primary secret without breaking in-flight inbound
202
+ * deliveries. */
203
+ readonly previousSecret?: string;
204
+ }
205
+
206
+ /**
207
+ * In-process LRU cache for idempotency keys. Map preserves insertion
208
+ * order, so eviction = `entries().next().value` (the oldest key).
209
+ *
210
+ * `claim(key, ttlMs)` returns one of:
211
+ * - `'fresh'` — first time we've seen this key; handler runs.
212
+ * - `'inflight'` — another concurrent request claimed it but
213
+ * hasn't completed. Receiver should 409 / 425.
214
+ * - `'duplicate'` — already processed within TTL. Receiver
215
+ * should 200 OK (idempotent re-acknowledge).
216
+ *
217
+ * After the handler completes, call `commit(key)` to flip the
218
+ * 'inflight' marker to 'processed'. On handler failure, call
219
+ * `release(key)` to drop the inflight marker so a retry can run.
220
+ */
221
+ export declare class IdempotencyCache {
222
+ private readonly capacity;
223
+ private readonly cache;
224
+ constructor(capacity?: number);
225
+ private evictExpired;
226
+ claim(key: string, ttlMs: number): 'fresh' | 'inflight' | 'duplicate';
227
+ commit(key: string): void;
228
+ release(key: string): void;
229
+ size(): number;
230
+ /** Test helper. */
231
+ clear(): void;
232
+ }
233
+
234
+ export declare interface IncomingLogRecord {
235
+ readonly webhookId: string;
236
+ readonly status: number;
237
+ readonly signatureOk: boolean | 'skipped';
238
+ readonly idempotency: 'fresh' | 'inflight' | 'duplicate' | 'skipped';
239
+ readonly durationMs: number;
240
+ readonly errorMessage?: string;
241
+ }
242
+
243
+ export declare interface IncomingRequest {
244
+ readonly method: string;
245
+ readonly path: string;
246
+ readonly headers: Readonly<Record<string, string>>;
247
+ readonly rawBody: Uint8Array;
248
+ }
249
+
250
+ export declare interface IncomingResponse {
251
+ readonly status: number;
252
+ readonly contentType: string;
253
+ readonly body: string;
254
+ readonly headers?: Readonly<Record<string, string>>;
255
+ }
256
+
257
+ export declare interface IncomingWebhookContext<Body> {
258
+ /** The fully-decoded body, validated against `payload`. */
259
+ readonly body: Body;
260
+ /** Raw bytes — needed to recompute signatures. Already used by
261
+ * the framework's middleware to verify the inbound signature
262
+ * before this handler runs; passed along in case the user wants
263
+ * to compute additional MAC's for downstream relays. */
264
+ readonly rawBody: Uint8Array;
265
+ /** Request headers (lowercased keys). The middleware has already
266
+ * consumed signature / idempotency headers. */
267
+ readonly headers: Readonly<Record<string, string>>;
268
+ /** Idempotency key extracted by the middleware (provider-specific
269
+ * extraction — Stripe uses `Stripe-Signature`'s `t=`, GitHub uses
270
+ * `X-GitHub-Delivery`, generic uses `Idempotency-Key`). */
271
+ readonly idempotencyKey: string;
272
+ /** Workflow facade supplied by the framework runtime. Use this for
273
+ * verified external incoming calls that should start or signal
274
+ * durable workflows after signature + idempotency checks pass. */
275
+ readonly workflows?: IncomingWorkflowFacade;
276
+ }
277
+
278
+ export declare interface IncomingWebhookDescriptor<Body> {
279
+ readonly _tag: 'incomingWebhook';
280
+ readonly id: WebhookId;
281
+ /** URL path the framework mounts the route on. Defaults to
282
+ * `/webhooks/<id>` if absent. Use a custom path for legacy
283
+ * integrations (`/integrations/stripe/v1`). */
284
+ readonly path?: `/${string}`;
285
+ /** Signature scheme used to authenticate inbound requests. Reject
286
+ * on mismatch with a 401. `undefined` (the default) means NO
287
+ * verification — only acceptable when behind a separate trust
288
+ * boundary (gateway + IP allow-list). The dashboard surfaces a
289
+ * warning for unsigned incoming webhooks. */
290
+ readonly signature?: SignatureScheme;
291
+ /** Idempotency key extraction. Default `'Idempotency-Key'` header.
292
+ * Provider-templates override this to match the provider's wire
293
+ * format. */
294
+ readonly idempotency?: {
295
+ /** Header name OR a function that reads from headers/body. */
296
+ readonly from: string | ((headers: Readonly<Record<string, string>>, rawBody: Uint8Array) => string | undefined);
297
+ /** TTL the framework retains the key for de-dup. Default 7 days. */
298
+ readonly ttl?: '5m' | '1h' | '6h' | '1d' | '7d' | '30d';
299
+ };
300
+ /** Validated body schema. The framework decodes the request body
301
+ * AFTER signature verification, BEFORE the handler runs. */
302
+ readonly payload: Schema.Schema<Body>;
303
+ /** Body parser — JSON by default. Stripe / GitHub send `application/json`
304
+ * but other providers use `application/x-www-form-urlencoded`. */
305
+ readonly bodyType?: 'json' | 'form' | 'raw';
306
+ /** Provider preset — when set, the framework fills `signature` +
307
+ * `idempotency` + `bodyType` from the provider's known shape.
308
+ * Explicit fields above always win. */
309
+ readonly provider?: WebhookProviderDescriptor;
310
+ /** Typed handler. Returns void on success, throws to reject. The
311
+ * HTTP status is 2xx for success, 4xx for typed validation
312
+ * errors, 5xx for handler exceptions. The framework retries
313
+ * 5xx-classified failures via the provider's expected behavior
314
+ * (most providers retry their own POST on 5xx). */
315
+ readonly handler: (context: IncomingWebhookContext<Body>) => Promise<void> | void;
316
+ }
317
+
318
+ export declare interface IncomingWorkflowFacade {
319
+ start<Payload = unknown>(workflowName: string, payload: Payload): Promise<{
320
+ readonly id: string;
321
+ readonly workflowName: string;
322
+ readonly executionId: string;
323
+ readonly status: 'running';
324
+ }>;
325
+ signal(target: {
326
+ readonly id?: string;
327
+ readonly executionId?: string;
328
+ readonly workflowName?: string;
329
+ }, signalName: string, payload?: unknown): Promise<{
330
+ readonly eventId: string;
331
+ }>;
332
+ update(target: {
333
+ readonly id?: string;
334
+ readonly executionId?: string;
335
+ readonly workflowName?: string;
336
+ }, updateName: string, payload?: unknown, options?: {
337
+ readonly timeoutMs?: number;
338
+ readonly pollIntervalMs?: number;
339
+ }): Promise<{
340
+ readonly eventId: string;
341
+ readonly updateId: string;
342
+ readonly completedEventId: string;
343
+ readonly result: unknown;
344
+ }>;
345
+ }
346
+
347
+ /** Type guard for file-walker discovery — every `*.webhook.tsx`
348
+ * must default-export one of these. */
349
+ export declare const isWebhookDescriptor: (value: unknown) => value is WebhookDescriptor;
350
+
351
+ /** Evaluate a target's filter against a payload. Returns `true`
352
+ * when the target should receive this delivery. Supports the
353
+ * simple key-path form `'payload.field': value` for v1; future
354
+ * versions can grow operators ({ gt, lt, in, … }). */
355
+ export declare const matchesFilter: (filter: Readonly<Record<string, unknown>> | null, payload: unknown) => boolean;
356
+
357
+ /**
358
+ * Build a request handler for a specific `IncomingWebhookDescriptor`.
359
+ * The returned function is what stage 4 mounts on the HTTP layer.
360
+ */
361
+ export declare const mountIncomingWebhook: <Body>(descriptor: IncomingWebhookDescriptor<Body>, options: MountOptions) => (request: IncomingRequest) => Promise<IncomingResponse>;
362
+
363
+ export declare interface MountOptions {
364
+ /** Resolves the per-webhook signing secret. Return `null` to
365
+ * skip signature verification (a warning logs to the framework
366
+ * logger; the user has explicitly opted out). */
367
+ readonly resolveSecret: (webhookId: string) => Promise<string | null>;
368
+ /** Optional idempotency cache override. Defaults to the
369
+ * process-singleton LRU. Pass a custom cache for tests or for a
370
+ * shared (Redis-backed) cache across processes. */
371
+ readonly idempotencyCache?: IdempotencyCache;
372
+ /** Optional structured logger — receives `{ webhookId, event,
373
+ * status, durationMs, signatureOk, idempotencyResult }` per
374
+ * request. Defaults to a no-op so unit tests don't print noise. */
375
+ readonly log?: (record: IncomingLogRecord) => void;
376
+ /** Optional workflow facade. Resolved lazily so host runtimes can
377
+ * mount incoming routes before workflow layers finish booting. */
378
+ readonly resolveWorkflows?: () => IncomingWorkflowFacade | undefined;
379
+ }
380
+
381
+ /** Compute the next-retry decision given the policy + the attempt
382
+ * number that just failed (1-indexed: `attempt=1` after the first
383
+ * try) + optional recipient hints from the failed response. Returns
384
+ * `null` when no more retries are allowed. */
385
+ export declare const nextRetry: (policy: RetryPolicy, attempt: number, lastResponse?: {
386
+ readonly status?: number;
387
+ readonly retryAfterSeconds?: number;
388
+ }) => RetryDecision | null;
389
+
390
+ export declare interface OutgoingEventDescriptor<Payload> {
391
+ readonly _tag: 'outgoingEvent';
392
+ readonly id: WebhookId;
393
+ /** Human-readable summary surfaced in the dashboard's events list. */
394
+ readonly description?: string;
395
+ /** Payload schema — drives JSON-Schema export for the dashboard's
396
+ * "view example" affordance and the typed `emit()` call site
397
+ * (pass the descriptor itself to `emit(descriptor, payload)` to
398
+ * type the payload; either way `emit` DECODES the payload against
399
+ * this schema and rejects a mismatch with `WebhookPayloadInvalid`
400
+ * before any delivery is created). Recipients receive
401
+ * `{ event, eventId, occurredAt, payload }` with `payload`
402
+ * matching this schema. */
403
+ readonly payload: Schema.Schema<Payload>;
404
+ /** Schema version. Increment when the payload shape changes in a
405
+ * way subscribers must adapt to. The dashboard surfaces version
406
+ * divergence per-target. New subscriptions pin to this version by
407
+ * default. Default 1. */
408
+ readonly version?: number;
409
+ /** Default retry policy for new subscriptions of this event —
410
+ * applied when the subscriber doesn't pass `retry` explicitly
411
+ * (explicit wins, then this, then the package default). */
412
+ readonly defaultRetry?: RetryPolicy;
413
+ /** Default signing scheme for new subscriptions of this event —
414
+ * applied when the subscriber doesn't pass `signing` explicitly
415
+ * (explicit wins, then this, then HMAC-SHA-256 with a generated
416
+ * 32-byte secret). */
417
+ readonly defaultSigning?: SignatureScheme;
418
+ /** Rate-limit ceiling that ALL deliveries of this event share —
419
+ * protects against a runaway emit loop. Enforced by the delivery
420
+ * workflow as a fixed-window counter at the shared store (holds
421
+ * across replicas); over-limit deliveries are DEFERRED to the
422
+ * next window (parked as `status='pending'` rows), never dropped.
423
+ * Per-target rate-limits are configured at subscribe time. */
424
+ readonly globalRateLimit?: {
425
+ readonly perMinute: number;
426
+ };
427
+ }
428
+
429
+ export declare const parseDuration: (literal: DurationLiteral) => number;
430
+
431
+ export declare const parseTtl: (literal: keyof typeof TTL_MS | undefined) => number;
432
+
433
+ /** The fixed window backing "per minute". Injectable in
434
+ * `buildDeliverWebhookExecute` options for tests; production always
435
+ * uses the default. */
436
+ export declare const RATE_WINDOW_MS = 60000;
437
+
438
+ export declare const RATE_WINDOW_TABLE = "_voltro_webhook_rate_windows";
439
+
440
+ export declare interface RateAcquireResult {
441
+ readonly acquired: boolean;
442
+ /** When `acquired === false`: milliseconds until the next window
443
+ * opens — the caller durable-sleeps this long, then re-acquires.
444
+ * `0` when acquired. */
445
+ readonly retryInMs: number;
446
+ }
447
+
448
+ /** One rate-limit scope a delivery must hold a slot in before POSTing.
449
+ * `target:<targetId>` for the per-target limit, `event:<eventId>` for
450
+ * an event's global limit. */
451
+ export declare interface RateScope {
452
+ readonly key: string;
453
+ /** Max deliveries admitted per window for this scope. */
454
+ readonly limit: number;
455
+ }
456
+
457
+ /**
458
+ * Record a terminal delivery outcome against the target's failure
459
+ * streak. `succeeded === true` resets the streak to 0; otherwise it
460
+ * CAS-increments and auto-disables when the streak reaches
461
+ * `autoDisableAfter` (a positive integer; null/≤0 disables the
462
+ * feature). `reason` is the terminal failure detail stamped onto the
463
+ * target when the auto-disable fires. Returns the post-write streak +
464
+ * whether THIS call auto-disabled.
465
+ */
466
+ export declare const recordDeliveryOutcome: (store: DataStore, targetId: string, succeeded: boolean, reason: string, now?: Date) => Promise<AutoDisableOutcome>;
467
+
468
+ /** Refund a slot claimed by `consumeRateSlot` — used when a later scope
469
+ * in the same acquisition denies, so a deferred delivery doesn't burn
470
+ * window budget it never used. */
471
+ export declare const releaseRateSlot: (store: DataStore, key: string, bucket: number) => Promise<void>;
472
+
473
+ /** Apply defaults + freeze the resolved target descriptor. Pure.
474
+ *
475
+ * Default resolution is three-tiered: the subscriber's explicit
476
+ * value wins, then the event descriptor's per-event defaults
477
+ * (`defaultSigning` / `defaultRetry` / `version` from
478
+ * `defineOutgoingEvent`), then the package-global defaults. */
479
+ export declare const resolveSubscribe: (input: SubscribeInput, event?: OutgoingEventDescriptor<unknown>) => {
480
+ readonly id: string;
481
+ readonly event: string;
482
+ readonly url: string;
483
+ readonly secret: string;
484
+ readonly signing: SignatureScheme;
485
+ readonly retry: RetryPolicy;
486
+ readonly filter: Readonly<Record<string, unknown>> | null;
487
+ readonly headers: Readonly<Record<string, string>> | null;
488
+ readonly rateLimitPerMinute: number | null;
489
+ readonly active: boolean;
490
+ readonly format: "json" | "form" | "xml";
491
+ readonly autoDisableAfter: number | null;
492
+ readonly consecutiveFailures: number;
493
+ readonly autoDisabledAt: Date | null;
494
+ readonly autoDisableReason: string | null;
495
+ readonly payloadVersion: number;
496
+ readonly description: string | null;
497
+ };
498
+
499
+ export declare interface RetryDecision {
500
+ /** Milliseconds to sleep before the next attempt. */
501
+ readonly delayMs: number;
502
+ /** The strategy-computed delay before jitter was applied — surfaced
503
+ * for log lines like `next attempt 1.2s (base 1.0s + jitter)`. */
504
+ readonly baseDelayMs: number;
505
+ }
506
+
507
+ export declare interface RetryPolicy {
508
+ readonly strategy: RetryStrategy;
509
+ /** Max total attempts including the first. After this, the
510
+ * delivery is marked `failed` permanently. Default 8 attempts
511
+ * with exponential gives ~8.5h of total wait before giving up. */
512
+ readonly maxAttempts: number;
513
+ /** First-retry delay. Subsequent delays derive from `strategy`. */
514
+ readonly initialDelay: DurationLiteral;
515
+ /** Cap on any single delay. Hard-stops exponential growth. */
516
+ readonly maxDelay: DurationLiteral;
517
+ /** HTTP status codes that trigger a retry. Anything outside this
518
+ * list is a permanent failure (won't retry). Default covers the
519
+ * conservative "transient" set: 408 / 425 / 429 / 5xx. */
520
+ readonly retryOn?: ReadonlyArray<number>;
521
+ /** When the recipient returns `Retry-After` (HTTP standard), honour
522
+ * it instead of computing our own delay. Default true. */
523
+ readonly honourRetryAfter?: boolean;
524
+ /** Jitter mode applied on top of computed delay. `none` is
525
+ * deterministic (useful for tests); `full` is the cheapest
526
+ * thundering-herd guard for many subscribers retrying together. */
527
+ readonly jitter?: 'none' | 'full';
528
+ }
529
+
530
+ export declare type RetryStrategy = 'fixed' | 'linear' | 'exponential';
531
+
532
+ export declare type SignatureScheme = HmacSignatureScheme | CustomSignatureScheme;
533
+
534
+ export declare interface SignedPayload {
535
+ /** Header value to set on the outbound request. */
536
+ readonly headerValue: string;
537
+ /** Header name from the scheme. */
538
+ readonly headerName: string;
539
+ /** Timestamp embedded in the header (when `includeTimestamp`).
540
+ * Surfaced so callers can log it alongside delivery records. */
541
+ readonly timestamp?: number;
542
+ }
543
+
544
+ /** Produce the signature header value for an outbound request. */
545
+ export declare const signPayload: (scheme: SignatureScheme, rawBody: Uint8Array, secret: string) => SignedPayload;
546
+
547
+ /** Slack-style: `X-Slack-Signature: v0=hex` with `X-Slack-Request-Timestamp` separate.
548
+ * Slack's actual header layout is non-standard — the timestamp lives
549
+ * in a SECOND header rather than embedded in the signature header.
550
+ * We model it via a custom scheme that reads both headers in `verify`. */
551
+ export declare const slackSignature: () => CustomSignatureScheme;
552
+
553
+ /** Stripe-style: `Stripe-Signature: t=NNN,v1=hex`, 5-min replay window. */
554
+ export declare const stripeSignature: (header?: string) => HmacSignatureScheme;
555
+
556
+ export declare interface SubscribeInput {
557
+ readonly event: string;
558
+ readonly url: string;
559
+ /** Secret used to sign deliveries to this target. Auto-generated
560
+ * when omitted (32-byte hex). The caller receives it ONCE in the
561
+ * return value — store it if you want to display it again later. */
562
+ readonly secret?: string;
563
+ readonly signing?: SignatureScheme;
564
+ readonly retry?: RetryPolicy;
565
+ /** Optional predicate filter — only emits whose payload matches
566
+ * this predicate fan-out to this target. The shape mirrors the
567
+ * schema-builder's `Predicate` from `@voltro/database`. */
568
+ readonly filter?: Readonly<Record<string, unknown>>;
569
+ readonly headers?: Readonly<Record<string, string>>;
570
+ readonly rateLimitPerMinute?: number;
571
+ readonly format?: 'json' | 'form' | 'xml';
572
+ /** Auto-disable the target after this many CONSECUTIVE terminal
573
+ * delivery failures (dead-letter guard). Omit / `undefined` to
574
+ * leave it OFF. Must be a positive integer when set. */
575
+ readonly autoDisableAfter?: number;
576
+ readonly payloadVersion?: number;
577
+ readonly description?: string;
578
+ }
579
+
580
+ export declare interface SubscribeResult {
581
+ readonly id: string;
582
+ readonly event: string;
583
+ readonly url: string;
584
+ /** Secret. Surfaced ONLY at subscribe time. The dashboard's
585
+ * rotate-secret UI returns the new secret here; subsequent reads
586
+ * via `getTarget(id)` redact it. */
587
+ readonly secret: string;
588
+ readonly signing: SignatureScheme;
589
+ readonly retry: RetryPolicy;
590
+ }
591
+
592
+ export declare const TARGETS_TABLE = "_voltro_webhook_targets";
593
+
594
+ export declare interface TargetSummary {
595
+ readonly id: string;
596
+ readonly event: string;
597
+ readonly url: string;
598
+ readonly active: boolean;
599
+ readonly rateLimitPerMinute: number | null;
600
+ readonly description: string | null;
601
+ readonly payloadVersion: number;
602
+ /** Auto-disable threshold (`null` = OFF). */
603
+ readonly autoDisableAfter: number | null;
604
+ /** Current consecutive terminal-failure streak. */
605
+ readonly consecutiveFailures: number;
606
+ /** When this target was auto-disabled (`null` = not auto-disabled).
607
+ * A non-null value + `active:false` distinguishes an auto-disabled
608
+ * target from a manually-paused one on the dashboard. */
609
+ readonly autoDisabledAt: string | null;
610
+ /** The terminal failure reason that tripped the auto-disable. */
611
+ readonly autoDisableReason: string | null;
612
+ }
613
+
614
+ declare const TTL_MS: Record<string, number>;
615
+
616
+ /**
617
+ * Convenience accessor for handlers. The framework's `AppContext`
618
+ * types the `webhooks` slot as `unknown` to avoid a circular dep
619
+ * between `@voltro/runtime` and `@voltro/plugin-webhooks`; this
620
+ * helper performs the structural cast and throws if the plugin
621
+ * isn't active.
622
+ *
623
+ * import { useWebhooks } from '@voltro/plugin-webhooks'
624
+ * const execute = async (input, ctx) => {
625
+ * const webhooks = useWebhooks(ctx)
626
+ * await webhooks.emit('order.completed', { orderId: input.id })
627
+ * }
628
+ */
629
+ export declare const useWebhooks: (ctx: {
630
+ readonly webhooks?: unknown;
631
+ }) => WebhooksServiceShape;
632
+
633
+ export declare const useWebhooksEffect: Effect.Effect<WebhooksServiceShape, never, WebhooksService>;
634
+
635
+ /** Validate subscribe input. Throws on policy violations so the
636
+ * caller's catch block can surface a 422-style error. */
637
+ export declare const validateSubscribe: (input: SubscribeInput) => void;
638
+
639
+ export declare type VerifyResult = {
640
+ readonly ok: true;
641
+ } | {
642
+ readonly ok: false;
643
+ readonly reason: string;
644
+ };
645
+
646
+ /** Constant-time signature verification for an incoming request.
647
+ * Returns the structured result so callers can attach the
648
+ * specific reason to a 401 response (helpful in dev, OK to elide
649
+ * in prod). */
650
+ export declare const verifySignature: (scheme: SignatureScheme, rawBody: Uint8Array, secret: string, receivedHeaderValue: string | undefined) => VerifyResult;
651
+
652
+ /** Compare a target's pinned `payloadVersion` against the event's
653
+ * current `version`. Three states:
654
+ *
655
+ * - `current` — version match. Target sees the current shape.
656
+ * - `behind` — target.payloadVersion < event.version. The
657
+ * consumer is reading an older shape; the dashboard
658
+ * surfaces a warning + "Re-pin" affordance.
659
+ * - `ahead` — target.payloadVersion > event.version. Rare —
660
+ * usually a config error (someone re-pinned to a
661
+ * not-yet-released version). The dashboard flags
662
+ * this as a config-issue, not a normal divergence.
663
+ */
664
+ export declare type VersionState = 'current' | 'behind' | 'ahead';
665
+
666
+ /**
667
+ * Thrown by `WebhooksService.replay` when no delivery row exists for the
668
+ * given `deliveryId` — the row can't be re-triggered because the
669
+ * original payload + target are unknown.
670
+ */
671
+ export declare class WebhookDeliveryNotFound extends WebhookDeliveryNotFound_base {
672
+ }
673
+
674
+ declare const WebhookDeliveryNotFound_base: Schema.TaggedErrorClass<WebhookDeliveryNotFound, "WebhookDeliveryNotFound", {
675
+ readonly _tag: Schema.tag<"WebhookDeliveryNotFound">;
676
+ } & {
677
+ /** The delivery id that did not resolve to a row. */
678
+ deliveryId: typeof Schema.String;
679
+ }>;
680
+
681
+ export declare type WebhookDescriptor = OutgoingEventDescriptor<unknown> | IncomingWebhookDescriptor<unknown> | WebhookProviderDescriptor;
682
+
683
+ /** Stable identifier for an event or webhook. Drives the database
684
+ * primary key, the inspect endpoint URL, the dashboard listing. Use
685
+ * dotted-camelCase (`order.completed`, `user.signedUp`). */
686
+ export declare type WebhookId = string;
687
+
688
+ /**
689
+ * Thrown by `WebhooksService.emit` when the payload does not decode
690
+ * against the outgoing event's declared `payload` schema (from its
691
+ * `defineOutgoingEvent` descriptor). The emit is rejected BEFORE any
692
+ * delivery row is written or workflow triggered — a schema-violating
693
+ * payload never reaches a subscriber.
694
+ */
695
+ export declare class WebhookPayloadInvalid extends WebhookPayloadInvalid_base {
696
+ }
697
+
698
+ declare const WebhookPayloadInvalid_base: Schema.TaggedErrorClass<WebhookPayloadInvalid, "WebhookPayloadInvalid", {
699
+ readonly _tag: Schema.tag<"WebhookPayloadInvalid">;
700
+ } & {
701
+ /** The outgoing event id the payload was emitted for. */
702
+ event: typeof Schema.String;
703
+ /** Tree-formatted schema decode issues (one line per violation). */
704
+ issues: typeof Schema.String;
705
+ }>;
706
+
707
+ /**
708
+ * Raised by the delivery workflow's wire-encoder when the target's
709
+ * `format` (`form` | `xml`) cannot honestly represent the payload
710
+ * shape — e.g. a `form` target whose payload is a bare array/scalar
711
+ * (form encoding is a flat key=value list with no top-level array
712
+ * representation), or an `xml` target whose object key is not a valid
713
+ * XML element name. Surfaced BEFORE any wire POST: the delivery row is
714
+ * written `failed` with this reason, so a mis-formatted target never
715
+ * silently ships JSON bytes under a wrong content-type.
716
+ */
717
+ export declare class WebhookPayloadUnrepresentable extends WebhookPayloadUnrepresentable_base {
718
+ }
719
+
720
+ declare const WebhookPayloadUnrepresentable_base: Schema.TaggedErrorClass<WebhookPayloadUnrepresentable, "WebhookPayloadUnrepresentable", {
721
+ readonly _tag: Schema.tag<"WebhookPayloadUnrepresentable">;
722
+ } & {
723
+ /** The wire format that could not represent the payload. */
724
+ format: Schema.Literal<["json", "form", "xml"]>;
725
+ /** Human-readable detail — which shape/key defeated the encoder. */
726
+ reason: typeof Schema.String;
727
+ }>;
728
+
729
+ /**
730
+ * Thrown by `WebhooksService.updateTargetPayloadVersion` when the
731
+ * requested version is not a positive integer.
732
+ */
733
+ export declare class WebhookPayloadVersionInvalid extends WebhookPayloadVersionInvalid_base {
734
+ }
735
+
736
+ declare const WebhookPayloadVersionInvalid_base: Schema.TaggedErrorClass<WebhookPayloadVersionInvalid, "WebhookPayloadVersionInvalid", {
737
+ readonly _tag: Schema.tag<"WebhookPayloadVersionInvalid">;
738
+ } & {
739
+ /** The rejected version value (as supplied by the caller). */
740
+ version: typeof Schema.Number;
741
+ }>;
742
+
743
+ export declare interface WebhookProviderDescriptor {
744
+ readonly _tag: 'webhookProvider';
745
+ readonly id: string;
746
+ /** Display name for the dashboard. */
747
+ readonly name: string;
748
+ readonly signature: SignatureScheme;
749
+ readonly idempotency: NonNullable<IncomingWebhookDescriptor<unknown>['idempotency']>;
750
+ readonly bodyType: 'json' | 'form' | 'raw';
751
+ /** Optional discriminator: provider-specific event-type extraction
752
+ * (e.g. Stripe's top-level `type` field). Used by typed handlers
753
+ * to narrow on payload variant. */
754
+ readonly eventTypeFrom?: (body: unknown) => string | undefined;
755
+ }
756
+
757
+ export declare class WebhooksService extends WebhooksService_base {
758
+ }
759
+
760
+ declare const WebhooksService_base: Context.TagClass<WebhooksService, "@voltro/webhooks/WebhooksService", WebhooksServiceShape>;
761
+
762
+ export declare interface WebhooksServiceOptions {
763
+ /** Discovered `defineOutgoingEvent` descriptors. When present,
764
+ * `subscribe` resolves the event's `defaultSigning` /
765
+ * `defaultRetry` / `version` before the package-global defaults,
766
+ * and `emit` decodes the payload against the event's schema. */
767
+ readonly events?: ReadonlyArray<OutgoingEventDescriptor<unknown>>;
768
+ }
769
+
770
+ export declare interface WebhooksServiceShape {
771
+ readonly subscribe: (input: SubscribeInput) => Promise<SubscribeResult>;
772
+ /** Emit an event to every subscribed target. Accepts the event id
773
+ * OR the `defineOutgoingEvent` descriptor itself — passing the
774
+ * descriptor types `payload` against its schema at the call site.
775
+ * Either way, when the descriptor is known (directly or via the
776
+ * discovered-events registry) the payload is DECODED against its
777
+ * schema and a mismatch throws `WebhookPayloadInvalid` before any
778
+ * delivery is created. */
779
+ readonly emit: <P>(event: string | OutgoingEventDescriptor<P>, payload: P) => Promise<EmitResult>;
780
+ /** Manual re-trigger for the dashboard's "Replay" button on a
781
+ * failed delivery row. Re-runs the workflow at attempt 1 with
782
+ * the original payload. */
783
+ readonly replay: (deliveryId: string) => Promise<void>;
784
+ /** List currently-subscribed targets for an event, or all when
785
+ * `event` is omitted. The dashboard's "Targets" page consumes
786
+ * this. */
787
+ readonly listTargets: (event?: string) => Promise<ReadonlyArray<TargetSummary>>;
788
+ /** Soft-disable a target without deleting the row. While paused,
789
+ * emits against this target queue under
790
+ * `_voltro_webhook_deliveries` with status='pending' (no POST
791
+ * happens); `resumeTarget` flushes them. */
792
+ readonly pauseTarget: (targetId: string) => Promise<void>;
793
+ /** Re-enable a paused target AND flush its queued
794
+ * `status='pending'` deliveries through the normal delivery
795
+ * workflow, in emit order (`createdAt` ascending — millisecond
796
+ * granularity) per target. */
797
+ readonly resumeTarget: (targetId: string) => Promise<void>;
798
+ /** Hard-delete a target. Its queued `status='pending'` rows are
799
+ * deleted with it (nothing left to flush); an in-flight workflow
800
+ * run's `fetch-target` activity sees a null row and exits
801
+ * cleanly. */
802
+ readonly deleteTarget: (targetId: string) => Promise<void>;
803
+ /** Rotate the per-target signing secret. Returns the new secret
804
+ * ONCE — store it client-side if you need to display it again.
805
+ * In-flight retries continue with the OLD secret since signing
806
+ * happens at attempt time using the row's then-current value
807
+ * (acceptable: providers retry within seconds, the rotation
808
+ * window is tight). */
809
+ readonly rotateSecret: (targetId: string) => Promise<{
810
+ readonly secret: string;
811
+ }>;
812
+ /** Re-pin a target's `payloadVersion` to the event's current
813
+ * version. Called from the dashboard's "Re-pin" action after
814
+ * the consumer has updated their handler to accept the new
815
+ * payload shape. */
816
+ readonly updateTargetPayloadVersion: (targetId: string, version: number) => Promise<void>;
817
+ }
818
+
819
+ /**
820
+ * Thrown by `WebhooksService.subscribe` (via `validateSubscribe`) when
821
+ * the subscribe input fails a policy check — a non-http(s) URL, a secret
822
+ * shorter than the minimum, a degenerate retry policy, or an
823
+ * out-of-range rate limit. `field` names the offending input field;
824
+ * `reason` is the human-readable detail (carries the offending value).
825
+ */
826
+ export declare class WebhookSubscribeInvalid extends WebhookSubscribeInvalid_base {
827
+ }
828
+
829
+ declare const WebhookSubscribeInvalid_base: Schema.TaggedErrorClass<WebhookSubscribeInvalid, "WebhookSubscribeInvalid", {
830
+ readonly _tag: Schema.tag<"WebhookSubscribeInvalid">;
831
+ } & {
832
+ /** The offending subscribe-input field: `'url'` | `'event'` |
833
+ * `'secret'` | `'retry'` | `'rateLimitPerMinute'`. */
834
+ field: typeof Schema.String;
835
+ /** Human-readable detail, including the offending value. */
836
+ reason: typeof Schema.String;
837
+ }>;
838
+
839
+ /**
840
+ * Return the framework-managed tables the webhooks plugin adds.
841
+ * Consumed by the plugin entry's `registerSchema` hook so the
842
+ * bootstrap migration creates the tables BEFORE any user
843
+ * migration runs.
844
+ */
845
+ export declare const webhookTables: () => ReadonlyArray<TableLike>;
846
+
847
+ export declare type WireFormat = 'json' | 'form' | 'xml';
848
+
849
+ export { }