@voltro/plugin-auth-auth0 0.12.0 → 0.14.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 +378 -0
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,384 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.14.0] — 2026-07-26
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/cli, @voltro/database** — `voltro dev` no longer applies file-based migrations to a REMOTE database unattended.
|
|
47
|
+
|
|
48
|
+
The planner refuses lossy operations without `VOLTRO_DESTRUCTIVE_OK=1`, and its own error text names the way through: "if intentional, set VOLTRO_DESTRUCTIVE_OK=1 OR add a file-based migration". So the documented route around the safety belt had none of its own. Measured, not hypothesised: a file containing `DROP TABLE ... CASCADE` was saved at 15:11; at 15:12:41 the table was gone from a live production database, recorded `appliedBy: boot:dev`. An already-running dev server had picked the file up on its next reboot. Nothing was started, no command was typed, no review happened — writing the file WAS the deployment. What prevented harm was a hand-written guard inside that particular migration; the framework contributed nothing.
|
|
49
|
+
|
|
50
|
+
Local counts as: loopback, private LAN (RFC 1918), `host.docker.internal`, a `.local` / `.localhost` name, a `file:` / `sqlite:` URL, or a BARE hostname (`postgres`, `db`, `voltro-test-mariadb`) — only a container network resolves those, so docker-compose setups are untouched. An unparseable `DB_URL` counts as remote on purpose: guessing "local" wrongly writes to production, guessing "remote" wrongly costs one env var.
|
|
51
|
+
|
|
52
|
+
Three deliberate non-choices. It does **not** detect destructive SQL — in arbitrary SQL that is not decidable, so the gate would be either leaky or noisy; what it separates is *saving a file* from *applying it to production*. It **refuses** rather than skipping quietly — a skipped migration leaves the database in a shape the app does not expect, and the failures that follow point everywhere except at the cause. And it stays **silent when nothing is pending**, so dev against a remote database is unaffected until the moment a file would actually execute against it.
|
|
53
|
+
|
|
54
|
+
`voltro db files`, `voltro db apply` and `voltro serve` are unchanged: an explicit command is already an explicit decision. Escape hatch: `VOLTRO_REMOTE_MIGRATIONS_OK=1`.
|
|
55
|
+
|
|
56
|
+
`@voltro/database` gains `pendingFileMigrationIds(sql, projectRoot)` — the ids that would run next, without running them. The gate needs to name what it is refusing, and must not have executed anything to find out.
|
|
57
|
+
- **@voltro/cli, @voltro/web** — **Only `*.page.tsx` under `src/pages/` is a route.**
|
|
58
|
+
|
|
59
|
+
Pages were the last primitive without a file convention. Every other one carries its type in the name — `*.query.ts`, `*.cron.tsx`, even `*.island.tsx`, which is a *part* of a page — while a page was any `.tsx` that happened to sit under `src/pages/`. Two discovery models, and the positional one made colocation impossible: a component next to its page got a URL.
|
|
60
|
+
|
|
61
|
+
The breakage was silent, which is why this is a correction and not a taste argument. Nobody navigates to an accidental URL in dev, so the route existed, was broken, and said nothing. `voltro build`'s prerender is the first thing that ever evaluates the module — one app carried ~600 accidental routes for months and found out at its first production build, with `SSR received a descriptor with no Component`.
|
|
62
|
+
|
|
63
|
+
```
|
|
64
|
+
src/pages/users/index.page.tsx → /users
|
|
65
|
+
src/pages/users/[id].page.tsx → /users/[id]
|
|
66
|
+
src/pages/(marketing)/pricing.page.tsx → /pricing
|
|
67
|
+
src/pages/users/UserTable.tsx → not a route — colocation is now legal
|
|
68
|
+
src/pages/users/index.page.test.tsx → not a route
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
The suffix sits *behind* the segment, so dynamic params, catch-alls and route groups are unchanged. `layout.tsx` / `error.tsx` / `loading.tsx` / `not-found.tsx` keep their exact names — `layout.layout.tsx` is nonsense — but they are now **scoped to route-bearing directories**: a special file in a directory with no `*.page.tsx` at or below it is inert and is reported at boot rather than silently wired. That scoping is not cosmetic. The change *invites* colocation, so a co-located `error.tsx` (a 404 illustration, say) would otherwise install a real error boundary for the whole subtree.
|
|
72
|
+
|
|
73
|
+
**Separately, and independent of the suffix: a page with no default export now fails at codegen.** The suffix declares intent, and intent can be wrong — a `*.page.tsx` with no component is still a broken route. `voltro dev` / `build` / `start` all run the check before serving anything, so the first `voltro dev` says it, naming the file and both fixes (add the export, or drop the suffix). This is the half of the report that actually removes the failure class.
|
|
74
|
+
|
|
75
|
+
**`voltro doctor` gained a page-convention scan**, in both directions: a `.tsx` under `src/pages/` that looks like an unmigrated page (default export, imported by nothing — a real page is only ever found by the router), and a `*.page.tsx` *outside* `src/pages/`, which will never route. The second failure class is created by this change, so it ships with its own check. A third, advisory bucket names a page with no `*.page.test.tsx` beside it.
|
|
76
|
+
|
|
77
|
+
**Migration** is `git mv` and nothing else — page files are not imported, so no import path moves with them. The codemod renames every file that is a route *today*, which is behaviour-preserving by construction. It deliberately does **not** use the tempting rule "pages are the files nobody imports": that rule is right about pages and wrong about the case that costs you a route — a page with a co-located test *is* imported, by its own test, which our own testing guidance encourages. Such a page would keep its name, stop being a route, and 404 with nothing in any build log. Files that something other than a test imports are renamed anyway and then **reported**, so a human decides whether they should instead lose the suffix and become ordinary co-located code.
|
|
78
|
+
|
|
79
|
+
A library that happens to have a `src/pages/` directory (the framework's own `@voltro/devtools-ui` keeps 31 shared page components there) is left alone — the codemod requires an `app.config.ts` at the app root, the same signal the CLI uses.
|
|
80
|
+
- **@voltro/database, @voltro/cli, @voltro/ai, @voltro/plugin-webhooks** — **Reactivity is the default. `.reactive()` is gone; `.nonReactive()` opts out.**
|
|
81
|
+
|
|
82
|
+
A reactive framework whose reactivity is opt-in has the default backwards. Every table now participates in cross-instance change capture, and you write nothing to get it.
|
|
83
|
+
|
|
84
|
+
The old keyword failed in both directions silently, which is why this is a correction rather than a preference:
|
|
85
|
+
|
|
86
|
+
- Written on mysql/mariadb/mssql it did **nothing** — those readers tail every table anyway. A team read its doc ("opt the table into the reactive engine"), found 26 tables without it, and reasonably concluded much of their app was never live. It always was; the doc was wrong, and the investigation was the cost. - Omitted on postgres with `changeStrategy: 'cdc'` it meant a write on one instance **never reached another instance's subscribers**. Correct on the writing pod, stale everywhere else, and perfect in single-instance dev.
|
|
87
|
+
|
|
88
|
+
**`.nonReactive()` turns reactivity OFF — not "off across instances".** The table emits no change events at all: no local subscriber fires, no cross-instance transport carries it. A version that silenced only the cross-instance half would leave every in-process subscription live on a table whose declaration says it is not reactive, which is not what the name says.
|
|
89
|
+
|
|
90
|
+
Implemented at each store's emit, in every dialect — memory, sqlite (and turso, which reuses it), postgres, mysql/mariadb, mssql — with the predicate living once in the table registry. On postgres, mysql/mariadb and mssql it ALSO drops the cross-instance half: no `REPLICA IDENTITY FULL` and no `pg_notify` trigger, excluded from the binlog reader's filter, excluded from the Change Tracking set. The dialect-dependent meaning is gone, not moved.
|
|
91
|
+
|
|
92
|
+
The WRITE is unaffected — this is about notification, never persistence.
|
|
93
|
+
|
|
94
|
+
Keep it for a genuinely hot, genuinely unsubscribed table — an append-only event log, a metrics sink. `REPLICA IDENTITY FULL` widens every UPDATE/DELETE in the WAL and the trigger fires on every write, so opting one of those out is a real saving. Opting out a table a query still reads is not, and `voltro dev` / `voltro serve` warn when you do.
|
|
95
|
+
|
|
96
|
+
The codemod deletes every `.reactive()` call — the behaviour it opted into is now universal, so removing it changes nothing for those tables. Tables that never had it gain the trigger on the next migration; that is the point.
|
|
97
|
+
|
|
98
|
+
**sqlite and turso.** Every table is reactive there too — the store emits its committed deltas inline, exactly as on every other dialect. `.nonReactive()` has nothing to suppress on them, because neither has a cross-instance transport to opt out of.
|
|
99
|
+
|
|
100
|
+
For sqlite that is honest: a local file is one process. **Turso is not**, and that is now said at boot. It reuses the SQLite store, so it has no cross-instance change capture at all — while `libsql:` / `https:` / `wss:` and the embedded-replica mode exist precisely to point several app instances at one primary. On a shared turso, every table's subscribers see only their own instance's writes, regardless of any flag. Somebody who chose a distributed database and a reactive framework has every reason to assume otherwise, so `voltro dev` / `voltro serve` warn when the URL is a shared one. A local `file:` turso stays silent.
|
|
101
|
+
|
|
102
|
+
**Type-surface note.** The `Reactive` type parameter on `Table<…>` now defaults to `true`, so exported table constants in `@voltro/ai` and `@voltro/plugin-webhooks` are typed `Table<…, true, …>` where they read `false` before. Code that only USES those tables is unaffected — the parameter is not part of any call signature. Code that ANNOTATES one by hand (`const t: Table<'x', …, false, never> = …`) has to drop the explicit parameter or write `true`; the framework's own annotations were updated the same way.
|
|
103
|
+
- **@voltro/cli** — `auth.resolveScopes` receives the app's DataStore.
|
|
104
|
+
|
|
105
|
+
The hook shipped with `(subject, { headers, clientId })`, which is missing the one thing a role-based resolver needs. A role lives in the database — the reporting app's is two joins deep — so reaching it meant opening a SECOND connection path beside the framework's, to the same database the request store opens a moment later. That is why an otherwise willing adopter could not adopt, and it made the hook's stated purpose unreachable by its own signature.
|
|
106
|
+
|
|
107
|
+
The second argument now carries `store`. It is the BOOT store, not a request-scoped one, and it arrives through a lazy ref because strategies resolve before a request store exists — `undefined` only while the store is still being built. Wired identically under `voltro dev` (which builds the store after the auth chain) and `voltro serve` (which builds it before).
|
|
108
|
+
- **@voltro/client** — **Tracking catalogues are typed against the component's props.**
|
|
109
|
+
|
|
110
|
+
```tsx
|
|
111
|
+
interface ButtonProps { readonly plan: 'free' | 'pro'; readonly onClick: () => void }
|
|
112
|
+
|
|
113
|
+
const spec = defineTracking<ButtonProps>('Checkout', {
|
|
114
|
+
onClick: (props) => ({ event: 'checkout.started', plan: props.plan }),
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
const tracked = useTracking(spec, props, sink)
|
|
118
|
+
return <button {...tracked}>Checkout</button> // now actually typechecks
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Found by using the primitive on real code for the first time. Two defects, both invisible until then:
|
|
122
|
+
|
|
123
|
+
- a payload builder could only reach `props['plan']` as `unknown` and cast it — in the ONE file whose job is to state exactly what leaves the browser. A cast is the last thing that belongs there. - `useTracking` returned `Record<string, unknown>`, so the usage our own docs show — `<button {...tracked}>` — did not typecheck under `strict`. The sample survived because it was never compiled against a typed handler.
|
|
124
|
+
|
|
125
|
+
The type parameter defaults to the old untyped bag, so an untyped catalogue is unchanged. What breaks is the **return type narrowing** from `Record<string, unknown>` to the props type: `tracked['notAProp']` was `unknown` and is now an error, and `(tracked['onClick'] as () => void)()` no longer needs its cast. A `manual` codemod, because a transform cannot typecheck and so cannot tell a now-redundant cast from one that was hiding a real disagreement.
|
|
126
|
+
- **@voltro/cli** — **The web file taxonomy — seven contract suffixes, each enforced.**
|
|
127
|
+
|
|
128
|
+
Every entry had to pass one test: *does another file's correctness depend on this file keeping its promise?* If yes the promise belongs in the name, because a contract you cannot see is one you break without noticing. If no it is a category, and categories are read out of the file.
|
|
129
|
+
|
|
130
|
+
| Suffix | Promise | |---|---| | `*.component.tsx` | exactly one component (+ types) | | `*.component.ui.tsx` | one component, **reads only** — never writes | | `*.hook.ts` | exactly one `use*` hook (+ types) | | `*.types.ts` | zero runtime exports | | `*.internal.ts` | only its own directory subtree may import it | | `*.fixture.ts` | no production path may reach it | | `*.tracking.ts` | analytics is called nowhere else |
|
|
131
|
+
|
|
132
|
+
`voltro doctor` enforces all of them; `voltro doctor --json` emits every finding.
|
|
133
|
+
|
|
134
|
+
**`*.component.ui.tsx` may read and must not write.** `useT`, `useCan`, `usePermissions` stay allowed on purpose — threading translations and permissions through props is prop-drilling, and it makes every call site worse without making the component more portable. Importing a write hook (`useMutation`, `useAction`, `useUpload`, …) is the violation, because a component that can mutate cannot be rendered ten thousand times in a list, reused across features, or prerendered without first reading its source. That property is what its callers rely on. It must also be *reached* from a `*.component.tsx`, another `*.component.ui.tsx`, or a page: an unrendered presentational component is carried, reviewed and refactored forever without reaching a user.
|
|
135
|
+
|
|
136
|
+
**`*.types.ts` having no runtime export is a guarantee, not tidiness.** It makes importing the module free in the bundle *and* makes it impossible for it to participate in a runtime import cycle — and in a large codebase the second one is the valuable half, because a cycle is invisible until it throws.
|
|
137
|
+
|
|
138
|
+
**`*.tracking.ts` confines the event catalogue.** The rule is global: `defineTracking(...)` may be called from nowhere else, so every event name, property bag and decision about which fields leave the building lives in files you can list. A component wires one up with `useTracking(spec, props, sink)` — it NAMES a spec, it never declares one.
|
|
139
|
+
|
|
140
|
+
`useTracking` itself is deliberately not confined: it is a hook, so it must run inside a component and could not be moved into a plain module. A rule nobody can satisfy is a rule everybody disables. The payoff stands either way — "what do we send to third parties" becomes a file listing rather than an archaeology project, which is the only form in which that question can be answered on demand.
|
|
141
|
+
|
|
142
|
+
**What deliberately has no suffix.** A generic "one component per file" rule would be worth enforcing everywhere, so tying it to a rename would make it opt-in: less coverage for more cost. The shape rules fire only on files that *declared* the contract. There is also no `*.store.ts` — nothing in the framework depends on a store, so the suffix would promise nobody anything. Suffixes follow primitives, never the reverse.
|
|
143
|
+
|
|
144
|
+
**Migration.** The codemod renames what the exports decide unambiguously: one component → `*.component.tsx`, one hook → `*.hook.ts`, no runtime exports → `*.types.ts`. Imports travel with the file. It refuses two cases on purpose — a file exporting a component *and* a hook (the one the taxonomy most wants split, and no codemod can decide which half keeps the name), and `*.component.ui.tsx`, which is never inferred because "presentational" is a promise about what a component *may* do, not an observation that it currently does not.
|
|
145
|
+
|
|
146
|
+
**The rules apply to code you AUTHOR, never to vendored code.** A shadcn component arrives via `npx shadcn add`, follows shadcn's conventions, and is overwritten by the next `add` — renaming it breaks their convention, is undone next run, and leaves the directory half-migrated the moment one file fails to classify. Our own devtools dashboard demonstrated all three before this landed.
|
|
147
|
+
|
|
148
|
+
A directory is exempt when the app's `components.json` names it (`aliases.ui` only — `aliases.components` is where your own components live too, and honouring it silenced the taxonomy across our whole dashboard) or when it carries a `.voltro-vendored` file whose first line names the source. The marker is a file with a reason rather than a config list on purpose: a config list is invisible from the directory it exempts and quietly becomes where people put their own code to silence a rule. Every honoured exemption is printed, so the escape hatch is never silent.
|
|
149
|
+
|
|
150
|
+
### Added
|
|
151
|
+
|
|
152
|
+
- **@voltro/protocol, @voltro/cli** — Actions can declare `source` and `target`, and `orphan/unread-table` stops advising deletion on a conclusion it cannot support.
|
|
153
|
+
|
|
154
|
+
An action is non-transactional external I/O, and it very often touches a table on the way — a cache it fills, a job row it stamps. There was no slot to declare that, so every such table was invisible: `voltro check` reported one that five action paths read and wrote as an orphan, and the suggested fix was **remove the table**. A wrong finding is bad; a wrong finding whose remedy is destructive is worse.
|
|
155
|
+
|
|
156
|
+
Two halves. `defineAction` now takes the same `source` / `target` a query and a mutation take, so the answer can be declared. And while any action declares neither, the orphan rule says so and asks for the declaration instead of proposing a delete — absence of a declaration is not a declaration of absence, the same distinction the manifest already draws for guards.
|
|
157
|
+
- **@voltro/cli** — **`*.client.ts` — declare a shared file browser-safe, and have it checked.**
|
|
158
|
+
|
|
159
|
+
`*.server.ts` works because it declares a *permission*, not a fact: "this file may import `node:*`". An import graph cannot derive that — it can tell you what a file imports, never what it is allowed to import. `*.client.ts` is the mirror: *I, and everything I transitively import, am browser-safe.* `voltro dev` walks the claim at boot with the same walker and forbidden list the rpcGroup guard uses, and refuses to start if it is false — and **`voltro check` walks it too**, reporting `client/not-browser-safe` with the import chain.
|
|
160
|
+
|
|
161
|
+
The check matters more than it sounds: `voltro dev` only sees the app it boots, so a marker in a WEB app or in a package no api boot touches was a promise nobody ever read. CI runs `voltro check`, which walks every marked file in the project regardless of which app owns it.
|
|
162
|
+
|
|
163
|
+
It exists for the trap this framework records as its worst, which is **transitive**: a descriptor imports a shared `lib/` helper, that helper also imports the `database` handle, and the whole server graph lands in the browser bundle. The rpcGroup guard already catches that — but only once some descriptor happens to reach the file, and it reports a forty-module chain you read backwards to find the one shared file that should never have touched the database. The marker moves the failure to that file, at the moment it is written.
|
|
164
|
+
|
|
165
|
+
An unmarked file makes no claim, and that is deliberate. This is not a `*.component.tsx`-style label: those describe what a file already obviously is, nothing would enforce them, and a convention nothing enforces gets half-adopted — after which an unmarked file means nothing at all.
|
|
166
|
+
|
|
167
|
+
**`voltro check` gained `rbac/unenforced-scope`** — a scope a role *grants* that no handler ever guards on.
|
|
168
|
+
|
|
169
|
+
`rbac/unknown-scope` already read the registry the other way (a guard naming a scope nobody grants), and that direction is easy because the guard is a thing you can look at. This one has no artefact at all: you cannot grep for an authorization check that was never written, which is exactly why it survives review. One app modelled `api-keys:write` in its role catalogue, complete and reviewed, and no handler checked it — any member could mint a shared credential, and nothing failed. Tests pass when an authorization check is missing.
|
|
170
|
+
|
|
171
|
+
A **warning**, not an error: a plugin route may enforce it internally (the graph cannot see inside a plugin), a REST route carries its own guards, or it may be a UI-affordance scope `useCan` reads to hide a button with no server check by design. All three are legitimate. Not knowing which is not.
|
|
172
|
+
|
|
173
|
+
Note what this deliberately is NOT: a `*.guard.ts` file convention. Guards are already declarative and already live on the descriptor, beside the thing they protect. Moving them to a separate file would make them *less* discoverable, and their absence from the filesystem would mean nothing — failing the same test `*.client.ts` passes.
|
|
174
|
+
- **@voltro/cli** — **`voltro check` enforces the file conventions — CI, not just the doctor.**
|
|
175
|
+
|
|
176
|
+
`voltro doctor` is what a person runs when something feels wrong. `voltro check` is what CI runs on every commit. A contract enforced only by the first is enforced on the days nobody is looking, which is every day — so the taxonomy and the page convention now produce `check` diagnostics and set its exit code.
|
|
177
|
+
|
|
178
|
+
What FAILS a build: a `*.types.ts` with a runtime export, a foreign import of an `*.internal.ts`, a fixture reachable from production code, a `*.component.ui.tsx` that writes, a store mirroring server state, two stores in one file, and a `*.page.tsx` outside `src/pages/` (which can never route).
|
|
179
|
+
|
|
180
|
+
What is REPORTED and does not fail: a missing test beside a file, an orphaned presentational component, and an unsuffixed `.tsx` under `src/pages/`. The last one fires on a correct co-located component often enough that blocking on it would be wrong, and a gate that blocks on a missing test gets disabled within a week — taking the real rules with it.
|
|
181
|
+
|
|
182
|
+
Vendored directories are exempt here exactly as they are in the doctor: the rules apply to code you author.
|
|
183
|
+
- **@voltro/client, @voltro/web** — **`defineStore` — client state that is not server state.**
|
|
184
|
+
|
|
185
|
+
Server state already had a home: a subscription *is* live server state, and it stays live. What had none was the rest — which rows are selected, which wizard step you are on, the draft you have not submitted. Without a primitive for it a team reaches for zustand or jotai, which is a **parallel runtime** — the exact thing the framework's own guidance tells them not to bring. Shipping nothing here was never neutrality; it was an instruction to import something.
|
|
186
|
+
|
|
187
|
+
```ts
|
|
188
|
+
const wizard = defineStore('wizard', () => ({ step: 0 }))
|
|
189
|
+
|
|
190
|
+
wizard.use((s) => s.step) // the global instance
|
|
191
|
+
wizard.use((s) => s.step, { key: orderId }) // one instance per order
|
|
192
|
+
```
|
|
193
|
+
|
|
194
|
+
**There is no `useStore()` that hands back the whole state**, because it would be used, and a component holding the whole state re-renders on every change to any field. Reads go through a selector or they do not happen — that is the only way "no needless re-renders" is a property rather than an aspiration. Proven against a real renderer, not argued: a component reading `coupon` does not re-render when `note` changes, and one selecting `items.length` does not re-render when `['a']` becomes `['b']`.
|
|
195
|
+
|
|
196
|
+
**Scoping is by KEY, not by a Provider.** A Provider re-renders every consumer when its value identity changes, whether or not that consumer reads the field that moved — that *is* the context-hell mechanism, so putting a store behind one would reintroduce the problem in nicer clothing. An instance is addressed by a key the caller already has (an order id, a table id, a URL param), which is also the model the framework uses everywhere else: `useSubscription('orders.list', { orgId })` is keyed by input, not by tree position. One read form, an optional key.
|
|
197
|
+
|
|
198
|
+
**SSR seeding adds no new channel.** `seedStore(wizard, { step: 2 }, { key })` is callable anywhere on the server during a render, and the value rides the hydration payload the router already writes — no `dehydrate()` to remember and no `hydrate()` to forget, because a step you can forget is a step somebody will. Calling it on the client **throws**: a silent no-op would leave the store empty in the browser and full on the server, which surfaces as a hydration mismatch that reads like a React bug.
|
|
199
|
+
|
|
200
|
+
Request scoping is real, not assumed. Two requests interleave at every `await`, so a module-level "current bag" would put one request's values into another's document; the scope is `AsyncLocalStorage`, installed by the server-only `@voltro/web/ssr` entry (the client package is loaded by browsers and cannot import `node:async_hooks`, so it exposes a resolver and the server supplies the scoping). A test drives two interleaved renders and asserts neither sees the other's seeds.
|
|
201
|
+
|
|
202
|
+
`withStoreSeeds` is a member of the shared `SsrHelpers` contract rather than an ad-hoc import, so a new boot path cannot quietly omit it — the type is what `voltro dev`, `voltro start` and `voltro build` all resolve against.
|
|
203
|
+
|
|
204
|
+
Three details that are load-bearing rather than incidental: `initial` is a **function**, so keyed instances never alias one object (the bug where editing order A also edits order B, found weeks later); the notify loop iterates a **copy**, so subscribing from inside a listener cannot mutate the set mid-iteration; and a `set` whose value is unchanged wakes **nobody**, because a form re-submitting the same draft is the cheapest needless re-render there is.
|
|
205
|
+
|
|
206
|
+
**All three SSR emitters open the scope**, and a lockstep test keeps that true. `voltro dev`, `voltro start` and `voltro build` each call `beginStoreSeeds()` once per render — `enterWith`, not a callback wrap, because `renderPageForRequest` spans hundreds of lines with early returns and a streaming branch that returns from the middle, and restructuring production render code buys the scope nothing. The prerender opens a FRESH scope per artefact: one scope for the whole build would put every page's seeds into every page's document. The seeds are folded into the payload inside `renderRouterStateScript` — one place, which every emitter already calls, so a seed cannot be collected in dev and dropped in production.
|
|
207
|
+
|
|
208
|
+
**`*.store.ts` joins the file taxonomy**, now that something depends on it: the seed pass and the devtools address a store by NAME, so a file holding two of them breaks something other than itself. `voltro doctor` enforces one `defineStore` per file, and reports a store that reads a subscription — detected by the CALL, not by field names, because a store legitimately holds an `orderId` and guessing from names would fire on correct code.
|
|
209
|
+
|
|
210
|
+
**The server renders the seeded value too** — and that is the correctness of seeding, not a refinement of it. Store instances are module-level and shared by every concurrent request, so a seed must never be written into one (request A's wizard step would appear in request B's document). But a server rendering the UNSEEDED instance while the client applies the seed before hydrating produces a mismatch on every seeded page — the exact failure this feature exists to prevent. So the two sides read different sources and arrive at the same value: the server's `getServerSnapshot` reads through the request-scoped bag, the client reads the instance `mount()` already seeded.
|
|
211
|
+
|
|
212
|
+
It took an end-to-end test to see it. Six unit tests were green while React threw `Hydration failed` on every seeded render, because a mismatch is a class no server-side assertion can observe — the same reason `hydrateLoaderData.test.tsx` exists.
|
|
213
|
+
|
|
214
|
+
**Computed selectors, memoised, with the footgun turned into a warning.** The re-render promise held only for selectors returning a PRIMITIVE. One returning an object or a derived list — the exact shape a computed value has — hands back a fresh reference every call, so `Object.is` reports "changed" forever and the component re-renders on every change to any field. Measured before the fix: an object selector re-rendered on an unrelated `set`, and so did `items.filter(…)`. A promise that fails precisely in the case it was sold for is worse than no promise.
|
|
215
|
+
|
|
216
|
+
`{ equals: shallow }` fixes it, the result is cached so an equal value keeps its PREVIOUS reference (which is what makes React skip the render rather than merely recompute), and the selector is not re-run at all while the state object is unchanged. In dev the framework detects the case — a value that would have compared equal one level deep but is not identical, which is exactly the wasted re-render and nothing else — and warns ONCE, naming the fix.
|
|
217
|
+
|
|
218
|
+
**Keyed instances are released with their last subscriber.** They were created on demand and nothing ever removed them: a table keyed by row id accumulated one per row EVER rendered — measured at 1000 live after 1000 keys. The drop is deferred by a macrotask so a remount keeps its state (StrictMode double-invokes, and a route change can unmount and remount the same key within a tick); `{ retain: true }` opts out for state that must survive navigating away.
|
|
219
|
+
|
|
220
|
+
**Every write passes through one seam**, which is where inspection and undo come from. Instrumenting `set` rather than shipping a declared `actions:` bag is a coverage decision: a declared API only sees the writes somebody remembered to declare, and the write that causes the bug is the one written in a hurry, inline, in an event handler.
|
|
221
|
+
|
|
222
|
+
`storeHistory()` / `subscribeStoreHistory()` expose a bounded feed of `{ store, key, label?, prev, next, at }` — bounded because an unbounded log leaks in exactly the long-lived sessions where it would be useful. `store.set(next, key, 'checkout.applyCoupon')` labels a write for that feed. `store.undo(key?)` / `store.redo(key?)` walk an instance's history, with `canUndo` / `canRedo` for the buttons. Restoring is by IDENTITY rather than a merge (a merge would leave behind fields a later write added — a state nobody ever wrote), an undo never becomes undoable itself, and a NEW write after an undo drops the redo tail, which is what every editor does.
|
|
223
|
+
|
|
224
|
+
The first version had no redo, because `undo` SPLICED the entry out of the log: nothing was left to step forward to, and the devtools panel had to invent a second model for the same idea. Both are cursors over intact histories now — the store's per instance, the panel's over the global log.
|
|
225
|
+
|
|
226
|
+
**Battle-tested against the days it is used badly**, which is where a state library is actually judged. A hostile-conditions suite covers tearing between two readers in one commit, a write from inside a listener, a write during render, unsubscribing mid-notification, a thousand subscribers, a thousand keyed instances, a listener that throws, NaN fields, and the awkward corners of shallow equality. Two of those found real defects:
|
|
227
|
+
|
|
228
|
+
- **`set` compared IDENTITY, so a partial merge never short-circuited.** A merge always builds a new object, which meant `set({ step: 2 })` twice with the same 2 woke every subscriber both times — and a listener that wrote could never converge, producing a stack overflow. `set` now compares one level deep, so setting the same values again is free. - **A non-converging listener blew the stack** with an error naming nothing. A depth guard now reports it at the point the loop is still legible.
|
|
229
|
+
|
|
230
|
+
And the piece no jsdom test could reach: the fixture app's layout loader seeds a store, and the dev-SSR boot test asserts the SEEDED value in the server HTML. That proves the seed bag the CLI opens and the `seedStore` the app module calls land on the same `@voltro/client` instance under Vite's dev transform — two copies would throw "outside a server render", green in every unit test and fatal on the first real page. It caught an ORDERING defect immediately: the scope was opened AFTER `buildSegmentChain`, so every seeding layout loader threw and `voltro start` answered `server error` while dev fell through to the SPA shell. The build path happened to be ordered correctly and passed.
|
|
231
|
+
|
|
232
|
+
**A Stores tab in the `voltro dev` overlay, with time travel.** Every defined store with its live state (global and per key), a feed of every write — store, key, label, and the fields that actually changed — and `◀ Back` / `Forward ▶` that restore the state as it was before or after each one. The state a component reads moves with it.
|
|
233
|
+
|
|
234
|
+
This is what putting client state IN the framework buys: no extension, no connector, no version to match. The panel is just another subscriber to the seam every write already passes through, so it sees writes made by code that never heard of devtools, on any machine.
|
|
235
|
+
|
|
236
|
+
Travel is deliberately NOT built on `undo`: undo is a stack that CONSUMES entries, so stepping forward again would be impossible. It moves a position over an intact log instead. The first version tracked "the entry we are parked on" and could not tell "stepped back to the beginning" from "live" — Forward was disabled exactly when it was needed. A count of applied writes has no such ambiguity.
|
|
237
|
+
|
|
238
|
+
`storeHistory()` returns a STABLE reference until the log changes, because `useSyncExternalStore` requires a cached snapshot and a fresh array per call sends any subscriber into an infinite render loop. Our own panel hit that within a minute of being written, so the safety lives in the API rather than in a note every consumer has to read.
|
|
239
|
+
|
|
240
|
+
**Verified in a real browser**, because three of the store's claims cannot be settled anywhere else. `scripts/browser-client-store.mjs` drives chromium against the fixture app and checks: the seeded value is in the FIRST PAINT with **JavaScript disabled** (the only way to prove the server rendered it rather than the client filling it a tick later); React reported no hydration mismatch (a mismatch is a console error in a browser and nothing anywhere else — which is exactly how the seed once shipped rendering 0 on the server and 7 on the client); and a component reading a DIFFERENT field of the same store does not re-render when the first one moves, counted in the DOM because a render count is not observable from outside a page any other way. Writes, undo, redo and the redo-tail truncation are all exercised through real clicks.
|
|
241
|
+
|
|
242
|
+
It caught two fixture defects on its first run, one of them the trap the docs name: the layout seeded the GLOBAL instance while the page read a KEY.
|
|
243
|
+
- **@voltro/testing, @voltro/cli** — **`ctx.webhooks` exists in the test harness, and `voltro test` stops collecting `e2e/` specs.** Both were found by running the starter's own suite, which had been red on both counts.
|
|
244
|
+
|
|
245
|
+
`ctx.webhooks` is a field PRODUCTION supplies (`makeAppContextBuilder`, when the webhooks plugin is configured) and the harness did not — so a mutation written the documented way, `useWebhooks(ctx).emit(todoCreated, {...})`, threw "`ctx.webhooks` is not set" in every unit test. The only way to test one was to hand-roll a context, and the hand-rolled version in the starter was itself broken: it assigned through a cast onto the OUTER context while the handler runs against the transaction context `invoke` derives, so it recorded an emission the handler never made. Every derived context now shares one `MockWebhooks`:
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
await invoke(createTodo, handler, { title: 'hi' }, ctx)
|
|
249
|
+
expect(ctx.webhooks.last('todo.created')?.payload).toMatchObject({ title: 'hi' })
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
It records; it does not deliver, sign, or consult subscriptions — a unit test asks what the handler emitted, and delivery is covered where the plugin lives.
|
|
253
|
+
|
|
254
|
+
**`e2e/` belongs to `voltro e2e`.** Its specs drive a browser through tsx against a booted api + web and define no vitest suite, so `voltro test` collected them and reported "No test suite found" — a red run for an app laid out exactly as the framework asks. The exclusion EXTENDS vitest's defaults rather than replacing them (vitest does not merge `exclude`, so a bare glob would silently re-admit `node_modules` and `dist`), and a user-supplied `--exclude` still wins outright.
|
|
255
|
+
|
|
256
|
+
`voltro test` REPORTS every spec it skipped, on every run. A project that had real vitest specs under `e2e/` would otherwise just start running fewer tests and still print green — a silent cap is worse than the red run this replaced, because nothing says it happened. The shipped codemod prints the same thing during `voltro update`, and only for a project that actually has such files.
|
|
257
|
+
- **@voltro/client** — **`store.batch(label, fn)` — many writes, one meaning.**
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
checkout.batch('applyCoupon', () => {
|
|
261
|
+
checkout.set({ coupon })
|
|
262
|
+
checkout.set({ total: recompute(coupon) })
|
|
263
|
+
})
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
One notification, one devtools entry named `applyCoupon`, **one undo step**. Without it that action is three of each: Ctrl-Z walks back through a third of a change at a time, and the feed shows three anonymous writes instead of the thing that happened. React batches the re-*renders* on its own — it cannot batch the meaning, and undo and the devtools feed both read the meaning.
|
|
267
|
+
|
|
268
|
+
**A throwing callback rolls back every write it made.** Nothing was announced yet, so an action that fails halfway cannot leave the half-applied state that is the usual reason people reach for a transaction. Writes that cancel each other out record nothing at all. A nested batch joins its parent.
|
|
269
|
+
|
|
270
|
+
**An `async` callback is a hard error, not a warning.** Everything after its first `await` would land outside the batch — writes escaping one at a time, a rollback covering only the synchronous head, and a devtools entry that lies about what the action did. The error says what to do instead: await first, then batch the writes.
|
|
271
|
+
- **@voltro/client** — *(`apiSurface: compatible` — `defineStore` gained an OPTIONAL third parameter and `StoreHandle` gained a member. Every existing call site compiles unchanged; the handle is only ever obtained from `defineStore`, never constructed.)*
|
|
272
|
+
|
|
273
|
+
**`defineStore(..., { persist })` — client state that survives a reload.**
|
|
274
|
+
|
|
275
|
+
```tsx
|
|
276
|
+
export const filters = defineStore(
|
|
277
|
+
'inbox:filters',
|
|
278
|
+
() => ({ status: 'open', sort: 'newest' }),
|
|
279
|
+
{ persist: { key: 'inbox:filters', pick: (s) => ({ status: s.status }) } },
|
|
280
|
+
)
|
|
281
|
+
```
|
|
282
|
+
|
|
283
|
+
Every hand-rolled version of this gets the same three things wrong, so the framework version does them and the tests pin them:
|
|
284
|
+
|
|
285
|
+
- **The stored value is merged over `initial()`, not substituted for it.** Add a field and every returning user otherwise has state missing it — `undefined` where the type promises a string. - **`migrate` returning `undefined` DISCARDS the value.** A stale draft is an annoyance; a half-migrated one is a bug report nobody can reproduce. - **Every storage touch is guarded and wrapped.** The module is imported by the server render too, and Safari in private mode throws on *reading* `localStorage`. A store that throws at import time takes the page with it.
|
|
286
|
+
|
|
287
|
+
Only the **global** instance persists — a keyed instance is per entity, and writing every key into one bucket grows without bound.
|
|
288
|
+
|
|
289
|
+
**A persisted store on a server-rendered page hydrates against the SERVER value.** The server has no `localStorage`, so it renders `initial()` and the stored value lands in the commit right after hydration. Without that split, every returning user got a hydration mismatch — a flash plus a console error that reads like a React bug. `get()` is not deferred, only the render.
|
|
290
|
+
- **@voltro/database, @voltro/cli** — Three diagnostics, each for a failure that had already happened to somebody.
|
|
291
|
+
|
|
292
|
+
**The database disagrees that a table is reactive.** On postgres, reactivity is carried by a per-table trigger, and the schema fingerprint covers columns — not triggers. A restored dump, a hand-run `DROP TRIGGER`, or a table migrated under a release that installed none all leave the schema "up to date" and the trigger absent, with subscriptions silently not reaching other instances. The boot now compares the two and names the tables, including the reverse case: a `.nonReactive()` table still carrying a trigger keeps paying `REPLICA IDENTITY FULL` and a NOTIFY on every write for a subscription nobody receives. Reported, never repaired — `voltro db apply` owns DDL, and a boot that quietly re-created triggers would be a boot doing migrations.
|
|
293
|
+
|
|
294
|
+
**`apiKeys: true` with no issuance scope declared anywhere.** The management routes gate on `apikeys:issue:self|org|other`. If no role declares one, the capability is switched on and reachable by nobody: every issue request fails its guard, which reads as a permissions bug in the app rather than a missing declaration. The two halves live apart — the flag in `app.config`, the scopes in a role map — and neither side can see the other.
|
|
295
|
+
|
|
296
|
+
**A handler that writes `ctx.request.subject.id` with no guard.** The highest-yield finding from a downstream migration: seven per-user mutations with no authentication check at all, each writing a `string | null` subject id into a NOT NULL column, so an anonymous caller reached the database and got `Failed to execute statement` instead of a typed refusal. Their compiler only surfaced it once the write became typed; `voltro doctor` now finds the shape directly. Narrow on purpose — the subject id must be read, a write must be present, and the file must name no guard at all. It strips comments and strings first, so a reassuring note about a guard that is not there does not clear it.
|
|
297
|
+
|
|
298
|
+
### Fixed
|
|
299
|
+
|
|
300
|
+
- **@voltro/cli** — Two more places where the framework wrote or looked in the wrong place.
|
|
301
|
+
|
|
302
|
+
**`voltro build` rewrote `.gitignore` even when nothing was missing**, and rebuilt it from `filter(l => l.trim() !== '')` — silently deleting every blank line from a file a human maintains and git tracks. Where the file was not writable, an identical-content rewrite aborted the build. It now checks first, APPENDS rather than re-emitting, and treats a failure as a tidiness miss rather than a reason to fail a production build.
|
|
303
|
+
|
|
304
|
+
**`voltro doctor` walked a hand-kept list of directories.** A consumer's `schedules/…cron.tsx` was never scanned and they diagnosed it as the `.tsx` extension; the extension was always handled — `schedules/` simply was not on the list. That is the third whitelist in this codebase to drift, so it is gone: the scan walks the app root recursively with the existing prune list, and reports the directories that actually contributed.
|
|
305
|
+
- **@voltro/cli** — The observed-graph check no longer reports "never read" from an empty recording.
|
|
306
|
+
|
|
307
|
+
A procedure can be recorded as having RUN while no table access was captured for it, and "ran and touched nothing" is then indistinguishable from "ran and nothing was recorded". A consumer saw `edges: []` with three procedures in `exercised`, and every one was reported as declaring a source it never read — including a handler that demonstrably reads its table.
|
|
308
|
+
|
|
309
|
+
This is the distinction the manifest already draws for guards, where omitting the field made "no authorization" indistinguishable from "not reported". For reads it had collapsed again. With zero captured edges nothing is claimed, and the procedure is reported in a third bucket alongside `unexercised`. Coverage excludes it too — counting it would overstate the denominator in precisely the run where the recorder produced nothing.
|
|
310
|
+
- **@voltro/database, @voltro/cli** — `.reactive()` says what it does, and the framework says which dialect case you are in.
|
|
311
|
+
|
|
312
|
+
The keyword is named for a general capability and implements one dialect's transport detail, and both ways of getting it wrong were silent:
|
|
313
|
+
|
|
314
|
+
- **Declared where it does nothing.** On mysql/mariadb the ROW-format binlog reader tails every table; on mssql Change Tracking is configured with the whole app table set; sqlite is single-process. The flag is inert on all of them. Its doc said "opt the table into the reactive engine", which is false — every table is already in it, because the store emits committed deltas inline. A team on MariaDB read that, found 26 tables without the flag and 44 query descriptors reading them, and reasonably concluded a large part of their app was never live. It always was; the investigation was the cost. - **Missing where it is required.** On postgres with `changeStrategy: 'cdc'`, only a `.reactive()` table gets the `pg_notify` trigger the CDC consumer listens on. A table without it never propagates a write to another instance's subscribers — correct on the writing pod, stale everywhere else, and perfect in single-instance dev.
|
|
315
|
+
|
|
316
|
+
`voltro dev` and `voltro serve` now name whichever case applies: an info line when the flag is inert on this dialect (including that nothing is missing), and a warning listing the tables a query reads that lack it under postgres+cdc. A warning rather than a refusal, because the framework cannot tell from inside one process whether a second one exists.
|
|
317
|
+
|
|
318
|
+
Also corrected: the DSL doc, and a `reactiveTables` variable in the CDC wiring that was actually every app table and consulted no flag at all.
|
|
319
|
+
- **@voltro/cli** — `voltro test`'s tsconfig reader destroyed any config carrying a `@/*` alias, so the alias derivation shipped in the previous release was correct and never ran.
|
|
320
|
+
|
|
321
|
+
The comment stripper was a regex. It read the `/`+`*` inside the alias key `"@/*"` as an opening block comment and closed it on the `*`+`/` inside an `include` glob like `src/**`, deleting everything between — `paths` included. The parse then failed, and an unparseable tsconfig degrades to "no aliases" by design, so the whole thing was silent. Essentially every real tsconfig has both an `@/*`-style alias and a `**` glob, which made this every real tsconfig.
|
|
322
|
+
|
|
323
|
+
It now scans string state in one pass instead of regexing over strings, and the trailing-comma pass does the same — a `,` inside a string is not a trailing comma either. Reported with a five-line comment-free repro, which is exactly what made it obvious that comments were never the trigger.
|
|
324
|
+
|
|
325
|
+
---
|
|
326
|
+
|
|
327
|
+
## [0.13.0] — 2026-07-25
|
|
328
|
+
|
|
329
|
+
### ⚠ BREAKING
|
|
330
|
+
|
|
331
|
+
- **@voltro/protocol, @voltro/plugin-scim, @voltro/plugin-prometheus** — SCIM was served UNAUTHENTICATED whenever its token was an empty string.
|
|
332
|
+
|
|
333
|
+
`checkBearer(headers, expected)` returned `true` when `expected` was unset or empty, documented as "no token configured = open; the caller decided not to gate this surface". Its one production caller had decided the opposite: `scimPlugin` declares `token: string`, and `scimPlugin({ token: process.env.SCIM_TOKEN ?? '' })` — the shape anyone writes — turned the gate off silently. The result was SCIM 2.0 Users and Groups readable with no credentials: a full directory dump plus the provisioning surface that can deactivate accounts. Likeliest exactly where it hurts, too: an env var set in production and missing in a preview environment.
|
|
334
|
+
|
|
335
|
+
`checkBearer` is now fail-closed by default, with the permissive behaviour available as an explicit `{ openWhenUnset: true }` — a two-argument helper cannot know its caller's intent, so it must not assume the permissive one. `@voltro/plugin-prometheus` passes it (its token is documented as optional), and `scimPlugin` now throws at construction — i.e. at boot — rather than answering the first anonymous request.
|
|
336
|
+
- **@voltro/database, @voltro/cli** — `voltro db apply` and boot auto-migrate could report success while applying nothing, and then record a fingerprint that made every later boot short-circuit on "schema up to date".
|
|
337
|
+
|
|
338
|
+
Reported from a live pod: `applied 31 op(s)` on every boot for two releases, with none of the 31 present in the database. Nothing was wrong with the transport, the lock or the transaction — the applier emitted statements that postgres accepted and that changed nothing. Two independent causes:
|
|
339
|
+
|
|
340
|
+
- A `ColumnSnapshot` carried no `vector` dimension / `array` element / `enum` name, so the applier's type renderers collapsed all three to `text`. A declared `vector(1536)` over a live `text` column planned an `alter-column-type` that emitted `ALTER COLUMN … TYPE text`. Valid, applied, no-op, re-planned forever. (Also meant an `add-column` for a vector, array, enum or PostGIS column created a plain `text` column.) - The default-clause renderers excluded ARRAYS, returning `null`, and the call site turned that into `SET DEFAULT NULL`. A declared `.default([])` on a `json()` column therefore never landed — thirty columns were stuck this way in the reporting schema.
|
|
341
|
+
|
|
342
|
+
Fixed: the snapshot carries the type parameters and the renderers delegate to `migrate.ts`'s canonical `sqlType`, so the applier and the CREATE-TABLE emitter cannot disagree; array defaults render (a real `text[]` literal on a native `array()` column, a jsonb literal otherwise); and a default the renderer cannot express now FAILS instead of degrading to `DEFAULT NULL`.
|
|
343
|
+
|
|
344
|
+
And the structural guard, which is the part that matters: **`applyPlan` re-plans against the live schema before it records a fingerprint, and refuses to record one if any operation remains.** DDL that changes nothing succeeds exactly as quietly as DDL that works, so the only evidence a plan applied is that the same planner has nothing left to do. `ApplyPlanCtx` gains a required `replan`; `AppliedMigration` gains `appliedOps` (what EXECUTED, not `plan.operations.length`), and the boot log quotes that.
|
|
345
|
+
- **@voltro/plugin-storage** — `storage.share`, `storage.revoke` and `storage.listGrants` performed no authorization at all.
|
|
346
|
+
|
|
347
|
+
Each took an object id straight off the wire and passed it to a service method that (correctly, for a trusted server-side API) checks nothing, with nothing in between. Any authenticated caller could grant themselves read or write on any object in the installation, revoke anyone else's grants, and enumerate who an object is shared with.
|
|
348
|
+
|
|
349
|
+
All three now require that the caller owns the object, or carries `admin:full`. A missing object and an unowned object report the same 403 — a 404 would let an unauthorized caller probe which ids exist. `GrantStore` gains `getById`, which `revoke` needs to resolve a grant id back to its object.
|
|
350
|
+
|
|
351
|
+
### Added
|
|
352
|
+
|
|
353
|
+
- **@voltro/runtime, @voltro/database** — API keys carry app-owned `metadata` — the second ownership axis.
|
|
354
|
+
|
|
355
|
+
`tenantId` and `onBehalfOf` are the two relationships the framework models. Plenty of apps have a third that actually authorizes the key: a team, a project, an environment. `ApiKeyRecord` in `@voltro/protocol` has carried a `metadata` slot all along — its doc comment even names `teamId` as the example — but the SERVICE had nowhere to store it and nowhere to return it. So an app with a team axis could authenticate through the built-in strategy and still not authorize, and `apiKeys: true` was unusable for it. Reported as the one thing that stopped an otherwise complete adoption; their alternatives were a second table joined on the hot auth path, or smuggling `team:<id>` into `scopes`, where `hasScope` would then see a scope that is not a scope.
|
|
356
|
+
|
|
357
|
+
`IssueInput`, `ApiKeyRow` and `ResolvedApiKey` now carry it, stored as JSON on `_voltro_api_keys`, and it survives `rotate` — a rotated key is the same credential with a new secret, so dropping it would silently de-authorize every rotated key.
|
|
358
|
+
|
|
359
|
+
It is app data, never identity. The strategy merges it UNDER the framework's own claims: `provider` and the acting `userId` are written afterwards from `onBehalfOf` and always win, including when the answer is "none". A bag that could set `userId` would let whoever minted the key choose who the request is. Pinned end-to-end, not just at the protocol layer.
|
|
360
|
+
|
|
361
|
+
`PublicApiKey` also gains `createdBy` and `onBehalfOf`, so `service.list` can answer the two questions an admin actually asks about a shared credential. Neither is a secret — they are the accountability record, and omitting them hid them from the person responsible for the key.
|
|
362
|
+
- **@voltro/protocol, @voltro/cli** — A boot warning when two auth strategies claim the same bearer-token prefix.
|
|
363
|
+
|
|
364
|
+
The chain is first-match-wins, so a duplicate claim is not a harmless redundancy: whichever strategy runs first decides the Subject. An app that already has its own `sk_` keys and then sets `apiKeys: true` gets the framework strategy appended on the same prefix — resolving without the app's own team binding — and *which strategy answered* decides whether authorization works. Reported by an app that had to pin a test asserting it never enables the flag.
|
|
365
|
+
|
|
366
|
+
`AuthStrategy` gains an optional `claimsBearerPrefix`, set by `apiKeyStrategy` from its `prefix` option. Making the claim declarative is what makes the collision detectable at all — the same "only what is declared can be checked" argument the scope rules run on. Checked in `buildResolveSubject`, which both `voltro dev` and `voltro serve` call, so the two boot paths cannot drift.
|
|
367
|
+
|
|
368
|
+
A warning rather than a refusal: two strategies on one prefix can be deliberate (a migration window where old and new keys share a shape). What must not happen is that it goes unmentioned.
|
|
369
|
+
- **@voltro/protocol, @voltro/cli** — `auth.resolveScopes` — add scopes to an authenticated Subject from your own data, so ROLE-based authorization becomes declarable.
|
|
370
|
+
|
|
371
|
+
An app whose authorization is a database role (`requireCallerAdmin(ctx)` reading an `employees.role` column) is invisible to every static check the framework has: `voltro check`'s `rbac/unguarded-mutation` reports its writes as unguarded, and it is right to — nothing about the decision is declared. But the declarative alternative was unusable for exactly those apps: their subjects come from an external IdP's JWTs and carry no scopes, so `requireScope('employee:admin')` would lock out every real user. One app measured 1566 findings it had no way to act on.
|
|
372
|
+
|
|
373
|
+
Lifting the role into `subject.scopes` makes the SAME authorization declarable, visible in the manifest and checkable in CI. Deliberately narrow: the hook returns SCOPES, never a Subject — it cannot change `id` or `tenantId` (identity belongs to the auth strategy), and the result is unioned with the strategy's own scopes, so it can grant but never revoke. It runs per matched request, so cache the lookup yourself; the framework does not, because only the app knows how fast a role change must take effect. Wired identically in `voltro dev` and `voltro serve`.
|
|
374
|
+
- **@voltro/cli** — `voltro doctor` reports packages resolved at more than one version.
|
|
375
|
+
|
|
376
|
+
A consumer reported type errors inside the GENERATED `rpcGroup.generated.ts` — `Property '[TypeId]' is missing`, `typeof Never is not assignable to All`, an `Rpc<…, Stream<…>, …>` refused where `Any` was expected — and reasonably concluded the framework emits bad types, because the errors land in a file they cannot edit and did not write. That is the signature of two copies of `effect` in one install: Effect's types are nominal, so a Schema built by one copy is not the type the other expects.
|
|
377
|
+
|
|
378
|
+
It deserves its own check because the RUNTIME usually stays green — two instances only diverge where identity matters — so an app boots, serves and passes its tests while `tsc` is red, which sends people looking at the compiler instead of the dependency tree. The report names the versions, the paths, and the errors it explains. Only identity-sensitive packages count (`effect`, `@effect/*`, `@voltro/*`, react/react-dom); a duplicated string utility is wasteful, not a bug class.
|
|
379
|
+
- **@voltro/cli** — `voltro doctor` flags an executor that never names its own descriptor.
|
|
380
|
+
|
|
381
|
+
Descriptor/executor pairing is by FILENAME, which is right — and it means a `*.server.ts` can be a complete, correct executor with no reference at all to the contract it implements. Those are exactly the files where a hand-written input drifts from the wire.
|
|
382
|
+
|
|
383
|
+
Reported after a 426-executor migration to `ExecutorInput<typeof descriptor>`: three files were skipped by the app's own codemod for a reason no reviewer would guess — they never imported their descriptor, so there was no `typeof` to point at. In the same codebase, six executors had written `boardPurpose: string` where their descriptor declared `Schema.Literal(...)`, discarding the contract at the executor boundary. Only imports of a SIBLING module clear the finding: an executor importing nothing but `@voltro/*` and `node:*` has still not named its contract.
|
|
384
|
+
- **@voltro/database** — `updateManyRow(store, table, patch, { where })` — the last untyped write is now typed against its table.
|
|
385
|
+
|
|
386
|
+
`insertRow` and `upsertRow` already were; `ctx.store.updateMany(table, row, { where })` still took a string table name and an untyped row literal. Worth closing because the typed versions were measured: migrating 29 `store.upsert` call sites to `upsertRow` produced 15 `tsc` errors across 8 distinct defects that no test had caught — including seven per-user mutations with no authentication check at all (they wrote `ctx.request.subject.id`, typed `string | null`, into a NOT NULL column, so an anonymous caller reached the database and got a raw statement failure instead of a typed refusal).
|
|
387
|
+
|
|
388
|
+
### Fixed
|
|
389
|
+
|
|
390
|
+
- **@voltro/runtime** — A `cache:` declared on a query whose handler returns a COMPUTED value was silently ignored; it now says so.
|
|
391
|
+
|
|
392
|
+
The snapshot cache wraps the store read, and a computed query has none — its handler has already run by the time the binding is built. Caching one would mean wrapping the handler invocation, which is a different feature. Until that exists, the honest failure is a loud one: silently ignoring the config is how an author ends up believing a hot query is cached while every subscriber re-runs it. The data stays correct, so nothing else would ever tell them. Warned once per query name, not per subscribe.
|
|
393
|
+
- **@voltro/cli** — The minted `.env.local` is handed to the workspace's owner, and an unreadable env file explains itself.
|
|
394
|
+
|
|
395
|
+
A dev container running as root with the host workspace bind-mounted wrote `apps/api/.env.local` as `root:root 0600` INTO THE SHARED WORKSPACE. On the host, everything that loads env then died with EACCES — vitest, `voltro doctor`, the editor — and the developer could not even read the file, while the next container boot recreated it. Container-with-bind-mount is the ordinary dev shape, not an edge case.
|
|
396
|
+
|
|
397
|
+
`0600` stays (the file holds a real signing key), because loosening it to `0644` would make that key readable by every account on the machine for the far more common single-user case. Ownership was the wrong variable, so that is the one corrected: the mint chowns the file to whoever owns the directory, which root can do — exactly the case that needs it — and reports loudly when it cannot. A plain EACCES while loading an env file now names the owning uid, the mode and the current uid, because that pair IS the diagnosis and none of it appears in node's message.
|
|
398
|
+
- **@voltro/cli** — Framework-generated output is handed to the workspace's owner, not left owned by whoever the process happens to be.
|
|
399
|
+
|
|
400
|
+
The previous release fixed this for the minted `.env.local`. The report that followed showed the scope was wrong: it is EVERY directory the framework generates. A dev pod running as root with the host monorepo bind-mounted leaves `.framework/` and `app.graph.observed.*` as `root:root` inside the developer's own tree, and on the host:
|
|
401
|
+
|
|
402
|
+
```
|
|
403
|
+
voltro build . → EACCES: permission denied, open '…/apps/display/.framework/index.html'
|
|
404
|
+
```
|
|
405
|
+
|
|
406
|
+
That is the harder failure. `.env.local` broke env loading; this breaks the production build of every web app outright, with no workaround short of chown-ing by hand after each pod boot. One team could only verify their frontends through test suites and live requests against the running pods.
|
|
407
|
+
|
|
408
|
+
`voltro dev` and `voltro build` now hand their generated output — `.framework`, `.env.local`, every `*.generated.*` — to the uid that owns the app root, and say so loudly when they cannot. A no-op on every ordinary run and in any container started with `--user <uid>:<gid>`: when the process already owns the root it returns without touching the tree. Only generated state is claimed; the framework never chowns a file a human wrote.
|
|
409
|
+
- **@voltro/cli** — The observed app-graph no longer restarts the dev server.
|
|
410
|
+
|
|
411
|
+
`app.graph.observed.json` was written into the watched app root every 10 seconds, and the supervisor's watcher fired on each write. A downstream pod measured two restarts before every boot over 2000 log lines — the rule, not an outlier — and paid a ~46 s boot three times per save.
|
|
412
|
+
|
|
413
|
+
The watcher excludes `<name>.generated.<ext>`, a substring rule chosen precisely because a per-extension whitelist had already let a generated file slip twice. This file slipped it a third time by not carrying the segment at all. It is now `app.graph.observed.generated.json`, which matches the convention instead of adding a fourth special case to a list that has drifted three times; a stale un-suffixed file from an older dev server is removed on boot so it cannot keep triggering restarts.
|
|
414
|
+
- **@voltro/cli** — Four tooling fixes, all from downstream reports:
|
|
415
|
+
|
|
416
|
+
- **`voltro check --offline` crashed on any app that declares a workflow.** It built workflow entries as `{ name }` behind an `as never` while `InspectWorkflowEntry` is keyed by `tag`, so the manifest's sort read `undefined` and threw — surfacing as "could not assemble the graph from source" rather than the type error underneath. The cast is what let the two shapes disagree. - **`voltro check --offline` reported plugin tables as `dangling-source`.** It collected only the app's own `*.entity.ts` tables, so a query reading `_voltro_storage_refs` was an `error` — which sets the exit code, failing the CI gate the offline mode exists for. It now uses the same `assembleFrameworkTables` the migrator does. - **`voltro test` now derives `resolve.alias` from the app's tsconfig `paths`.** An app mapping `@/* → ./src/*` could not test any module importing through it (`Cannot find package '@/locales/en'`), and the workaround was a local `vitest.config.ts` restating what tsconfig already said. - **The `raw-fetch` doctor rule follows the import graph.** Keyed on filename conventions it caught 9 of 39 outbound calls on the reporting app; the other 30 were in `lib/*.ts` helpers only server code imports. A file reachable from a server-convention file and from nothing else is server code; one a page also imports is not, and stays unflagged.
|
|
417
|
+
|
|
418
|
+
---
|
|
419
|
+
|
|
42
420
|
## [0.12.0] — 2026-07-25
|
|
43
421
|
|
|
44
422
|
### ⚠ BREAKING
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/plugin-auth-auth0",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.14.0",
|
|
4
4
|
"description": "Auth0-backed AuthStrategy for the Voltro framework. Verifies Auth0-issued JWTs via the tenant's JWKS endpoint. Conforms to @voltro/protocol AuthStrategy so it composes with other IdP plugins.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -32,7 +32,7 @@
|
|
|
32
32
|
"node": ">=24.0.0"
|
|
33
33
|
},
|
|
34
34
|
"dependencies": {
|
|
35
|
-
"@voltro/protocol": "0.
|
|
35
|
+
"@voltro/protocol": "0.14.0"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"effect": "^3.21.4"
|