@voltro/plugin-webhooks 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/index.d.ts +28 -4
- package/dist/index.js +225 -220
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,169 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.31.0] — 2026-08-10
|
|
43
|
+
|
|
44
|
+
### Added
|
|
45
|
+
|
|
46
|
+
- **@voltro/i18n, @voltro/cli** — **`LOCALE_COOKIE` and `THEME_COOKIE` are exported from `@voltro/i18n`.**
|
|
47
|
+
|
|
48
|
+
`voltro doctor` flags a hand-written `'voltro:locale'` and tells you to "import the constant … from `@voltro/ui-shadcn`" — which was the only package exporting one. An app on the framework's i18n and not on the shadcn kit could not follow that advice without adopting a UI kit for two strings. Reported by a consumer, who added that the rule "does not fire for us, and we think that is correct-by-accident".
|
|
49
|
+
|
|
50
|
+
The names live in `@voltro/i18n` now (the package that resolves the locale from a request already hardcoded the literal), doctor's remedy names it, and `cookieNameParity.test.ts` asserts every declaration in the repo agrees AND that the remedy names a package which actually exports what it names.
|
|
51
|
+
|
|
52
|
+
That second assertion is one `scripts/check-message-apis.mjs` structurally cannot make: it verifies a member EXISTS in the published surface, and `LOCALE_COOKIE` always did — just not anywhere the reader could import it from. A reachability claim needs its own check.
|
|
53
|
+
- **@voltro/cli** — **`voltro doctor` reports two things that were decidable and unreported: dead endpoints and never-fired events.**
|
|
54
|
+
|
|
55
|
+
**A procedure requiring a scope no declared role grants.** A consumer shipped `webhooks.test` guarded by `webhooks:test` while their roles granted `webhooks:read` and `webhooks:write` and nothing else — uncallable by every user in every team, with nothing failing at boot to say so — and asked us to build the rule. The rule already existed: `rbac/unknown-scope`, in `voltro check`. It was in the wrong PLACE for them. `check` is the CI gate; `doctor` is what someone runs when something feels wrong, and a rule that only fires where you already suspect the problem fires for the people who did not need it. Doctor now surfaces the same finding from the same function — reused, not re-derived, because two answers to "which scope is ungranted" would diverge on the day it mattered.
|
|
56
|
+
|
|
57
|
+
**A declared event with no emit call site.** Asked for as a dashboard column, with the number that motivated it: seven of eleven advertised events had no emit anywhere, a week of work to remove. From a partner's side that is the expensive failure — they write a subscriber, test it, see nothing, and cannot tell "not implemented" from "my endpoint is broken". It is source-decidable, so it lands before deploy for every app rather than for whoever opens a panel afterwards. ADVISORY, and it says so: an emit through a variable reads as missing here.
|
|
58
|
+
|
|
59
|
+
Both reach `--json` and the human report. `serverOnly` and `authz` were each in one and absent from the other, twice, and "absent" read as "nothing to report".
|
|
60
|
+
- **@voltro/cli** — **`voltro dev` and `voltro doctor` report `@voltro/*` packages that are not on the same release.**
|
|
61
|
+
|
|
62
|
+
A consumer bumped 34 packages to 0.30.2 and `@voltro/i18n` stayed on 0.30.0 — their bump script's pattern was `@voltro/[a-z-]+`, and `i18n` contains digits. Their point was not the typo:
|
|
63
|
+
|
|
64
|
+
> *"Der erwähnenswerte Teil ist, dass eine Versionsschiefe innerhalb einer > Release-Familie nirgends auffällt: tsc, Tests, doctor und drei Builds waren > mit dem 0.30.0-Paket im Baum alle grün. Wenn ihr eine Stelle habt, an der das > billig zu prüfen wäre, wäre eine Warnung dort mehr wert als eine perfekte > Fehlermeldung anderswo."*
|
|
65
|
+
|
|
66
|
+
`tsc` in four apps, 19 317 tests, `voltro doctor` exit 0, three production builds — all green with a package two releases behind. None of those checks is ABOUT version agreement, so none of them could have caught it.
|
|
67
|
+
|
|
68
|
+
The framework ships in LOCKSTEP (`prepare-publish.mjs` stamps one version across every package), which is what makes this exact rather than a compatibility guess: two versions in one tree is a state the release process cannot produce. Reported at dev boot — where they asked for it, because that is where it is cheap — and in doctor's human report and `--json`. WARN, never fatal: we have no evidence a skewed tree cannot work, only that the combination was never released as a set.
|
|
69
|
+
|
|
70
|
+
Distinct from the duplicate-instance check, which asks a different question ("is one package resolved at two versions?") and has a different fix.
|
|
71
|
+
|
|
72
|
+
### Fixed
|
|
73
|
+
|
|
74
|
+
- **@voltro/cli** — **`voltro db scan-credentials` on 0.30.0–0.30.2 did not look at the column credentials are in, and exited 0.**
|
|
75
|
+
|
|
76
|
+
0.30.0 fixed a crash — the scan asked every table for a column called `subject`, `_voltro_row_history` has none, and the command died on postgres with `code=42703`. The fix replaced `subject` with `subjectId` on BOTH tables. That is right for one table and wrong for the other: `_voltro_audit_log.subject` is a `json()` blob and is the exact column the 0.28.0 upgrade note named — the one a reporting team found 117 rows of `jiraToken` in. `subjectId` is a flat opaque id that cannot hold a credential.
|
|
77
|
+
|
|
78
|
+
So for three releases a security command ran cleanly, printed a scanned count beside a hit count, and had never looked where credentials are. **A clean answer from a scan that searched the wrong place is worse than the crash it replaced.**
|
|
79
|
+
|
|
80
|
+
The default targets are derived from where a credential can physically land now: every `json()` column on `_voltro_audit_log` (`subject`, `actor`, `scope`, `metadata`, plus `input` / `outcome` — a procedure's arguments and result, the likeliest accidental home for a token), and `data` on `_voltro_row_history` — the full-row snapshot, which carries a credential column from ANY user table and outlives deleting the source row. The test that signed off on the narrowing is derived from the table declarations now, so it fails in both directions: a target naming a column that does not exist, and a column that exists and goes unscanned.
|
|
81
|
+
|
|
82
|
+
A manual codemod under 0.31.0 tells anyone who ran the command on 0.30.x to run it again — a codemod note is delivered once at a version boundary and cannot be revised, so a correction has to be re-issued under a version nobody has reached.
|
|
83
|
+
- **@voltro/cli** — **`voltro dev` announced `ready` while a different process served its port.**
|
|
84
|
+
|
|
85
|
+
Reported as an aside — "it fails with `Port 5190 is already in use` and the OLD process keeps answering" — and measured to be worse than that. With a squatter on IPv4 `127.0.0.1:5299`, vite's `host: true` bound IPv6 `*:5299` SUCCESSFULLY and the boot printed `listening on http://localhost:5299` and `ready in 56 ms`. Two listeners, one port, different stacks; every request went to the old process, with the old module graph. Not a failed boot — a boot that reports ready while your edits do nothing.
|
|
86
|
+
|
|
87
|
+
`strictPort: true` was set and working: it asks "can I bind?", and the failure is "is somebody already serving this?", which on a dual-stack host is a different question with a different answer. Both dev boot paths (api and web) now CONNECT to `127.0.0.1` and `::1` before creating a server and refuse with a `bootRefusal` naming the port, the stack, and `lsof -nP -iTCP:<port> -sTCP:LISTEN`. Exit 1, no `ready` line. Deliberately dev-only: in production the ambiguous cases are real (a sidecar, a health proxy, a rolling restart sharing a namespace) and refusing a legitimate boot is the worse failure — `serve` keeps failing on the bind alone.
|
|
88
|
+
- **@voltro/cli** — **`voltro doctor`'s duplicate-version check could not see the tree the consumer who asked for it actually had.**
|
|
89
|
+
|
|
90
|
+
They shipped the app on `@voltro/i18n@0.30.1` while `packages/ui-admin` and `ui-admin-shared` — which depend on it themselves — were left on 0.30.0. Two physical copies of a package carrying a React context; nothing failed at boot, no test caught it, and the framework's own dev-SSR diagnosis names that condition as a cause. They asked us to build the check. **It existed, and it was blind to their case.**
|
|
91
|
+
|
|
92
|
+
It walked the app's `node_modules` plus every ancestor's, on the reasoning that the pnpm virtual store is reached through the symlinks those contain. True for one copy: an ancestor's `node_modules/@voltro/i18n` is ONE symlink to ONE version. A second version pulled in by a SIBLING workspace package is linked only from that sibling's own `node_modules`, which is neither the app root nor an ancestor of it — so it was structurally invisible from the directory doctor is run in.
|
|
93
|
+
|
|
94
|
+
The pnpm store is read directly now, from its directory NAMES (`@voltro+i18n@0.30.0`), which is one `readdir` rather than a descent into thousands of inner `node_modules`. The peer-hash suffix pnpm appends is not read as a version — one package linked twice for two peer sets is normal, and reporting it would train people to ignore the rule.
|
|
95
|
+
- **@voltro/database, @voltro/sql-mysql** — **`json().default([])` emitted MySQL-only DDL and died mid-plan on MariaDB.**
|
|
96
|
+
|
|
97
|
+
The emitter had one branch for both servers, under a comment asserting MariaDB 10.2.7+ accepts the MySQL form. It does not. Measured against the real servers:
|
|
98
|
+
|
|
99
|
+
| | statement | result | |---|---|---| | MariaDB 11.8.8 | `SET DEFAULT (CAST('[]' AS JSON))` | `ERROR 1064 … near 'JSON))'` | | MariaDB 11.8.8 | `SET DEFAULT ('[]')` | OK | | MySQL 8.4.10 | `SET DEFAULT ('[]')` | `ERROR 1101 … can't have a default value` | | MySQL 8.4.10 | `SET DEFAULT (CAST('[]' AS JSON))` | OK | | BOTH | `SET DEFAULT (CONVERT('[]' USING utf8mb4))` | OK |
|
|
100
|
+
|
|
101
|
+
MariaDB has no JSON *type* — the column is `LONGTEXT` with `CHECK (json_valid(...))` — so `AS JSON` is not a cast target it has; MySQL treats a parenthesised literal as a literal and demands a real expression. The failure was not graceful: the plan died mid-apply, leaving a boot migration part-applied.
|
|
102
|
+
|
|
103
|
+
`CONVERT(… USING utf8mb4)` is emitted for both, chosen over splitting the branch because the declarative applier cannot tell the two servers apart — `sql.onDialectOrElse` collapses the family to one token — so a per-server answer would have meant plumbing the real dialect through three call sites. Both forms introspect back to something `normalizeDefault` unwraps, so the next plan is EMPTY instead of re-emitting the same op forever.
|
|
104
|
+
|
|
105
|
+
`sql-mysql/src/jsonColumnDefault.integration.test.ts` boots BOTH servers, applies the schema and asserts the inserted row carries the default — verified red against the old emitter (`ER_PARSE_ERROR 1064`) before it was made green.
|
|
106
|
+
- **@voltro/cli** — **`locales:` declared the app's languages in `voltro dev` and demanded catalogs in `voltro build` — so an app on its own i18n stack got the right language in development and `lang="en"` in production.**
|
|
107
|
+
|
|
108
|
+
`voltro dev` already separates the two: the catalogs decide the CATALOG wiring, `locales:` decides the language facts (`<html lang>`, the resolved locale, `meta.locale`). The build did not, which is this repo's dev/build drift in its worse direction — the environment nobody watches, on the day of the first deploy.
|
|
109
|
+
|
|
110
|
+
Reported by a consumer on react-i18next, who left `locales:` out because it demanded `src/locales/<lang>.ts` they do not have, and got `<html lang="en">` plus `locale: 'en'` handed to every page's `meta` in a German app:
|
|
111
|
+
|
|
112
|
+
> *"Der Parameter `locale` ist damit für uns nicht nur nutzlos, sondern > gefährlich: hätten wir ihm vertraut, wäre nach der Hydration jeder deutsche > Tab englisch geworden."*
|
|
113
|
+
|
|
114
|
+
A value the framework hands you that is confidently wrong is worse than one it withholds.
|
|
115
|
+
|
|
116
|
+
The build now emits a resolver-only i18n runtime when `locales:` is declared with no catalogs: `resolveLocale` (cookie > `Accept-Language` > default, negotiated against the declared set) with an identity `outerWrap`. No provider, no React, no catalog imports — and `voltro start` sets `<html lang>` and `meta.locale` from the real negotiation instead of falling back to `'en'`.
|
|
117
|
+
- **@voltro/cli** — **A plugin's depth was addressed as a 1 KB README that says "read the website" — a dead end for an agent working inside a repo.**
|
|
118
|
+
|
|
119
|
+
We spent a round correcting a documented recommendation (`resolveSubjectId`) that the one team who had lived it had retracted. That consumer went looking for the correction in the channel our own `AGENTS.md` advertises as authoritative:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
resolveSubjectId → 0 Treffer
|
|
123
|
+
| notifications | node_modules/@voltro/plugin-notifications/README.md |
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
> *"Diese Datei ist 1 039 Byte … Für einen Menschen ist das ein Klick. Für einen > Agenten, der im Repo arbeitet, ist es eine Sackgasse."*
|
|
127
|
+
|
|
128
|
+
Their measurement was slightly off — the correction WAS in `agent-docs/`, in `whats-new.md` — and the truth is worse than their reading. `whats-new.md` is regenerated from the LATEST release section only, so the lesson would have vanished at 0.31.0 regardless. Same structure as a codemod note: delivered once, at a boundary, unrevisable. **A durable lesson needs a durable address.**
|
|
129
|
+
|
|
130
|
+
Each plugin's docs page is now compiled into `agent-docs/plugins/<slug>.md` and the index points there first, README second — in the generated template AND in the per-project file the seeder writes, which is the one users actually read. The per-plugin modules stay out of the TOPIC table on purpose: 42 rows all titled "Plugins" would be a worse index than the dead end it replaces.
|
|
131
|
+
- **@voltro/database, @voltro/plugin-webhooks, @voltro/runtime** — **A `QueryDescriptor` without `order` took the SELECT compiler down, and the framework reported it as a database failure.**
|
|
132
|
+
|
|
133
|
+
{"msg":"mutation.webhooks.test failed: e.order is not iterable", "dbCauseChain":"TypeError: e.order is not iterable"}
|
|
134
|
+
|
|
135
|
+
`dbCauseChain` sent the reporter to the connection, the driver and the dialect. The database was never involved — the SELECT was never built. Nothing they could write in app code reached it either: the descriptor is constructed inside `@voltro/plugin-webhooks`, so `ctx.webhooks.testTarget(...)` was unusable and the outgoing-webhook path could not be exercised end to end.
|
|
136
|
+
|
|
137
|
+
The field is DECLARED required, which is why this looked impossible. A descriptor is hand-built at ~130 sites and nearly all of them cast (`as never`) past the generic row type — and a cast switches the required-field check off for the whole literal. Four sites shipped `order: undefined`.
|
|
138
|
+
|
|
139
|
+
Fixed as ONE read path (`orderClausesOf`) rather than a guard at the reported line: `order` was dereferenced bare in TWELVE places across two compilers (three in `sqlCompiler.ts`, nine in `jsonEagerCompiler.ts`), so patching the crash site would have moved it rather than removed it. The four lying call sites are corrected, and a source scan fails on a fifth.
|
|
140
|
+
- **@voltro/cli, @voltro/i18n** — **Dev SSR could build an `<I18nProvider>` the app's own `useT()` could not see, and the diagnosis we shipped pointed at a check that cannot observe the cause.**
|
|
141
|
+
|
|
142
|
+
`ssr.noExternal: ['react-intl']` without `@voltro/i18n` is itself an instance splitter: Vite externalizes a node_modules dependency in SSR, so Node — not Vite — resolves everything IT imports. An externalized `@voltro/i18n` therefore got Node's react-intl while every Vite-transformed module got the bundled one. Two instances, two contexts, and a provider invisible to a `useT()` with an error byte-identical to having no provider at all. Both are now bundled and deduped together in `voltro dev`, `voltro build` and `voltro start`, pinned by `i18nSingleInstance.test.ts` across all three configs.
|
|
143
|
+
|
|
144
|
+
The framework's diagnosis told readers to check with `pnpm ls -r --depth 10 @voltro/i18n react-intl`, and to report a framework bug if that showed one version of each. A consumer did exactly that — one version, one directory in the store, four fresh boots — and was still at a 500 on every SSR page. **The criterion was the defect: `pnpm ls` enumerates versions ON DISK and the failure is module INSTANCES in a process.** One file reached down two paths is two instances, and no package manager can see it. `@voltro/i18n` now registers each of its evaluations with the identity of the react-intl it is bound to, and the diagnosis reports the counts and the paths from the process where the render failed. When it counts one of each, it says the framework is at fault instead of making that conditional on a check that could not come back false.
|
|
145
|
+
|
|
146
|
+
The transferable half: an instruction to VERIFY has to be able to return false for the cause you are diagnosing. A check that cannot turns "I don't know" into "the framework is at fault", and the reader stops looking.
|
|
147
|
+
- **@voltro/plugin-webhooks** — **A webhook routing filter's operators were ANDed in the documentation and first-match-wins in the matcher, so the documented range OVER-matched.**
|
|
148
|
+
|
|
149
|
+
`{ gte: 100, lt: 1000 }` — the second example on the plugin's docs page — evaluated `gte` and returned, ignoring `lt`. A `total` of 5000 satisfied a filter declared as 100–1000. That is the worse direction of wrong: an under-matching filter delivers nothing and gets noticed, an over-matching one posts a partner data their own filter says they must not receive, and nothing anywhere reports it. Every present operator must now hold, and an operator object with no recognised key (`{ gtE: 5 }`, `{}`) matches nothing rather than everything.
|
|
150
|
+
|
|
151
|
+
`gt` / `gte` / `lt` / `lte` have always been TYPED `number | string` and compared only when both sides were numbers — so a string bound type-checked, subscribed, stored and matched nothing, which reads exactly like an event that never fired. Strings now compare lexicographically, which is what makes `{ 'payload.at': { lt: '2026-01-01' } }` work on an ISO timestamp. Mixed types still match nothing, deliberately: `'10' < 9` depends on which side JavaScript converts.
|
|
152
|
+
|
|
153
|
+
And the doc comment above the matcher called the equality form "v1" with operators as a future possibility, while `compareValue` implemented all six twenty lines below it. A consumer read that, refused a `resourceIds` filter for the whole life of the feature, and shipped a typed `ValidationError` telling their own users it was impossible. A comment describing an intention rather than the code under it is not a smaller doc — it is a wrong one, and more expensive than none.
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## [0.30.2] — 2026-08-09
|
|
158
|
+
|
|
159
|
+
### Changed
|
|
160
|
+
|
|
161
|
+
- **@voltro/plugin-notifications** — **The docs argued for `resolveSubjectId` using a rationale its own source had retracted.** The page justified the option with a consumer's measurement — rows belonging to people with no auth user, which employee-keying would "reach" and `subject.id` would not — and cited their number. That team then reversed the decision and wrote the correction themselves: *the subject is whatever signs in; if your addressing unit is not that, you are addressing something nobody can read.* An inbox belongs to whoever can OPEN it, and only an account can. Keying by employee never delivered those rows — it made them look addressed, and charged a translation on every read path and every push.
|
|
162
|
+
|
|
163
|
+
So the framework was teaching, in a permanent document, a lesson that the one team who had lived it had withdrawn. That is worse than an out-of-date example: it is an argument with a measurement attached, which is the most persuasive kind and here the wrong one.
|
|
164
|
+
|
|
165
|
+
The page and the option's docstring now lead with the caution, say which question to ask first ("can the thing I am addressing sign in?"), point at translating once at the SENDING seam, and keep `resolveSubjectId` recommended for the case it was actually built for — an app whose sign-in identity genuinely IS its own id, which is a different situation from a second identity some accounts happen to map to.
|
|
166
|
+
|
|
167
|
+
No behaviour change; the option works exactly as before.
|
|
168
|
+
|
|
169
|
+
### Fixed
|
|
170
|
+
|
|
171
|
+
- **@voltro/cli** — **`voltro doctor --write-authz-allowlist` deleted the list it exists to protect.** Measured by a consumer against their real file, one run, nothing else in between: 2 363 lines / 2 294 debt / 5 reviewed / 2 comment blocks before, and 31 lines / 0 debt / 5 reviewed / 0 comment blocks after. `doctor` went from exit 0 to exit 1 reporting 2 294 × "no access check" — executors that had been recorded as debt and were now recorded nowhere. They restored from backup.
|
|
172
|
+
|
|
173
|
+
The writer built the file from the scan's CURRENT `unchecked` set, and an executor already in the allowlist is classified `allowlisted`, not `unchecked` — so it appears in neither input. A first write looks perfect; the SECOND one empties the file. That hole predates the debt/reviewed split (the old writer was `formatAllowlist(res.unchecked.map(f => f.tag))`, identical shape). Rescuing `reviewed` and not debt did not cause the loss but INVERTED it: the five explained lines survived and the 2 294 unexplained ones did not, which is backwards. A reviewed line at least names a human who can be asked again; a lost debt line names nobody. The reporter's sentence is the one to keep — **the debt is the part you must not lose.**
|
|
174
|
+
|
|
175
|
+
The writer is **additive and byte-preserving** now. The existing file is kept verbatim — entries, comments, grouping, order — and only tags it does not already contain are appended under a marker saying nothing above it was touched. Removal is no longer a capability of this command; it is a hand edit, or it happens on its own when an executor gains a guard and its line stops mattering. The reporter's comment blocks survive as a consequence of the file not being rebuilt, not as a special case.
|
|
176
|
+
|
|
177
|
+
The message changed too, because the old one was true and told nobody: `wrote 0 debt tag(s)` becomes `+0 new, 2299 kept, 0 removed`, and a no-op says so. `removed` is reported although it is structurally always zero — a success line has to name the quantity that changed.
|
|
178
|
+
|
|
179
|
+
Also from the same report: a credential-shaped column that is ALSO `.unique()` is now its own finding (`plaintext-secret-lookup-key`) rather than being told to use `.encrypted()`. Encryption and equality lookup are mutually exclusive unless the cipher is deterministic, so the ordinary advice would make the column unfindable; the new advice names the hash-as-lookup-key shape the framework's own `_voltro_api_keys.hashedKey` uses, and the deterministic-cipher trade. Requested by the reporter, who hit it on two of five flagged columns.
|
|
180
|
+
- **@voltro/cli** — **The three items the previous round left open, closed.**
|
|
181
|
+
|
|
182
|
+
**An `app.config.ts` that cannot be imported silently produced an app with NO plugins — and a schema that proposes dropping every plugin table.** A consumer measured 517 tables becoming 506 when they added ONE import, with six `matches no declared table` warnings and a `pluginRef` FATAL naming a plugin that had nothing to do with the cause. The reader was `catch { return [] }`. That is not a smaller schema, it is a wrong one: plugins contribute their tables through `extendSchema.tables`, so zero plugins means every one of those tables reads as undeclared, and the differ plans a DROP. `dev.ts` carries a long comment about this exact class after it cost a release — written beside `loadApiConfigDiagnosed`, while this sibling reader kept the swallow, which is the argument for one reader rather than two. It throws `AppConfigLoadError` now, naming the consequence and the fact that the failure ran at config-EVALUATION time; `voltro check` reports it and continues with an explicit "this report is INCOMPLETE" rather than a silent empty list. The reporter's own caution is carried into the code: they measured the IMPORT and the CALL, not the library, so this is not about any one package — any module doing something non-trivial when first evaluated reaches it.
|
|
183
|
+
|
|
184
|
+
**`locales:` no longer forces you to adopt the framework's i18n.** Declaring `locales: ['de','en']` demanded `src/locales/<code>.ts` and killed the boot with `Failed to resolve import "../src/locales/de"`, so an app with its own i18n stack had to leave the option out — and then got `<html lang="en">` and `locale: 'en'` handed to every page's `meta`, for every visitor, in a German-language app. The parameter was not merely useless there, it was misleading: trusting it would have turned every German tab English after hydration. The two decisions are separate now. `locales:` declares the languages (`<html lang>`, the resolved locale, `meta.locale`); the PRESENCE of `src/locales/*` decides whether `<I18nProvider>` is wired. An app that ships catalogs is unchanged. An app that does not gets a boot line saying which mode it is in — silently not wiring a provider would have replaced one surprise with another.
|
|
185
|
+
|
|
186
|
+
**The `raw-fetch` advisory no longer reads sharper than its evidence.** It led with "no SSRF guard", flatly. A consumer opened all 13 of their call sites and measured that every host was a compile-time constant, the two dynamic URLs were built from a constant base, and every user-supplied URL already went through their own wrapper — "the remaining value is traceparent + retry + http.allowHosts, not security". Counted against this repo: 132 raw `fetch(` sites, 64 with a literal URL. So the smell now states what is true of every site (the framework client is not being used) and scopes the SSRF half to where the URL is not a constant. Being sharper than the evidence has a specific cost: it teaches the reader to skim the one line that would have named the genuinely user-supplied URL.
|
|
187
|
+
- **@voltro/cli, @voltro/web, @voltro/database, @voltro/plugin-notifications** — **Seven findings a consumer had carried for two to four releases, all measured by them, all closed.**
|
|
188
|
+
|
|
189
|
+
**No page could set its SSR `<title>`.** The generated shell bakes `<title>{app name}</title>`, and the render's head was APPENDED before `</head>` — so a response carried two title elements, and `document.title` is the FIRST per the HTML spec. The app name won on every server-rendered page; the real title appeared only after hydration. Everything that reads HTML without executing it — link previews, crawlers, a bookmark taken before hydration, a screen reader announcing the document — saw the app name. There was no app-side workaround: `.framework/index.html` is regenerated every boot and its title comes from `name`, which is one value per app. **FIVE call sites** spliced a head by hand (`ssrShell`, the static prerender and the SPA shell in `build.ts`, and two in `start.ts`); the fifth was found by the guard written for the first four. One `mergeHeadInjection` now, which replaces the shell's title when the render brought one. Verified in a real chromium against a real `voltro dev`: exactly one `<title>`, and it is the page's.
|
|
190
|
+
|
|
191
|
+
**`voltro db scan-credentials` died on postgres — the default dialect, against a framework table.** It asked for a column named `subject`; `_voltro_row_history` has `subjectId`, `actor` and `scope`. Two defects, and the second is the one to keep: only the `COUNT(*)` probe was inside the try, so a missing COLUMN escaped as an unhandled error — jumping clean over this module's own "a missing target reports as missing" promise and over `scanExitCode`'s exit-2-for-a-vacuous-run. Both defaults and both guards are fixed; all three subject-shaped columns are scanned, an unavailable column reports as unavailable, and a missing table is said ONCE rather than once per column. Verified against live postgres: a planted credential in `actor` exits 1, an empty schema exits 2 with "NOTHING WAS SCANNED".
|
|
192
|
+
|
|
193
|
+
**`doctor` described constrained executors as unconstrained.** An app that registers `setRowFilter` had all four of its filtered executors reported as "nothing constraining WHICH row" — for handlers that are, measured, already narrowed to the caller's own rows. It cannot be downgraded to a pass (the filter is a function from table to predicate; nothing static can say whether it covers a given table), but the wording pushed the reader toward the allowlist, where the line later reads as "checked and accepted" while meaning "the tool could not see it". The finding now names the row filter and what to verify, and the summary says once that the scan is blind there.
|
|
194
|
+
|
|
195
|
+
**A standing DELETE never introduced itself.** Retention is registered by plugins the app never wrote a line about, with defaults in months, and nothing announced it at boot — a consumer lost 1 944 freshly-migrated rows to a 180-day default, then got it wrong a second time by setting the env var in a running pod rather than a file. One line per policy now: the table, the age, the column it is measured on, whether it is conditional, and the variable that changes it.
|
|
196
|
+
|
|
197
|
+
**The notification inbox had no index for its own two read paths.** It declared `byInboxSubject(subjectId)` while the plugin itself issues `subjectId = ? ORDER BY createdAt DESC LIMIT ?` and `subjectId = ? AND readAt IS NULL`. Both are declared now, the unread one PARTIAL — 3 of 2 475 rows were unread in their fullest inbox.
|
|
198
|
+
|
|
199
|
+
**Four notification routes were served and unreachable.** `archive`, `unarchive`, `markUnread` and `markAllRead` were registered as routes and missing from `notificationsRpcClientImports`, whose comment said "kept in lockstep with the exports". A comment is not a lockstep; `rpcSurfaceLockstep.test.ts` counts both sides.
|
|
200
|
+
|
|
201
|
+
**Nothing reported a page with no `meta`.** 75 of 243 pages across three of their apps had none — never, not since a migration — each serving the app name as its title. `voltro doctor` reports them now. Their own first gate is worth repeating: it keyed on pages rendering a particular wrapper and ran green while 60 pages without it had no title at all. The condition is the PAGE, not the wrapper it happens to use.
|
|
202
|
+
|
|
203
|
+
---
|
|
204
|
+
|
|
42
205
|
## [0.30.1] — 2026-08-09
|
|
43
206
|
|
|
44
207
|
### Fixed
|
package/dist/index.d.ts
CHANGED
|
@@ -489,10 +489,34 @@ export declare const makeDeferredEmitHandler: (
|
|
|
489
489
|
*/
|
|
490
490
|
emitter: () => Pick<WebhooksServiceShape, "emit"> | undefined) => OutboxHandlerDefinition;
|
|
491
491
|
|
|
492
|
-
/**
|
|
493
|
-
*
|
|
494
|
-
*
|
|
495
|
-
*
|
|
492
|
+
/**
|
|
493
|
+
* Evaluate a target's filter against a payload. `true` → this target receives
|
|
494
|
+
* the delivery.
|
|
495
|
+
*
|
|
496
|
+
* **Every supported form, because under-describing this cost a consumer the
|
|
497
|
+
* whole life of the feature.** The sentence that used to be here said the
|
|
498
|
+
* key-path equality form was "v1" and that "future versions can grow operators
|
|
499
|
+
* ({ gt, lt, in, … })" — while the operators were implemented in
|
|
500
|
+
* `compareValue`, twenty lines below it, and had been all along. They read that
|
|
501
|
+
* as a roadmap note, refused a `resourceIds` filter they needed, and shipped a
|
|
502
|
+
* typed `ValidationError` telling their own users it was impossible.
|
|
503
|
+
*
|
|
504
|
+
* A doc comment that describes an intention rather than the code under it is
|
|
505
|
+
* not a smaller doc — it is a wrong one, and it is more expensive than none,
|
|
506
|
+
* because a reader who finds nothing goes and reads the source.
|
|
507
|
+
*
|
|
508
|
+
* `{ 'payload.status': 'paid' }` equality (a bare value)
|
|
509
|
+
* `{ 'payload.status': { eq: 'paid' } }` explicit equality
|
|
510
|
+
* `{ 'payload.id': { in: ['a', 'b'] } }` membership
|
|
511
|
+
* `{ 'payload.total': { gt: 100 } }` > (numbers, or strings
|
|
512
|
+
* `{ 'payload.total': { gte: 100 } }` >= compared lexicographically
|
|
513
|
+
* `{ 'payload.at': { lt: '2026-01-01' } }` < — ISO dates sort correctly)
|
|
514
|
+
* `{ 'payload.total': { lte: 100 } }` <=
|
|
515
|
+
*
|
|
516
|
+
* Keys are ANDed. The root of a path is `{ payload }`, so every path begins
|
|
517
|
+
* `payload.`; a path that does not resolve reads `undefined` and fails the
|
|
518
|
+
* comparison, which is how a typo routes nothing rather than everything.
|
|
519
|
+
*/
|
|
496
520
|
export declare const matchesFilter: (filter: WebhookFilter | Readonly<Record<string, unknown>> | null, payload: unknown) => boolean;
|
|
497
521
|
|
|
498
522
|
/**
|
package/dist/index.js
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
1
|
import { a as e, c as t, i as n, l as r, n as i, o as a, r as o, s, t as c } from "./signing-DUG4JWwk.js";
|
|
2
2
|
import { WebhookDeliveryNotFound as l, WebhookPayloadInvalid as u, WebhookPayloadUnrepresentable as d, WebhookPayloadVersionInvalid as f, WebhookSubscribeInvalid as p } from "./errors.js";
|
|
3
|
-
import { _voltroWebhookDeliveriesTable as m, _voltroWebhookEventStatsTable as h, _voltroWebhookRateWindowsTable as
|
|
4
|
-
import { randomBytes as
|
|
5
|
-
import { Context as
|
|
6
|
-
import { and as
|
|
7
|
-
import { assertPublicUrl as
|
|
8
|
-
import { publishServerError as
|
|
9
|
-
import { createLogger as
|
|
10
|
-
import { Activity as
|
|
11
|
-
import { defineOutboxHandler as
|
|
3
|
+
import { _voltroWebhookDeliveriesTable as m, _voltroWebhookEventStatsTable as h, _voltroWebhookRateWindowsTable as g, _voltroWebhookTargetsTable as _, webhookTables as v } from "./mixin.js";
|
|
4
|
+
import { randomBytes as y, randomUUID as b } from "node:crypto";
|
|
5
|
+
import { Context as ee, Effect as x, Either as te, Schema as S } from "effect";
|
|
6
|
+
import { and as C, eq as w } from "@voltro/database";
|
|
7
|
+
import { assertPublicUrl as T } from "@voltro/integration-http";
|
|
8
|
+
import { publishServerError as ne } from "@voltro/protocol";
|
|
9
|
+
import { createLogger as E } from "@voltro/logger";
|
|
10
|
+
import { Activity as D, DurableClock as O, Workflow as re } from "@effect/workflow";
|
|
11
|
+
import { defineOutboxHandler as ie } from "@voltro/runtime";
|
|
12
12
|
//#region src/eventAudience.ts
|
|
13
|
-
var
|
|
13
|
+
var ae = (e) => e.webhook !== void 0, k = (e) => {
|
|
14
14
|
let t = e.webhook;
|
|
15
15
|
if (t !== void 0) return {
|
|
16
16
|
_tag: "outgoingEvent",
|
|
@@ -20,8 +20,8 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
20
20
|
...t.version === void 0 ? {} : { version: t.version },
|
|
21
21
|
...t.rateLimit === void 0 ? {} : { globalRateLimit: t.rateLimit }
|
|
22
22
|
};
|
|
23
|
-
},
|
|
24
|
-
let t =
|
|
23
|
+
}, oe = /^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)?$/, A = (e) => {
|
|
24
|
+
let t = oe.exec(e);
|
|
25
25
|
if (!t) throw Error(`invalid duration literal: ${e}`);
|
|
26
26
|
let n = Number(t[1]), r = t[2] ?? "s";
|
|
27
27
|
switch (r) {
|
|
@@ -32,7 +32,7 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
32
32
|
case "d": return n * 864e5;
|
|
33
33
|
default: throw Error(`unreachable unit: ${r}`);
|
|
34
34
|
}
|
|
35
|
-
},
|
|
35
|
+
}, j = [
|
|
36
36
|
408,
|
|
37
37
|
425,
|
|
38
38
|
429,
|
|
@@ -40,16 +40,16 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
40
40
|
502,
|
|
41
41
|
503,
|
|
42
42
|
504
|
|
43
|
-
],
|
|
44
|
-
if (t >= e.maxAttempts || n?.status !== void 0 && !(e.retryOn ??
|
|
43
|
+
], M = (e, t, n) => {
|
|
44
|
+
if (t >= e.maxAttempts || n?.status !== void 0 && !(e.retryOn ?? j).includes(n.status)) return null;
|
|
45
45
|
if (e.honourRetryAfter !== !1 && n?.retryAfterSeconds !== void 0) {
|
|
46
|
-
let t = n.retryAfterSeconds * 1e3, r =
|
|
46
|
+
let t = n.retryAfterSeconds * 1e3, r = A(e.maxDelay), i = Math.min(t, r);
|
|
47
47
|
return {
|
|
48
48
|
delayMs: i,
|
|
49
49
|
baseDelayMs: i
|
|
50
50
|
};
|
|
51
51
|
}
|
|
52
|
-
let r =
|
|
52
|
+
let r = A(e.initialDelay), i = A(e.maxDelay), a;
|
|
53
53
|
switch (e.strategy) {
|
|
54
54
|
case "fixed":
|
|
55
55
|
a = r;
|
|
@@ -67,23 +67,23 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
67
67
|
delayMs: Math.max(1, Math.floor(o)),
|
|
68
68
|
baseDelayMs: a
|
|
69
69
|
};
|
|
70
|
-
},
|
|
70
|
+
}, se = () => ({
|
|
71
71
|
strategy: "exponential",
|
|
72
72
|
maxAttempts: 8,
|
|
73
73
|
initialDelay: "5s",
|
|
74
74
|
maxDelay: "1h",
|
|
75
|
-
retryOn:
|
|
75
|
+
retryOn: j,
|
|
76
76
|
honourRetryAfter: !0,
|
|
77
77
|
jitter: "full"
|
|
78
|
-
}),
|
|
78
|
+
}), ce = () => ({
|
|
79
79
|
strategy: "fixed",
|
|
80
80
|
maxAttempts: 120,
|
|
81
81
|
initialDelay: "30s",
|
|
82
82
|
maxDelay: "30s",
|
|
83
|
-
retryOn:
|
|
83
|
+
retryOn: j,
|
|
84
84
|
honourRetryAfter: !0,
|
|
85
85
|
jitter: "full"
|
|
86
|
-
}),
|
|
86
|
+
}), N = class extends ee.Tag("@voltro/webhooks/WebhooksService")() {}, le = 32, P = 32, F = () => y(le).toString("hex"), I = (e) => e.events ?? (e.event === void 0 ? [] : [e.event]), L = (e) => {
|
|
87
87
|
let t;
|
|
88
88
|
try {
|
|
89
89
|
t = new URL(e.url);
|
|
@@ -98,14 +98,14 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
98
98
|
reason: `url protocol must be http or https, got ${t.protocol}`
|
|
99
99
|
});
|
|
100
100
|
if (process.env.NODE_ENV === "production") try {
|
|
101
|
-
|
|
101
|
+
T(e.url);
|
|
102
102
|
} catch (e) {
|
|
103
103
|
throw new p({
|
|
104
104
|
field: "url",
|
|
105
105
|
reason: e.message
|
|
106
106
|
});
|
|
107
107
|
}
|
|
108
|
-
let n =
|
|
108
|
+
let n = I(e);
|
|
109
109
|
if (n.length === 0) throw new p({
|
|
110
110
|
field: "event",
|
|
111
111
|
reason: "event id required"
|
|
@@ -122,9 +122,9 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
122
122
|
field: "events",
|
|
123
123
|
reason: "duplicate event — one row per event, so a repeat would deliver twice"
|
|
124
124
|
});
|
|
125
|
-
if (e.secret !== void 0 && e.secret.length <
|
|
125
|
+
if (e.secret !== void 0 && e.secret.length < P) throw new p({
|
|
126
126
|
field: "secret",
|
|
127
|
-
reason: `secret must be at least ${
|
|
127
|
+
reason: `secret must be at least ${P} chars`
|
|
128
128
|
});
|
|
129
129
|
if (e.retry !== void 0) {
|
|
130
130
|
if (e.retry.maxAttempts < 1) throw new p({
|
|
@@ -144,14 +144,14 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
144
144
|
field: "autoDisableAfter",
|
|
145
145
|
reason: `autoDisableAfter must be a positive integer, got ${e.autoDisableAfter}`
|
|
146
146
|
});
|
|
147
|
-
},
|
|
148
|
-
id:
|
|
147
|
+
}, ue = (e, t, n, r) => ({
|
|
148
|
+
id: b(),
|
|
149
149
|
tenantId: e.tenantId ?? null,
|
|
150
150
|
event: n ?? e.event ?? "",
|
|
151
151
|
url: e.url,
|
|
152
|
-
secret: r ?? e.secret ??
|
|
152
|
+
secret: r ?? e.secret ?? F(),
|
|
153
153
|
signing: e.signing ?? t?.defaultSigning ?? c(),
|
|
154
|
-
retry: e.retry ?? t?.defaultRetry ??
|
|
154
|
+
retry: e.retry ?? t?.defaultRetry ?? se(),
|
|
155
155
|
filter: e.filter ?? null,
|
|
156
156
|
scope: e.scope ?? null,
|
|
157
157
|
headers: e.headers ?? null,
|
|
@@ -164,14 +164,14 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
164
164
|
autoDisableReason: null,
|
|
165
165
|
payloadVersion: e.payloadVersion ?? t?.version ?? 1,
|
|
166
166
|
description: e.description ?? null
|
|
167
|
-
}),
|
|
167
|
+
}), de = (e) => e instanceof Date ? e.getTime() : typeof e == "string" ? Date.parse(e) : 0, R = (e) => e instanceof Date ? e.toISOString() : typeof e == "string" ? e : null, fe = (e) => {
|
|
168
168
|
if (typeof e != "string") return e;
|
|
169
169
|
try {
|
|
170
170
|
return JSON.parse(e);
|
|
171
171
|
} catch {
|
|
172
172
|
return e;
|
|
173
173
|
}
|
|
174
|
-
},
|
|
174
|
+
}, pe = (e) => ({
|
|
175
175
|
id: String(e.id),
|
|
176
176
|
deliveryId: String(e.deliveryId),
|
|
177
177
|
targetId: String(e.targetId),
|
|
@@ -182,30 +182,35 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
182
182
|
responseStatus: e.responseStatus ?? null,
|
|
183
183
|
errorMessage: e.errorMessage ?? null,
|
|
184
184
|
latencyMs: e.latencyMs ?? null,
|
|
185
|
-
scheduledAt:
|
|
186
|
-
nextAttemptAt:
|
|
187
|
-
createdAt:
|
|
188
|
-
}), me = (e, t) => e === t ? "current" : e < t ? "behind" : "ahead",
|
|
185
|
+
scheduledAt: R(e.scheduledAt),
|
|
186
|
+
nextAttemptAt: R(e.nextAttemptAt),
|
|
187
|
+
createdAt: R(e.createdAt)
|
|
188
|
+
}), me = (e, t) => e === t ? "current" : e < t ? "behind" : "ahead", he = (e, t) => {
|
|
189
189
|
if (e === null) return !0;
|
|
190
190
|
let n = { payload: t };
|
|
191
|
-
for (let [t, r] of Object.entries(e)) if (!ge(
|
|
191
|
+
for (let [t, r] of Object.entries(e)) if (!_e(ge(n, t), r)) return !1;
|
|
192
192
|
return !0;
|
|
193
|
-
},
|
|
193
|
+
}, ge = (e, t) => {
|
|
194
194
|
let n = t.split("."), r = e;
|
|
195
195
|
for (let e of n) {
|
|
196
196
|
if (typeof r != "object" || !r) return;
|
|
197
197
|
r = r[e];
|
|
198
198
|
}
|
|
199
199
|
return r;
|
|
200
|
-
},
|
|
200
|
+
}, z = (e, t) => {
|
|
201
|
+
if (typeof e == "number" && typeof t == "number") return e - t;
|
|
202
|
+
if (typeof e == "string" && typeof t == "string") return e < t ? -1 : +(e > t);
|
|
203
|
+
}, _e = (e, t) => {
|
|
201
204
|
if (typeof t == "object" && t && !Array.isArray(t)) {
|
|
202
205
|
let n = t;
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
206
|
+
return "eq" in n && e !== n.eq || "in" in n && (!Array.isArray(n.in) || !n.in.includes(e)) || "gt" in n && !((z(e, n.gt) ?? 0) > 0) || "gte" in n && !((z(e, n.gte) ?? -1) >= 0) || "lt" in n && !((z(e, n.lt) ?? 0) < 0) || "lte" in n && !((z(e, n.lte) ?? 1) <= 0) ? !1 : [
|
|
207
|
+
"eq",
|
|
208
|
+
"in",
|
|
209
|
+
"gt",
|
|
210
|
+
"gte",
|
|
211
|
+
"lt",
|
|
212
|
+
"lte"
|
|
213
|
+
].some((e) => e in n);
|
|
209
214
|
}
|
|
210
215
|
return e === t;
|
|
211
216
|
}, B = (e, t) => e !== null && Object.entries(t).every(([t, n]) => e[t] === n), V = 500, H = 200, U = async (e, t) => {
|
|
@@ -225,17 +230,17 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
225
230
|
if (n.push(...i), i.length < V) return n;
|
|
226
231
|
}
|
|
227
232
|
throw Error(`webhooks: more than ${H * V} rows in \`_voltro_webhook_targets\` — refusing to answer a scope lookup from a partial scan. A truncated scan mints a fresh secret for a live endpoint (breaking its signatures) or reports that a real scope matches nothing.`);
|
|
228
|
-
},
|
|
233
|
+
}, ve = async (e, t) => {
|
|
229
234
|
let n = (await U(e)).filter((e) => B(e.scope, t));
|
|
230
235
|
if (n.length === 0) return;
|
|
231
236
|
let r = new Set(n.map((e) => e.secret));
|
|
232
237
|
if (r.size > 1) throw Error(`webhooks.subscribe: the scope ${JSON.stringify(t)} matches ${n.length} targets with ${r.size} different secrets, so it is not one endpoint. Pass \`secret\` explicitly, or narrow the scope.`);
|
|
233
238
|
return [...r][0];
|
|
234
|
-
},
|
|
239
|
+
}, ye = async (e, t) => {
|
|
235
240
|
try {
|
|
236
241
|
let n = await e.store.query({
|
|
237
242
|
table: "_voltro_webhook_event_stats",
|
|
238
|
-
predicate:
|
|
243
|
+
predicate: w("event", t),
|
|
239
244
|
order: [],
|
|
240
245
|
take: 1,
|
|
241
246
|
skip: void 0,
|
|
@@ -257,11 +262,11 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
257
262
|
updatedAt: r
|
|
258
263
|
});
|
|
259
264
|
} catch {}
|
|
260
|
-
},
|
|
265
|
+
}, be = (e, t, n = {}) => {
|
|
261
266
|
let r = new Map((n.events ?? []).map((e) => [e.id, e])), i = {
|
|
262
267
|
subscribe: async (t) => {
|
|
263
|
-
|
|
264
|
-
let n =
|
|
268
|
+
L(t);
|
|
269
|
+
let n = I(t), i = t.secret === void 0 && t.scope !== void 0 ? await ve(e, t.scope) : void 0, a = t.secret ?? i ?? F(), o = n.map((e) => ue(t, r.get(e), e, a));
|
|
265
270
|
for (let t of o) await e.store.insert("_voltro_webhook_targets", {
|
|
266
271
|
...t,
|
|
267
272
|
createdAt: /* @__PURE__ */ new Date(),
|
|
@@ -282,19 +287,19 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
282
287
|
};
|
|
283
288
|
},
|
|
284
289
|
emit: async (n, i, a) => {
|
|
285
|
-
let o = typeof n == "object" && n.kind === "event" ?
|
|
290
|
+
let o = typeof n == "object" && n.kind === "event" ? k(n) : void 0, s = typeof n == "string" ? r.get(n) : o ?? n, c = typeof n == "string" ? n : n.id ?? n.name;
|
|
286
291
|
if (s !== void 0) {
|
|
287
|
-
let e =
|
|
288
|
-
if (
|
|
292
|
+
let e = S.decodeUnknownEither(s.payload)(i, { errors: "all" });
|
|
293
|
+
if (te.isLeft(e)) throw new u({
|
|
289
294
|
event: c,
|
|
290
295
|
issues: e.left.message
|
|
291
296
|
});
|
|
292
297
|
}
|
|
293
|
-
let l =
|
|
294
|
-
await
|
|
295
|
-
let p = f.filter((e) =>
|
|
298
|
+
let l = b(), d = a?.tenantId, f = await U(e, d == null ? w("event", c) : C(w("event", c), w("tenantId", d)));
|
|
299
|
+
await ye(e, c);
|
|
300
|
+
let p = f.filter((e) => he(e.filter, i)), m = [], h = JSON.stringify(i);
|
|
296
301
|
for (let n of p) {
|
|
297
|
-
let r =
|
|
302
|
+
let r = b();
|
|
298
303
|
n.active ? (await t({
|
|
299
304
|
deliveryId: r,
|
|
300
305
|
targetId: n.id,
|
|
@@ -332,7 +337,7 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
332
337
|
replay: async (n) => {
|
|
333
338
|
let r = (await e.store.query({
|
|
334
339
|
table: "_voltro_webhook_deliveries",
|
|
335
|
-
predicate:
|
|
340
|
+
predicate: w("deliveryId", n),
|
|
336
341
|
order: [{
|
|
337
342
|
column: "attempt",
|
|
338
343
|
direction: "asc"
|
|
@@ -346,7 +351,7 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
346
351
|
deliveryId: r.deliveryId,
|
|
347
352
|
targetId: r.targetId,
|
|
348
353
|
event: r.event,
|
|
349
|
-
eventId: r.eventId ??
|
|
354
|
+
eventId: r.eventId ?? b(),
|
|
350
355
|
payloadJson: typeof r.payload == "string" ? r.payload : JSON.stringify(r.payload),
|
|
351
356
|
attemptEpoch: Date.now()
|
|
352
357
|
});
|
|
@@ -374,7 +379,7 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
374
379
|
});
|
|
375
380
|
let n = await e.store.query({
|
|
376
381
|
table: "_voltro_webhook_deliveries",
|
|
377
|
-
predicate:
|
|
382
|
+
predicate: C(w("targetId", r), w("status", "pending")),
|
|
378
383
|
order: [{
|
|
379
384
|
column: "createdAt",
|
|
380
385
|
direction: "asc"
|
|
@@ -387,17 +392,17 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
387
392
|
deliveryId: e.deliveryId,
|
|
388
393
|
targetId: r,
|
|
389
394
|
event: e.event,
|
|
390
|
-
eventId: e.eventId ??
|
|
395
|
+
eventId: e.eventId ?? b(),
|
|
391
396
|
payloadJson: typeof e.payload == "string" ? e.payload : JSON.stringify(e.payload),
|
|
392
397
|
attemptEpoch: i
|
|
393
398
|
});
|
|
394
399
|
}
|
|
395
400
|
},
|
|
396
401
|
deleteTarget: async (t) => {
|
|
397
|
-
for (let n of await i.resolveTargets(t)) await e.store.delete("_voltro_webhook_targets", n), await e.store.deleteMany("_voltro_webhook_deliveries", { where:
|
|
402
|
+
for (let n of await i.resolveTargets(t)) await e.store.delete("_voltro_webhook_targets", n), await e.store.deleteMany("_voltro_webhook_deliveries", { where: C(w("targetId", n), w("status", "pending")) });
|
|
398
403
|
},
|
|
399
404
|
rotateSecret: async (t) => {
|
|
400
|
-
let n =
|
|
405
|
+
let n = F();
|
|
401
406
|
for (let r of await i.resolveTargets(t)) await e.store.update("_voltro_webhook_targets", r, {
|
|
402
407
|
secret: n,
|
|
403
408
|
updatedAt: /* @__PURE__ */ new Date()
|
|
@@ -405,7 +410,7 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
405
410
|
return { secret: n };
|
|
406
411
|
},
|
|
407
412
|
updateTarget: async (t, n) => {
|
|
408
|
-
if (n.url !== void 0 &&
|
|
413
|
+
if (n.url !== void 0 && L({
|
|
409
414
|
event: "x",
|
|
410
415
|
url: n.url
|
|
411
416
|
}), n.rateLimitPerMinute !== void 0 && n.rateLimitPerMinute !== null && (!Number.isInteger(n.rateLimitPerMinute) || n.rateLimitPerMinute < 1)) throw Error("updateTarget: rateLimitPerMinute must be a positive integer or null");
|
|
@@ -425,14 +430,14 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
425
430
|
testTarget: async (n, r) => {
|
|
426
431
|
let i = (await e.store.query({
|
|
427
432
|
table: "_voltro_webhook_targets",
|
|
428
|
-
predicate:
|
|
429
|
-
order:
|
|
433
|
+
predicate: w("id", n),
|
|
434
|
+
order: [],
|
|
430
435
|
take: 1,
|
|
431
436
|
skip: void 0,
|
|
432
437
|
projection: void 0
|
|
433
438
|
}))[0];
|
|
434
439
|
if (i === void 0) throw Error(`testTarget: no target ${n}`);
|
|
435
|
-
let a =
|
|
440
|
+
let a = b(), o = JSON.stringify(r ?? {
|
|
436
441
|
test: !0,
|
|
437
442
|
targetId: n,
|
|
438
443
|
at: (/* @__PURE__ */ new Date()).toISOString()
|
|
@@ -441,7 +446,7 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
441
446
|
deliveryId: a,
|
|
442
447
|
targetId: i.id,
|
|
443
448
|
event: i.event,
|
|
444
|
-
eventId:
|
|
449
|
+
eventId: b(),
|
|
445
450
|
payloadJson: o
|
|
446
451
|
}) : await e.store.insert("_voltro_webhook_deliveries", {
|
|
447
452
|
id: `${a}:1`,
|
|
@@ -461,7 +466,7 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
461
466
|
listDeliveries: async (t) => {
|
|
462
467
|
let n = t?.scope === void 0 ? void 0 : new Set(await i.resolveTargets({ scope: t.scope })), r = await e.store.query({
|
|
463
468
|
table: "_voltro_webhook_deliveries",
|
|
464
|
-
predicate: t?.targetId === void 0 ? void 0 :
|
|
469
|
+
predicate: t?.targetId === void 0 ? void 0 : w("targetId", t.targetId),
|
|
465
470
|
order: [{
|
|
466
471
|
column: "createdAt",
|
|
467
472
|
direction: "desc"
|
|
@@ -470,20 +475,20 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
470
475
|
skip: void 0,
|
|
471
476
|
projection: void 0
|
|
472
477
|
}), a = t?.since?.getTime();
|
|
473
|
-
return r.filter((e) => n === void 0 || n.has(String(e.targetId))).filter((e) => t?.status === void 0 || e.status === t.status).filter((e) => a === void 0 ||
|
|
478
|
+
return r.filter((e) => n === void 0 || n.has(String(e.targetId))).filter((e) => t?.status === void 0 || e.status === t.status).filter((e) => a === void 0 || de(e.createdAt) >= a).slice(0, t?.limit ?? 200).map(pe);
|
|
474
479
|
},
|
|
475
480
|
getDelivery: async (t) => {
|
|
476
481
|
let n = (await e.store.query({
|
|
477
482
|
table: "_voltro_webhook_deliveries",
|
|
478
|
-
predicate:
|
|
479
|
-
order:
|
|
483
|
+
predicate: w("id", t),
|
|
484
|
+
order: [],
|
|
480
485
|
take: 1,
|
|
481
486
|
skip: void 0,
|
|
482
487
|
projection: void 0
|
|
483
488
|
}))[0];
|
|
484
489
|
return n === void 0 ? null : {
|
|
485
|
-
...
|
|
486
|
-
payload:
|
|
490
|
+
...pe(n),
|
|
491
|
+
payload: fe(n.payload),
|
|
487
492
|
responseBody: n.responseBody ?? null
|
|
488
493
|
};
|
|
489
494
|
},
|
|
@@ -497,7 +502,7 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
497
502
|
listTargets: async (t, n) => {
|
|
498
503
|
let r = await e.store.query({
|
|
499
504
|
table: "_voltro_webhook_targets",
|
|
500
|
-
predicate: t === void 0 ? void 0 :
|
|
505
|
+
predicate: t === void 0 ? void 0 : w("event", t),
|
|
501
506
|
order: [{
|
|
502
507
|
column: "createdAt",
|
|
503
508
|
direction: "desc"
|
|
@@ -523,15 +528,15 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
523
528
|
}
|
|
524
529
|
};
|
|
525
530
|
return i;
|
|
526
|
-
},
|
|
527
|
-
return yield*
|
|
528
|
-
}),
|
|
531
|
+
}, xe = x.gen(function* () {
|
|
532
|
+
return yield* N;
|
|
533
|
+
}), Se = (e) => {
|
|
529
534
|
if (e.webhooks === void 0) throw Error("useWebhooks: `ctx.webhooks` is not set. Add @voltro/plugin-webhooks to your app.config.ts plugin list and declare at least one *.webhook.tsx file.");
|
|
530
535
|
return e.webhooks;
|
|
531
|
-
},
|
|
536
|
+
}, Ce = 1e4, W = class {
|
|
532
537
|
capacity;
|
|
533
538
|
cache = /* @__PURE__ */ new Map();
|
|
534
|
-
constructor(e =
|
|
539
|
+
constructor(e = Ce) {
|
|
535
540
|
this.capacity = e;
|
|
536
541
|
}
|
|
537
542
|
evictExpired() {
|
|
@@ -577,9 +582,9 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
577
582
|
status: e,
|
|
578
583
|
contentType: "application/json; charset=utf-8",
|
|
579
584
|
body: JSON.stringify(t)
|
|
580
|
-
}),
|
|
585
|
+
}), we = (e, t) => {
|
|
581
586
|
let n = e.provider, r = e.signature ?? n?.signature ?? null, i = e.idempotency ?? n?.idempotency ?? null, o = e.bodyType ?? n?.bodyType ?? "json", s = t.idempotencyCache ?? K(), c = J(i?.ttl), l = t.log ?? ((e) => {
|
|
582
|
-
|
|
587
|
+
E({ scope: `webhook:${e.webhookId}` }).debug("incoming webhook", {
|
|
583
588
|
status: e.status,
|
|
584
589
|
signatureOk: e.signatureOk,
|
|
585
590
|
idempotency: e.idempotency,
|
|
@@ -652,7 +657,7 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
652
657
|
}
|
|
653
658
|
let p;
|
|
654
659
|
try {
|
|
655
|
-
p =
|
|
660
|
+
p = S.decodeUnknownSync(e.payload)(f);
|
|
656
661
|
} catch (t) {
|
|
657
662
|
let n = Y(422, {
|
|
658
663
|
error: "schema validation failed",
|
|
@@ -730,7 +735,7 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
730
735
|
idempotency: h,
|
|
731
736
|
durationMs: Date.now() - u,
|
|
732
737
|
errorMessage: t.message
|
|
733
|
-
}),
|
|
738
|
+
}), ne({
|
|
734
739
|
error: t,
|
|
735
740
|
source: "webhook",
|
|
736
741
|
name: e.id,
|
|
@@ -738,20 +743,20 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
738
743
|
}), n;
|
|
739
744
|
}
|
|
740
745
|
};
|
|
741
|
-
},
|
|
746
|
+
}, Te = (e) => `/webhooks/${e}`, Ee = "_voltro_webhook_rate_windows", De = 6e4, Oe = (e, t) => `${e}@${t}`, ke = async (e, t, n) => (await e.query({
|
|
742
747
|
table: "_voltro_webhook_rate_windows",
|
|
743
|
-
predicate:
|
|
748
|
+
predicate: C(w("scope", t), w("bucket", n)),
|
|
744
749
|
order: [],
|
|
745
750
|
take: 1,
|
|
746
751
|
skip: void 0,
|
|
747
752
|
projection: void 0
|
|
748
|
-
}))[0] ?? null, X = 32,
|
|
753
|
+
}))[0] ?? null, X = 32, Ae = async (e, t, n) => {
|
|
749
754
|
for (let r = 0; r < X; r++) {
|
|
750
755
|
r > 0 && await new Promise((e) => setTimeout(e, r));
|
|
751
|
-
let i = await
|
|
756
|
+
let i = await ke(e, t.key, n);
|
|
752
757
|
if (i === null) {
|
|
753
|
-
await e.insertIgnore(
|
|
754
|
-
id:
|
|
758
|
+
await e.insertIgnore(Ee, {
|
|
759
|
+
id: Oe(t.key, n),
|
|
755
760
|
scope: t.key,
|
|
756
761
|
bucket: n,
|
|
757
762
|
count: 0,
|
|
@@ -764,31 +769,31 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
764
769
|
if (await e.updateMany("_voltro_webhook_rate_windows", {
|
|
765
770
|
count: i.count + 1,
|
|
766
771
|
updatedAt: /* @__PURE__ */ new Date()
|
|
767
|
-
}, { where:
|
|
772
|
+
}, { where: C(w("id", i.id), w("count", i.count)) }) === 1) return !0;
|
|
768
773
|
}
|
|
769
774
|
throw Error(`webhook rate window: contention on (${t.key}, ${n}) exceeded ${X} attempts`);
|
|
770
|
-
},
|
|
775
|
+
}, je = async (e, t, n) => {
|
|
771
776
|
for (let r = 0; r < X; r++) {
|
|
772
777
|
r > 0 && await new Promise((e) => setTimeout(e, r));
|
|
773
|
-
let i = await
|
|
778
|
+
let i = await ke(e, t, n);
|
|
774
779
|
if (i === null || i.count <= 0 || await e.updateMany("_voltro_webhook_rate_windows", {
|
|
775
780
|
count: i.count - 1,
|
|
776
781
|
updatedAt: /* @__PURE__ */ new Date()
|
|
777
|
-
}, { where:
|
|
782
|
+
}, { where: C(w("id", i.id), w("count", i.count)) }) === 1) return;
|
|
778
783
|
}
|
|
779
784
|
throw Error(`webhook rate window: release contention on (${t}) exceeded ${X} attempts`);
|
|
780
|
-
},
|
|
785
|
+
}, Me = async (e, t, n, r = De) => {
|
|
781
786
|
if (t.length === 0) return {
|
|
782
787
|
acquired: !0,
|
|
783
788
|
retryInMs: 0
|
|
784
789
|
};
|
|
785
790
|
let i = Math.floor(n / r), a = [];
|
|
786
791
|
for (let o of t) {
|
|
787
|
-
if (await
|
|
792
|
+
if (await Ae(e, o, i)) {
|
|
788
793
|
a.push(o);
|
|
789
794
|
continue;
|
|
790
795
|
}
|
|
791
|
-
for (let t of a) await
|
|
796
|
+
for (let t of a) await je(e, t.key, i);
|
|
792
797
|
return {
|
|
793
798
|
acquired: !1,
|
|
794
799
|
retryInMs: (i + 1) * r - n
|
|
@@ -798,7 +803,7 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
798
803
|
acquired: !0,
|
|
799
804
|
retryInMs: 0
|
|
800
805
|
};
|
|
801
|
-
},
|
|
806
|
+
}, Ne = (e, t) => {
|
|
802
807
|
if (e === "json") return {
|
|
803
808
|
contentType: "application/json",
|
|
804
809
|
bytes: Buffer.from(t, "utf8")
|
|
@@ -814,15 +819,15 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
814
819
|
}
|
|
815
820
|
return e === "form" ? {
|
|
816
821
|
contentType: "application/x-www-form-urlencoded",
|
|
817
|
-
bytes: Buffer.from(
|
|
822
|
+
bytes: Buffer.from(Pe(n), "utf8")
|
|
818
823
|
} : {
|
|
819
824
|
contentType: "application/xml",
|
|
820
|
-
bytes: Buffer.from(
|
|
825
|
+
bytes: Buffer.from(Ie(n), "utf8")
|
|
821
826
|
};
|
|
822
|
-
},
|
|
827
|
+
}, Pe = (e) => {
|
|
823
828
|
if (typeof e != "object" || !e || Array.isArray(e)) throw new d({
|
|
824
829
|
format: "form",
|
|
825
|
-
reason: `form encoding requires a JSON object at the top level, got ${
|
|
830
|
+
reason: `form encoding requires a JSON object at the top level, got ${ze(e)}`
|
|
826
831
|
});
|
|
827
832
|
let t = new URLSearchParams();
|
|
828
833
|
for (let [n, r] of Object.entries(e)) Z(t, n, r);
|
|
@@ -841,13 +846,13 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
841
846
|
return;
|
|
842
847
|
}
|
|
843
848
|
e.append(t, String(n));
|
|
844
|
-
},
|
|
845
|
-
if (!
|
|
849
|
+
}, Fe = /^[A-Za-z_][A-Za-z0-9_.-]*$/, Ie = (e) => `<?xml version="1.0" encoding="UTF-8"?>${Q("webhook", e)}`, Q = (e, t) => {
|
|
850
|
+
if (!Fe.test(e)) throw new d({
|
|
846
851
|
format: "xml",
|
|
847
852
|
reason: `object key ${JSON.stringify(e)} is not a valid XML element name`
|
|
848
853
|
});
|
|
849
|
-
return `<${e}>${
|
|
850
|
-
},
|
|
854
|
+
return `<${e}>${Le(e, t)}</${e}>`;
|
|
855
|
+
}, Le = (e, t) => t == null ? "" : Array.isArray(t) ? t.map((e) => Q("item", e)).join("") : typeof t == "object" ? Object.entries(t).map(([e, t]) => Q(e, t)).join("") : Re(String(t)), Re = (e) => e.replace(/[&<>"']/g, (e) => {
|
|
851
856
|
switch (e) {
|
|
852
857
|
case "&": return "&";
|
|
853
858
|
case "<": return "<";
|
|
@@ -855,17 +860,17 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
855
860
|
case "\"": return """;
|
|
856
861
|
default: return "'";
|
|
857
862
|
}
|
|
858
|
-
}),
|
|
863
|
+
}), ze = (e) => e === null ? "null" : Array.isArray(e) ? "array" : typeof e, Be = "_voltro_webhook_targets", Ve = 32, He = async (e, t) => (await e.query({
|
|
859
864
|
table: "_voltro_webhook_targets",
|
|
860
|
-
predicate:
|
|
865
|
+
predicate: w("id", t),
|
|
861
866
|
order: [],
|
|
862
867
|
take: 1,
|
|
863
868
|
skip: void 0,
|
|
864
869
|
projection: void 0
|
|
865
|
-
}))[0] ?? null,
|
|
866
|
-
for (let a = 0; a <
|
|
870
|
+
}))[0] ?? null, Ue = async (e, t, n, r, i = /* @__PURE__ */ new Date()) => {
|
|
871
|
+
for (let a = 0; a < Ve; a++) {
|
|
867
872
|
a > 0 && await new Promise((e) => setTimeout(e, a));
|
|
868
|
-
let o = await
|
|
873
|
+
let o = await He(e, t);
|
|
869
874
|
if (o === null) return {
|
|
870
875
|
consecutiveFailures: 0,
|
|
871
876
|
autoDisabled: !1
|
|
@@ -875,7 +880,7 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
875
880
|
if (s === 0 || await e.updateMany("_voltro_webhook_targets", {
|
|
876
881
|
consecutiveFailures: 0,
|
|
877
882
|
updatedAt: i
|
|
878
|
-
}, { where:
|
|
883
|
+
}, { where: C(w("id", t), w("consecutiveFailures", s)) }) === 1) return {
|
|
879
884
|
consecutiveFailures: 0,
|
|
880
885
|
autoDisabled: !1
|
|
881
886
|
};
|
|
@@ -885,68 +890,68 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
885
890
|
consecutiveFailures: c,
|
|
886
891
|
updatedAt: i
|
|
887
892
|
};
|
|
888
|
-
if (u && (d.active = !1, d.autoDisabledAt = i, d.autoDisableReason = r.slice(0, 500)), await e.updateMany("_voltro_webhook_targets", d, { where:
|
|
893
|
+
if (u && (d.active = !1, d.autoDisabledAt = i, d.autoDisableReason = r.slice(0, 500)), await e.updateMany("_voltro_webhook_targets", d, { where: C(w("id", t), w("consecutiveFailures", s)) }) === 1) return {
|
|
889
894
|
consecutiveFailures: c,
|
|
890
895
|
autoDisabled: u
|
|
891
896
|
};
|
|
892
897
|
}
|
|
893
|
-
throw Error(`webhook auto-disable: streak CAS contention on target ${t} exceeded ${
|
|
894
|
-
},
|
|
895
|
-
status:
|
|
896
|
-
body:
|
|
897
|
-
latencyMs:
|
|
898
|
-
retryAfterSec:
|
|
899
|
-
transportError:
|
|
900
|
-
encodeError:
|
|
901
|
-
}),
|
|
902
|
-
outcome:
|
|
903
|
-
delayMs:
|
|
904
|
-
reason:
|
|
905
|
-
autoDisabled:
|
|
906
|
-
consecutiveFailures:
|
|
907
|
-
}),
|
|
908
|
-
acquired:
|
|
909
|
-
retryInMs:
|
|
910
|
-
}),
|
|
898
|
+
throw Error(`webhook auto-disable: streak CAS contention on target ${t} exceeded ${Ve} attempts`);
|
|
899
|
+
}, We = S.Struct({
|
|
900
|
+
status: S.Number,
|
|
901
|
+
body: S.String,
|
|
902
|
+
latencyMs: S.Number,
|
|
903
|
+
retryAfterSec: S.Union(S.Number, S.Undefined),
|
|
904
|
+
transportError: S.Union(S.String, S.Undefined),
|
|
905
|
+
encodeError: S.Union(S.String, S.Undefined)
|
|
906
|
+
}), Ge = S.Struct({
|
|
907
|
+
outcome: S.Literal("succeeded", "failed", "retry"),
|
|
908
|
+
delayMs: S.Union(S.Number, S.Undefined),
|
|
909
|
+
reason: S.Union(S.String, S.Undefined),
|
|
910
|
+
autoDisabled: S.Union(S.Boolean, S.Undefined),
|
|
911
|
+
consecutiveFailures: S.Union(S.Number, S.Undefined)
|
|
912
|
+
}), Ke = S.Struct({
|
|
913
|
+
acquired: S.Boolean,
|
|
914
|
+
retryInMs: S.Number
|
|
915
|
+
}), qe = re.make({
|
|
911
916
|
name: "voltro.deliverWebhook",
|
|
912
917
|
payload: {
|
|
913
|
-
deliveryId:
|
|
914
|
-
targetId:
|
|
915
|
-
event:
|
|
916
|
-
eventId:
|
|
917
|
-
payloadJson:
|
|
918
|
-
attemptEpoch:
|
|
918
|
+
deliveryId: S.String,
|
|
919
|
+
targetId: S.String,
|
|
920
|
+
event: S.String,
|
|
921
|
+
eventId: S.String,
|
|
922
|
+
payloadJson: S.String,
|
|
923
|
+
attemptEpoch: S.optionalWith(S.Number, { default: () => 0 })
|
|
919
924
|
},
|
|
920
|
-
success:
|
|
921
|
-
finalStatus:
|
|
922
|
-
attempts:
|
|
925
|
+
success: S.Struct({
|
|
926
|
+
finalStatus: S.Literal("succeeded", "failed", "deferred"),
|
|
927
|
+
attempts: S.Number
|
|
923
928
|
}),
|
|
924
929
|
idempotencyKey: ({ deliveryId: e, attemptEpoch: t }) => `voltro.deliverWebhook:${e}:${t ?? 0}`
|
|
925
|
-
}),
|
|
926
|
-
let { deliveryId: i, targetId: a, event: s, eventId: c, payloadJson: l } = n, u =
|
|
927
|
-
return
|
|
928
|
-
let n = yield*
|
|
930
|
+
}), Je = (e, t = {}) => (n, r) => {
|
|
931
|
+
let { deliveryId: i, targetId: a, event: s, eventId: c, payloadJson: l } = n, u = E({ scope: `webhook:${i}` });
|
|
932
|
+
return x.gen(function* () {
|
|
933
|
+
let n = yield* D.make({
|
|
929
934
|
name: "fetch-target",
|
|
930
|
-
success:
|
|
931
|
-
id:
|
|
932
|
-
event:
|
|
933
|
-
url:
|
|
934
|
-
secret:
|
|
935
|
-
signing:
|
|
936
|
-
retry:
|
|
937
|
-
headers:
|
|
938
|
-
key:
|
|
939
|
-
value:
|
|
940
|
-
}),
|
|
941
|
-
rateLimitPerMinute:
|
|
942
|
-
active:
|
|
943
|
-
format:
|
|
944
|
-
tenantId:
|
|
935
|
+
success: S.Union(S.Null, S.Struct({
|
|
936
|
+
id: S.String,
|
|
937
|
+
event: S.String,
|
|
938
|
+
url: S.String,
|
|
939
|
+
secret: S.String,
|
|
940
|
+
signing: S.Any,
|
|
941
|
+
retry: S.Any,
|
|
942
|
+
headers: S.Union(S.Record({
|
|
943
|
+
key: S.String,
|
|
944
|
+
value: S.String
|
|
945
|
+
}), S.Null),
|
|
946
|
+
rateLimitPerMinute: S.Union(S.Number, S.Null),
|
|
947
|
+
active: S.Boolean,
|
|
948
|
+
format: S.Literal("json", "form", "xml"),
|
|
949
|
+
tenantId: S.Union(S.String, S.Null)
|
|
945
950
|
})),
|
|
946
|
-
execute:
|
|
951
|
+
execute: x.tryPromise({
|
|
947
952
|
try: async () => (await e.store.query({
|
|
948
953
|
table: "_voltro_webhook_targets",
|
|
949
|
-
predicate:
|
|
954
|
+
predicate: w("id", a),
|
|
950
955
|
order: [{
|
|
951
956
|
column: "createdAt",
|
|
952
957
|
direction: "asc"
|
|
@@ -960,7 +965,7 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
960
965
|
event: s,
|
|
961
966
|
cause: String(e)
|
|
962
967
|
}), /* @__PURE__ */ Error(`fetch-target failed: ${e.message}`))
|
|
963
|
-
}).pipe(
|
|
968
|
+
}).pipe(x.orDie)
|
|
964
969
|
});
|
|
965
970
|
if (n === null) return {
|
|
966
971
|
finalStatus: "failed",
|
|
@@ -985,75 +990,75 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
985
990
|
...o
|
|
986
991
|
});
|
|
987
992
|
};
|
|
988
|
-
if (n.active !== !0) return yield*
|
|
993
|
+
if (n.active !== !0) return yield* D.make({
|
|
989
994
|
name: "record-paused-pending",
|
|
990
|
-
success:
|
|
991
|
-
execute:
|
|
995
|
+
success: S.Void,
|
|
996
|
+
execute: x.tryPromise({
|
|
992
997
|
try: () => r(`${i}:1`, 1, { status: "pending" }),
|
|
993
998
|
catch: (e) => /* @__PURE__ */ Error(`record-paused-pending failed: ${e.message}`)
|
|
994
|
-
}).pipe(
|
|
999
|
+
}).pipe(x.orDie)
|
|
995
1000
|
}), {
|
|
996
1001
|
finalStatus: "deferred",
|
|
997
1002
|
attempts: 0
|
|
998
1003
|
};
|
|
999
|
-
let f = (e) => typeof e == "string" ? JSON.parse(e) : e, p = f(n.retry), m = f(n.signing), h = n.headers === null ? null : f(n.headers),
|
|
1004
|
+
let f = (e) => typeof e == "string" ? JSON.parse(e) : e, p = f(n.retry), m = f(n.signing), h = n.headers === null ? null : f(n.headers), g = t.rateWindowMs ?? 6e4, _ = t.events?.find((e) => e.id === s)?.globalRateLimit, v = [...typeof n.rateLimitPerMinute == "number" && n.rateLimitPerMinute > 0 ? [{
|
|
1000
1005
|
key: `target:${n.id}`,
|
|
1001
1006
|
limit: n.rateLimitPerMinute
|
|
1002
|
-
}] : [], ...
|
|
1007
|
+
}] : [], ..._ !== void 0 && _.perMinute > 0 ? [{
|
|
1003
1008
|
key: `event:${s}`,
|
|
1004
|
-
limit:
|
|
1009
|
+
limit: _.perMinute
|
|
1005
1010
|
}] : []];
|
|
1006
1011
|
for (let t = 1; t <= p.maxAttempts; t++) {
|
|
1007
1012
|
let f = `${i}:${t}`;
|
|
1008
|
-
if (
|
|
1009
|
-
let i = yield*
|
|
1013
|
+
if (v.length > 0) for (let n = 0;; n++) {
|
|
1014
|
+
let i = yield* D.make({
|
|
1010
1015
|
name: `rate-acquire-${t}-${n}`,
|
|
1011
|
-
success:
|
|
1012
|
-
execute:
|
|
1013
|
-
try: () =>
|
|
1016
|
+
success: Ke,
|
|
1017
|
+
execute: x.tryPromise({
|
|
1018
|
+
try: () => Me(e.store, v, Date.now(), g),
|
|
1014
1019
|
catch: (e) => (u.debug("rate-acquire raw error", {
|
|
1015
1020
|
targetId: a,
|
|
1016
1021
|
event: s,
|
|
1017
1022
|
attempt: t,
|
|
1018
1023
|
cause: String(e)
|
|
1019
1024
|
}), /* @__PURE__ */ Error(`rate-acquire failed: ${e.message}`))
|
|
1020
|
-
}).pipe(
|
|
1025
|
+
}).pipe(x.orDie)
|
|
1021
1026
|
});
|
|
1022
1027
|
if (i.acquired) break;
|
|
1023
|
-
yield*
|
|
1028
|
+
yield* D.make({
|
|
1024
1029
|
name: `record-rate-deferral-${t}-${n}`,
|
|
1025
|
-
success:
|
|
1026
|
-
execute:
|
|
1030
|
+
success: S.Void,
|
|
1031
|
+
execute: x.tryPromise({
|
|
1027
1032
|
try: () => r(f, t, {
|
|
1028
1033
|
status: "pending",
|
|
1029
1034
|
nextAttemptAt: new Date(Date.now() + i.retryInMs)
|
|
1030
1035
|
}),
|
|
1031
1036
|
catch: (e) => /* @__PURE__ */ Error(`record-rate-deferral failed: ${e.message}`)
|
|
1032
|
-
}).pipe(
|
|
1033
|
-
}), yield*
|
|
1037
|
+
}).pipe(x.orDie)
|
|
1038
|
+
}), yield* O.sleep({
|
|
1034
1039
|
name: `rate-window-sleep-${t}-${n}`,
|
|
1035
1040
|
duration: `${i.retryInMs} millis`
|
|
1036
1041
|
});
|
|
1037
1042
|
}
|
|
1038
|
-
yield*
|
|
1043
|
+
yield* D.make({
|
|
1039
1044
|
name: `record-attempt-start-${t}`,
|
|
1040
|
-
success:
|
|
1041
|
-
execute:
|
|
1045
|
+
success: S.Void,
|
|
1046
|
+
execute: x.tryPromise({
|
|
1042
1047
|
try: () => r(f, t, {
|
|
1043
1048
|
status: "inFlight",
|
|
1044
1049
|
nextAttemptAt: null,
|
|
1045
1050
|
scheduledAt: /* @__PURE__ */ new Date()
|
|
1046
1051
|
}),
|
|
1047
1052
|
catch: (e) => /* @__PURE__ */ Error(`record-attempt-start failed: ${e.message}`)
|
|
1048
|
-
}).pipe(
|
|
1053
|
+
}).pipe(x.orDie)
|
|
1049
1054
|
});
|
|
1050
|
-
let
|
|
1055
|
+
let _ = yield* D.make({
|
|
1051
1056
|
name: `sign-and-post-${t}`,
|
|
1052
|
-
success:
|
|
1053
|
-
execute:
|
|
1057
|
+
success: We,
|
|
1058
|
+
execute: x.promise(async () => {
|
|
1054
1059
|
let e = Date.now(), r;
|
|
1055
1060
|
try {
|
|
1056
|
-
r =
|
|
1061
|
+
r = Ne(n.format, l);
|
|
1057
1062
|
} catch (t) {
|
|
1058
1063
|
if (t instanceof d) return {
|
|
1059
1064
|
status: 0,
|
|
@@ -1066,7 +1071,7 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
1066
1071
|
throw t;
|
|
1067
1072
|
}
|
|
1068
1073
|
let a = Buffer.from(r.bytes), { headerName: u, headerValue: f } = o(m, a, n.secret);
|
|
1069
|
-
process.env.NODE_ENV === "production" &&
|
|
1074
|
+
process.env.NODE_ENV === "production" && T(n.url);
|
|
1070
1075
|
try {
|
|
1071
1076
|
let o = await fetch(n.url, {
|
|
1072
1077
|
method: "POST",
|
|
@@ -1101,18 +1106,18 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
1101
1106
|
};
|
|
1102
1107
|
}
|
|
1103
1108
|
})
|
|
1104
|
-
}),
|
|
1109
|
+
}), y = yield* D.make({
|
|
1105
1110
|
name: `classify-and-record-${t}`,
|
|
1106
|
-
success:
|
|
1107
|
-
execute:
|
|
1111
|
+
success: Ge,
|
|
1112
|
+
execute: x.tryPromise({
|
|
1108
1113
|
try: async () => {
|
|
1109
|
-
let r =
|
|
1114
|
+
let r = _.status >= 200 && _.status < 300, i = _.transportError !== void 0, o = _.encodeError !== void 0, s = (t, n) => Ue(e.store, a, t, n);
|
|
1110
1115
|
if (r) {
|
|
1111
1116
|
await e.store.update("_voltro_webhook_deliveries", f, {
|
|
1112
1117
|
status: "succeeded",
|
|
1113
|
-
responseStatus:
|
|
1114
|
-
responseBody:
|
|
1115
|
-
latencyMs:
|
|
1118
|
+
responseStatus: _.status,
|
|
1119
|
+
responseBody: _.body,
|
|
1120
|
+
latencyMs: _.latencyMs,
|
|
1116
1121
|
updatedAt: /* @__PURE__ */ new Date()
|
|
1117
1122
|
});
|
|
1118
1123
|
let t = await s(!0, "");
|
|
@@ -1124,18 +1129,18 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
1124
1129
|
consecutiveFailures: t.consecutiveFailures
|
|
1125
1130
|
};
|
|
1126
1131
|
}
|
|
1127
|
-
let c = o ? null :
|
|
1128
|
-
...i ? {} : { status:
|
|
1129
|
-
...
|
|
1132
|
+
let c = o ? null : M(p, t, {
|
|
1133
|
+
...i ? {} : { status: _.status },
|
|
1134
|
+
..._.retryAfterSec === void 0 ? {} : { retryAfterSeconds: _.retryAfterSec }
|
|
1130
1135
|
});
|
|
1131
1136
|
if (c === null) {
|
|
1132
|
-
let t = o ? `encode (${n.format}): ${
|
|
1137
|
+
let t = o ? `encode (${n.format}): ${_.encodeError}` : i ? `transport: ${_.transportError}` : `http ${_.status}: ${_.body.slice(0, 200)}`;
|
|
1133
1138
|
await e.store.update("_voltro_webhook_deliveries", f, {
|
|
1134
1139
|
status: "failed",
|
|
1135
|
-
responseStatus: i || o ? null :
|
|
1136
|
-
responseBody: i || o ? null :
|
|
1137
|
-
errorMessage: o ? t :
|
|
1138
|
-
latencyMs:
|
|
1140
|
+
responseStatus: i || o ? null : _.status,
|
|
1141
|
+
responseBody: i || o ? null : _.body,
|
|
1142
|
+
errorMessage: o ? t : _.transportError ?? null,
|
|
1143
|
+
latencyMs: _.latencyMs,
|
|
1139
1144
|
updatedAt: /* @__PURE__ */ new Date()
|
|
1140
1145
|
});
|
|
1141
1146
|
let r = await s(!1, t);
|
|
@@ -1149,10 +1154,10 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
1149
1154
|
}
|
|
1150
1155
|
return await e.store.update("_voltro_webhook_deliveries", f, {
|
|
1151
1156
|
status: "retryScheduled",
|
|
1152
|
-
responseStatus: i ? null :
|
|
1153
|
-
responseBody: i ? null :
|
|
1154
|
-
errorMessage:
|
|
1155
|
-
latencyMs:
|
|
1157
|
+
responseStatus: i ? null : _.status,
|
|
1158
|
+
responseBody: i ? null : _.body,
|
|
1159
|
+
errorMessage: _.transportError ?? null,
|
|
1160
|
+
latencyMs: _.latencyMs,
|
|
1156
1161
|
nextAttemptAt: new Date(Date.now() + c.delayMs),
|
|
1157
1162
|
updatedAt: /* @__PURE__ */ new Date()
|
|
1158
1163
|
}), {
|
|
@@ -1169,24 +1174,24 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
1169
1174
|
attempt: t,
|
|
1170
1175
|
cause: String(e)
|
|
1171
1176
|
}), /* @__PURE__ */ Error(`classify-and-record failed: ${e.message}`))
|
|
1172
|
-
}).pipe(
|
|
1177
|
+
}).pipe(x.orDie)
|
|
1173
1178
|
});
|
|
1174
|
-
if (
|
|
1179
|
+
if (y.autoDisabled === !0 && u.warn("target auto-disabled after consecutive failures", {
|
|
1175
1180
|
targetId: a,
|
|
1176
1181
|
event: s,
|
|
1177
|
-
consecutiveFailures:
|
|
1178
|
-
reason:
|
|
1179
|
-
}),
|
|
1182
|
+
consecutiveFailures: y.consecutiveFailures ?? null,
|
|
1183
|
+
reason: y.reason ?? null
|
|
1184
|
+
}), y.outcome === "succeeded") return {
|
|
1180
1185
|
finalStatus: "succeeded",
|
|
1181
1186
|
attempts: t
|
|
1182
1187
|
};
|
|
1183
|
-
if (
|
|
1188
|
+
if (y.outcome === "failed") return {
|
|
1184
1189
|
finalStatus: "failed",
|
|
1185
1190
|
attempts: t
|
|
1186
1191
|
};
|
|
1187
|
-
|
|
1192
|
+
y.delayMs !== void 0 && (yield* O.sleep({
|
|
1188
1193
|
name: `retry-sleep-${t}`,
|
|
1189
|
-
duration: `${
|
|
1194
|
+
duration: `${y.delayMs} millis`
|
|
1190
1195
|
}));
|
|
1191
1196
|
}
|
|
1192
1197
|
return {
|
|
@@ -1194,15 +1199,15 @@ var oe = (e) => e.webhook !== void 0, O = (e) => {
|
|
|
1194
1199
|
attempts: p.maxAttempts
|
|
1195
1200
|
};
|
|
1196
1201
|
});
|
|
1197
|
-
}, $ = "voltro.webhook.emit",
|
|
1202
|
+
}, $ = "voltro.webhook.emit", Ye = (e) => typeof e.event == "string", Xe = (e) => ie({
|
|
1198
1203
|
effect: $,
|
|
1199
1204
|
maxAttempts: 1,
|
|
1200
1205
|
handler: async ({ payload: t }) => {
|
|
1201
|
-
if (!
|
|
1206
|
+
if (!Ye(t)) throw Error(`${$}: row payload has no \`event\` name — cannot resolve a descriptor. Got keys: ${Object.keys(t).join(", ") || "(none)"}`);
|
|
1202
1207
|
let n = e();
|
|
1203
1208
|
if (n === void 0) throw Error(`${$}: the outgoing-webhooks service is not available in this process. An emit was enqueued by a build that had the plugin registered; this one does not.`);
|
|
1204
1209
|
await n.emit(t.event, t.payload, { tenantId: t.tenantId });
|
|
1205
1210
|
}
|
|
1206
1211
|
});
|
|
1207
1212
|
//#endregion
|
|
1208
|
-
export { W as IdempotencyCache,
|
|
1213
|
+
export { W as IdempotencyCache, De as RATE_WINDOW_MS, Ee as RATE_WINDOW_TABLE, Be as TARGETS_TABLE, $ as WEBHOOK_EMIT_EFFECT, l as WebhookDeliveryNotFound, u as WebhookPayloadInvalid, d as WebhookPayloadUnrepresentable, f as WebhookPayloadVersionInvalid, p as WebhookSubscribeInvalid, N as WebhooksService, m as _voltroWebhookDeliveriesTable, h as _voltroWebhookEventStatsTable, g as _voltroWebhookRateWindowsTable, _ as _voltroWebhookTargetsTable, Me as acquireRateSlots, k as asOutgoingEvent, Je as buildDeliverWebhookExecute, be as buildWebhooksService, me as compareVersions, Ae as consumeRateSlot, Te as defaultIncomingPath, c as defaultOutgoingSignature, se as defaultRetryPolicy, s as defineIncomingWebhook, t as defineWebhookProvider, qe as deliverWebhookWorkflow, Ne as encodePayload, I as eventsOf, ce as fastRetryPolicy, F as generateSecret, K as getIdempotencyCache, i as githubSignature, ae as hasWebhookAudience, r as isWebhookDescriptor, Xe as makeDeferredEmitHandler, he as matchesFilter, we as mountIncomingWebhook, M as nextRetry, A as parseDuration, J as parseTtl, Ue as recordDeliveryOutcome, je as releaseRateSlot, ue as resolveSubscribe, o as signPayload, n as slackSignature, e as stripeSignature, Se as useWebhooks, xe as useWebhooksEffect, L as validateSubscribe, a as verifySignature, v as webhookTables };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/plugin-webhooks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.31.0",
|
|
4
4
|
"description": "Webhooks plugin — first-class outgoing (multi-target, durable-workflow delivery, HMAC signing, retry policies, rate limits) + incoming (signature verification, idempotency, provider templates for Stripe / GitHub / Slack / generic).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -48,12 +48,12 @@
|
|
|
48
48
|
},
|
|
49
49
|
"dependencies": {
|
|
50
50
|
"@effect/workflow": "^0.19.0",
|
|
51
|
-
"@voltro/database": "0.
|
|
52
|
-
"@voltro/integration-http": "0.
|
|
53
|
-
"@voltro/logger": "0.
|
|
54
|
-
"@voltro/plugin-multitenancy": "0.
|
|
55
|
-
"@voltro/protocol": "0.
|
|
56
|
-
"@voltro/runtime": "0.
|
|
51
|
+
"@voltro/database": "0.31.0",
|
|
52
|
+
"@voltro/integration-http": "0.31.0",
|
|
53
|
+
"@voltro/logger": "0.31.0",
|
|
54
|
+
"@voltro/plugin-multitenancy": "0.31.0",
|
|
55
|
+
"@voltro/protocol": "0.31.0",
|
|
56
|
+
"@voltro/runtime": "0.31.0"
|
|
57
57
|
},
|
|
58
58
|
"peerDependencies": {
|
|
59
59
|
"effect": "^3.22.0"
|