@voltro/plugin-audit 0.30.1 → 0.30.2
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 +48 -0
- package/package.json +4 -4
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,54 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.30.2] — 2026-08-09
|
|
43
|
+
|
|
44
|
+
### Changed
|
|
45
|
+
|
|
46
|
+
- **@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.
|
|
47
|
+
|
|
48
|
+
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.
|
|
49
|
+
|
|
50
|
+
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.
|
|
51
|
+
|
|
52
|
+
No behaviour change; the option works exactly as before.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **@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.
|
|
57
|
+
|
|
58
|
+
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.**
|
|
59
|
+
|
|
60
|
+
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.
|
|
61
|
+
|
|
62
|
+
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.
|
|
63
|
+
|
|
64
|
+
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.
|
|
65
|
+
- **@voltro/cli** — **The three items the previous round left open, closed.**
|
|
66
|
+
|
|
67
|
+
**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.
|
|
68
|
+
|
|
69
|
+
**`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.
|
|
70
|
+
|
|
71
|
+
**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.
|
|
72
|
+
- **@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.**
|
|
73
|
+
|
|
74
|
+
**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.
|
|
75
|
+
|
|
76
|
+
**`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".
|
|
77
|
+
|
|
78
|
+
**`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.
|
|
79
|
+
|
|
80
|
+
**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.
|
|
81
|
+
|
|
82
|
+
**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.
|
|
83
|
+
|
|
84
|
+
**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.
|
|
85
|
+
|
|
86
|
+
**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.
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
42
90
|
## [0.30.1] — 2026-08-09
|
|
43
91
|
|
|
44
92
|
### Fixed
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/plugin-audit",
|
|
3
|
-
"version": "0.30.
|
|
3
|
+
"version": "0.30.2",
|
|
4
4
|
"description": "Audit plugin — ships the `audit()` schema mixin (createdAt/updatedAt/createdBy/updatedBy → Actor) plus an optional mutation interceptor that records every call to a configurable sink (console / memory / custom function).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -37,9 +37,9 @@
|
|
|
37
37
|
"node": ">=24.0.0"
|
|
38
38
|
},
|
|
39
39
|
"dependencies": {
|
|
40
|
-
"@voltro/database": "0.30.
|
|
41
|
-
"@voltro/logger": "0.30.
|
|
42
|
-
"@voltro/protocol": "0.30.
|
|
40
|
+
"@voltro/database": "0.30.2",
|
|
41
|
+
"@voltro/logger": "0.30.2",
|
|
42
|
+
"@voltro/protocol": "0.30.2"
|
|
43
43
|
},
|
|
44
44
|
"peerDependencies": {
|
|
45
45
|
"effect": "^3.22.0"
|