@voltro/protocol 0.5.0 → 0.7.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 +68 -1
- package/dist/apikey.d.ts +6 -0
- package/dist/apikey.js +4 -1
- package/dist/index.d.ts +202 -0
- package/dist/index.js +278 -230
- package/dist/rest.js +1 -1
- package/dist/{serverErrorBus-XDdIJk0c.js → serverErrorBus-B3hDwGoL.js} +0 -0
- package/dist/session.d.ts +22 -11
- package/dist/session.js +40 -35
- package/package.json +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -39,11 +39,79 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.7.0] — 2026-07-20
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/runtime** — Row-level security: a `load` failure is now retried, and then surfaces as an **error** instead of an empty result. `setRowFilter({ load, predicate })` resolves `load` once per request; previously ANY failure was answered with a predicate matching zero rows. A downstream app reported the consequence correctly — for shared/team visibility `load` must read the store, so one transient DB blip denied every constrained table for that request and the whole UI rendered empty. Two distinct defects, fixed separately, because conflating them was the original mistake. **A transient failure should never reach the decision:** `load` had no retry at all, so a reaped connection was answered as if it were an authorization fact. It now runs under a bounded `retry` schedule — `DEFAULT_ROW_FILTER_RETRY`, three attempts backing off exponentially from 20ms, so ~60ms worst case — configurable per filter with your own `Schedule`, or `retry: false` for one attempt. **And refusal was expressed as an empty result, which is a lie:** "we could not determine your visibility" and "you may see nothing" are different facts, and only one of them is a fact. An empty list is byte-identical to legitimate emptiness, so the user reads "you have no tickets", the operator reads a healthy 200, and the outage is invisible to both — the most misleading outcome available. The module header used to defend this ("an empty result rather than a 500 on every page"); every constrained page IS broken, and saying so is the correct behavior. `onLoadError` now defaults to `'fail'`, raising the exported typed `RowFilterUnavailable`; `onLoadError: 'deny'` keeps the old degradation for apps that have looked at the screen and genuinely prefer an empty list to an error state, with the `onError` reporter still firing so the choice is not silent. The app asked for `onLoadError: 'fallthrough'` — fail OPEN to the handler's own check. Deliberately not implemented, and not planned. Serving unfiltered rows when the authorization filter is unavailable leaks data precisely when the system is under stress and nobody is reading dashboards, and it is only safe if every handler still carries the check that row filters exist to replace. Fail-closed is retained under both policies; a test asserts neither branch can ever yield an unfiltered read. **Migration.** `resolveRowFilterScope` gains an error channel — `Effect<RowFilterScope, RowFilterUnavailable>` instead of an infallible Effect — so direct callers (a custom entrypoint, a test harness) must handle it; TypeScript points at each one. Apps that want the previous behavior add `onLoadError: 'deny'`, but should decide that rather than default into it. Tests asserting "a broken load yields an empty result" now fail with `RowFilterUnavailable`, which is the fix working. Subscriptions changed shape too: a re-resolve that fails mid-delivery now REVOKES the subscription and emits a typed error frame — following the existing withdrawn-guard precedent — because an empty snapshot on a live subscription reads to a client as "every row you could see was just deleted". The same failure at subscribe time aborts the subscribe instead of opening a stream on a fabricated snapshot, and unwinds the matcher + dependent-table registration it had already made (previously leaked on any subscribe-time throw).
|
|
47
|
+
- **@voltro/testing, @voltro/runtime** — `@voltro/testing` now mirrors the runtime store's POLICY path, not only its DATA path. An adopting app found three places where the harness diverged, each of which made a class of rule untestable in-repo while leaving the suite green. **`invoke` runs Effect-mode handlers.** The framework's contract is "async OR Effect, your choice per handler", and every production runner honours it (`Effect.isEffect(result) ? … : …`). `invoke` only awaited the executor's return value, so an Effect-mode handler handed back its own un-run `EffectPrimitive`: nothing executed, nothing was written, and a test asserting on the "result" asserted on a description of work. The Effect now runs through `runProvidedEffect` — the same function the serve entrypoints use — so a handler failing with a typed error REJECTS WITH THAT ERROR rather than an opaque FiberFailure, and `EffectStore` + `SubjectService` are provided over the context the handler is actually given. Guards, the input decode, the transactional wrap, the deadlock replay, `afterCommit` and the plugin interceptor chain apply identically to both modes. **`makeTestContext({ relations: [spec] })`** registers `relations()` specs. `relations()` is pure — it returns a spec, it does not register one — and production registration is a boot step (`voltro dev` → `registerDiscoveredRelations`), so under the harness an eager load failed with "no relations registered" no matter what the test imported. The option REPLACES the process-global registry with exactly the specs given (the registry is a `Symbol.for` global; additive registration would throw `duplicate relation` on the second `makeTestContext` in a file and leak the first test's relations into the second). Omitting it touches the registry not at all. **The harness store applies row-level security.** After `setRowFilter(...)` a `makeTestContext` read of a constrained table returned the unfiltered set, so "user A cannot see user B's row" could not be asserted at all. `ctx.store` now resolves the filter for its subject and AND-merges it into every read — fluent builders and descriptor reads alike, not bypassed by `.unscoped()`, bypassed for a `system` subject. A new `rowFilter:` option passes a filter directly for tests that would rather not write to a process global. Resolution goes through the runtime's new `resolveRowFilterScopeFor` (`resolveRowFilterScope` is now that function applied to the registered filter), so the retry schedule, the system bypass and the `onLoadError` policy are the runtime's single definition rather than a second copy in the harness. **Breaking, and how to migrate** (`voltro update` prints this): delete any `Effect.isEffect(out) ? await Effect.runPromise(out) : out` shim around an `invoke` call — it is dead code that also destroyed your typed errors. `ProcedureExecutor<Input, Output>` gained an `Effect<Output, E>` arm and an optional third parameter `E` (default `never`); `invoke` now infers from the executor's whole return type instead of taking `Output` as its second type parameter, so an explicit `invoke<typeof d, Note>(…)` type-argument list must be dropped in favour of inference. Handlers themselves keep compiling — what changes is the type of a CALL.
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **@voltro/plugin-auth** — `subjectFromUser(user, { metadata })` now carries arbitrary app metadata onto the Subject. Previously the helper set the metadata slot ONLY when `memberships` were supplied, so an app whose Subject must carry a provider credential — an Atlassian PAT captured at login and read back as `subject.metadata.jiraToken` by `@voltro/plugin-atlassian`'s `credentialsResolver`, say — could not adopt the helper at all: calling it silently dropped the credential, and building the Subject by hand was the only way to keep it. The framework shipped both halves of that gap itself. `metadata` MERGES with the memberships projection rather than replacing it. **Precedence when a `memberships` key appears in both:** the dedicated `memberships` option wins — it is the specific, typed input, and it is the one projected into the `{ tenantId, role }` shape `subjectMemberships()` and the switch-tenant menu read, so letting a free-form bag shadow it would break tenant switching in a way nothing type-checks. Without the option, a `memberships` key inside `metadata` passes through unchanged. A Subject built with neither option still has NO `metadata` key (not an empty object). The later stamps compose unchanged: the sign-in / sign-up / magic-link / passkey handlers spread the existing slot before adding `sessionId`, and the password strategy does the same for `provider`, so keys set through this option survive every login path — unless a caller names a key `sessionId` or `provider`, which those merges overwrite by design. **`handleSwitchTenant` now carries that metadata across a switch**, via a new optional `metadata` on `SwitchTenantInput` (the built-in `/switch-tenant` route passes the caller's `subject.metadata` for you). Without this the option above would have been a trap rather than a feature: a switch rebuilds the Subject from the user record, so an app parking a provider credential in the slot would lose it the first time a user changed tenant — staying authenticated while every call to the provider began failing, with no signal at the point that caused it. `memberships` is deliberately NOT carried: it is re-derived for the target tenant, and a carried copy would report a role the user does not hold there. Covered by a test that fails on the exact assertion when the carry is removed. `SwitchTenantInput` is now exported too — every sibling handler input (`SignInInput`, `MfaVerifyInput`, …) already was, and this one had simply been forgotten, so an app calling `handleSwitchTenant` directly could not name its argument type. Additive: the option is optional and callers that pass neither get byte-identical Subjects. `packages/plugin-auth/etc/plugin-auth.api.md` gains one line and changes none.
|
|
52
|
+
- **@voltro/database** — `timestampMs` and `timestampMsOrNull` — the timestamp wire mapping as STANDALONE field schemas, for hand-written `Schema.Struct` outputs. `Date` in the handler, epoch-ms `number` on the wire; `timestampMsOrNull` is the `.nullable()` column's variant, so a "never archived" row stays `null` instead of becoming `new Date(null)` (1970-01-01, which renders as a plausible date rather than as nothing). ```ts output: Schema.Struct({ id: Schema.String, addedAt: timestampMs, // Date in the handler, epoch ms on the wire archivedAt: timestampMsOrNull, seenAt: Schema.optional(timestampMs), }) ``` This closes the half of the problem `rowSchema(table)` left open. `rowSchema` only helps a handler returning a RAW FULL TABLE ROW, and real handlers overwhelmingly return a COMPUTED struct assembled across several tables — `{ id, name, slug, addedAt, jiraProjectKey }` — where there is no single table to derive from. That is exactly where the hand-written `Date → epoch` converters accumulate: the app that reported the original gap has ~228 of them, all in shaped outputs, and found zero clean applications for whole-row `rowSchema`. Single-sourced, not a parallel declaration: `columnSchema` now READS `timestampMs` for `timestamp()` / `date()` columns, so the derived-row and hand-written-struct paths are the same schema by identity and cannot drift into different wire representations. `rowSchema.test.ts` asserts that identity rather than asserting both merely produce a number. There is deliberately no `timestampMsOptional` — an absent field is `Schema.optional(timestampMs)`, which composes without a third export. Both exports live in the browser-safe `@voltro/database` main entry (pure `effect/Schema`, no driver, no `node:*`), which is what a descriptor's `output` needs. Additive: two new exports, no existing declaration changed. `packages/database/etc/database.api.md` gains two entries and changes none.
|
|
53
|
+
|
|
54
|
+
### Fixed
|
|
55
|
+
|
|
56
|
+
- **@voltro/sql-turso** — Turso (local Rust engine): pooled connections now WAIT for a held lock instead of failing instantly with `database is locked`. The engine keeps SQLite's default of `PRAGMA busy_timeout = 0`, so any statement that met a lock held by another connection failed on the spot — and with the default pool of 4 connections on one file, two concurrent writers are enough to reach it. `makeConnection` now issues `busy_timeout` for every pooled connection, beside the mandatory MVCC and foreign-key pragmas. MVCC did not cover this and was the reason it was missed: `journal_mode=experimental_mvcc` resolves write-write conflicts BETWEEN transactions, while DDL and the schema lock stay exclusive, so the failure lands on statements the concurrency design appears to have handled. It also only reproduces under CPU contention — green on an idle machine, sporadic under load — which is the worst shape for a defect to have. It surfaced as a flaky `CREATE TABLE` in the MVCC keystone test during a full local gate run, where 78 packages build in parallel; a user would see it as an intermittent `database is locked` under production traffic with no obvious trigger. The default is 5000ms, matching better-sqlite3's own default — which is why the sibling `@voltro/sql-sqlite` never needed this: that driver sets the timeout for us, and the turso NAPI driver does not. Tunable via `busyTimeoutMs` on `makeTursoSqlLayer` / `TursoClientConfig`, beside `maxConnections`; `busyTimeoutMs: 0` explicitly restores the fail-immediately behavior (asserted by a test, so the default can never be implemented as a floor that silently ignores 0). It is deliberately NOT on the cross-dialect `ConnectionConfig` — that shape stays free of engine-specific knobs, the same reason the Turso auth token is env-sourced rather than threaded through it.
|
|
57
|
+
- **@voltro/cli** — `voltro update` now honors the project's actual package manager instead of defaulting to npm. It resolves the manager by walking from the app directory **up to the repo root**, preferring the corepack `packageManager` field over a lockfile (`pnpm-lock.yaml` / `yarn.lock` / `bun.lock` / `bun.lockb` / `package-lock.json`), and only falls back to npm when nothing declares one. Walking up fixes the workspace case: a scaffolded project keeps its lockfile at the monorepo root, so running `voltro update` from `apps/api` previously found no lockfile and ran `npm install` against a pnpm/yarn workspace — writing a stray lockfile and a nested `node_modules`. The resolved manager is also used for the latest-version registry lookup (`pnpm view` / `yarn` / `bun pm view`, with `npm view` as a last-resort fallback), so a private or scoped registry configured in `.npmrc` / `.yarnrc.yml` is honored. The yarn query dispatches on the installed yarn MAJOR version rather than probing berry syntax first: on yarn classic, `yarn npm info …` parses as `yarn run npm` and **executes a `npm` script from the project's package.json** if one exists — verified against yarn 1.22.22. Resolving a version number must never run user code, so classic gets `yarn info … --silent` and only berry (>=2) gets `yarn npm info`. All three managers are verified against real binaries in throwaway Docker containers — yarn classic 1.22.22, yarn berry 4.6.0, bun 1.3.14 — each asserting both that the query resolves a version and that it does not execute a same-named script. Re-run with `node scripts/smoke-package-managers.mjs`.
|
|
58
|
+
- **@voltro/cli** — Three fixes to `voltro update`, all reported by an app upgrading a pnpm workspace. **The bump is now LOCKSTEP across the whole workspace.** `voltro update` in `apps/api` bumped only that `package.json`, leaving the sibling web app and shared `packages/*` on the previous version — an api on 0.6.0 and a web client on 0.5.0 disagree about the generated rpcGroup types and the session cookie shape, and that disagreement surfaces as a runtime decode error, not a build error. When the app sits inside a workspace (`pnpm-workspace.yaml`, or a `workspaces` field in an ancestor `package.json`, found by the same bounded upward walk that resolves the package manager and stops at the first `.git`), every member `package.json` that declares `@voltro/*` is bumped to the target together, and the install runs **once at the workspace root** — running it inside `apps/api` corrupts a pnpm/yarn workspace's layout. Each file that will be bumped is listed in the plan output and in `--dry-run`. A standalone (non-workspace) project is unchanged: its own `package.json`, its own install, in place. The codemod re-exec now also looks for the installed `voltro` bin at the workspace root, since npm and yarn hoist it there. **A failed install now says that the codemods were skipped.** It previously printed only "install failed — package.json was bumped; fix the install and re-run", never mentioning codemods, so a user could boot on target-version code with source shaped for the old one and no signal as to why. The codemods for a jump ship *inside* the target version, which a failed install did not put on disk, so running them is impossible rather than merely undesirable — the fix is the message. It now states plainly that no codemods were applied, why, and prints the exact copy-pasteable recovery command with the concrete versions: `voltro update --codemods-only --from <from> --to <to>`. **`--help` / `-h` is answered before every guard.** `voltro update --help` on a dirty tree printed "working tree is not clean" — at precisely the moment the user was trying to discover `--dry-run` and `--codemods-only`. Help is documentation, not an operation, so it is now handled first, ahead of the `package.json` check, the `@voltro/*`-deps check and the clean-tree guard, and lists every flag (`--to`, `--from`, `--root`, `--dry-run`, `--force`, `--exact`, `--codemods-only`). `voltro doctor --help` had the same shape — it fell through to the preflight and reported on the tree instead — and gets the same treatment. `voltro help`'s `update` line now names `--from` and `--codemods-only` too.
|
|
59
|
+
|
|
60
|
+
---
|
|
61
|
+
|
|
62
|
+
## [0.6.0] — 2026-07-19
|
|
63
|
+
|
|
64
|
+
### ⚠ BREAKING
|
|
65
|
+
|
|
66
|
+
- **@voltro/plugin-atlassian, @voltro/cli** — The Atlassian avatar proxy gained a **per-user auth mode**, and its config is now a discriminated union on a required `mode`. ```ts // service mode — one shared token, public route (what the old shape meant) avatar: { mode: 'service', resolveCredentials: () => creds } // per-user mode — the fetch runs with the VIEWING subject's own PAT avatar: { mode: 'perUser', resolveSubject: (req) => verifySession(readCookie(req.headers['cookie'], 'voltro:session'), secret), resolveCredentials: (subject) => credsFor(subject), } ``` Why a required discriminant rather than overloading `resolveCredentials` on its arity: for a deployment whose hard constraint is that a service PAT must not exist, the difference between the two modes is the whole security posture, and it must be readable at the config site rather than inferred from a callback's signature. In `perUser` mode an unauthenticated caller is refused with 401 — never served from cache — a subject with no PAT on file gets 403, and there is **no fallback to a service credential**. `PluginHttpRouteRequest` carries no subject (the framework does not authenticate plugin HTTP routes), so `resolveSubject` is app-supplied; it is the same seam `@voltro/plugin-storage`'s serve route uses. An optional byte cache (`avatar.cache`) now serves resolved avatars without a second upstream call. Its key includes the **viewer** in per-user mode (`type:id:tenant:ref`) — keying by avatar owner alone would let one user's fetch populate an entry served to another whose PAT was never checked. Service mode keys by ref alone, which is correct there. Migration: `voltro update` adds `mode: 'service'` to every existing `avatar` config (the old shape had exactly that meaning). Opting into `perUser` is a deliberate follow-up — it needs a `resolveSubject` only the app can write.
|
|
67
|
+
- **@voltro/plugin-billing** — Billing runs on the official Stripe SDK, and everything Stripe already does is now Stripe's. The change that matters most is not stylistic: **`changeSeats` and `changePlan` never reached Stripe.** They patched a local row and returned a proration figure nothing ever charged. A customer could be granted forty seats while Stripe billed for one. Both now call `subscriptions.update` with a `proration_behavior`, and the local row is written FROM Stripe's answer so the two cannot drift. Removed, because Stripe does them properly: the whole `proration.ts` module (Stripe computes AND invoices proration), and the entire dunning subsystem — `dunning.ts`, `DunningStore`, the `_voltro_billing_dunning` table, the `dunningSchedule` option, and `recordPaymentFailure` / `recordPaymentSuccess` / `runDunningCycle`. Stripe Smart Retries runs the schedule and reports the outcome as a subscription status; our fixed `[1,3,5,7]`-day machine could only ever drift from it. Three defects the SDK's types surfaced immediately: the hand-rolled client sent no `Stripe-Version`, so it read `subscription.current_period_start/end` — a field Stripe has moved onto the subscription ITEM — and stored `null`, which silently disabled every period-dependent behaviour. Seat quantity was read from `items.data[0]` only, undercounting the common base-plan-plus-seat-item layout. And a subscription created in the Stripe Dashboard was dropped entirely for lacking our own `metadata.plan`; it now resolves through the price id. New: `billing.previewChange` (Stripe's invoice preview — quote this, never a local estimate), `billing.invoices` (hosted + PDF links), `startCheckout({ quantity })`, and `billingPlugin({ checkout: { adjustableQuantity, trialDays } })` so Stripe renders the seat stepper and runs trials. Checkout now enables Stripe automatic tax and promotion codes. Webhook signatures verify through `stripe.webhooks.constructEvent`; usage goes to Stripe Billing Meters with an `identifier`, whose 24h dedupe is what makes a replayed flush free. Set `STRIPE_WEBHOOK_SECRET` and a `priceId` per paid plan before upgrading, and reconcile existing subscriptions against Stripe first — see the codemod.
|
|
68
|
+
- **@voltro/protocol, @voltro/plugin-auth, @voltro/env, @voltro/cli** — The framework no longer ships a session secret, and no longer invents one. `VOLTRO_DEV_SESSION_SECRET` is removed from `@voltro/protocol/session` and `@voltro/plugin-auth`. It was a fixed string in the framework source applied automatically whenever `VOLTRO_SESSION_SECRET` was unset — so any deployment that reached it signed cookies with a value published in the source, and anyone could forge any user's session. The guard against it ran only when `NODE_ENV` was exactly `production`, which meant staging boxes and a bare `voltro serve` skipped it entirely. `resolveSessionSecret()` now throws when the variable is unset, and `assertProductionSessionSecret()` checks on every `voltro serve` boot regardless of `NODE_ENV`. Development stays zero-config by MINTING instead of sharing: declare a secret with the new `envVar.secret({ generate })` and `voltro dev` writes a unique per-project value into a gitignored `.env.local` on first boot. `generate` is opt-in per variable so third-party credentials (a WorkOS API key) still fail the boot gate rather than being invented. Templates therefore ship no secret values at all, and every template `.gitignore` now covers `.env` / `.env.local`. Also in this change: `AuthConfig.secret` is now optional on `authRoutesPlugin` / `voltroPasswordStrategy` — omit it and the key is resolved at request time, which removes the config-time `process.env` read that pushed apps into writing hardcoded fallbacks. The CLI's default session strategy now reads the cookie before resolving the secret, so an app without sessions no longer depends on one. Migration: see the codemod. In short — drop any import of the removed constant, review (do not blindly delete) hardcoded `?? '…'` fallbacks in case one is signing live sessions, stop passing `secret:` explicitly, and set a real `VOLTRO_SESSION_SECRET` in every deployed environment (`voltro secret generate session`).
|
|
69
|
+
- **@voltro/database, @voltro/cli** — A relation whose key options cannot resolve is now refused at boot, and `one` enforces its own cardinality at query time. An app declared `parent: one(() => teams, { foreignKey: 'parentTeamId' })` meaning "the parent whose id my column holds". `foreignKey` names a column on the TARGET; the source-side option is `sourceKey`. Nothing checked the column existed, so for that self-referencing table the walker emitted valid SQL — `WHERE parentTeamId IN (<parent ids>)` — which loads the CHILDREN. It typechecked, it booted, it matched a hand-written SQL join the author wrote to double-check, and it returned `null`. A row that HAD children would have resolved to the wrong row entirely; the reporter's test row was a leaf, which is why it read as "no parent" rather than as a bug. `validateRegisteredRelations()` now runs from the shared discovery both `voltro dev` and `voltro serve` use, rejecting a `sourceKey`/`foreignKey`/junction key that names no such column — and naming the option that was meant whenever the column exists on the other side. Passing both keys is refused too, because a resolvable `sourceKey` selects the source-side shape and `foreignKey` is then never read: the declaration would silently mean less than it says. Static validation provably cannot decide the SELF-REFERENCING case, where the column exists on both sides by definition. That half is caught by the `one` contract: the walker used to keep the last of several matching rows, so a mis-declared relation handed back an arbitrary child instead of a parent. It now fails, and when the relation is self-referential the message explains which side each option names. Same defect class as a `.one()` that returned the first of several rows, one layer down.
|
|
70
|
+
- **@voltro/client, @voltro/web** — `SubscriptionState<T>` is a discriminated union on `loading`, so `loading` narrows `data`. `if (loading) return <Skeleton/>` now leaves `data` typed as `T` — no `?? []`, no `!`. The flag shipped in 0.4.0 as a plain boolean beside `data: T | undefined` with no type-level relationship between them, which meant adopting it required KEEPING the `data === undefined` check redundantly beside it: the ergonomic helper made call sites longer. The reported shape that proved it: `const isLoading = !currentUser || loading` fails to compile where `!currentUser || data === undefined` narrowed fine, so the working "fix" was to re-add the very check the flag was meant to delete. A boolean that does not narrow is not an improvement over the check it replaces. Passing `fallback` returns `SubscriptionStateWithFallback<T>`, where `data` is always present and there is nothing to narrow. Call sites need no change — existing `data === undefined` guards still compile, merely redundantly. Two shapes do: an `interface X extends SubscriptionState<…>` becomes a type intersection (TS cannot extend a union), and hand-built state literals must have `loading` and `data` agree. Fixes a bug in the same stroke: `loading` is now derived from whether data arrived rather than from the revision counter. The in-band per-subscription error path bumps the revision while the base stays undefined, so a subscription whose COLD START failed reported `loading: false` with `isEmpty: true` — it rendered "no results" for a failed stream, the exact flash-of-empty-state class these flags exist to prevent. It now stays pending with `error` set.
|
|
71
|
+
- **@voltro/ui** — `UiStrings` gained a required `connectAccount` section (labels for the new `<ConnectAccount>` control). Every section of `UiStrings` is required, so a value annotated as the FULL interface — `const strings: UiStrings = { … }` — stops compiling with TS2741 until the section is added. `<UiStringsProvider strings={…}>` takes `PartialUiStrings` and is unaffected, which is the overwhelmingly common usage. Migration: add a `connectAccount` section with copy for your locale (the codemod prints the English defaults to translate), or switch the annotation to `PartialUiStrings` if the object was only ever an override bag. The defaults are deliberately NOT injected automatically — a localised app would then silently ship English copy, which is harder to notice than a compile error.
|
|
72
|
+
|
|
73
|
+
### Added
|
|
74
|
+
|
|
75
|
+
- **@voltro/client** — `useAction().run(input, options)` takes the same per-call `onSuccess` / `onError` / `notify` bag as `useMutation`'s `mutate`, including the load-bearing semantic: supplying an error handler marks the failure HANDLED, so `run` resolves with `undefined` instead of rejecting — which is what actually deletes the try/catch. With no options it rejects exactly as before, so unhandled failures stay loud. The asymmetry was arbitrary and expensive: roughly half of a real app's write sites are actions (sends, captures, invites), and they were the half that could not use the ergonomics, so they kept the try/catch. Documented alongside it: the callback form is for SINGLE-SHOT writes. A `for`-loop or a multi-step sequence relies on the throw to stop; once the failure is handled the promise resolves and the loop keeps going. Those want the bare `run(input)` and a real try/catch.
|
|
76
|
+
- **@voltro/protocol, @voltro/cli** — Boot now reports procedures whose `guards:` declare a per-resource check that nothing will enforce. `guards: [{ scope: 'teams:write', resource: (i) => i.teamId }]` reads as "may you write THIS team", but without a registered `setResourceScopeResolver` the extractor is advisory and the check runs against the caller's GLOBAL scopes — so an app whose authorization is per-resource (roles held on a membership row, subjects carrying no global scopes) gets a guard that passes callers it should refuse. The declaration looking stricter than the enforcement is the shape of every security bug that survives review. A production app hit exactly this, concluded the declarative path could not express per-team authorization, and reached for the far heavier policy machinery instead — nothing had told it the guard was being downgraded. A report rather than a refusal: a single-tenant app declaring `resource` for documentation value is not broken, and a boot that dies over an authorization nuance the app may enforce elsewhere would be its own kind of wrong. Silent is the one thing it must not be. Relationship guards are never flagged — they resolve through the tuple source and deny when unanswerable, so they cannot degrade to a global check, and flagging them would be the cry-wolf failure that gets a warning ignored. Wired into `voltro dev` and `voltro serve` through one shared helper.
|
|
77
|
+
- **@voltro/ui** — `<ConnectAccountFor api connectionId />` resolves the connection handle through `useConnection` itself, so the common case no longer requires wiring the hook by hand. The prop form stays for a custom connection source, a fixture, or a test. The absence of a bound variant was defended as "@voltro/ui is prop-driven", which is only half the rule. The kit's actual convention is that it does not depend on a PLUGIN — which is why `<PresenceAvatars>` takes a roster rather than calling `usePresence` from `@voltro/plugin-presence/web`. Depending on `@voltro/client` is routine and already the norm here: `<AgentChat>` calls `useAgentChat`, `<AsyncSelect>` calls `useQueryField`, `<AutoForm>` calls `useFormBinding`. `useConnection` is core, so the prop-only shape made this one component the exception rather than the rule. A separate component rather than an overload, because a hook cannot be called conditionally: one component calling `useConnection` only when `api` was present would break the rules of hooks the moment a caller switched between forms.
|
|
78
|
+
- **@voltro/runtime, @voltro/protocol, @voltro/cli, @voltro/client, @voltro/ui, @voltro/plugin-atlassian** — **Connections — a per-user credentials vault (`defineConnection`).** One declaration in a `*.connection.ts` yields the whole connected-accounts layer an app previously hand-rolled: an encrypted per-subject token store, the OAuth 2.0 authorize/callback pair (PKCE + single-use anti-CSRF state), refresh-before-use, a resolver for handlers and plugins, and a connect UI. ```ts // api/connections/jira.connection.ts export default defineConnection({ id: 'jira', kind: 'oauth2', label: 'Jira', authorizeUrl: '…', tokenUrl: '…', clientId: serverEnv.JIRA_CLIENT_ID, clientSecret: serverEnv.JIRA_CLIENT_SECRET, scopes: ['read:jira-work', 'offline_access'], }) // in any handler const jira = await ctx.connections.get('jira') // the CALLING subject's, refreshed ``` - **Per-subject by construction.** Every vault read and write folds `subjectId` into its predicate, and the built-in procedures take the subject from the resolved request — their input schemas have no subject field at all. There is no code path that returns a credential given only a connection id. - **Encrypted at rest via the existing cipher.** Tokens go through `encryptField` (the same `FieldCipher` `.encrypted()` columns use). An app that declares a connection with no cipher configured **refuses to boot**, naming the connections — there is no plaintext fallback. - **Refresh before use, without stampeding.** Renewal happens 60s ahead of expiry, guarded by an in-process single-flight AND a compare-and-set lease on the row, so two replicas cannot both spend a rotating refresh token. A 4xx from the token endpoint is classified `revoked` (tokens cleared, re-consent needed); a 5xx/network failure is `error` (tokens retained, next use retries). - **Client + UI.** `useConnection` / `useConnections` (`@voltro/client`) and `<ConnectAccount>` (`@voltro/ui`). The wire shape carries status, account and expiry — never a token. - **Plugin injection.** `@voltro/plugin-atlassian/connection` exports `connectionCredentials({ connectionId, baseUrl })`, a drop-in `credentialsResolver` backed by the vault. The plugin's existing `credentialsResolver` option is unchanged and keeps working. Two framework tables (`_voltro_connections`, `_voltro_connection_grants`) are created only when the app declares a connection; they ride the declarative differ like every other `_voltro_*` table. The callback endpoint (`GET /_voltro/connections/<id>/callback`) is mounted in both `voltro dev` and `voltro serve` from one shared builder.
|
|
79
|
+
- **@voltro/cli** — `voltro doctor` flags a handler that converts a `Date` by hand on the way out — `.getTime()` / `.toISOString()` inside a result mapping — and names `rowSchema(table)` in the descriptor's `output` instead. The advice states the part that was actually misunderstood: `output` IS the serializer (it is the rpc success schema), so a Date column declared as a Date crosses as epoch ms on its own, while declaring `Schema.Number` describes the WIRE type and leaves nothing to convert. Deliberately narrow: a lone `.getTime()` is ordinary arithmetic — a duration, a comparison — and only the mapping shape says "this is being shaped for the wire". A test pins that a duration calculation does NOT fire, because a rule that cries wolf is the one people stop reading.
|
|
80
|
+
- **@voltro/cli** — `voltro doctor --json` prints the complete hand-roll scan — every file path per finding, plus the scanned file count and directories — as machine-readable output with no preflight prose interleaved. The human view shows three paths per finding, which is fine as a summary and useless as a work list: those paths ARE the actionable part of a finding, and the matching rule lives inside the CLI, so nobody could re-derive the remainder with their own grep. A finding worth printing at all has a file list worth retrieving. The human view now also states how many paths it withheld and where to get them — a cap that does not announce itself reads as completeness. This is the opposite of the narrowing applied to `voltro capabilities`: that one cut which findings are reported (fewer false positives); this one stops hiding detail of findings already judged worth reporting. Mirrors `voltro capabilities --json`.
|
|
81
|
+
- **@voltro/testing** — `invoke(descriptor, executor, rawInput, ctx)` now enforces the descriptor's `guards:` against `ctx.request.subject` before decoding the input, rejecting with the typed `ScopeError` exactly as the server does. Scope guards, resource-scoped guards (through a registered `setResourceScopeResolver`), and relationship guards are all covered — the last FAIL CLOSED when no tuple source is registered, matching production, because a harness that quietly allowed them would train tests to pass on precisely the configuration that denies in prod. The previous version declined this and explained why: authorization was rpc middleware wired at dispatch in the CLI, not carried on the descriptor, so no util here could reach it without a layering inversion. That was true when written and became obsolete when `guards:` moved onto the descriptor and `checkGuardsEffect` landed in `@voltro/protocol`. The blocker dissolved; the comment outlived it, and an app read it as "the framework cannot test this". Guards run BEFORE the decode, mirroring the dispatch spine — an unauthorized caller must not be able to distinguish a malformed payload from a well-formed one. Still not covered, deliberately: transport concerns (connection info, rate limiting, the tenant header) are properties of the HTTP hop rather than the procedure, and a mutation invoked this way does not open a transaction.
|
|
82
|
+
- **@voltro/database** — `manyToMany` eager loads can return the junction's own columns: `with({ teams: { junction: ['role', 'addedAt'] } })` puts them under `_junction` on each target row, and `junction: true` takes all of them. A junction carrying meaningful columns — a role, a joined-at stamp, a permission tier — is the rule rather than the exception, and until now those columns could be FILTERED on (`onJunction`) but never returned, so any relation with real membership data had to stay a hand-written join. The junction rows were already being fetched in full to resolve the target ids; this stops discarding them. Nested rather than merged onto the target row: a junction and its target routinely share column names (`createdAt` is the obvious one) and merging would silently overwrite real target data with membership data. Each link gets its own clone, because one target row object is shared across every parent linking to it — attaching junction data in place would hand every parent the last writer's membership row. Without `junction` the shared-reference path is unchanged, so no existing m2m load pays for this. A branch requesting junction columns is served by the walker rather than the single-query JSON path. Compiling it would mean hand-writing a nested JSON object per dialect to save one round trip — four chances to get a dialect subtly wrong against a documented correctness reference that already works. Worth revisiting as an optimisation; not worth leading with.
|
|
83
|
+
- **@voltro/runtime, @voltro/cli** — The transactional outbox now keeps a queryable per-attempt delivery history, and can be resent on demand. `ctx.outbox` was a fire-and-deliver driver: `_voltro_outbox` holds one row per intent and mutates it in place, so it answers "is this still owed" but not "what did the remote say on attempt 3", "how long did it take", "who resent it" — the questions a delivery-history UI exists to answer. Apps were keeping their own per-attempt audit table alongside it. **`_voltro_outbox_attempts`** is that table, framework-owned. One append-only row per delivery attempt: `outboxId`, `effect` (denormalised so the history survives the entry's purge), `attempt` (1-indexed, monotonic across the entry's whole life), `trigger` (`automatic` | `manual`), `triggeredBy` / `reason`, `outcome` (`delivered` | `failed` | `dead`), `startedAt` / `finishedAt` / `durationMs`, `error`, `response`, and the entry's `subjectId` / `tenantId` / `traceId`. `response` is a JSON snapshot of whatever the handler RETURNED — that is how an HTTP-shaped handler records `{ status, body }` without the framework pretending to model HTTP. Reading it is a normal store read (`ctx.store.query({ table: '_voltro_outbox_attempts', … })`); no new query API was added because none is needed. Attempt recording is best-effort: a failed log write never fails the delivery it observes. **`ctx.outbox.resend(outboxId, { reason?, attempts? })`** re-arms one entry for immediate delivery, including one that already reached `dead`. Legitimate because delivery is at-least-once and handlers are already required to be idempotent — but never invisible: the resulting attempt is recorded as `trigger: 'manual'` with the requesting subject and reason, and the entry carries a `resendCount`. It re-arms the existing row rather than enqueuing a copy (a copy would carry the same `idempotencyKey` and split one entry's history across two ids), grants a small fresh budget (default 1 attempt, since a dead row has already spent its allowance), and refuses an entry whose attempt is in flight. **Retention** is bounded twice: a per-entry cap of 50 attempts trimmed on write (dialect-neutral; the automatic path can never reach it, so it bounds the repeatedly-resent entry) and a 30-day sweep over `startedAt` (`VOLTRO_OUTBOX_ATTEMPTS_TTL_HOURS`). The boot sweep now also ages out **delivered** `_voltro_outbox` rows (`VOLTRO_OUTBOX_TTL_HOURS`) — never `dead` or `pending` ones, which are an unresolved incident and an outstanding debt respectively. No codemod: `_voltro_outbox_attempts` and the three new `_voltro_outbox` columns ride the declarative differ, so `voltro db apply` / `voltro dev` boot reconcile them, and every existing call site keeps compiling.
|
|
84
|
+
- **@voltro/runtime, @voltro/cli** — Row-level security: `setRowFilter({ load, predicate })` registers a subject-derived predicate that is AND-merged into every read, so "tickets on teams I hold a role on" stops being a filter each list handler and each subscription must remember to write. Two halves on purpose. `load(subject)` is async and runs ONCE per request — read your membership tables there. `predicate(ctx, table)` is pure and sync and runs per read. A single `(subject, table) => Promise<Predicate>` would be simpler to declare and much worse to run: the obvious implementation re-queries memberships once per query in a handler that touches five tables. The filter narrows, never replaces, so it cannot grant access. It applies to BOTH read paths — `ctx.store.query(descriptor)` and the fluent `select(...)` builder — because a filter present on one of them is a detour rather than a boundary; a test asserts the fluent path specifically, and it caught that path being unwired during development. Fail-closed where it counts. A `load` that fails denies every constrained read instead of degrading to "no filter", because a row filter that evaporates under load failure is worse than none: the system keeps serving and nothing looks wrong. The cause is reported rather than swallowed. `.unscoped()` / `crossTenant` do NOT bypass it — those opt out of tenant ISOLATION for admin reads, and letting them drop row visibility too would convert an isolation escape into an authorization one. Only a `system` subject bypasses, because that is the framework acting as itself. Subscriptions re-resolve it before every delivery, derived from the descriptor WITHOUT the filter so successive deliveries cannot accumulate stale predicates and so visibility GAINED mid-subscription actually appears. Same reasoning as the per-delivery guard re-check: a membership can end while the socket stays open, and a filter frozen at subscribe keeps serving rows the subject has lost.
|
|
85
|
+
- **@voltro/database** — `rowSchema(table)` builds an effect/Schema for a table's rows with the WIRE representation already correct — `timestamp()` columns cross as epoch ms and arrive back as `Date`, `bigint()` crosses as a decimal string. ```ts export const listNotes = defineQuery({ output: Schema.Array(rowSchema(notes)) }) ``` This was reported as "the `output` schema is not used as a serializer", after an app wrote ~228 hand `Date → epoch` converters at its handler tails. That diagnosis was wrong and the verification is worth recording: `output` is passed straight to `Rpc.make` as `success`, so a handler's result IS encoded through it and decoded on the client. Declaring `Schema.Number` for a `timestamp()` column describes the WIRE type rather than the domain type, which leaves the schema nothing to convert and the handler doing it by hand. What was actually missing is the schema worth declaring — nobody wants to work out the right Encoded/Type split for thirty columns per table. `bigint` crosses as a string rather than a number because a number silently rounds past 2^53. Columns whose shape the declaration does not pin down (`json()`, `vector()`, `raw()`) map to `Schema.Unknown` rather than a guess: a wrong schema rejects valid rows at the wire boundary and reports it far from the column responsible. `omit` keeps a column off the wire. It is a convenience, not a security boundary — an omitted column is simply absent from THIS schema.
|
|
86
|
+
- **@voltro/testing** — `makeTestContext` now provides `ctx.outbox`, and `invoke` drains the handler's `afterCommit` callbacks after a mutation's transaction COMMITS — never after a rollback. This was previously listed as "not covered", alongside plugin interceptors and the deadlock replay. That was the wrong company for it: those two are serve entrypoint wiring a unit harness has no equivalent for, while this was a hole. `ctx.outbox.enqueue` schedules its delivery nudge through `afterCommit`, and the harness had neither the facade nor the hook — so the framework shipped a transactional-outbox primitive whose test story was "you cannot". The facade is the same `makeOutboxFacade` production builds, over the same store, so an enqueue inside a mutation is atomic with the domain write here too and a test exercises the real idempotency path rather than a stand-in. A test asserts that a throwing handler loses BOTH the row and the outbox entry. The ordering is the property worth having, not the presence: a drain that ran on the way out regardless would pass every naive test and be exactly wrong. Building it surfaced a real defect on the first run — the transactional context is derived, so callbacks queued inside the transaction landed in an array nobody drained. Derived contexts (the transaction, `withSubject`, `withTenant`) now share one collector, which is what production does by passing a single `afterCommit` into `buildContext`.
|
|
87
|
+
- **@voltro/testing** — `invoke()` now runs a MUTATION inside a real store transaction, matching the serve pipeline: everything the handler writes through `ctx.store` commits together, and a handler that throws leaves nothing written. This makes "the mutation failed, therefore nothing was written" a testable assertion — before, a half-applied mutation looked correct under test and only came apart in production, where `makeMutationRunner`'s `store.transactional(...)` is real. The kind comes off the descriptor (`kind: 'mutation'`), so nothing is declared at the call site. Queries and actions are deliberately NOT wrapped — actions run outside a transaction in production because their external I/O cannot be rolled back, and the harness reproduces that rather than being uniform. The rollback is the store's own transactional view discarding its overlay, not a copy the harness restores. Also adds `runInStoreTransaction(ctx, work)` for tests that want the same guarantee around a block of their own: `work` receives a full `TestContext` re-derived over the transaction — its store, loader, and `withSubject` / `withTenant` re-scopers all read and write through it, so a handler cannot escape the transaction mid-mutation.
|
|
88
|
+
- **@voltro/runtime** — `ctx.store.one(...)` / `.first(...)` / `.maybeOne(...)` return the ROW TYPE. They take the typed builder itself or its `.descriptor`, and the row type is inferred — no explicit type argument, no cast: ```ts const user = await ctx.store.one(database.users.where(eq('id', id))) user.name // string ``` The two things we recommend did not compose. The typed read path lives on the typed builder, while the single-row terminals lived only on the string-keyed `select('users')` builder, which yields untyped `Row` — so adopting `.one()` re-introduced exactly the casts the typed path had just removed. You could have typed rows or the terminal, not both. That is not a last-mile gap for sophisticated apps: both shipped in the same release and we never tried composing our own two recommendations. `one()` probes with LIMIT 2 and fails with `NoRowFound` on zero AND on two or more, matching the existing terminal. Scoping is inherited from `query()` rather than re-implemented — a second scoping path is how one of them quietly stops filtering by tenant.
|
|
89
|
+
|
|
90
|
+
### Fixed
|
|
91
|
+
|
|
92
|
+
- **@voltro/database** — `VOLTRO_DESTRUCTIVE_OK=1` can now actually drop a table. It never could: the boot gate decided the plan may proceed but left every operation still marked `blocked`, and `applyPlan` carries its own unconditional refusal on exactly that flag — so the documented escape hatch, which the planner's own fix hint tells users to set, was refused a second time one call later. The boot path now clears the flag on the lossy operations it has approved (`unblockLossy`). Non-lossy blocked ops — a rename without a marker, a NOT NULL without a backfill — stay blocked regardless of the opt-in, because those lose data whether or not you meant it.
|
|
93
|
+
- **@voltro/cli** — `voltro doctor`'s hand-roll scan now reports what it covered — the file count and the directories — and says so explicitly when it found no source directories at all. It previously printed nothing in both the "scanned nothing" and the "scanned everything, found nothing" cases, which are not the same result. A downstream user read that silence as "this command has no hand-roll detector" and kept hand-rolling primitives that ship, then reported the detector as missing. Absence of output is not evidence of absence of findings; a check that can be silent about having done nothing will eventually be believed.
|
|
94
|
+
- **@voltro/cli** — Two `voltro doctor` rules were wrong often enough to train people to ignore them. The credential-column rule matched any name CONTAINING a credential word, so it flagged `jiraSecretId` (a Vault identifier), `apiKeyHash` (a hash — which IS the protection, and in that app a unique lookup column, so encrypting it would break authentication), and vault handles. It now skips names ending in `Id`/`_id` or `Hash`/`_hash` and names beginning with `vault`, and says why in its advice. The sequential-reads rule told the reader to convert to `relations()` + `.with()`. Measured against a real 74-hit codebase, about two handlers were clean full-parity conversions; the other ~72 are multi-source assemblies — ids collected from several sources, JSON-array references, junctions with meaningful columns — where `.with()` covers only part or shifts behaviour. The smell is real; the prescribed remedy was wrong roughly 97% of the time. The advice now names both levers with the criterion: `relations()` when the reads are a parent→child walk on ONE key, `Effect.all` over the independent leading reads when the handler collects ids from several sources — the same queries and results, only concurrent. The rule is deliberately NOT split on a heuristic: a parent→child walk also collects ids from its parent result, so "later query uses an earlier id" does not separate the two shapes, and a confidently-wrong split would be worse than one honest finding.
|
|
95
|
+
- **@voltro/database, @voltro/sql-postgres, @voltro/sql-mysql, @voltro/sql-mssql, @voltro/sql-sqlite** — The single-query JSON eager-load path now serves the two shapes it used to hand back to the row-by-row walker, so `.with()` stays one round trip in more cases — and one of them stops being silently wrong on SQL. **Junction-column projection.** `manyToMany` branches asking for `junction: ['role', 'addedAt']` / `junction: true` used to decline the whole descriptor, costing extra queries on exactly the relations that carry real membership data. Each dialect now builds the `_junction` object natively — postgres routes the projection out of the correlated join as one `jsonb` carrier and strips it back off `to_jsonb(t.*)`, sqlite / mysql / MariaDB carry `__j_`-aliased scalars, mssql uses `FOR JSON PATH`'s dotted aliases. Values are computed inside the per-parent join, so a target row under two parents carries each parent's own junction row; a requested column the through table lacks still reads `undefined`, never raises. **`one` cardinality on the fast path.** A `one` whose foreign key sits on the TARGET can match a second row when the relation is mis-declared. The JSON path emitted `LIMIT 1` / `TOP 1` and handed back the first of them — the same defect the walker raises `EagerCardinalityError` for, so which path ran decided whether you got an error or an arbitrary row. Those branches now over-fetch two rows and the decoder raises on the second, with the walker's message verbatim (both call the same `oneCardinalityMessage`). Cost is a two-row cap instead of one, only on target-side `one` relations; correct data still returns a single row. The stores re-throw `EagerCardinalityError` instead of retrying on the walker. Also fixed while verifying against live engines: mssql wrapped the WHOLE result set in one `FOR JSON PATH … WITHOUT_ARRAY_WRAPPER`, so any eager read matching more than one row produced concatenated documents that failed `JSON.parse` and degraded to the walker — it now emits one document per row; and MariaDB's paginated `manyToMany` referenced the target alias from outside the derived table that hid it.
|
|
96
|
+
- **@voltro/database** — The single-query JSON path decoded dates by dialect convention instead of one blanket rule, fixing an mssql divergence where the same row came back with a different instant depending on which path served it. Measured against the live engines rather than reasoned about. A value stored as `12:34:56Z` returned correctly from the walker and as `11:34:56Z` from the JSON path on mssql, because `FOR JSON` emits a `DATETIME2` as its UTC wall-clock with NO zone marker and `new Date(string)` resolves a zone-less date-time as LOCAL. The error tracks the DST offset OF THE DATE BEING READ — 60 minutes for a March date read in July, 120 for a July one — so a single table came back with rows shifted by different amounts, which is close to undiagnosable from the symptom. The obvious fix is wrong: decoding everything as UTC repairs mssql and BREAKS mysql, whose `DATETIME` holds a local wall-clock that the driver also writes and reads as local, and which therefore round-trips correctly today. Verified both directions against live engines before choosing. Postgres carries its own offset (`TIMESTAMPTZ`) and needed nothing. The parity suites previously used an ISO `text()` column for the junction timestamp specifically to route around this. They now use a real `timestamp()`, because a workaround that outlives its bug hides the regression it was invented to dodge — and removing the per-dialect rule turns exactly the two junction parity tests on mssql red while the other five stay green.
|
|
97
|
+
- **@voltro/database** — Postgres introspection reads a composite primary key's columns in DECLARED order. The pg_catalog query joined `pg_attribute` on `attnum = ANY (con.conkey)` with no `ORDER BY`, so Postgres returned the members in physical column order while `conkey` stores them in the order the key was declared. The assembly folds those rows into an insertion-ordered set that becomes the synthetic `<table>_pkey` index, and the planner compares index columns order-sensitively — so a table declared `.primaryKey(['userId','orgId'])` whose `orgId` sits earlier physically introspected as `['orgId','userId']` and emitted a phantom `_pkey` diff on every plan, forever, never converging. Ordering by `array_position(con.conkey, att.attnum)` restores the declared order. Verified against live Postgres with a declare → apply → introspect → re-plan round-trip that yields zero operations; the same round-trip reproduces the phantom diff when the ordering is removed.
|
|
98
|
+
- **@voltro/testing, @voltro/runtime** — `invoke` now reproduces the last two hops of the dispatch spine it used to declare out of scope: **plugin interceptors** and the **deadlock replay**. - `makeTestContext({ plugins: [myPlugin] })` wires a plugin's `interceptMutation` / `interceptQuery` / `interceptAction` into every `invoke` on that context — kind-selected and composed with the same `composeRpcInterceptors` the serve entrypoints use (first plugin outermost), running OUTSIDE the transaction and AROUND the guards, exactly as in production. `rpcInterceptorFor(ctx, kind)` exposes the composed chain. - A mutation whose transaction fails with a deadlock-shaped error is replayed, via the runtime's own `runWithDeadlockRetry` (now exported) rather than a second copy of the transient-error classification. Each attempt starts clean: `resetAfterCommit(ctx)` drops the rolled-back attempt's queued post-commit work, so a replayed mutation no longer fires outbox nudges for writes that never landed. Backoff is zero under test — jitter exists to de-correlate concurrent lock victims, which a unit harness has none of. - Ordering fix: `invoke` ran the descriptor's `guards:` BEFORE the input decode, describing that as mirroring the serve pipeline. It does not — the wire (`@effect/rpc`) decodes before any runner is reached, and guards run inside the plugin-interceptor chain so an rbac-style plugin can publish role scopes first. `invoke` now runs `decode → interceptor(guards → txn → afterCommit)`. A test that asserted a `ScopeError` for a payload that ALSO fails to decode now sees the parse failure, which is what a client gets.
|
|
99
|
+
- **@voltro/cli** — The 0.5.0 typed-`store.query()` change is reclassified as BREAKING and now ships the `0.5.0/02_typed-store-rows` manual codemod. It was released under `Added` with the claim that it "never types less than before, so it is additive: existing code keeps compiling" — the premise is true, the conclusion is not. Making a type more precise breaks every cast that previously widened from `unknown`: `rows[0]['meta'] as AppMeta` was `unknown → AppMeta` and always legal; `rows[0].meta as AppMeta` converts between two known types and fails with TS2352 when they do not overlap. One app upgrading from 0.3.0 hit 102 of these by hand, with no note and no codemod, because the entry was not typed BREAKING and the changelog gate only enforces codemod-or-`none` on entries that are. The gate now also flags a public-API surface change whose golden `etc/*.api.md` lines are MODIFIED or REMOVED (as opposed to purely added) when no entry classifies it, so a narrowing filed under `Added` cannot slip through again.
|
|
100
|
+
- **@voltro/cli, @voltro/plugin-auth** — - **A production web image no longer needs the tsx loader.** `voltro start` imported the app's `app.config.ts` at boot — the last TypeScript file on the serve path — so every web image had to ship tsx and, with it, @voltro/cli's whole optional build toolchain. `voltro build` now precompiles the config to `.framework/dist/server/appConfig.js` beside the SSR bundle (bare specifiers stay external, so `@voltro/env` and friends remain a single instance), `loadConfig` prefers that artefact, and `bin/voltro.mjs` runs `start` IN-PROCESS from the precompiled pair — no child process, no loader hook. A production start without those artefacts now fails loud instead of silently transpiling, and the config precompile is fatal like the SSR bundle build. Verified: with `app.config.ts` removed entirely, `voltro start` still boots and serves from the compiled config. - **The SSR bundle build is fatal.** It previously swallowed its own failure with a warning, and `voltro start` then silently booted Vite middleware mode, compiling TSX on demand, per request, in production — the slow path the bundle exists to avoid, entered unnoticed. - **`@voltro/plugin-auth`** — the pre-wired session strategy omits `secret` when unconfigured instead of passing `undefined`, so it typechecks under `exactOptionalPropertyTypes` and keeps the documented default (`resolveSessionSecret()`) rather than meaning "no secret".
|
|
101
|
+
|
|
102
|
+
### Internal (no consumer-facing effect)
|
|
103
|
+
|
|
104
|
+
- **@voltro/cli** — The doc-sample checker now resolves free framework primitives instead of letting them type as `any`. Samples are fragments, so TS2304 stays unreported — but an unresolved identifier is `any`, which meant a block using `useSubscription` without importing it typechecked vacuously: every member access unchecked, no drift code able to fire. Measured over the EN corpus, 292 blocks use a `use*` / `define*` primitive and 122 of them — 41% — were in exactly that state, reported as covered while checking nothing. The checker now runs two passes: the first asks the compiler which names are genuinely unresolved, the second appends an import for each one that a single `@voltro/*` entry point uniquely exports. Restricted to `use[A-Z]` / `define[A-Z]` names, because a docs page builds a scene across fences (`const view = …` in one block, `view.html()` in the next) and an unrestricted index imported `@voltro/database`'s `view()` over such a name and invented a failure. Imports are APPENDED, so every existing line number — which the diagnostic-to-doc mapping depends on — stays put. It exposed seven real drift findings on first run, all fixed in the docs.
|
|
105
|
+
- **@voltro/runtime** — Regenerate the `@voltro/runtime` api-extractor golden. `ApiKeyServiceShape`'s `verify` / `resolveByHash` returns were extracted into a named `ResolvedApiKey` interface (a14ce089) without the golden being regenerated, so the pre-push api-report check blocked every push. Consumer-compatible: the anonymous return type became a named one with the same fields plus `createdBy`, and a read of the previous fields is unaffected — a returned object gaining a field gives callers more, not less. Hence `apiSurface: compatible` rather than a BREAKING classification.
|
|
106
|
+
|
|
107
|
+
---
|
|
108
|
+
|
|
42
109
|
## [0.5.0] — 2026-07-18
|
|
43
110
|
|
|
44
111
|
### ⚠ BREAKING
|
|
45
112
|
|
|
46
113
|
- **@voltro/runtime** — `.one()` now fails when a query matches MORE than one row, not only when it matches none. It previously probed with `LIMIT 1`, so a filter that quietly stopped being unique returned an arbitrary row while the thrown error still claimed "expected exactly one row". It now probes with `LIMIT 2` and fails with `NoRowFound({ found: 2 })`. Migration: a `.one()` whose filter is not unique and that meant "any match" becomes `.first()` / `.maybeOne()`; one that meant "the unique row" stays as-is and now fails loudly when that assumption breaks. `NoRowFound` and `OptimisticLockError` are now `Schema.TaggedError`s, so they can be declared directly in a descriptor's `error:` union and caught with `Effect.catchTag`. Previously they carried a `_tag` field that looked declarable but did not typecheck there, forcing every caller to catch and re-wrap them in a hand-written tagged error — that wrapper can be deleted. Constructing one directly now takes an object: `new NoRowFound({ table, found })`, `new OptimisticLockError({ table, expected })`. `instanceof` and `_tag` checks are unaffected.
|
|
114
|
+
- **@voltro/database, @voltro/runtime** — `ctx.store.query()` now returns the row type instead of `Readonly<Record<string, unknown>>`. `QueryDescriptor<R>` carries a phantom row type, so the shape the typed builder already knew survives `.descriptor` into the store: ```ts const rows = await ctx.store.query(database.notes.where(eq('id', id)).descriptor) rows[0].title // string — previously `rows[0]['title'] as string` ``` The type was always available; it was dropped at exactly this boundary, which is why reading a field meant casting. One downstream app accumulated 2,032 of those casts against a surface that could have typed them all along. `EffectStore.query` is typed the same way, so an Effect-form handler keeps both the row type and the `StoreError` channel. The driver-level `DataStore.query` stays untyped deliberately. It is the SPI every dialect store and transactional view implements, and those genuinely do return untyped rows off the wire; the type is re-applied one layer up, at the handler-facing `FluentStore`. **Corrected after release — this entry originally appeared under `Added` with the claim that it "never types less than before, so it is additive: existing code keeps compiling." The premise is true and the conclusion does not follow.** Making a type more precise is source-breaking for every cast that previously widened from `unknown`: `rows[0]['meta'] as AppMeta` was `unknown → AppMeta` and always legal, while `rows[0].meta as AppMeta` is a conversion between two known types and fails with TS2352 when they do not overlap. One app upgrading from 0.3.0 hit 102 of these by hand. Migration (now shipped as the `0.5.0/02_typed-store-rows` manual codemod): delete the cast — most are simply redundant now; where the types genuinely differ, fix the column type rather than the call site (a `json<T>()` whose runtime shape is not `T` is a modelling bug the old `unknown` was hiding); reach for `as unknown as T` only when the divergence is real and intended. A hand-built descriptor still resolves to `Row`, so untyped call sites are unaffected.
|
|
47
115
|
|
|
48
116
|
### Added
|
|
49
117
|
|
|
@@ -51,7 +119,6 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
51
119
|
- **@voltro/database, @voltro/runtime, @voltro/cli** — `paginateBy(descriptor, column, cursor, limit, direction?)` generalises `paginateById` to any orderable column. `paginateById` hardcoded `id`, which is right for a sortable key and useless for what feeds actually need — "the next page by `createdAt`" — so apps fell back to hand-rolled `limit + 1` / slice / `hasMore` triples on cursors the helper could not express. `paginateById` is now literally `paginateBy(descriptor, 'id', …)`, so the two cannot drift. `direction` flips the comparison as well as the sort: a `desc` feed pages with `<`. Mismatching those is the classic keyset bug — an ascending comparison under a descending sort returns the same first page forever. The docs example previously demonstrated a related trap (ordering by `createdAt desc` and then calling `paginateById`, which silently re-orders by `id asc`); it now shows the correct form and names the trap. `ctx.load` / `ctx.loadMany` add request-scoped batching. Same-tick reads of one table coalesce into a single `WHERE id IN (...)`, so a breadth-first walk costs one query per LEVEL rather than per node — the assembly shape `relations()` + `.with()` cannot express, because each level's ids come from the level above. Misses are cached too (a repeated dangling reference is fetched once) and a failed batch rejects its waiters without poisoning the cache, so a transient store error does not become "these rows do not exist" for the rest of the request. The cache is request-scoped as a correctness requirement, not a tuning choice: anything longer-lived would serve one subject's rows to another. Workflow steps can now `yield* EffectStore`. Handlers always could; workflow executors could not, so a step reading the store had to lift `ctx.store` with `Effect.tryPromise` — the idiom the rest of the framework tells you to avoid, because it discards the typed `StoreError` channel. The asymmetry was an oversight; the layer is now provided from the workflow context's own store.
|
|
52
120
|
- **@voltro/cli** — `voltro capabilities [--json]` enumerates the framework's export surface by reading the `.d.ts` files in the project's own `node_modules`, so the answer to "what does this framework export" can be verified rather than recalled. The `--json` form is locale-independent and byte-stable, so it can be diffed across upgrades. Symbols that ship but appear nowhere in the project's seeded agent guide are flagged — limited to primitives and hooks, because counting every undocumented export on a real tree gave 1,088 (mostly types and internal Layers), a number too large to act on. `voltro doctor`'s hand-roll detector gained server rules: a hand-written not-found branch on `rows[0]` (→ `.one()`), several sequential `store.query` calls assembling related data (→ `relations()` + `.with()`), `ctx.store` lifted with `Effect.promise` (→ `EffectStore`), an imperative scope check at the top of an executor (→ `guards:`), a credential-shaped column with no `.encrypted()`, a notify/webhook helper at a mutation's tail (→ `defineSubscriber` / `defineReaction`), and hand-rolled cursor pagination (→ `paginateById`). Its scan roots now include the API app directories (`queries/`, `mutations/`, `database/`, …) — without that the server rules could never have fired. The always-loaded agent core now carries a "Pick the SERVER primitive" rubric alongside the client one, and a doc-coverage gate keeps the server surface from drifting out of it.
|
|
53
121
|
- **@voltro/runtime, @voltro/cli** — `ctx.outbox.enqueue(effect, payload, options?)` — a reliable external side effect from a mutation. The enqueue writes through `ctx.store`, which inside a mutation IS the transactional view, so the intent to deliver commits in the same transaction as the domain write or not at all. That closes the window a post-commit tap cannot: `@voltro/plugin-cdc-out` is at-least-once *from enqueue* (its own docs say so — a crash between commit and tap loses the event). Here the enqueue cannot be lost, because losing it means the domain write rolled back too. Delivery after commit remains at-least-once, so handlers must be idempotent; `idempotencyKey` makes that easy to honour. Delivery is declared per effect in a `*.outbox.ts` via `defineOutboxHandler`. The worker is nudged on commit for the fast path and polls every 5s — the poll is the contract, not the optimisation: it recovers rows whose nudge was lost to a crash, rows from another replica, and rows waiting out a backoff. Exponential backoff capped at 5 minutes, configurable `maxAttempts` (default 8), and a dead-letter that stays queryable in `_voltro_outbox` with its `lastError`. An effect with no registered handler is left PENDING rather than dead-lettered — the usual cause is a deploy where the enqueuing code shipped ahead of its handler, and discarding those would turn a rollout ordering detail into permanent loss of a side effect the app believes happened. Two handlers claiming one effect are refused at boot with both filenames. `_voltro_outbox` rides the declarative differ and is created only when the app declares at least one handler. Wired in both `voltro dev` and `voltro serve`.
|
|
54
|
-
- **@voltro/database, @voltro/runtime** — `ctx.store.query()` now returns the row type instead of `Readonly<Record<string, unknown>>`. `QueryDescriptor<R>` carries a phantom row type, so the shape the typed builder already knew survives `.descriptor` into the store: ```ts const rows = await ctx.store.query(database.notes.where(eq('id', id)).descriptor) rows[0].title // string — previously `rows[0]['title'] as string` ``` The type was always available; it was dropped at exactly this boundary, which is why reading a field meant casting. One downstream app accumulated 2,032 of those casts against a surface that could have typed them all along. `EffectStore.query` is typed the same way, so an Effect-form handler keeps both the row type and the `StoreError` channel. A hand-built descriptor still resolves to `Row` — this never types less than before, so it is additive: existing code keeps compiling, and the casts it contains simply become redundant. The driver-level `DataStore.query` stays untyped deliberately. It is the SPI every dialect store and transactional view implements, and those genuinely do return untyped rows off the wire; the type is re-applied one layer up, at the handler-facing `FluentStore`.
|
|
55
122
|
|
|
56
123
|
### Fixed
|
|
57
124
|
|
package/dist/apikey.d.ts
CHANGED
|
@@ -4,6 +4,12 @@ export declare interface ApiKeyRecord {
|
|
|
4
4
|
readonly id: string;
|
|
5
5
|
/** The key's organization id (D5 active org). */
|
|
6
6
|
readonly tenantId: string;
|
|
7
|
+
/** The user this key was minted for / by, when the app records one. Surfaced
|
|
8
|
+
* on the Subject as `metadata.userId` so a request made with a PERSONAL key
|
|
9
|
+
* can be attributed to that person — the basis for per-person accounting,
|
|
10
|
+
* audit attribution, and telling a human's credential apart from a shared
|
|
11
|
+
* machine one. Omit (or null) for keys with no accountable user. */
|
|
12
|
+
readonly createdBy?: string | null;
|
|
7
13
|
readonly scopes: ReadonlyArray<string>;
|
|
8
14
|
}
|
|
9
15
|
|
package/dist/apikey.js
CHANGED
|
@@ -19,7 +19,10 @@ var t = (t) => e("sha256").update(t, "utf8").digest("hex"), n = (e) => {
|
|
|
19
19
|
id: c.id,
|
|
20
20
|
tenantId: c.tenantId,
|
|
21
21
|
scopes: c.scopes,
|
|
22
|
-
metadata: { provider: n }
|
|
22
|
+
metadata: c.createdBy == null ? { provider: n } : {
|
|
23
|
+
provider: n,
|
|
24
|
+
userId: c.createdBy
|
|
25
|
+
}
|
|
23
26
|
}
|
|
24
27
|
} : {
|
|
25
28
|
kind: "failed",
|
package/dist/index.d.ts
CHANGED
|
@@ -27,6 +27,10 @@ export declare const actionToRpc: <Name extends string, Input extends Schema.Sch
|
|
|
27
27
|
|
|
28
28
|
export declare const ADMIN_SCOPE = "admin:full";
|
|
29
29
|
|
|
30
|
+
/** The message a boot prints for {@link findAdvisoryResourceGuards}. Kept here
|
|
31
|
+
* so the wording lives beside the semantics it describes. */
|
|
32
|
+
export declare const advisoryResourceGuardWarning: (tags: ReadonlyArray<string>) => string;
|
|
33
|
+
|
|
30
34
|
export declare const anonymousSubject: (tenantId: string | null) => Subject;
|
|
31
35
|
|
|
32
36
|
/** A guard entry is either a scope check or a relationship check. */
|
|
@@ -284,6 +288,34 @@ export declare const composeAuthStrategies: (strategies: ReadonlyArray<AuthStrat
|
|
|
284
288
|
*/
|
|
285
289
|
export declare const composeRpcInterceptors: (interceptors: ReadonlyArray<RpcInterceptor>) => RpcInterceptor | undefined;
|
|
286
290
|
|
|
291
|
+
export declare const CONNECTION_DISCONNECT_TAG: "__voltro.connections.disconnect";
|
|
292
|
+
|
|
293
|
+
export declare const CONNECTION_START_TAG: "__voltro.connections.start";
|
|
294
|
+
|
|
295
|
+
export declare const CONNECTION_SUBMIT_TOKEN_TAG: "__voltro.connections.submitToken";
|
|
296
|
+
|
|
297
|
+
/** `__voltro.connections.disconnect` — forget the calling subject's credential
|
|
298
|
+
* for this connection. Deletes the row; the provider-side grant (if any) is
|
|
299
|
+
* the provider's to revoke. */
|
|
300
|
+
export declare const connectionDisconnectDescriptor: MutationProcedureDescriptor<"__voltro.connections.disconnect", Schema.Struct<{
|
|
301
|
+
connectionId: typeof Schema.String;
|
|
302
|
+
}>, Schema.Struct<{
|
|
303
|
+
ok: typeof Schema.Boolean;
|
|
304
|
+
}>, Schema.Union<[typeof ConnectionNotDeclared, typeof ConnectionSubjectRequired, typeof ConnectionKindMismatch, typeof ConnectionHandshakeFailed]>>;
|
|
305
|
+
|
|
306
|
+
/** The provider rejected the token / the handshake failed. `transient`
|
|
307
|
+
* distinguishes "try again" from "the user must re-consent". */
|
|
308
|
+
export declare class ConnectionHandshakeFailed extends ConnectionHandshakeFailed_base {
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
declare const ConnectionHandshakeFailed_base: Schema.TaggedErrorClass<ConnectionHandshakeFailed, "ConnectionHandshakeFailed", {
|
|
312
|
+
readonly _tag: Schema.tag<"ConnectionHandshakeFailed">;
|
|
313
|
+
} & {
|
|
314
|
+
connectionId: typeof Schema.String;
|
|
315
|
+
reason: typeof Schema.String;
|
|
316
|
+
transient: typeof Schema.Boolean;
|
|
317
|
+
}>;
|
|
318
|
+
|
|
287
319
|
export declare class ConnectionInfo extends ConnectionInfo_base {
|
|
288
320
|
}
|
|
289
321
|
|
|
@@ -315,6 +347,151 @@ export declare interface ConnectionInfoValue {
|
|
|
315
347
|
readonly clientId: number;
|
|
316
348
|
}
|
|
317
349
|
|
|
350
|
+
/** The two credential shapes a connection can hold. `oauth2` = an
|
|
351
|
+
* authorization-code grant the framework refreshes; `pat` = a long-lived
|
|
352
|
+
* personal token the user pastes and the framework only stores. */
|
|
353
|
+
export declare const ConnectionKind: Schema.Literal<["oauth2", "pat"]>;
|
|
354
|
+
|
|
355
|
+
export declare type ConnectionKind = Schema.Schema.Type<typeof ConnectionKind>;
|
|
356
|
+
|
|
357
|
+
/** The operation is not valid for this connection's kind (e.g. submitting a
|
|
358
|
+
* pasted token to an oauth2 connection, or starting a redirect flow for a
|
|
359
|
+
* pat one). */
|
|
360
|
+
export declare class ConnectionKindMismatch extends ConnectionKindMismatch_base {
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
declare const ConnectionKindMismatch_base: Schema.TaggedErrorClass<ConnectionKindMismatch, "ConnectionKindMismatch", {
|
|
364
|
+
readonly _tag: Schema.tag<"ConnectionKindMismatch">;
|
|
365
|
+
} & {
|
|
366
|
+
connectionId: typeof Schema.String;
|
|
367
|
+
expected: Schema.Literal<["oauth2", "pat"]>;
|
|
368
|
+
actual: Schema.Literal<["oauth2", "pat"]>;
|
|
369
|
+
}>;
|
|
370
|
+
|
|
371
|
+
/** No connection with that id is declared in the app (no `*.connection.ts`). */
|
|
372
|
+
export declare class ConnectionNotDeclared extends ConnectionNotDeclared_base {
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
declare const ConnectionNotDeclared_base: Schema.TaggedErrorClass<ConnectionNotDeclared, "ConnectionNotDeclared", {
|
|
376
|
+
readonly _tag: Schema.tag<"ConnectionNotDeclared">;
|
|
377
|
+
} & {
|
|
378
|
+
connectionId: typeof Schema.String;
|
|
379
|
+
}>;
|
|
380
|
+
|
|
381
|
+
export declare const CONNECTIONS_LIST_TAG: "__voltro.connections.list";
|
|
382
|
+
|
|
383
|
+
/**
|
|
384
|
+
* `__voltro.connections.list` — every DECLARED connection, projected for the
|
|
385
|
+
* calling subject. Reactive (source `_voltro_connections`) so a connect /
|
|
386
|
+
* disconnect / refresh updates an open UI with no polling.
|
|
387
|
+
*
|
|
388
|
+
* It lists declarations, not rows: a connection the subject has never touched
|
|
389
|
+
* comes back `status: 'disconnected'`. A UI can therefore render the whole
|
|
390
|
+
* "connected accounts" settings page from this one subscription.
|
|
391
|
+
*/
|
|
392
|
+
export declare const connectionsListQueryDescriptor: QueryProcedureDescriptor<"__voltro.connections.list", Schema.Struct<{}>, Schema.Array$<Schema.Struct<{
|
|
393
|
+
/** The `defineConnection({ id })` value. */
|
|
394
|
+
connectionId: typeof Schema.String;
|
|
395
|
+
kind: Schema.Literal<["oauth2", "pat"]>;
|
|
396
|
+
/** Human label from the declaration; falls back to the id. */
|
|
397
|
+
label: typeof Schema.String;
|
|
398
|
+
status: Schema.Literal<["disconnected", "connected", "expired", "revoked", "error"]>;
|
|
399
|
+
/** Provider-side account identifier, when the declaration could resolve one. */
|
|
400
|
+
accountId: Schema.NullOr<typeof Schema.String>;
|
|
401
|
+
/** Display name for the connected account ("mario@example.com"). */
|
|
402
|
+
accountLabel: Schema.NullOr<typeof Schema.String>;
|
|
403
|
+
/** Granted scopes (oauth2); empty for pat. */
|
|
404
|
+
scopes: Schema.Array$<typeof Schema.String>;
|
|
405
|
+
/** Access-token deadline as an ISO string, or null (pat / no expiry). */
|
|
406
|
+
expiresAt: Schema.NullOr<typeof Schema.String>;
|
|
407
|
+
/** Last refresh failure message, when `status` is `error` / `revoked`. */
|
|
408
|
+
lastError: Schema.NullOr<typeof Schema.String>;
|
|
409
|
+
/** When the credential was first stored, ISO. Null while disconnected. */
|
|
410
|
+
connectedAt: Schema.NullOr<typeof Schema.String>;
|
|
411
|
+
}>>, typeof Schema.Never>;
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* `__voltro.connections.start` — begin an oauth2 handshake. Returns the
|
|
415
|
+
* provider's consent URL, which the client navigates to (popup or full
|
|
416
|
+
* redirect). An action, not a mutation: it mints a single-use handshake grant
|
|
417
|
+
* and talks to no domain table, so it must not be optimistic-patched.
|
|
418
|
+
*/
|
|
419
|
+
export declare const connectionStartDescriptor: ActionProcedureDescriptor<"__voltro.connections.start", Schema.Struct<{
|
|
420
|
+
connectionId: typeof Schema.String;
|
|
421
|
+
/** Where the callback should send the browser once the handshake lands.
|
|
422
|
+
* Same-origin PATH only (validated server-side) — an absolute URL would
|
|
423
|
+
* make the callback an open redirector. */
|
|
424
|
+
redirectTo: Schema.optional<typeof Schema.String>;
|
|
425
|
+
}>, Schema.Struct<{
|
|
426
|
+
authorizeUrl: typeof Schema.String;
|
|
427
|
+
state: typeof Schema.String;
|
|
428
|
+
}>, Schema.Union<[typeof ConnectionNotDeclared, typeof ConnectionSubjectRequired, typeof ConnectionKindMismatch, typeof ConnectionHandshakeFailed]>>;
|
|
429
|
+
|
|
430
|
+
/** One declared connection, projected for the calling subject. */
|
|
431
|
+
export declare const ConnectionState: Schema.Struct<{
|
|
432
|
+
/** The `defineConnection({ id })` value. */
|
|
433
|
+
connectionId: typeof Schema.String;
|
|
434
|
+
kind: Schema.Literal<["oauth2", "pat"]>;
|
|
435
|
+
/** Human label from the declaration; falls back to the id. */
|
|
436
|
+
label: typeof Schema.String;
|
|
437
|
+
status: Schema.Literal<["disconnected", "connected", "expired", "revoked", "error"]>;
|
|
438
|
+
/** Provider-side account identifier, when the declaration could resolve one. */
|
|
439
|
+
accountId: Schema.NullOr<typeof Schema.String>;
|
|
440
|
+
/** Display name for the connected account ("mario@example.com"). */
|
|
441
|
+
accountLabel: Schema.NullOr<typeof Schema.String>;
|
|
442
|
+
/** Granted scopes (oauth2); empty for pat. */
|
|
443
|
+
scopes: Schema.Array$<typeof Schema.String>;
|
|
444
|
+
/** Access-token deadline as an ISO string, or null (pat / no expiry). */
|
|
445
|
+
expiresAt: Schema.NullOr<typeof Schema.String>;
|
|
446
|
+
/** Last refresh failure message, when `status` is `error` / `revoked`. */
|
|
447
|
+
lastError: Schema.NullOr<typeof Schema.String>;
|
|
448
|
+
/** When the credential was first stored, ISO. Null while disconnected. */
|
|
449
|
+
connectedAt: Schema.NullOr<typeof Schema.String>;
|
|
450
|
+
}>;
|
|
451
|
+
|
|
452
|
+
export declare type ConnectionState = Schema.Schema.Type<typeof ConnectionState>;
|
|
453
|
+
|
|
454
|
+
/**
|
|
455
|
+
* Lifecycle of ONE subject's credential for ONE connection.
|
|
456
|
+
*
|
|
457
|
+
* - `disconnected` — synthesised for a declared connection with no stored
|
|
458
|
+
* row, so the client can render every declared connection uniformly
|
|
459
|
+
* without a second "what exists" call.
|
|
460
|
+
* - `connected` — a usable credential is on file.
|
|
461
|
+
* - `expired` — the access token is past its deadline and there is no
|
|
462
|
+
* refresh token to renew it. Distinct from `revoked`: nothing was taken
|
|
463
|
+
* away, we simply cannot renew without the user consenting again.
|
|
464
|
+
* - `revoked` — the provider REFUSED the refresh (invalid_grant). The
|
|
465
|
+
* stored tokens are cleared; only re-consent restores it.
|
|
466
|
+
* - `error` — the last refresh failed transiently (5xx / network).
|
|
467
|
+
* The tokens are retained and the next use retries.
|
|
468
|
+
*/
|
|
469
|
+
export declare const ConnectionStatus: Schema.Literal<["disconnected", "connected", "expired", "revoked", "error"]>;
|
|
470
|
+
|
|
471
|
+
export declare type ConnectionStatus = Schema.Schema.Type<typeof ConnectionStatus>;
|
|
472
|
+
|
|
473
|
+
/** The caller is anonymous. A credential belongs to a SUBJECT — there is no
|
|
474
|
+
* app-wide connection, by construction. */
|
|
475
|
+
export declare class ConnectionSubjectRequired extends ConnectionSubjectRequired_base {
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
declare const ConnectionSubjectRequired_base: Schema.TaggedErrorClass<ConnectionSubjectRequired, "ConnectionSubjectRequired", {
|
|
479
|
+
readonly _tag: Schema.tag<"ConnectionSubjectRequired">;
|
|
480
|
+
} & {
|
|
481
|
+
connectionId: typeof Schema.String;
|
|
482
|
+
}>;
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* `__voltro.connections.submitToken` — store a pasted personal access token.
|
|
486
|
+
* A mutation (it writes the credential row) targeting no user table.
|
|
487
|
+
*/
|
|
488
|
+
export declare const connectionSubmitTokenDescriptor: MutationProcedureDescriptor<"__voltro.connections.submitToken", Schema.Struct<{
|
|
489
|
+
connectionId: typeof Schema.String;
|
|
490
|
+
token: typeof Schema.String;
|
|
491
|
+
}>, Schema.Struct<{
|
|
492
|
+
ok: typeof Schema.Boolean;
|
|
493
|
+
}>, Schema.Union<[typeof ConnectionNotDeclared, typeof ConnectionSubjectRequired, typeof ConnectionKindMismatch, typeof ConnectionHandshakeFailed]>>;
|
|
494
|
+
|
|
318
495
|
/**
|
|
319
496
|
* A handle to a cluster-coordinated periodic task the plugin armed via
|
|
320
497
|
* `PluginBindContext.scheduleCoordinated`. Keep it to `stop()` the task
|
|
@@ -502,6 +679,31 @@ export declare type ExtraErrors = ReadonlyArray<Schema.Schema.All>;
|
|
|
502
679
|
/** Release a claim after the handler errored, so a retry can re-process. */
|
|
503
680
|
export declare const failIdempotent: (store: IdempotencyStore, scope: string, key: string) => Promise<void>;
|
|
504
681
|
|
|
682
|
+
/**
|
|
683
|
+
* Report descriptors whose `guards:` declare a per-resource check that nothing
|
|
684
|
+
* will enforce.
|
|
685
|
+
*
|
|
686
|
+
* `guards: [{ scope: 'teams:write', resource: (i) => i.teamId }]` READS as "may
|
|
687
|
+
* you write THIS team". Without a registered `setResourceScopeResolver` the
|
|
688
|
+
* extractor is advisory and the check is against the caller's GLOBAL scopes —
|
|
689
|
+
* so an app whose authorization is per-team (roles held on a membership row,
|
|
690
|
+
* subjects carrying no global scopes) gets a guard that is simply wrong, and
|
|
691
|
+
* nothing says so.
|
|
692
|
+
*
|
|
693
|
+
* That is a security-shaped silent downgrade: the declaration looks stricter
|
|
694
|
+
* than the enforcement. It is also exactly what a downstream app hit, then
|
|
695
|
+
* reached for the heavier `defineResourcePolicy` path having concluded the
|
|
696
|
+
* declarative one could not express per-team authorization.
|
|
697
|
+
*
|
|
698
|
+
* Returns the offending `<tag>` list so a boot can print it. Empty when a
|
|
699
|
+
* resolver IS registered (the extractors are live) or when no descriptor
|
|
700
|
+
* declares one.
|
|
701
|
+
*/
|
|
702
|
+
export declare const findAdvisoryResourceGuards: (procedures: ReadonlyArray<{
|
|
703
|
+
readonly tag: string;
|
|
704
|
+
readonly guards?: ReadonlyArray<AnyCheckSpec>;
|
|
705
|
+
}>) => ReadonlyArray<string>;
|
|
706
|
+
|
|
505
707
|
/** Record a completed response for replay. */
|
|
506
708
|
export declare const finishIdempotent: (store: IdempotencyStore, scope: string, key: string, response: IdempotencyResponse, now: number) => Promise<void>;
|
|
507
709
|
|