@voltro/plugin-webhooks 0.33.0 → 0.35.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/dist/index.d.ts CHANGED
@@ -8,8 +8,10 @@ import { EventDescriptor } from '@voltro/protocol';
8
8
  import { EventWebhookSpec } from '@voltro/protocol';
9
9
  import { FieldDefinitions } from '@voltro/database';
10
10
  import { OutboxHandlerDefinition } from '@voltro/runtime';
11
+ import { PluginPermission } from '@voltro/protocol';
11
12
  import { Schema } from 'effect';
12
13
  import { Table } from '@voltro/database';
14
+ import { VoltroPlugin } from '@voltro/protocol';
13
15
  import { Workflow } from '@effect/workflow';
14
16
  import { WorkflowEngine } from '@effect/workflow/WorkflowEngine';
15
17
  import { WorkflowInstance } from '@effect/workflow/WorkflowEngine';
@@ -93,7 +95,10 @@ export declare interface CustomSignatureScheme {
93
95
  readonly _tag: 'custom';
94
96
  readonly header: string;
95
97
  readonly sign: (rawBody: Uint8Array, secret: string) => string;
96
- readonly verify: (rawBody: Uint8Array, secret: string, signatureHeader: string) => boolean;
98
+ /** Verify against the request's FULL header map (lowercased keys) a scheme
99
+ * whose timestamp lives in a second header can read it here instead of
100
+ * relying on a caller to splice the two values together. */
101
+ readonly verify: (rawBody: Uint8Array, secret: string, headers: Readonly<Record<string, string>>) => boolean;
97
102
  }
98
103
 
99
104
  /** The structural slice of a `defineEvent` descriptor this plugin needs.
@@ -117,8 +122,17 @@ export declare interface DeclaredEventLike {
117
122
  * register routes. */
118
123
  export declare const defaultIncomingPath: (webhookId: string) => `/${string}`;
119
124
 
120
- /** Generic HMAC-SHA-256 — sensible default for new outgoing webhooks. */
121
- export declare const defaultOutgoingSignature: () => HmacSignatureScheme;
125
+ /**
126
+ * The DEFAULT for a new outgoing subscription: Standard Webhooks v1.0.0.
127
+ *
128
+ * An interoperable spec beats a house format for the one signature shape a
129
+ * third party has to implement against. Every Standard-Webhooks consumer
130
+ * library — and the package `voltro webhooks consumer` generates — verifies
131
+ * these deliveries with no per-vendor code. That is the whole argument for a
132
+ * spec, and it only pays if the spec is what we send by DEFAULT rather than
133
+ * what you can opt into.
134
+ */
135
+ export declare const defaultOutgoingSignature: () => StandardWebhooksSignatureScheme;
122
136
 
123
137
  /** Sensible default for new outgoing subscriptions: exponential, 8
124
138
  * attempts, 5s → 1h. Roughly: 5s, 10s, 20s, 40s, 80s, 160s, 320s,
@@ -154,7 +168,16 @@ declare interface DeliverInput {
154
168
  readonly attemptEpoch?: number;
155
169
  }
156
170
 
157
- export declare const deliverWebhookWorkflow: Workflow.Workflow<"voltro.deliverWebhook", Workflow.AnyStructSchema | Schema.Struct<Schema.Struct.Fields>, Schema.Struct<{
171
+ export declare const deliverWebhookWorkflow: Workflow.Workflow<"voltro.deliverWebhook", Schema.Struct<{
172
+ deliveryId: typeof Schema.String;
173
+ targetId: typeof Schema.String;
174
+ event: typeof Schema.String;
175
+ eventId: typeof Schema.String;
176
+ payloadJson: typeof Schema.String;
177
+ attemptEpoch: Schema.optionalWith<typeof Schema.Number, {
178
+ default: () => number;
179
+ }>;
180
+ }>, Schema.Struct<{
158
181
  finalStatus: Schema.Literal<["succeeded", "failed", "deferred"]>;
159
182
  attempts: typeof Schema.Number;
160
183
  }>, typeof Schema.Never>;
@@ -168,6 +191,17 @@ declare interface DeliverWorkflowOptions {
168
191
  /** The fixed window backing "per minute" — injectable for tests
169
192
  * (production uses the 60s default). */
170
193
  readonly rateWindowMs?: number;
194
+ /**
195
+ * Per-attempt wire timeout, milliseconds. Default 30 000.
196
+ *
197
+ * There was no timeout at all before this, so one receiver that accepted the
198
+ * connection and never answered held a durable workflow — and its rate-limit
199
+ * slot — indefinitely. Standard Webhooks recommends "somewhere between 15 and
200
+ * 30s"; the top of that band is the default because a slow-but-alive receiver
201
+ * being cut off produces a retry storm, which is the worse of the two
202
+ * failures. Also settable per deployment with `VOLTRO_WEBHOOK_TIMEOUT_MS`.
203
+ */
204
+ readonly timeoutMs?: number;
171
205
  }
172
206
 
173
207
  /** `getDelivery` adds the two heavy columns `listDeliveries` omits. */
@@ -282,6 +316,26 @@ export declare const fastRetryPolicy: () => RetryPolicy;
282
316
 
283
317
  export declare const generateSecret: () => string;
284
318
 
319
+ /**
320
+ * Mint a spec-shaped signing secret: `whsec_` + base64 of 32 random bytes.
321
+ *
322
+ * 32 bytes sits inside the spec's 24..64 band and matches the SHA-256 block
323
+ * this key feeds. Per the spec, keys "should be unique per endpoint" — which is
324
+ * already how `subscribe` mints them.
325
+ */
326
+ export declare const generateStandardWebhooksSecret: () => string;
327
+
328
+ /**
329
+ * The house HMAC shape: `X-Webhook-Signature: t=NNN,v1=hex` over
330
+ * `<timestamp>.<body>`.
331
+ *
332
+ * Kept as a named CHOICE, not as a default and not as a fallback: a receiver
333
+ * that already implemented this exact format should not have to change to keep
334
+ * receiving. It is a peer of `stripeSignature()` / `githubSignature()` — a
335
+ * specific counterparty's format — rather than a second general-purpose path.
336
+ */
337
+ export declare const genericHmacSignature: () => HmacSignatureScheme;
338
+
285
339
  export declare const getIdempotencyCache: () => IdempotencyCache;
286
340
 
287
341
  /** GitHub-style: `X-Hub-Signature-256: sha256=hex` (no timestamp). */
@@ -402,11 +456,24 @@ export declare interface IncomingWebhookDescriptor<Body> {
402
456
  * integrations (`/integrations/stripe/v1`). */
403
457
  readonly path?: `/${string}`;
404
458
  /** Signature scheme used to authenticate inbound requests. Reject
405
- * on mismatch with a 401. `undefined` (the default) means NO
406
- * verification — only acceptable when behind a separate trust
407
- * boundary (gateway + IP allow-list). The dashboard surfaces a
408
- * warning for unsigned incoming webhooks. */
459
+ * on mismatch with a 401. Usually filled in by `provider`. */
409
460
  readonly signature?: SignatureScheme;
461
+ /**
462
+ * How this endpoint authenticates its caller. An incoming webhook is a
463
+ * PUBLIC POST that runs your application code, so the framework will not
464
+ * mount one that has made no decision here: `mountIncomingWebhook` throws
465
+ * at boot when there is no effective `signature` (from this descriptor or
466
+ * from `provider`) AND no explicit value below.
467
+ *
468
+ * - omitted → derived. `signature` / `provider` present → `'signature'`;
469
+ * nothing present → boot refuses.
470
+ * - `'provider'` → the handler verifies with the provider's own SDK
471
+ * (Stripe's `constructEvent`, etc.). The framework's generic HMAC layer
472
+ * is not the authority and does not require a framework-side secret.
473
+ * - `'none'` → deliberately unverified, because a gateway + IP allow-list
474
+ * owns the trust boundary. Logged as a warning at every boot, on purpose.
475
+ */
476
+ readonly verification?: 'signature' | 'provider' | 'none';
410
477
  /** Idempotency key extraction. Default `'Idempotency-Key'` header.
411
478
  * Provider-templates override this to match the provider's wire
412
479
  * format. */
@@ -434,6 +501,26 @@ export declare interface IncomingWebhookDescriptor<Body> {
434
501
  readonly handler: (context: IncomingWebhookContext<Body>) => Promise<void> | void;
435
502
  }
436
503
 
504
+ /** How a mounted incoming webhook authenticates its caller. Mirrors
505
+ * `@voltro/runtime`'s `WebhookVerification` STRUCTURALLY — this package must
506
+ * not depend on the runtime (it is imported by browser-safe descriptor files),
507
+ * and the runtime reads the stamped value back by shape. */
508
+ export declare type IncomingWebhookVerification = 'signature' | 'provider' | 'none';
509
+
510
+ /**
511
+ * The verification decision for a descriptor, WITHOUT mounting it.
512
+ *
513
+ * One function, two readers: `mountIncomingWebhook` (which turns `null` into a
514
+ * boot refusal) and `voltro doctor` (which reports it as a preflight, before a
515
+ * deploy discovers it). A doctor that re-derived this could disagree with the
516
+ * boot it is supposed to predict — the failure shape a preflight must not have.
517
+ *
518
+ * `null` = nothing authenticates the caller. Declaring nothing, and declaring
519
+ * `'signature'` with no scheme to verify AGAINST, are the same open endpoint,
520
+ * so both answer `null`.
521
+ */
522
+ export declare const incomingWebhookVerification: <Body>(descriptor: IncomingWebhookDescriptor<Body>) => IncomingWebhookVerification | null;
523
+
437
524
  export declare interface IncomingWorkflowFacade {
438
525
  start<Payload = unknown>(workflowName: string, payload: Payload): Promise<WorkflowRunHandle>;
439
526
  signal(target: {
@@ -458,6 +545,9 @@ export declare interface IncomingWorkflowFacade {
458
545
  }>;
459
546
  }
460
547
 
548
+ /** Is this a spec-shaped secret? Used to decide whether a target needs one minted. */
549
+ export declare const isStandardWebhooksSecret: (secret: string) => boolean;
550
+
461
551
  /** Type guard for file-walker discovery — every `*.webhook.tsx`
462
552
  * must default-export one of these. */
463
553
  export declare const isWebhookDescriptor: (value: unknown) => value is WebhookDescriptor;
@@ -519,16 +609,24 @@ emitter: () => Pick<WebhooksServiceShape, "emit"> | undefined) => OutboxHandlerD
519
609
  */
520
610
  export declare const matchesFilter: (filter: WebhookFilter | Readonly<Record<string, unknown>> | null, payload: unknown) => boolean;
521
611
 
612
+ /** A mounted handler, carrying its verification declaration. */
613
+ export declare type MountedIncomingWebhook = ((request: IncomingRequest) => Promise<IncomingResponse>) & {
614
+ readonly [WEBHOOK_VERIFICATION_PROPERTY]: IncomingWebhookVerification;
615
+ };
616
+
522
617
  /**
523
618
  * Build a request handler for a specific `IncomingWebhookDescriptor`.
524
619
  * The returned function is what stage 4 mounts on the HTTP layer.
620
+ *
621
+ * @throws {UnverifiedIncomingWebhook} at mount time when the descriptor has no
622
+ * effective signature scheme and no explicit `verification`.
525
623
  */
526
- export declare const mountIncomingWebhook: <Body>(descriptor: IncomingWebhookDescriptor<Body>, options: MountOptions) => (request: IncomingRequest) => Promise<IncomingResponse>;
624
+ export declare const mountIncomingWebhook: <Body>(descriptor: IncomingWebhookDescriptor<Body>, options: MountOptions) => MountedIncomingWebhook;
527
625
 
528
626
  export declare interface MountOptions {
529
- /** Resolves the per-webhook signing secret. Return `null` to
530
- * skip signature verification (a warning logs to the framework
531
- * logger; the user has explicitly opted out). */
627
+ /** Resolves the per-webhook signing secret. Returning `null` for a
628
+ * webhook whose verification is `'signature'` makes every delivery
629
+ * answer 503 it does NOT skip verification. */
532
630
  readonly resolveSecret: (webhookId: string) => Promise<string | null>;
533
631
  /** Optional idempotency cache override. Defaults to the
534
632
  * process-singleton LRU. Pass a custom cache for tests or for a
@@ -562,9 +660,16 @@ export declare interface OutgoingEventDescriptor<Payload> {
562
660
  * (pass the descriptor itself to `emit(descriptor, payload)` to
563
661
  * type the payload; either way `emit` DECODES the payload against
564
662
  * this schema and rejects a mismatch with `WebhookPayloadInvalid`
565
- * before any delivery is created). Recipients receive
566
- * `{ event, eventId, occurredAt, payload }` with `payload`
567
- * matching this schema. */
663
+ * before any delivery is created).
664
+ *
665
+ * The BODY on the wire is this payload and nothing else — no envelope. It
666
+ * used to say recipients receive `{ event, eventId, occurredAt, payload }`,
667
+ * which was never true of the code: `emit` posts `JSON.stringify(payload)`
668
+ * verbatim (asserted in `deliverWorkflow.integration.test.ts`). The event
669
+ * name, ids and attempt count ride in HEADERS (`x-voltro-event`,
670
+ * `webhook-id`, `x-voltro-attempt`), which is also where Standard Webhooks
671
+ * puts the delivery metadata — so the generated consumer package reads them
672
+ * from there. */
568
673
  readonly payload: Schema.Schema<Payload>;
569
674
  /** Schema version. Increment when the payload shape changes in a
570
675
  * way subscribers must adapt to. The dashboard surfaces version
@@ -628,7 +733,17 @@ export declare interface RateScope {
628
733
  * target when the auto-disable fires. Returns the post-write streak +
629
734
  * whether THIS call auto-disabled.
630
735
  */
631
- export declare const recordDeliveryOutcome: (store: DataStore, targetId: string, succeeded: boolean, reason: string, now?: Date) => Promise<AutoDisableOutcome>;
736
+ export declare const recordDeliveryOutcome: (store: DataStore, targetId: string, succeeded: boolean, reason: string, now?: Date,
737
+ /**
738
+ * Disable the target on THIS failure, whatever the streak says.
739
+ *
740
+ * Standard Webhooks is explicit that `410 Gone` means "disable the endpoint",
741
+ * and that is a different signal from a streak: the receiver has TOLD us the
742
+ * endpoint is gone, so waiting for `autoDisableAfter` more failures is us
743
+ * ignoring an answer we asked for. It also fires when `autoDisableAfter` is
744
+ * unset — the streak feature being off does not make a 410 ambiguous.
745
+ */
746
+ disableNow?: boolean) => Promise<AutoDisableOutcome>;
632
747
 
633
748
  /** Refund a slot claimed by `consumeRateSlot` — used when a later scope
634
749
  * in the same acquisition denies, so a deferred delivery doesn't burn
@@ -699,26 +814,160 @@ export declare interface RetryPolicy {
699
814
 
700
815
  export declare type RetryStrategy = 'fixed' | 'linear' | 'exponential';
701
816
 
702
- export declare type SignatureScheme = HmacSignatureScheme | CustomSignatureScheme;
817
+ export declare type SignatureScheme = HmacSignatureScheme | CustomSignatureScheme | StandardWebhooksSignatureScheme;
703
818
 
704
- export declare interface SignedPayload {
705
- /** Header value to set on the outbound request. */
706
- readonly headerValue: string;
707
- /** Header name from the scheme. */
708
- readonly headerName: string;
709
- /** Timestamp embedded in the header (when `includeTimestamp`).
710
- * Surfaced so callers can log it alongside delivery records. */
819
+ export declare interface SignedRequest {
820
+ /** Every header the scheme contributes, lowercased. */
821
+ readonly headers: Readonly<Record<string, string>>;
822
+ /** The instant embedded in the signature, when the scheme embeds one.
823
+ * Surfaced so callers can log it beside the delivery record. */
711
824
  readonly timestamp?: number;
712
825
  }
713
826
 
714
- /** Produce the signature header value for an outbound request. */
715
- export declare const signPayload: (scheme: SignatureScheme, rawBody: Uint8Array, secret: string) => SignedPayload;
827
+ /**
828
+ * Render the signature headers for an outbound request.
829
+ *
830
+ * ONE function for every scheme: a caller that has to know which scheme it is
831
+ * holding in order to assemble the right headers is a caller that will get a
832
+ * new scheme wrong.
833
+ */
834
+ export declare const signRequest: (scheme: SignatureScheme, input: SignRequestInput) => SignedRequest;
716
835
 
717
- /** Slack-style: `X-Slack-Signature: v0=hex` with `X-Slack-Request-Timestamp` separate.
718
- * Slack's actual header layout is non-standard — the timestamp lives
719
- * in a SECOND header rather than embedded in the signature header.
720
- * We model it via a custom scheme that reads both headers in `verify`. */
721
- export declare const slackSignature: () => CustomSignatureScheme;
836
+ /** Everything a scheme may need to render its headers. */
837
+ export declare interface SignRequestInput {
838
+ /** The exact bytes on the wire the signature covers these, never the
839
+ * pre-encoded JSON. */
840
+ readonly rawBody: Uint8Array;
841
+ /** The signing secret for this target. */
842
+ readonly secret: string;
843
+ /**
844
+ * The unique message identifier. Standard Webhooks signs OVER it and sends it
845
+ * as `webhook-id`; the other schemes ignore it.
846
+ *
847
+ * Required rather than optional, and the reason is worth keeping: it is also
848
+ * the consumer's idempotency key, so a delivery without one cannot be
849
+ * de-duplicated by the receiver at all. Making it optional would let a call
850
+ * site omit it and produce a spec-shaped message that is missing the one
851
+ * field the spec tells consumers to rely on.
852
+ */
853
+ readonly messageId: string;
854
+ /** Unix seconds. Defaults to now — a test pins it. */
855
+ readonly timestampSeconds?: number;
856
+ }
857
+
858
+ /**
859
+ * Slack-style: `X-Slack-Signature: v0=hex`, with the timestamp in the SEPARATE
860
+ * `X-Slack-Request-Timestamp` header and a signed content of
861
+ * `v0:<timestamp>:<body>`.
862
+ *
863
+ * This used to be a stub whose `verify` returned `false` unconditionally, with a
864
+ * comment explaining that the route adapter spliced the two header values into
865
+ * one string before calling it. It did — so `slackSignature()` was an exported,
866
+ * documented API that rejected every request when used as written, and the only
867
+ * thing that made Slack work was a special case in `incoming.ts` keyed on
868
+ * `_tag === 'custom'`. A verifier that receives the whole header map does not
869
+ * need either.
870
+ */
871
+ export declare const slackSignature: (options?: {
872
+ readonly replayWindowSeconds?: number;
873
+ }) => CustomSignatureScheme;
874
+
875
+ /** Default replay tolerance. The spec requires A tolerance and names no number,
876
+ * so this is ours: 5 minutes, matching the window every other scheme here uses. */
877
+ export declare const STANDARD_WEBHOOKS_DEFAULT_TOLERANCE_SECONDS = 300;
878
+
879
+ /** The three headers, exactly as the spec names them (lowercase). */
880
+ export declare const STANDARD_WEBHOOKS_ID_HEADER = "webhook-id";
881
+
882
+ export declare const STANDARD_WEBHOOKS_MAX_KEY_BYTES = 64;
883
+
884
+ /** Spec: "Between 24 bytes (192 bits) and 64 bytes (512 bits)". */
885
+ export declare const STANDARD_WEBHOOKS_MIN_KEY_BYTES = 24;
886
+
887
+ /** Secret serialization prefix. */
888
+ export declare const STANDARD_WEBHOOKS_SECRET_PREFIX = "whsec_";
889
+
890
+ export declare const STANDARD_WEBHOOKS_SIGNATURE_HEADER = "webhook-signature";
891
+
892
+ export declare const STANDARD_WEBHOOKS_TIMESTAMP_HEADER = "webhook-timestamp";
893
+
894
+ /** The symmetric signature identifier. `v1a` is the asymmetric one — see the
895
+ * header for why it is refused rather than ignored. */
896
+ export declare const STANDARD_WEBHOOKS_VERSION = "v1";
897
+
898
+ /**
899
+ * The three outbound headers for one delivery.
900
+ *
901
+ * `secrets` may carry more than one: during a rotation the spec has the producer
902
+ * sign "with both the current and old keys" and space-delimit the tokens, so a
903
+ * consumer holding either key still verifies. Order is producer-chosen; a
904
+ * consumer tries each.
905
+ */
906
+ export declare const standardWebhooksHeaders: (input: {
907
+ readonly secrets: ReadonlyArray<string>;
908
+ readonly messageId: string;
909
+ readonly timestampSeconds: number;
910
+ readonly rawBody: Uint8Array;
911
+ }) => Readonly<Record<string, string>>;
912
+
913
+ /**
914
+ * The raw HMAC key behind a `whsec_…` secret.
915
+ *
916
+ * The prefix is REQUIRED, and that strictness is deliberate. A hex secret (what
917
+ * this plugin's generic schemes mint) is also valid base64, so a lenient
918
+ * "decode if it looks like base64" rule would silently HMAC 48 bytes of garbage
919
+ * — self-consistently, so our own round-trip would pass while every conformant
920
+ * consumer library rejected the delivery. Refusing loudly at signing time is the
921
+ * only version of this that cannot ship a broken endpoint.
922
+ */
923
+ export declare const standardWebhooksKey: (secret: string) => Buffer;
924
+
925
+ /**
926
+ * Standard Webhooks v1.0.0 — the interoperable scheme.
927
+ *
928
+ * Use it on an INCOMING webhook whose sender signs to the spec, and read it as
929
+ * the OUTGOING default via `defaultOutgoingSignature()`.
930
+ *
931
+ * Symmetric (HMAC-SHA256, `v1`) only. The spec's asymmetric half (ed25519,
932
+ * `v1a`, `whsk_`/`whpk_`) is not implemented, and `verifyRequest` says so
933
+ * explicitly rather than reporting a generic mismatch.
934
+ */
935
+ export declare const standardWebhooksSignature: (options?: {
936
+ readonly toleranceSeconds?: number;
937
+ readonly previousSecret?: string;
938
+ }) => StandardWebhooksSignatureScheme;
939
+
940
+ /**
941
+ * The Standard Webhooks (v1.0.0) scheme — three `webhook-*` headers, a
942
+ * `msg_id.timestamp.payload` signed content, base64 `v1,` signatures, a
943
+ * `whsec_`-prefixed base64 key. Implemented in `./standardWebhooks`, which
944
+ * carries the spec quotations and the interop vector it is verified against.
945
+ */
946
+ export declare interface StandardWebhooksSignatureScheme {
947
+ readonly _tag: 'standardWebhooks';
948
+ /** Replay tolerance. The spec REQUIRES a tolerance and names no number; 300s
949
+ * is ours. */
950
+ readonly toleranceSeconds?: number;
951
+ /** A second key accepted on verify AND signed alongside on send — the spec's
952
+ * zero-downtime rotation, which is what the space-delimited signature list
953
+ * exists for. */
954
+ readonly previousSecret?: string;
955
+ }
956
+
957
+ /** One `v1,<base64>` token. */
958
+ export declare const standardWebhooksSignatureToken: (secret: string, messageId: string, timestampSeconds: number, rawBody: Uint8Array) => string;
959
+
960
+ /** The exact bytes the spec signs: `msg_id.timestamp.payload`. */
961
+ export declare const standardWebhooksSignedContent: (messageId: string, timestampSeconds: number, rawBody: Uint8Array) => Buffer;
962
+
963
+ export declare type StandardWebhooksVerifyResult = {
964
+ readonly ok: true;
965
+ readonly messageId: string;
966
+ readonly timestampSeconds: number;
967
+ } | {
968
+ readonly ok: false;
969
+ readonly reason: string;
970
+ };
722
971
 
723
972
  /** Stripe-style: `Stripe-Signature: t=NNN,v1=hex`, 5-min replay window. */
724
973
  export declare const stripeSignature: (header?: string) => HmacSignatureScheme;
@@ -876,6 +1125,15 @@ export declare interface TargetSummary {
876
1125
 
877
1126
  declare const TTL_MS: Record<string, number>;
878
1127
 
1128
+ /** Thrown at mount (i.e. at boot) for a webhook that verifies nothing and never
1129
+ * said so. The message is the deliverable — it names the endpoint and every
1130
+ * way out of the failure. */
1131
+ export declare class UnverifiedIncomingWebhook extends Error {
1132
+ readonly webhookId: string;
1133
+ readonly name = "UnverifiedIncomingWebhook";
1134
+ constructor(webhookId: string);
1135
+ }
1136
+
879
1137
  /**
880
1138
  * Convenience accessor for handlers. The framework's `AppContext`
881
1139
  * types the `webhooks` slot as `unknown` to avoid a circular dep
@@ -897,6 +1155,24 @@ export declare const useWebhooksEffect: Effect.Effect<WebhooksServiceShape, neve
897
1155
 
898
1156
  export declare const validateSubscribe: (input: SubscribeInput) => void;
899
1157
 
1158
+ /**
1159
+ * Constant-time verification of an incoming request.
1160
+ *
1161
+ * Returns the structured reason so a caller can attach it to a 401 (helpful in
1162
+ * dev, fine to elide in prod).
1163
+ */
1164
+ export declare const verifyRequest: (scheme: SignatureScheme, input: VerifyRequestInput) => VerifyResult;
1165
+
1166
+ export declare interface VerifyRequestInput {
1167
+ readonly rawBody: Uint8Array;
1168
+ readonly secret: string;
1169
+ /** The request's headers, LOWERCASED keys. A scheme reads whichever of them
1170
+ * it needs (Slack needs two; Standard Webhooks needs three). */
1171
+ readonly headers: Readonly<Record<string, string>>;
1172
+ /** Overridable clock for the replay window — tests pin it. */
1173
+ readonly nowSeconds?: number;
1174
+ }
1175
+
900
1176
  export declare type VerifyResult = {
901
1177
  readonly ok: true;
902
1178
  } | {
@@ -904,11 +1180,20 @@ export declare type VerifyResult = {
904
1180
  readonly reason: string;
905
1181
  };
906
1182
 
907
- /** Constant-time signature verification for an incoming request.
908
- * Returns the structured result so callers can attach the
909
- * specific reason to a 401 response (helpful in dev, OK to elide
910
- * in prod). */
911
- export declare const verifySignature: (scheme: SignatureScheme, rawBody: Uint8Array, secret: string, receivedHeaderValue: string | undefined) => VerifyResult;
1183
+ /**
1184
+ * Verify an inbound Standard-Webhooks request.
1185
+ *
1186
+ * Order matters and follows the spec's own reasoning: shape → timestamp
1187
+ * tolerance (a replay is rejected before any HMAC work) constant-time compare
1188
+ * against every accepted key × every offered token.
1189
+ */
1190
+ export declare const verifyStandardWebhooks: (input: {
1191
+ readonly secrets: ReadonlyArray<string>;
1192
+ readonly rawBody: Uint8Array;
1193
+ readonly headers: Readonly<Record<string, string>>;
1194
+ readonly toleranceSeconds?: number;
1195
+ readonly nowSeconds?: number;
1196
+ }) => StandardWebhooksVerifyResult;
912
1197
 
913
1198
  /** Compare a target's pinned `payloadVersion` against the event's
914
1199
  * current `version`. Three states:
@@ -953,7 +1238,7 @@ export declare const _voltroWebhookDeliveriesTable: Table<"_voltro_webhook_deliv
953
1238
  readonly eventId: ColumnBuilder<string | null, "text", boolean>;
954
1239
  /** Attempt counter (1-indexed). */
955
1240
  readonly attempt: ColumnBuilder<number, "integer", boolean>;
956
- readonly status: ColumnBuilder<"failed" | "succeeded" | "inFlight" | "pending" | "retryScheduled", "text", boolean>;
1241
+ readonly status: ColumnBuilder<"failed" | "pending" | "succeeded" | "inFlight" | "retryScheduled", "text", boolean>;
957
1242
  /** Payload as sent over the wire. Stored verbatim — re-rendering
958
1243
  * from a referenced event row would lose the snapshot if the
959
1244
  * source event was deleted. */
@@ -1184,6 +1469,9 @@ export declare const _voltroWebhookTargetsTable: Table<"_voltro_webhook_targets"
1184
1469
  * duplicate-effect check is what enforces that. */
1185
1470
  export declare const WEBHOOK_EMIT_EFFECT = "voltro.webhook.emit";
1186
1471
 
1472
+ /** The property name the runtime's boot gate reads off a mounted handler. */
1473
+ export declare const WEBHOOK_VERIFICATION_PROPERTY: "voltroWebhookVerification";
1474
+
1187
1475
  /**
1188
1476
  * Thrown by `WebhooksService.replay` when no delivery row exists for the
1189
1477
  * given `deliveryId` — the row can't be re-triggered because the
@@ -1293,6 +1581,45 @@ export declare interface WebhookProviderDescriptor {
1293
1581
  readonly eventTypeFrom?: (body: unknown) => string | undefined;
1294
1582
  }
1295
1583
 
1584
+ /**
1585
+ * The permissions webhook delivery declares.
1586
+ *
1587
+ * `network:outbound:*` and nothing else, deliberately: this entry contributes
1588
+ * no interceptors, no inspect endpoints, no `extendSchema` tables (the webhook
1589
+ * tables ride the framework's feature-mix assembly the moment a `*.webhook.tsx`
1590
+ * file exists — see `cli/src/frameworkTableAssembly.ts`), so no other hook
1591
+ * permission would be truthful either.
1592
+ */
1593
+ export declare const WEBHOOKS_PLUGIN_PERMISSIONS: ReadonlyArray<PluginPermission>;
1594
+
1595
+ /**
1596
+ * Register webhook delivery with the plugin system.
1597
+ *
1598
+ * ```ts
1599
+ * // app.config.ts
1600
+ * import { webhooksPlugin } from '@voltro/plugin-webhooks'
1601
+ *
1602
+ * export default {
1603
+ * plugins: [webhooksPlugin()],
1604
+ * }
1605
+ * ```
1606
+ *
1607
+ * Adding it changes no behavior — `*.webhook.tsx` discovery, delivery and the
1608
+ * incoming routes work exactly as before. What it changes is the boot audit:
1609
+ * webhooks now appears in the plugin permission report and the plugin manifest
1610
+ * with its outbound declaration, instead of being invisible to both.
1611
+ */
1612
+ export declare const webhooksPlugin: (options?: WebhooksPluginOptions) => VoltroPlugin;
1613
+
1614
+ export declare interface WebhooksPluginOptions {
1615
+ /**
1616
+ * Distinguishing suffix when an app registers the entry more than once
1617
+ * (matching the `@voltro/plugin-mail#<name>` convention). Plugin names must
1618
+ * be unique in one app's plugin list.
1619
+ */
1620
+ readonly name?: string;
1621
+ }
1622
+
1296
1623
  export declare class WebhooksService extends WebhooksService_base {
1297
1624
  }
1298
1625
 
@@ -1564,7 +1891,7 @@ export declare const webhookTables: () => readonly [ Table<"_voltro_webhook_targ
1564
1891
  readonly eventId: ColumnBuilder<string | null, "text", boolean>;
1565
1892
  /** Attempt counter (1-indexed). */
1566
1893
  readonly attempt: ColumnBuilder<number, "integer", boolean>;
1567
- readonly status: ColumnBuilder<"failed" | "succeeded" | "inFlight" | "pending" | "retryScheduled", "text", boolean>;
1894
+ readonly status: ColumnBuilder<"failed" | "pending" | "succeeded" | "inFlight" | "retryScheduled", "text", boolean>;
1568
1895
  /** Payload as sent over the wire. Stored verbatim — re-rendering
1569
1896
  * from a referenced event row would lose the snapshot if the
1570
1897
  * source event was deleted. */