@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.
- package/CHANGELOG.md +163 -0
- package/dist/apiBuild-BOqL1dlg.js +2 -0
- package/dist/{apiBuild-DmkzRyv7.js → apiBuild-CLPt-pX6.js} +2 -2
- package/dist/appGraph-CvQCte0z.js +2 -0
- package/dist/appGraph-gQ_6GkQQ.js +110 -0
- package/dist/bin.js +3 -3
- package/dist/checkCommand-ClRvH_SN.js +2 -0
- package/dist/{checkCommand-C4konUy9.js → checkCommand-D7r6_pk7.js} +439 -530
- package/dist/{commands-BkJft9K2.js → commands-CNVVv03h.js} +1941 -1880
- package/dist/{dbCommand-qn__kLEt.js → dbCommand-BH8GAYuf.js} +76 -19
- package/dist/dbCommand-BwQ_Jl98.js +2 -0
- package/dist/{dev-DXhhZ7-f.js → dev-DOZVMVfi.js} +1 -1
- package/dist/{dev-C_NPcLDp.js → dev-DdQEn2Ia.js} +1986 -1967
- package/dist/{frameworkTableAssembly-Cpgn2e88.js → frameworkTableAssembly-BwIrO5nv.js} +122 -103
- package/dist/frameworkTableAssembly-D-EebUQX.js +2 -0
- package/dist/index.js +1 -1
- package/dist/{inspectMetrics-CkB2J0Zn.js → inspectMetrics-BeEAnjqw.js} +774 -527
- package/dist/{sdkgen-wsWLrq4l.js → sdkgen-v9WV8zRi.js} +1 -1
- package/dist/{serveCommand-HtI4X5kj.js → serveCommand-KsKXXWn4.js} +294 -293
- package/dist/serveEntry.js +2 -2
- package/dist/{start-egkbpugc.js → start-CI2QdKy9.js} +351 -346
- package/dist/startEntry.js +2 -2
- package/package.json +17 -17
- package/templates/AGENTS.md +43 -43
- package/templates/agent-docs/_index.md +43 -43
- package/templates/agent-docs/_manifest.json +84 -42
- package/templates/agent-docs/cli.md +4 -2
- package/templates/agent-docs/database/migrations.md +14 -1
- package/templates/agent-docs/internationalization.md +54 -3
- package/templates/agent-docs/plugins/ai-flows.md +148 -0
- package/templates/agent-docs/plugins/analytics-postgres.md +90 -0
- package/templates/agent-docs/plugins/atlassian.md +378 -0
- package/templates/agent-docs/plugins/audit.md +324 -0
- package/templates/agent-docs/plugins/auth-auth0.md +92 -0
- package/templates/agent-docs/plugins/auth-clerk.md +90 -0
- package/templates/agent-docs/plugins/auth-kinde.md +88 -0
- package/templates/agent-docs/plugins/auth-oidc.md +95 -0
- package/templates/agent-docs/plugins/auth-supabase.md +103 -0
- package/templates/agent-docs/plugins/auth-workos.md +105 -0
- package/templates/agent-docs/plugins/auth.md +227 -0
- package/templates/agent-docs/plugins/billing.md +324 -0
- package/templates/agent-docs/plugins/broadcast.md +144 -0
- package/templates/agent-docs/plugins/cdc-out.md +116 -0
- package/templates/agent-docs/plugins/clickhouse.md +91 -0
- package/templates/agent-docs/plugins/datadog.md +105 -0
- package/templates/agent-docs/plugins/deactivation.md +114 -0
- package/templates/agent-docs/plugins/duckdb.md +82 -0
- package/templates/agent-docs/plugins/flags.md +146 -0
- package/templates/agent-docs/plugins/governance.md +143 -0
- package/templates/agent-docs/plugins/licensing.md +74 -0
- package/templates/agent-docs/plugins/logship.md +56 -0
- package/templates/agent-docs/plugins/mail.md +334 -0
- package/templates/agent-docs/plugins/moderation.md +65 -0
- package/templates/agent-docs/plugins/multitenancy.md +99 -0
- package/templates/agent-docs/plugins/notifications.md +252 -0
- package/templates/agent-docs/plugins/openapi.md +94 -0
- package/templates/agent-docs/plugins/postgis.md +140 -0
- package/templates/agent-docs/plugins/posthog.md +91 -0
- package/templates/agent-docs/plugins/presence.md +115 -0
- package/templates/agent-docs/plugins/prometheus.md +124 -0
- package/templates/agent-docs/plugins/ratelimit.md +274 -0
- package/templates/agent-docs/plugins/rbac.md +270 -0
- package/templates/agent-docs/plugins/scim.md +63 -0
- package/templates/agent-docs/plugins/search.md +116 -0
- package/templates/agent-docs/plugins/sentry.md +192 -0
- package/templates/agent-docs/plugins/soft-delete.md +72 -0
- package/templates/agent-docs/plugins/sso-saml.md +132 -0
- package/templates/agent-docs/plugins/storage.md +709 -0
- package/templates/agent-docs/plugins/tinybird.md +92 -0
- package/templates/agent-docs/plugins/versioning.md +226 -0
- package/templates/agent-docs/plugins/webhooks.md +585 -0
- package/templates/agent-docs/security.md +12 -1
- package/templates/agent-docs/whats-new.md +81 -37
- package/templates/apps/api-ai/package.json +7 -7
- package/templates/apps/api-auth/package.json +8 -8
- package/templates/apps/api-backend/package.json +7 -7
- package/templates/apps/api-backend-deactivation/package.json +7 -7
- package/templates/apps/api-backend-mail/package.json +8 -8
- package/templates/apps/api-backend-mariadb/package.json +9 -9
- package/templates/apps/api-backend-sqlite/package.json +8 -8
- package/templates/apps/api-backend-storage/package.json +8 -8
- package/templates/apps/api-cms/package.json +10 -10
- package/templates/apps/api-collab/package.json +8 -8
- package/templates/apps/api-data-advanced/package.json +8 -8
- package/templates/apps/api-durable/package.json +8 -8
- package/templates/apps/api-feature-flags/package.json +9 -9
- package/templates/apps/api-governance/package.json +8 -8
- package/templates/apps/api-kv/package.json +8 -8
- package/templates/apps/api-moderation/package.json +8 -8
- package/templates/apps/api-observability/package.json +8 -8
- package/templates/apps/api-ratelimit/package.json +8 -8
- package/templates/apps/api-rbac/package.json +8 -8
- package/templates/apps/api-rest/package.json +7 -7
- package/templates/apps/api-saas/package.json +11 -11
- package/templates/apps/api-saas-starter/package.json +10 -10
- package/templates/apps/api-search/package.json +8 -8
- package/templates/apps/api-status/package.json +8 -8
- package/templates/apps/api-versioning/package.json +8 -8
- package/templates/apps/api-webhooks/package.json +9 -9
- package/templates/apps/changelog/package.json +6 -6
- package/templates/apps/edge-functions/package.json +2 -2
- package/templates/apps/frontend-admin/package.json +8 -8
- package/templates/apps/frontend-app/package.json +8 -8
- package/templates/apps/frontend-auth/package.json +8 -8
- package/templates/apps/frontend-blank/package.json +7 -7
- package/templates/apps/frontend-cms/package.json +9 -9
- package/templates/apps/frontend-collab/package.json +9 -9
- package/templates/apps/frontend-contact/package.json +7 -7
- package/templates/apps/frontend-dashboard/package.json +7 -7
- package/templates/apps/frontend-docs/package.json +7 -7
- package/templates/apps/frontend-i18n/package.json +6 -6
- package/templates/apps/frontend-landing/package.json +7 -7
- package/templates/apps/frontend-portal/package.json +8 -8
- package/templates/apps/frontend-saas/package.json +8 -8
- package/templates/apps/frontend-spa/package.json +7 -7
- package/templates/apps/frontend-ssr/package.json +7 -7
- package/templates/apps/frontend-ssr-api/package.json +8 -8
- package/templates/apps/frontend-static-blog/package.json +6 -6
- package/templates/apps/frontend-status/package.json +8 -8
- package/templates/apps/mobile-app/package.json +2 -2
- package/dist/apiBuild-Ca6Sq5jy.js +0 -2
- package/dist/checkCommand-DHD0aBeR.js +0 -2
- package/dist/dbCommand-wfGi3FgG.js +0 -2
- 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
|
|
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
|
|