@voltro/cli 0.30.1 → 0.31.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.
Files changed (124) hide show
  1. package/CHANGELOG.md +163 -0
  2. package/dist/apiBuild-BOqL1dlg.js +2 -0
  3. package/dist/{apiBuild-DmkzRyv7.js → apiBuild-CLPt-pX6.js} +2 -2
  4. package/dist/appGraph-CvQCte0z.js +2 -0
  5. package/dist/appGraph-gQ_6GkQQ.js +110 -0
  6. package/dist/bin.js +3 -3
  7. package/dist/checkCommand-ClRvH_SN.js +2 -0
  8. package/dist/{checkCommand-C4konUy9.js → checkCommand-D7r6_pk7.js} +439 -530
  9. package/dist/{commands-BkJft9K2.js → commands-CNVVv03h.js} +1941 -1880
  10. package/dist/{dbCommand-qn__kLEt.js → dbCommand-BH8GAYuf.js} +76 -19
  11. package/dist/dbCommand-BwQ_Jl98.js +2 -0
  12. package/dist/{dev-DXhhZ7-f.js → dev-DOZVMVfi.js} +1 -1
  13. package/dist/{dev-C_NPcLDp.js → dev-DdQEn2Ia.js} +1986 -1967
  14. package/dist/{frameworkTableAssembly-Cpgn2e88.js → frameworkTableAssembly-BwIrO5nv.js} +122 -103
  15. package/dist/frameworkTableAssembly-D-EebUQX.js +2 -0
  16. package/dist/index.js +1 -1
  17. package/dist/{inspectMetrics-CkB2J0Zn.js → inspectMetrics-BeEAnjqw.js} +774 -527
  18. package/dist/{sdkgen-wsWLrq4l.js → sdkgen-v9WV8zRi.js} +1 -1
  19. package/dist/{serveCommand-HtI4X5kj.js → serveCommand-KsKXXWn4.js} +294 -293
  20. package/dist/serveEntry.js +2 -2
  21. package/dist/{start-egkbpugc.js → start-CI2QdKy9.js} +351 -346
  22. package/dist/startEntry.js +2 -2
  23. package/package.json +17 -17
  24. package/templates/AGENTS.md +43 -43
  25. package/templates/agent-docs/_index.md +43 -43
  26. package/templates/agent-docs/_manifest.json +84 -42
  27. package/templates/agent-docs/cli.md +4 -2
  28. package/templates/agent-docs/database/migrations.md +14 -1
  29. package/templates/agent-docs/internationalization.md +54 -3
  30. package/templates/agent-docs/plugins/ai-flows.md +148 -0
  31. package/templates/agent-docs/plugins/analytics-postgres.md +90 -0
  32. package/templates/agent-docs/plugins/atlassian.md +378 -0
  33. package/templates/agent-docs/plugins/audit.md +324 -0
  34. package/templates/agent-docs/plugins/auth-auth0.md +92 -0
  35. package/templates/agent-docs/plugins/auth-clerk.md +90 -0
  36. package/templates/agent-docs/plugins/auth-kinde.md +88 -0
  37. package/templates/agent-docs/plugins/auth-oidc.md +95 -0
  38. package/templates/agent-docs/plugins/auth-supabase.md +103 -0
  39. package/templates/agent-docs/plugins/auth-workos.md +105 -0
  40. package/templates/agent-docs/plugins/auth.md +227 -0
  41. package/templates/agent-docs/plugins/billing.md +324 -0
  42. package/templates/agent-docs/plugins/broadcast.md +144 -0
  43. package/templates/agent-docs/plugins/cdc-out.md +116 -0
  44. package/templates/agent-docs/plugins/clickhouse.md +91 -0
  45. package/templates/agent-docs/plugins/datadog.md +105 -0
  46. package/templates/agent-docs/plugins/deactivation.md +114 -0
  47. package/templates/agent-docs/plugins/duckdb.md +82 -0
  48. package/templates/agent-docs/plugins/flags.md +146 -0
  49. package/templates/agent-docs/plugins/governance.md +143 -0
  50. package/templates/agent-docs/plugins/licensing.md +74 -0
  51. package/templates/agent-docs/plugins/logship.md +56 -0
  52. package/templates/agent-docs/plugins/mail.md +334 -0
  53. package/templates/agent-docs/plugins/moderation.md +65 -0
  54. package/templates/agent-docs/plugins/multitenancy.md +99 -0
  55. package/templates/agent-docs/plugins/notifications.md +252 -0
  56. package/templates/agent-docs/plugins/openapi.md +94 -0
  57. package/templates/agent-docs/plugins/postgis.md +140 -0
  58. package/templates/agent-docs/plugins/posthog.md +91 -0
  59. package/templates/agent-docs/plugins/presence.md +115 -0
  60. package/templates/agent-docs/plugins/prometheus.md +124 -0
  61. package/templates/agent-docs/plugins/ratelimit.md +274 -0
  62. package/templates/agent-docs/plugins/rbac.md +270 -0
  63. package/templates/agent-docs/plugins/scim.md +63 -0
  64. package/templates/agent-docs/plugins/search.md +116 -0
  65. package/templates/agent-docs/plugins/sentry.md +192 -0
  66. package/templates/agent-docs/plugins/soft-delete.md +72 -0
  67. package/templates/agent-docs/plugins/sso-saml.md +132 -0
  68. package/templates/agent-docs/plugins/storage.md +709 -0
  69. package/templates/agent-docs/plugins/tinybird.md +92 -0
  70. package/templates/agent-docs/plugins/versioning.md +226 -0
  71. package/templates/agent-docs/plugins/webhooks.md +585 -0
  72. package/templates/agent-docs/security.md +12 -1
  73. package/templates/agent-docs/whats-new.md +81 -37
  74. package/templates/apps/api-ai/package.json +7 -7
  75. package/templates/apps/api-auth/package.json +8 -8
  76. package/templates/apps/api-backend/package.json +7 -7
  77. package/templates/apps/api-backend-deactivation/package.json +7 -7
  78. package/templates/apps/api-backend-mail/package.json +8 -8
  79. package/templates/apps/api-backend-mariadb/package.json +9 -9
  80. package/templates/apps/api-backend-sqlite/package.json +8 -8
  81. package/templates/apps/api-backend-storage/package.json +8 -8
  82. package/templates/apps/api-cms/package.json +10 -10
  83. package/templates/apps/api-collab/package.json +8 -8
  84. package/templates/apps/api-data-advanced/package.json +8 -8
  85. package/templates/apps/api-durable/package.json +8 -8
  86. package/templates/apps/api-feature-flags/package.json +9 -9
  87. package/templates/apps/api-governance/package.json +8 -8
  88. package/templates/apps/api-kv/package.json +8 -8
  89. package/templates/apps/api-moderation/package.json +8 -8
  90. package/templates/apps/api-observability/package.json +8 -8
  91. package/templates/apps/api-ratelimit/package.json +8 -8
  92. package/templates/apps/api-rbac/package.json +8 -8
  93. package/templates/apps/api-rest/package.json +7 -7
  94. package/templates/apps/api-saas/package.json +11 -11
  95. package/templates/apps/api-saas-starter/package.json +10 -10
  96. package/templates/apps/api-search/package.json +8 -8
  97. package/templates/apps/api-status/package.json +8 -8
  98. package/templates/apps/api-versioning/package.json +8 -8
  99. package/templates/apps/api-webhooks/package.json +9 -9
  100. package/templates/apps/changelog/package.json +6 -6
  101. package/templates/apps/edge-functions/package.json +2 -2
  102. package/templates/apps/frontend-admin/package.json +8 -8
  103. package/templates/apps/frontend-app/package.json +8 -8
  104. package/templates/apps/frontend-auth/package.json +8 -8
  105. package/templates/apps/frontend-blank/package.json +7 -7
  106. package/templates/apps/frontend-cms/package.json +9 -9
  107. package/templates/apps/frontend-collab/package.json +9 -9
  108. package/templates/apps/frontend-contact/package.json +7 -7
  109. package/templates/apps/frontend-dashboard/package.json +7 -7
  110. package/templates/apps/frontend-docs/package.json +7 -7
  111. package/templates/apps/frontend-i18n/package.json +6 -6
  112. package/templates/apps/frontend-landing/package.json +7 -7
  113. package/templates/apps/frontend-portal/package.json +8 -8
  114. package/templates/apps/frontend-saas/package.json +8 -8
  115. package/templates/apps/frontend-spa/package.json +7 -7
  116. package/templates/apps/frontend-ssr/package.json +7 -7
  117. package/templates/apps/frontend-ssr-api/package.json +8 -8
  118. package/templates/apps/frontend-static-blog/package.json +6 -6
  119. package/templates/apps/frontend-status/package.json +8 -8
  120. package/templates/apps/mobile-app/package.json +2 -2
  121. package/dist/apiBuild-Ca6Sq5jy.js +0 -2
  122. package/dist/checkCommand-DHD0aBeR.js +0 -2
  123. package/dist/dbCommand-wfGi3FgG.js +0 -2
  124. package/dist/frameworkTableAssembly-Bn_c4Mnl.js +0 -2
@@ -0,0 +1,585 @@
1
+ # Webhooks
2
+
3
+ > First-class outgoing + incoming webhooks — declared events with a webhook: block, defineIncomingWebhook in *.webhook.tsx files, runtime subscriptions, durable delivery via @effect/workflow, HMAC signing, provider presets, and idempotency.
4
+
5
+
6
+
7
+ ---
8
+
9
+ <!-- source: en/plugins/webhooks.md -->
10
+ ## Webhooks
11
+
12
+ _First-class outgoing + incoming webhooks — declared events with a webhook: block, defineIncomingWebhook in *.webhook.tsx files, runtime subscriptions, durable delivery via @effect/workflow, HMAC signing, provider presets, and idempotency._
13
+
14
+ `@voltro/plugin-webhooks` adds first-class outgoing **and** incoming webhooks.
15
+ Both directions are declared in `*.webhook.tsx` files: outgoing events the app
16
+ emits, and incoming HTTP endpoints the app exposes to third parties. Delivery is
17
+ durable — each outgoing delivery runs as an `@effect/workflow`, so signing,
18
+ retries, rate limits, and `Retry-After` honouring all happen inside a crash-safe
19
+ workflow body.
20
+
21
+ The framework discovers `*.webhook.tsx` files and wires the queries + codegen.
22
+ The managed tables (`_voltro_webhook_targets`, `_voltro_webhook_deliveries`, and
23
+ the rate-limit counter table `_voltro_webhook_rate_windows`) are created by the
24
+ bootstrap migration; you can register them explicitly in your schema via the
25
+ `webhookTables()` mixin from `@voltro/plugin-webhooks/mixin`.
26
+
27
+ The targets + deliveries tables carry `tenant()` (the rate-window table holds
28
+ only operational counters — no tenant data), so webhook subscriptions are **per-tenant**: a
29
+ target subscribed under tenant A is invisible to tenant B, and `emit()` from a
30
+ tenant fans out only to that tenant's targets. The runtime stamps `tenantId`
31
+ from the caller's subject on subscribe and carries it through to delivery — you
32
+ don't pass it by hand.
33
+
34
+ ## Outgoing — a declared event with a `webhook:` block
35
+
36
+ Declare an event the app emits. External systems subscribe to it at runtime via
37
+ `ctx.webhooks.subscribe(...)`. Each subscribed target is a row in
38
+ `_voltro_webhook_targets`; each delivery attempt is a row in
39
+ `_voltro_webhook_deliveries`.
40
+
41
+ ```ts
42
+ // events/orders.event.ts
43
+ import { defineEvent } from '@voltro/protocol'
44
+ import { Schema } from 'effect'
45
+
46
+ export const orderCompleted = defineEvent({
47
+ name: 'order.completed',
48
+ key: Schema.Struct({}),
49
+ payload: Schema.Struct({
50
+ orderId: Schema.String,
51
+ tenantId: Schema.String,
52
+ total: Schema.Number,
53
+ items: Schema.Array(Schema.Struct({ sku: Schema.String, qty: Schema.Number })),
54
+ }),
55
+ webhook: {
56
+ description: 'Order moved into "fulfilled" — payment captured, items shipped.',
57
+ version: 1,
58
+ },
59
+ })
60
+ ```
61
+
62
+ Recipients receive `{ event, eventId, occurredAt, payload }` with `payload`
63
+ matching the schema — `emit()` decodes every payload against it and rejects a
64
+ mismatch with the typed `WebhookPayloadInvalid` before any delivery is created.
65
+ `version` defaults to `1`; increment it whenever the payload shape changes in a
66
+ way subscribers must adapt to — new subscriptions pin to the event's current
67
+ version. Optional `defaultRetry` and `defaultSigning` apply to new subscriptions
68
+ when the subscriber doesn't pass `retry` / `signing` explicitly (explicit input
69
+ wins, then the event default, then the package default). Optional
70
+ `globalRateLimit: { perMinute }` caps ALL deliveries of this event combined: the
71
+ delivery workflow claims a slot in a fixed one-minute window counter at the
72
+ shared store (so the cap holds across replicas) before every POST; over-limit
73
+ deliveries are deferred — parked as `status='pending'` rows and durable-slept
74
+ until the next window opens — never dropped.
75
+
76
+ ### Two audiences, one publish — and one direct door
77
+
78
+ A declared event with a `webhook:` block has **two** audiences: in-app
79
+ subscribers (`useEvent`) and subscribed HTTP targets. `ctx.events.publish(...)`
80
+ reaches **both**, from one call, on the commit boundary:
81
+
82
+ ```ts
83
+ await ctx.events.publish(orderCompleted, {}, { orderId, tenantId, total, items })
84
+ ```
85
+
86
+ Inside a mutation the publish is deferred to commit, so a rollback discards
87
+ every audience together — a partner is never told about an order the database
88
+ rolled back.
89
+
90
+ `ctx.webhooks.emit(descriptor, payload)` is the direct door, for an app that
91
+ wants the HTTP audience *only* (no in-app subscribers, no workflow triggers). It
92
+ is also post-commit inside a mutation, and it returns `{ deferred: true }` with
93
+ an empty `deliveries` list in that case — the deliveries do not exist until the
94
+ transaction commits, and an unmarked empty list would read as "no endpoint
95
+ wanted it".
96
+
97
+ **Prefer `publish`.** One declaration, every audience, and the boot's wiring
98
+ check sees it. Reaching for `emit` because you assumed `publish` did not reach
99
+ HTTP targets is a mistake a reporter made — they grepped `@voltro/runtime`,
100
+ where the fan-out is not; it lives where `ctx.events` is assembled.
101
+
102
+ ### The fan-out is tenant-scoped, and the filter is the *second* boundary
103
+
104
+ `ctx.webhooks.emit` delivers only to targets of the acting subject's tenant.
105
+
106
+ That was not true before: the service is built once at boot with the app-level
107
+ store and no subject, so the `tenant()` mixin on `_voltro_webhook_targets` had
108
+ nothing to scope by, and confinement rested entirely on each target's own
109
+ `filter`. It looked safe because a filter usually predicates on a globally
110
+ unique app id — a cross-tenant match was impossible *by accident*. It stops
111
+ being accidental the moment you introduce a value deliberately equal across
112
+ tenants.
113
+
114
+ A **system** emit — a schedule, a replay, a startup task — has no tenant to be
115
+ confined to and stays unscoped. Pass `{ tenantId }` explicitly to fan out across
116
+ tenants from a request: a deliberate cross-tenant delivery has to say so.
117
+
118
+ ```ts
119
+ await ctx.webhooks.emit(orderCompleted, payload) // this tenant
120
+ await ctx.webhooks.emit(orderCompleted, payload, { tenantId: null }) // every tenant, deliberately
121
+ ```
122
+
123
+ ### Subscribing targets
124
+
125
+ Subscribers register at runtime — from a settings page, an admin mutation, or a
126
+ seed. Targets persist; the dashboard surfaces them under a "Webhooks" tab.
127
+
128
+ ```ts
129
+ // mutations/webhooks.subscribe.mutation.server.ts
130
+ const execute = async (input: { url: string; secret?: string }, ctx: AppContext) => {
131
+ const target = await ctx.webhooks.subscribe({
132
+ event: 'order.completed',
133
+ url: input.url,
134
+ // Optional — the server generates a 32-byte hex secret if absent.
135
+ // The caller receives it ONCE in the response; subsequent reads
136
+ // never expose it again.
137
+ secret: input.secret,
138
+ retry: { // overrides the event's defaultRetry
139
+ strategy: 'exponential',
140
+ maxAttempts: 8,
141
+ initialDelay: '5s',
142
+ maxDelay: '1h',
143
+ retryOn: [408, 429, 500, 502, 503, 504],
144
+ jitter: 'full',
145
+ },
146
+ filter: { 'payload.total': { gt: 1000 } }, // optional predicate filter
147
+ headers: { 'X-Tenant': 'acme' }, // optional per-target headers
148
+ rateLimitPerMinute: 60, // optional — excess deliveries defer to the next minute window
149
+ format: 'json', // optional wire format: 'json' (default) | 'form' | 'xml'
150
+ autoDisableAfter: 20, // optional — auto-pause after N consecutive terminal failures (off by default)
151
+ })
152
+ return { targetId: target.id, secret: target.secret }
153
+ }
154
+ ```
155
+
156
+ `rateLimitPerMinute` caps wire POSTs to this target (retries count too): the
157
+ delivery workflow claims a fixed-window slot at the shared store before each
158
+ POST and defers over-limit deliveries to the next window — queued as
159
+ `status='pending'` rows, never dropped.
160
+
161
+ #### Delivery format — `json` (default) · `form` · `xml`
162
+
163
+ `format` decides the bytes the delivery workflow signs and POSTs. The producer
164
+ side always stringifies the payload to JSON; the workflow **re-encodes** it into
165
+ the target's format at delivery time and **signs the re-encoded bytes** (never
166
+ the source JSON). If a format can't represent the payload shape the delivery is
167
+ marked `failed` with a typed `WebhookPayloadUnrepresentable` reason *before* any
168
+ wire POST — a mis-formatted target never silently ships JSON under a wrong
169
+ content-type.
170
+
171
+ - **`json`** (default) — `content-type: application/json`, the JSON string
172
+ verbatim. Represents any JSON value.
173
+ - **`form`** — `content-type: application/x-www-form-urlencoded`. The payload
174
+ MUST be a JSON **object** (a top-level array/scalar is unrepresentable).
175
+ Nested structure flattens to **bracketed keys** (the PHP/Rails convention):
176
+ `{ a: 1, b: { c: 2 }, items: [{ sku: 'x' }, 'y'] }` →
177
+ `a=1&b[c]=2&items[0][sku]=x&items[1]=y`. Leaf primitives become their string
178
+ form; `null` becomes an empty value (`key=`); keys/values are percent-encoded.
179
+ - **`xml`** — `content-type: application/xml`. The payload is wrapped in a single
180
+ `<webhook>` root: object keys → child elements (`<key>…</key>`), arrays →
181
+ repeated `<item>…</item>` elements under the array's key, primitives → escaped
182
+ text, `null` → an empty element. Prefixed with the XML declaration. An object
183
+ key that isn't a valid XML element name is unrepresentable.
184
+
185
+ #### Auto-disable — dead-letter guard for failing endpoints
186
+
187
+ `autoDisableAfter` (off by default; a positive integer when set) auto-pauses a
188
+ target after that many **consecutive terminal** delivery failures. The delivery
189
+ workflow tracks a per-target streak at the shared store (multi-replica-correct):
190
+ each terminal `failed` delivery increments it, any `succeeded` resets it to
191
+ zero. When the streak reaches the threshold the target flips to `active:false`
192
+ (stamped with `autoDisabledAt` + the failure reason, surfaced on the inspect
193
+ panel) and a `warn` log fires.
194
+
195
+ Auto-disable reuses the **same pause machinery** as a manual `pauseTarget`:
196
+ while disabled, subsequent emits **queue** as `status='pending'` rows (nothing is
197
+ dropped — see the pause/flush section below). A manual `resumeTarget`
198
+ re-activates the target, **flushes** the accumulated queue, and **clears the
199
+ streak** so the next failure starts a fresh count.
200
+
201
+ ### The routing filter — all six operators
202
+
203
+ ```ts
204
+ filter: { 'payload.teamId': 'team_7' } // equality
205
+ filter: { 'payload.teamId': { in: ['team_7', 'team_9'] } } // one of
206
+ filter: { 'payload.total': { gte: 100, lt: 1000 } } // a range
207
+ ```
208
+
209
+ Dotted paths INTO the emitted envelope, whose root is `{ payload }`. A bare value
210
+ means equality; an object names an operator. `eq`, `in`, `gt`, `gte`, `lt`, `lte`
211
+ — those six and nothing else. **An unsupported operator falls through to `===`
212
+ and therefore matches nothing.**
213
+
214
+ **This was typed `Readonly<Record<string, unknown>>` and it cost a consumer a
215
+ feature for a year.** They wrote in a code comment that the filter was key-path
216
+ equality and could not express "id is one of these", refused that capability in
217
+ their own API with a typed error, and shipped it — while `in` had been supported
218
+ the whole time. One grep would have corrected it; the type did not.
219
+
220
+ It is the one place where being wrong is silent in **both** directions: a
221
+ predicate matching nothing reads as "no endpoint wanted it", and one matching
222
+ everything reads as working. A path that does not resolve is `undefined`, so a
223
+ typo routes nothing, forever, without an error.
224
+
225
+ **Operators inside one object are ANDed, and the range above did not used to
226
+ be.** The matcher returned on the first operator it found, so
227
+ `{ gte: 100, lt: 1000 }` evaluated `gte` and ignored `lt` — a `total` of 5000
228
+ satisfied a filter declared as 100–1000. That is the worse direction of wrong:
229
+ an under-matching filter delivers nothing and gets noticed, an over-matching one
230
+ posts a partner data their own filter says they must not receive, and nothing
231
+ anywhere reports it. Fixed in 0.31.0; the example on this page is the one that
232
+ was broken.
233
+
234
+ **`gt` / `gte` / `lt` / `lte` compare strings too**, lexicographically — which is
235
+ what makes an ISO timestamp work:
236
+
237
+ ```ts
238
+ filter: { 'payload.at': { lt: '2026-01-01' } } // before a date
239
+ ```
240
+
241
+ They are typed `number | string` and used to compare only when both sides were
242
+ numbers, so a string bound type-checked, subscribed, stored, and matched
243
+ nothing. Mixed types (a number bound against a string value) still match
244
+ nothing, deliberately: `'10' < 9` depends on which side JavaScript converts, and
245
+ a routing rule resting on that is worse than one that does not fire.
246
+
247
+ An operator object with **no** recognised key — `{ gtE: 5 }`, or `{}` — matches
248
+ nothing rather than everything, for the same reason a typo'd path does.
249
+
250
+ ### Emitting
251
+
252
+ Emit from any handler that imports the plugin:
253
+
254
+ ```ts
255
+ import { useWebhooks } from '@voltro/plugin-webhooks'
256
+
257
+ const execute = async (input: { orderId: string }, ctx: AppContext) => {
258
+ // ... do the mutation ...
259
+ const webhooks = useWebhooks(ctx)
260
+ await webhooks.emit('order.completed', {
261
+ orderId: newOrder.id,
262
+ tenantId: newOrder.tenantId,
263
+ total: newOrder.total,
264
+ items: newOrder.items,
265
+ })
266
+ }
267
+ ```
268
+
269
+ `useWebhooks(ctx)` returns the typed `WebhooksServiceShape` the framework
270
+ attached to `AppContext`; it throws a clear message if the plugin isn't active.
271
+ `emit()` accepts the event id or the declared event itself —
272
+ `emit(orderCompleted, payload)` types `payload` against the event schema at the
273
+ call site. Either way the payload is decoded against the schema and a mismatch
274
+ throws `WebhookPayloadInvalid`. It returns `{ eventId, deliveries }` — one entry
275
+ per matched target, each `'dispatched'` (workflow kicked off) or `'queued'` (the
276
+ target is paused — see below), for log correlation. The actual HTTP POSTs happen
277
+ in the background delivery workflow; the call returns as soon as the workflows
278
+ are kicked off, not when they complete.
279
+
280
+ The service also exposes `replay(deliveryId)`, `listTargets(event?)`,
281
+ `pauseTarget` / `resumeTarget`, `deleteTarget`, `rotateSecret`, and
282
+ `updateTargetPayloadVersion` — consumed by the dashboard's Webhooks panel.
283
+
284
+ While a target is **paused** — whether by a manual `pauseTarget` or by the
285
+ `autoDisableAfter` dead-letter guard above — emits against it queue as
286
+ `status='pending'` rows in `_voltro_webhook_deliveries` instead of POSTing —
287
+ nothing is dropped. `resumeTarget` re-activates the target and flushes its queue
288
+ through the normal delivery workflow in emit order (`createdAt` ascending,
289
+ millisecond granularity) per target, and clears any auto-disable streak.
290
+ `deleteTarget` removes the target's queued pending rows along with it. An
291
+ auto-disabled target is distinguishable from a manually-paused one by its
292
+ non-null `autoDisabledAt` / `autoDisableReason`.
293
+
294
+ ### Typed errors — `@voltro/plugin-webhooks/errors`
295
+
296
+ The outgoing `WebhooksService` ops throw `Schema.TaggedError`s, not bare
297
+ `Error`s — catch them with `Effect.catchTag` server-side, or declare them on a
298
+ mutation/action descriptor's `error:` union so the client decodes them typed:
299
+
300
+ - **`WebhookSubscribeInvalid`** (`{ field, reason }`) — `subscribe` input failed
301
+ a policy check (a non-http(s) URL, a secret below the minimum length, a
302
+ degenerate retry policy, or an out-of-range rate limit).
303
+ - **`WebhookDeliveryNotFound`** (`{ deliveryId }`) — `replay` found no delivery
304
+ row for that id.
305
+ - **`WebhookPayloadVersionInvalid`** (`{ version }`) —
306
+ `updateTargetPayloadVersion` got a non-positive-integer version.
307
+ - **`WebhookPayloadInvalid`** (`{ event, issues }`) — `emit` got a payload that
308
+ doesn't decode against the event's `payload` schema; rejected before any
309
+ delivery row or workflow run exists.
310
+ - **`WebhookPayloadUnrepresentable`** (`{ format, reason }`) — the target's
311
+ `form`/`xml` encoder can't represent the payload shape (e.g. a `form` target
312
+ whose payload is a top-level array/scalar, or an `xml` object key that isn't a
313
+ valid element name). The delivery is marked `failed` with this reason before
314
+ any wire POST.
315
+
316
+ Import these from the **browser-safe** `@voltro/plugin-webhooks/errors` subpath
317
+ when a descriptor (`*.mutation.ts` / `*.action.ts`) names one in its `error:`
318
+ schema. The package root re-exports them too but is server-only (it also
319
+ re-exports `node:crypto` / `@voltro/database` modules), so a descriptor
320
+ importing from the root would pull server code into the browser bundle — the
321
+ same rule as `@voltro/plugin-multitenancy/guard`.
322
+
323
+ ```ts
324
+ // mutations/webhooks.subscribe.mutation.ts — DESCRIPTOR (browser-safe)
325
+ import { defineMutation } from '@voltro/protocol'
326
+ import { WebhookSubscribeInvalid } from '@voltro/plugin-webhooks/errors'
327
+ import { Schema } from 'effect'
328
+
329
+ export const subscribeWebhook = defineMutation({
330
+ name: 'webhooks.subscribe',
331
+ input: Schema.Struct({ url: Schema.String }),
332
+ output: Schema.Struct({ targetId: Schema.String }),
333
+ error: WebhookSubscribeInvalid,
334
+ })
335
+ ```
336
+
337
+ ## Incoming — `defineIncomingWebhook(...)`
338
+
339
+ Declare a public HTTP endpoint the app exposes to receive webhooks from a third
340
+ party (Stripe, GitHub, Slack, a custom partner). The framework's incoming
341
+ middleware verifies the signature, decodes the body, claims an idempotency key,
342
+ and only then calls your handler.
343
+
344
+ ```ts
345
+ // webhooks/stripe.webhook.tsx
346
+ import { defineIncomingWebhook } from '@voltro/plugin-webhooks'
347
+ import { stripeProvider } from '@voltro/plugin-webhooks/providers'
348
+ import { Schema } from 'effect'
349
+
350
+ export default defineIncomingWebhook({
351
+ id: 'stripe',
352
+ provider: stripeProvider(),
353
+ payload: Schema.Struct({
354
+ id: Schema.String,
355
+ type: Schema.String,
356
+ // Stripe payloads vary by event type — narrow per handler via ctx.body.type.
357
+ data: Schema.Any,
358
+ }),
359
+ handler: async (ctx) => {
360
+ // ctx.body is the validated payload.
361
+ // ctx.idempotencyKey is the Stripe event id (from the provider preset).
362
+ // ctx.headers carries the original request headers (lowercased keys).
363
+ // ctx.rawBody is the raw bytes, if you need to recompute a MAC.
364
+ if (ctx.body.type === 'invoice.payment_succeeded') {
365
+ await store.update('invoices', ctx.body.data.object.metadata.invoiceId, { paidAt: new Date() })
366
+ }
367
+ },
368
+ })
369
+ ```
370
+
371
+ The endpoint mounts at `/webhooks/<id>` by default; override with
372
+ `path: '/integrations/stripe/v1'`. The signing secret comes from the env var
373
+ `VOLTRO_WEBHOOK_SECRET_<UPPER_ID>` — for `id: 'stripe'`, that's
374
+ `VOLTRO_WEBHOOK_SECRET_STRIPE` (non-alphanumeric characters in the id become
375
+ `_`).
376
+
377
+ ### Provider presets
378
+
379
+ Built-in presets configure the signature scheme, idempotency extraction, and
380
+ body type for the common integrations. Import them from
381
+ `@voltro/plugin-webhooks/providers`:
382
+
383
+ | Preset | Signature header | Idempotency key |
384
+ |---------------------|-------------------------|----------------------------|
385
+ | `stripeProvider()` | `Stripe-Signature` | `body.id` |
386
+ | `githubProvider()` | `X-Hub-Signature-256` | `X-GitHub-Delivery` header |
387
+ | `slackProvider()` | `X-Slack-Signature` | `X-Slack-Request-Timestamp`|
388
+ | `genericProvider()` | `X-Webhook-Signature` | `Idempotency-Key` header |
389
+
390
+ Explicit fields on `defineIncomingWebhook` always override the preset.
391
+
392
+ ### Custom providers — `defineWebhookProvider(...)`
393
+
394
+ For partners not covered by the built-ins, define your own preset in a
395
+ `*.webhook.tsx` file:
396
+
397
+ ```ts
398
+ import { defineWebhookProvider } from '@voltro/plugin-webhooks'
399
+
400
+ export default defineWebhookProvider({
401
+ id: 'acme.custom',
402
+ name: 'Acme Custom',
403
+ signature: {
404
+ _tag: 'hmac', algorithm: 'hmacSha256', header: 'X-Acme-Sig',
405
+ includeTimestamp: true, replayWindowSeconds: 600,
406
+ encoding: 'hex', versionPrefix: 'v1=',
407
+ },
408
+ idempotency: { from: 'x-acme-delivery-id', ttl: '7d' },
409
+ bodyType: 'json',
410
+ })
411
+ ```
412
+
413
+ Then reference it from another `*.webhook.tsx`:
414
+
415
+ ```ts
416
+ import { defineIncomingWebhook } from '@voltro/plugin-webhooks'
417
+ import acmeProvider from '../providers/acme.webhook'
418
+ import { Schema } from 'effect'
419
+
420
+ export default defineIncomingWebhook({
421
+ id: 'acme',
422
+ provider: acmeProvider,
423
+ payload: Schema.Struct({ id: Schema.String }),
424
+ handler: async (ctx) => { /* ... */ },
425
+ })
426
+ ```
427
+
428
+ ## Managing targets
429
+
430
+ ```ts
431
+ // Scope a target to your own dimension — opaque to the framework.
432
+ subscribe({ event: 'order.completed', url, scope: { teamId: 'q970abc' } })
433
+ listTargets('order.completed', { teamId: 'q970abc' })
434
+
435
+ // Edit in place. Absent key = leave alone; explicit null = clear.
436
+ updateTarget(id, { url: 'https://new.example/hook', headers: null })
437
+
438
+ // Send one delivery to ONE target — the first button in every webhook UI.
439
+ testTarget(id)
440
+ testTarget(id, { hello: 'world' })
441
+
442
+ // Read the delivery log without touching the framework table.
443
+ listDeliveries({ targetId, status: 'failed', since, limit: 50 })
444
+ getDelivery(id) // adds payload + responseBody
445
+ ```
446
+
447
+ **`scope` is yours and opaque.** The framework stores and returns it verbatim
448
+ and never interprets it; `listTargets` filters on equality against the keys you
449
+ pass, so a target scoped `{ teamId, projectId }` still matches `{ teamId }`.
450
+ It exists because `.with(tenant())` is one level too coarse for most
451
+ deployments — endpoints are commonly scoped to a team, a project or a workspace,
452
+ and a tenant has many of those.
453
+
454
+ **`updateTarget` writes only the keys present.** That distinction is the point:
455
+ without it, "I did not mention headers" and "delete the headers" are the same
456
+ call, which is why editing a URL used to mean delete + re-subscribe — rotating
457
+ the secret and orphaning the delivery history. `event` and `secret` stay
458
+ unpatchable: a different event is a different subscription, and the secret has
459
+ `rotateSecret`.
460
+
461
+ **`testTarget` bypasses fan-out and the filter, but not `active`.** A filter
462
+ excluding your probe payload would make a healthy endpoint look dead; a paused
463
+ target, on the other hand, queues exactly as an emit would, so the test tells the
464
+ truth about what production will do.
465
+
466
+ **`listDeliveries` omits `payload` and `responseBody`** so a list view over 200
467
+ rows does not pull every response body — `getDelivery` adds them. Timestamps
468
+ come back as ISO strings whatever the dialect returned, and an unparseable
469
+ payload is returned verbatim rather than throwing: a management view has to
470
+ render a malformed row, not 500 on it.
471
+
472
+
473
+ ## `emit` inside a mutation rides the commit — durably
474
+
475
+ An emit inside a mutation does not POST when you await it. It writes a **transactional outbox row**, and the delivery goes out after the transaction commits. A mutation that emits and then throws rolls its rows back and no request ever leaves — the subscriber is never told about a change that did not happen.
476
+
477
+ ```ts
478
+ await ctx.store.insert(order)
479
+ await ctx.webhooks.emit(orderCreated, payload)
480
+ throw new Error('boom')
481
+ // rows rolled back, outbox row rolled back with them, no POST
482
+ ```
483
+
484
+ The enqueue writes through `ctx.store`, which inside a mutation *is* the transactional view — so the intent to deliver commits with your data or not at all. There is no window in which the row landed and the delivery was lost, and a crash between commit and dispatch is a retry rather than a loss. Delivery itself stays at-least-once, which is the strongest guarantee available without distributed transactions into the receiver: **your receiver must be idempotent**, and the signature it verifies makes a duplicate cheap to recognise.
485
+
486
+ Two consequences worth knowing before you meet them:
487
+
488
+ - **The result carries no deliveries.** A deferred emit returns `{ event, deliveries: [], deferred: true }` — the rows do not exist yet and cannot, since the emit may still be rolled back. If you want the subscriber count, ask `listTargets(event)`; that is a question about configuration, not about this delivery.
489
+ - **Outside a mutation nothing defers.** A query, an action, a schedule or a workflow step has no transaction to ride, so the emit dispatches immediately. That is correct rather than a gap.
490
+
491
+ For the cases that genuinely want the request on the wire now — a diagnostic ping, an emit whose receiver you are about to poll — say so:
492
+
493
+ ```ts
494
+ await ctx.webhooks.emit(pingEvent, payload, { immediate: true })
495
+ ```
496
+
497
+ This does **not** make the emit safe: a rollback after it still tells a subscriber about a change that did not happen. The point of the flag is that the trade is written at the call site.
498
+
499
+ The deferral is durable whether or not your app declares an `*.outbox.ts` of its own — the framework registers its own delivery handler for webhook emits, so `_voltro_outbox` is a framework table every app carries. The in-memory after-commit callback is only what an app WITHOUT a webhook surface would have used, and such an app has no emit to defer.
500
+
501
+ ## "Subscribed, never emitted" — the two columns, and why there are two
502
+
503
+ The Webhooks panel's Events tab reports **delivery** and **emit** separately, because one cannot answer for the other.
504
+
505
+ `everDelivered: false` conflates three different facts:
506
+
507
+ 1. no `emit(...)` call site exists, or none ever ran — **the defect**
508
+ 2. it ran, but nobody was subscribed yet
509
+ 3. it ran, but every target's filter excluded the payload, or every target was paused
510
+
511
+ Only the first is a bug. It is also the one that is expensive to find by hand: a team shipped a create dialog offering eleven event checkboxes of which four were wired, and ticking an unwired one returned 200, showed the endpoint enabled and healthy, and delivered nothing, forever.
512
+
513
+ So `_voltro_webhook_event_stats` records every emit **regardless of whether any target matched** — the axis delivery history structurally cannot see. Their disagreement is the useful signal: *emitted, never delivered* means every target is paused, filtered out, or failing.
514
+
515
+ The table is deliberately not tenant-scoped (the question is whether the code has a live call site, not whether one tenant has triggered it) and deliberately not retention-swept (an event that fires quarterly must not read as dead). An unreadable stats table renders as **unknown**, never as "never" — "we did not look" and "it never fired" are different answers, and only one of them is a finding.
516
+
517
+ ## Failure semantics
518
+
519
+ - **Signature mismatch** → `401`, handler never runs.
520
+ - **Schema validation failure** → `422`, handler never runs.
521
+ - **Duplicate idempotency key** (within TTL) → `200` + `{ duplicate: true }`,
522
+ handler never runs.
523
+ - **Concurrent in-flight delivery** with the same idempotency key → `409`, the
524
+ provider retries later.
525
+ - **Handler exception** → `500`, the idempotency claim is released so the
526
+ provider's retry can re-process. Common providers (Stripe, GitHub, Slack)
527
+ retry `5xx` automatically.
528
+
529
+ ## A subscription is a set of events
530
+
531
+ ```ts
532
+ // One URL, a list of events — one subscription, N rows, ONE secret.
533
+ subscribe({ events: ['user.created', 'user.updated'], url, scope: { teamId } })
534
+
535
+ // Address the GROUP, not the rows.
536
+ pauseTarget({ scope: { teamId } })
537
+ updateTarget({ scope: { teamId } }, { url: 'https://new.example/hook' })
538
+ rotateSecret({ scope: { teamId } }) // → one new secret for every row
539
+ listDeliveries({ scope: { teamId } }) // → the endpoint's whole history
540
+ ```
541
+
542
+ A row is one event, but a subscription — as every webhook UI models it, ours
543
+ included — is one URL with a list of event checkboxes. `events` creates the rows
544
+ in one call, and a **`{ scope }` selector addresses them as a group** anywhere a
545
+ target id is accepted.
546
+
547
+ **The shared secret is the reason this is correctness, not ergonomics.** The
548
+ receiver verifies one signature for one URL, so N rows for one endpoint must
549
+ sign identically. `subscribe` mints ONE secret for the whole set, and
550
+ `rotateSecret({ scope })` rotates every row to the same new value — which also
551
+ replaces the delete-and-re-subscribe that used to be the only way, minting new
552
+ target ids and orphaning the delivery history.
553
+
554
+ `secret` is deliberately **not** patchable via `updateTarget`. Making a live
555
+ credential app-writable would close the same gap by weakening the invariant that
556
+ it is surfaced once and never again.
557
+
558
+ A scope that matches no row is an error, not a no-op: "pause the endpoint" that
559
+ pauses nothing and reports success is the failure this selector exists to avoid.
560
+
561
+
562
+ **Adding an event to an existing endpoint inherits its secret.** Pass the same
563
+ `scope` and `subscribe` reuses the group's secret instead of minting a new one —
564
+ the last reason an app had to read a plugin column. It refuses a scope whose rows
565
+ do not all share one secret: that is not one endpoint, and signing it as one
566
+ would re-sign half a group with a key the receiver does not hold.
567
+
568
+ ```ts
569
+ await ctx.webhooks.subscribe({ url, scope, events: ['user.deleted'] }) // same secret
570
+ ```
571
+
572
+ ## Anti-patterns
573
+
574
+ - **Inventing an `events.emit` accessor or a plugin-list entry to enable
575
+ webhooks.** Neither exists. Emit via `useWebhooks(ctx).emit(...)`; declare
576
+ events and endpoints in `*.webhook.tsx` files (the framework discovers them).
577
+ - **Trusting webhook input without signature verification.** The framework
578
+ rejects unsigned incoming requests by default. If you genuinely need to accept
579
+ unsigned traffic, set `signature: undefined` explicitly and gate the query at
580
+ the network boundary (IP allow-list, VPC peering).
581
+ - **Mutating domain state synchronously in the outgoing emit path.** Outbound
582
+ delivery is async by design. Mutate state in your mutation, THEN emit.
583
+ - **Using the same idempotency-key TTL as the provider's retry window.** Pick a
584
+ TTL of at least 2× the provider's max retry window so the dedup catches the
585
+ slowest retry.
@@ -95,7 +95,18 @@ voltro db scan-credentials
95
95
  voltro db scan-credentials --table my_events:actor
96
96
  ```
97
97
 
98
- Counts rows whose Subject carries a credential-shaped key — `token`, `secret`, `password`, `apikey`, `credential`, `privatekey` — in `_voltro_audit_log` and `_voltro_row_history`, plus any table you name with `--table <name>[:<column>]` (the column defaults to `subject`). Exit code `1` on a hit, so CI can gate on it.
98
+ Counts rows carrying a credential-shaped key — `token`, `secret`, `password`, `apikey`, `credential`, `privatekey` — plus any table you name with `--table <name>[:<column>]`. Exit code `1` on a hit, so CI can gate on it.
99
+
100
+ By default it scans every column that can physically HOLD one:
101
+
102
+ | Table | Columns |
103
+ |---|---|
104
+ | `_voltro_audit_log` | `subject`, `actor`, `scope`, `metadata`, `input`, `outcome`, `subjectId` |
105
+ | `_voltro_row_history` | `data`, `actor`, `scope`, `subjectId` |
106
+
107
+ `input` and `outcome` are a procedure's arguments and result — the likeliest place of all for a token to arrive by accident, since nobody puts a credential on a subject on purpose and plenty of code passes one as an argument. `_voltro_row_history.data` is a full-row snapshot, so a credential column on *any* of your tables is in there, and it outlives deleting the source row.
108
+
109
+ > **If you ran this on 0.30.0, 0.30.1 or 0.30.2, run it again.** Those releases scanned `subjectId` on both tables — a flat opaque id that cannot hold a credential — and none of the blob columns above. The command ran cleanly, printed a scanned count beside a hit count, and exited `0` having never looked where credentials are. It came from fixing a crash: the default column had been the literal `subject` for every table, `_voltro_row_history` has no such column, and the fix replaced the name on *both* tables instead of the one that was wrong. A clean answer from a scan that looked in the wrong place is worse than the crash it replaced. `voltro update` prints this as a manual step on the way to 0.31.0.
99
110
 
100
111
  **It is a command and not a documented query on purpose.** The same check once shipped as SQL you were asked to run yourself, in its postgres spelling (`subject::text ILIKE '%token%'`). On MySQL/MariaDB the natural translation is a bare `LIKE` — and against the `utf8mb4_bin` collation the migrator emits for a `json()` column, `LIKE` is case-**sensitive**. So `'%token%'` does not match `jiraToken`, and almost every JSON key that carries a credential is camelCase. A team ran the translated query over 141 rows, got `0`, and nearly reported themselves clean; 117 of those rows held a working credential. Every dialect now casts to its own text type before lowering, in code you do not have to translate.
101
112