@voltro/web 0.35.0 → 0.36.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 -0
- package/dist/index.d.ts +33 -4
- package/dist/ssr.d.ts +33 -4
- package/package.json +3 -3
package/CHANGELOG.md
CHANGED
|
@@ -39,6 +39,74 @@ _Changes staged for the next release accumulate here (rolled up from
|
|
|
39
39
|
|
|
40
40
|
---
|
|
41
41
|
|
|
42
|
+
## [0.36.0] — 2026-08-13
|
|
43
|
+
|
|
44
|
+
### ⚠ BREAKING
|
|
45
|
+
|
|
46
|
+
- **@voltro/integration-http, @voltro/plugin-atlassian** — A 401 from an upstream now produces `code: 'unauthorized'`, not `code: 'session_expired'`. The connection vault's own failure — where we DO know the credential is unusable — becomes `code: 'credential_unusable'`.
|
|
47
|
+
|
|
48
|
+
`session_expired` asserted a cause the status cannot support. A 401 says the credential was not accepted and says nothing about why: expired, revoked, insufficient scope and MALFORMED all produce it. A consumer's plugin sent ciphertext as a bearer token (a separate defect, fixed in the same release), the upstream answered 401, this name called it an expired session, and their health check acted on the name and deleted a valid session. Login loop, with every symptom pointing at a revoked credential.
|
|
49
|
+
|
|
50
|
+
Names get acted on, which is the whole reason to split them:
|
|
51
|
+
|
|
52
|
+
- `'unauthorized'` — the upstream refused. Non-transient, so still never retried; `status` rides along so a caller that knows more about its own upstream can decide for itself. Deciding for them is what this gives up. - `'credential_unusable'` — the connection vault could not produce a credential (no grant, revoked grant, refresh failed). Here the claim is ours to make, because the failure is ours rather than the far end's.
|
|
53
|
+
|
|
54
|
+
The 401 message stopped saying "session expired" too. It now says the credential was refused and that the reason is not in the response — which is the honest sentence and the one that would have saved the day this cost.
|
|
55
|
+
|
|
56
|
+
Its test asserts the CLAIM rather than banning the word: the first version forbade `/expired/i` and went red against the corrected message, which lists expiry as one of several things a 401 can mean. That distinction is the point of the change, so the assertion had to be about `session expired` specifically.
|
|
57
|
+
|
|
58
|
+
**`voltro update` carries you across this** — codemod `0.35.1/01_unauthorized-replaces-session-expired`.
|
|
59
|
+
|
|
60
|
+
### Added
|
|
61
|
+
|
|
62
|
+
- **@voltro/web** — **`apiSurface: compatible` — why the three altered golden lines cannot break a caller.** `LoaderContext` and `LoaderFn` each gained a type parameter WITH a default, so an unparameterised reference still resolves. The one that needed proving is `query?`, which went from a written-out signature to `LoaderQuery<Procedures>` — and `LoaderQuery` is a conditional whose false branch is character-for-character the previous signature. `unknown` does not extend `ProcedureTypeMap`, so the defaulted instantiation takes that branch.
|
|
63
|
+
|
|
64
|
+
Proved with `tsc` rather than by reading it: a probe asserting mutual assignability between `LoaderQuery<unknown>` and the old signature compiles, and inverting the probe fails — with tsc printing the resolved type as `<T = unknown>(tag: string, input?: Record<string, unknown> | undefined) => Promise<T>`, which is the old signature verbatim.
|
|
65
|
+
|
|
66
|
+
`LoaderContext` takes the app's procedure map, so a loader's `query` infers its input and output from the descriptor instead of returning `unknown`.
|
|
67
|
+
|
|
68
|
+
```ts
|
|
69
|
+
import type { AppProcedures } from '<your-api>/rpcGroup'
|
|
70
|
+
|
|
71
|
+
export const loader = async ({ query }: LoaderContext<AppProcedures>) => {
|
|
72
|
+
const rows = await query?.('bookmarks.list', { limit: 100 })
|
|
73
|
+
// ^ inferred; an unknown tag or a wrong input shape is a compile error
|
|
74
|
+
}
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
`AppProcedures` is generated already and has been for a while — it was wired to `createHooks` on the CLIENT and to nothing on the server, so every loader call site spelled its own output type by hand and a typo in a tag compiled. A consumer reported it twice.
|
|
78
|
+
|
|
79
|
+
The extraction reuses `ProcedureInput` / `ProcedureOutput` from `@voltro/client` rather than re-deriving them: a second answer to "what does this tag return" drifts the first time a descriptor field is renamed, and both answers look right in isolation.
|
|
80
|
+
|
|
81
|
+
Opt-in, and non-breaking: with no map named, the signature is the previous `<T = unknown>(tag: string, …)`. The framework cannot import an app's generated file, which is the same reason `createHooks<AppProcedures>` takes it explicitly.
|
|
82
|
+
|
|
83
|
+
Covered by a `.test-d.ts`, because the failure mode is "it compiles when it should not" and no runtime assertion can observe that. Two of its cases exist because the first version was vacuous: an `interface` fixture does not satisfy the map constraint (no implicit index signature — the codegen emits an alias for exactly this reason), so the typed branch fell back silently and every `@ts-expect-error` came back unused.
|
|
84
|
+
|
|
85
|
+
### Fixed
|
|
86
|
+
|
|
87
|
+
- **@voltro/cli** — The store a plugin receives through `bindDataStore` now carries the storage codec, so an `.encrypted()` column read through it decrypts.
|
|
88
|
+
|
|
89
|
+
A consumer measured both stores inside one request: `ctx.store` gave a 44-character plaintext PAT, and the store their plugin's `credentialsResolver` received gave 113 characters of `enc:v1:…`. Ciphertext is a syntactically valid bearer token, so nothing threw. Jira answered 401, `@voltro/integration-http` named that `session_expired`, their PAT health check did the reasonable thing with that name and deleted the session, and the user got login → dashboard → login forever. A configuration error in the costume of an authentication refusal, where every symptom pointed at the one explanation that was wrong.
|
|
90
|
+
|
|
91
|
+
The part worth recording is that `bootStoreCodec.ts` was written for exactly this, after it happened at two other seams, and its header predicts this consumer's symptom verbatim: "a route reading an `.encrypted()` column got the literal string `enc:v1:…` back … the failure reads as 'wrong credential'". The fix was applied per-seam. `bindDataStore` was not one of the seams anybody listed, so it happened a third time — and a per-seam test stayed green throughout, because it covered the two seams somebody remembered.
|
|
92
|
+
|
|
93
|
+
`bootStoreHandouts.test.ts` asserts the rule instead: no boot path hands a plugin the raw driver, on either boot path, with the wrapper applied before the handout. The codec needs no Subject — it is how a column is spelled on disk versus in JS — so there was never anything a boot-level store could not carry.
|
|
94
|
+
|
|
95
|
+
Also relevant to anyone who followed the 0.28.0 codemod: that codemod told apps to stop carrying a credential on the Subject and look it up in the resolver instead. Doing exactly that is what put an app on this seam, so the instruction and `.encrypted()` were not simultaneously satisfiable through it.
|
|
96
|
+
- **@voltro/runtime, @voltro/cli** — The rpc/WebSocket query and stream arms now resolve row visibility before the executor sees a context. Fixes a 0.35.0 regression that made every read throw for an app with a registered row filter, and the older leak underneath it.
|
|
97
|
+
|
|
98
|
+
0.35.0 shipped two things for the row filter: the registration moved to `globalThis` (so a duplicate `@voltro/runtime` instance cannot hide it), and a scoped store built without a resolved scope started throwing instead of silently serving unfiltered rows. The first was a real fix for a real hazard. The second was correct in principle and immediately fatal in practice, because the framework itself had a path that did exactly what it now refuses.
|
|
99
|
+
|
|
100
|
+
The consumer who reported the original leak ran the two-line check we asked for and `getRowFilter()` was visible from their request path — so the instance split was NOT their cause, and our hypothesis was wrong. Their measurement is what found the real one: the refusal fired, meaning the registration was FOUND and `ctx.rowFilter` was still undefined at the store. Nothing was missing; a step was.
|
|
101
|
+
|
|
102
|
+
Four arms reach a request context. `makeOneShotQueryRunner` (REST) and `makeQuerySubscriber` (SSE) both `await withRowFilter(...)` and say so in a comment. The rpc query handler and the stream handler — each hand-copied into both boot paths — handed the raw request straight through. So a user's executor received a context whose `ctx.store` applied no row filter, on the two arms that carry the most traffic. It survived because subscriptions are refiltered per DELIVERY, which made a descriptor-returning query look correct end to end while the executor's own reads were not.
|
|
103
|
+
|
|
104
|
+
`withScopedRequest` is the seam that fixes it once: a request that already carries a scope passes through untouched (resolving twice would run the app's `load` twice per request), an app with NO filter stays fully synchronous, and an app with one gets an Effect — which every one of these call sites already accepts. A boot-path parity test pins both stream arms and the shared producer.
|
|
105
|
+
|
|
106
|
+
The refusal also stopped firing for a SYSTEM subject. That is not a softening: `resolveRowFilterScopeFor` returns `NO_ROW_FILTER` for a system subject, so the only correct value was already determined, and several legitimate paths (schedules, resumed workflows, the webhook trigger context) build a context directly with no scope. Demanding a decision there is what took the api down.
|
|
107
|
+
|
|
108
|
+
---
|
|
109
|
+
|
|
42
110
|
## [0.35.0] — 2026-08-13
|
|
43
111
|
|
|
44
112
|
### ⚠ BREAKING
|
package/dist/index.d.ts
CHANGED
|
@@ -2,6 +2,9 @@ import { ComponentType } from 'react';
|
|
|
2
2
|
import { Context } from 'react';
|
|
3
3
|
import { ImgHTMLAttributes } from 'react';
|
|
4
4
|
import { MouseEvent as MouseEvent_2 } from 'react';
|
|
5
|
+
import { ProcedureInput } from '@voltro/client';
|
|
6
|
+
import { ProcedureOutput } from '@voltro/client';
|
|
7
|
+
import { ProcedureTypeMap } from '@voltro/client';
|
|
5
8
|
import { ReactElement } from 'react';
|
|
6
9
|
import { ReactNode } from 'react';
|
|
7
10
|
import { Ref } from 'react';
|
|
@@ -500,7 +503,7 @@ export declare class LoaderCache {
|
|
|
500
503
|
|
|
501
504
|
export declare const loaderCacheKey: (pattern: string, params: Readonly<Record<string, string>>) => string;
|
|
502
505
|
|
|
503
|
-
export declare interface LoaderContext {
|
|
506
|
+
export declare interface LoaderContext<Procedures = unknown> {
|
|
504
507
|
readonly params: Readonly<Record<string, string>>;
|
|
505
508
|
readonly pathname: string;
|
|
506
509
|
/**
|
|
@@ -541,8 +544,24 @@ export declare interface LoaderContext {
|
|
|
541
544
|
* Present ONLY when the loader runs server-side (`voltro start` /
|
|
542
545
|
* `voltro dev` SSR). `undefined` for client-side loader invocations —
|
|
543
546
|
* in the browser, use `useSubscription` in the component for live data
|
|
544
|
-
* instead; the loader's `query` is for SSR first-paint + `meta`.
|
|
545
|
-
|
|
547
|
+
* instead; the loader's `query` is for SSR first-paint + `meta`.
|
|
548
|
+
*
|
|
549
|
+
* UNTYPED by default, and typed by naming your app's procedure map — the
|
|
550
|
+
* same map `createHooks` already takes, and the same extraction, so the two
|
|
551
|
+
* cannot disagree about what a tag returns:
|
|
552
|
+
*
|
|
553
|
+
* import type { AppProcedures } from '<your-api>/rpcGroup'
|
|
554
|
+
*
|
|
555
|
+
* export const loader = async ({ query }: LoaderContext<AppProcedures>) => {
|
|
556
|
+
* const rows = await query?.('bookmarks.list', { limit: 100 })
|
|
557
|
+
* // ^ inferred from the descriptor, and an unknown tag is an error
|
|
558
|
+
* }
|
|
559
|
+
*
|
|
560
|
+
* The default parameter keeps every existing loader compiling: with no map
|
|
561
|
+
* the signature is the old `<T = unknown>(tag: string, …)`. It is opt-in for
|
|
562
|
+
* the same reason `createHooks<AppProcedures>` is — the framework cannot
|
|
563
|
+
* import an app's generated file, so the app has to name it. */
|
|
564
|
+
readonly query?: LoaderQuery<Procedures>;
|
|
546
565
|
}
|
|
547
566
|
|
|
548
567
|
/**
|
|
@@ -569,7 +588,17 @@ declare type LoaderEntry<T = unknown> = {
|
|
|
569
588
|
readonly error: unknown;
|
|
570
589
|
};
|
|
571
590
|
|
|
572
|
-
export declare type LoaderFn<T = unknown> = (ctx: LoaderContext) => Promise<T> | T;
|
|
591
|
+
export declare type LoaderFn<T = unknown, Procedures = unknown> = (ctx: LoaderContext<Procedures>) => Promise<T> | T;
|
|
592
|
+
|
|
593
|
+
/**
|
|
594
|
+
* The loader's server-side `query`, typed by a procedure map when one is given.
|
|
595
|
+
*
|
|
596
|
+
* Reuses `ProcedureInput` / `ProcedureOutput` from `@voltro/client` rather than
|
|
597
|
+
* re-deriving them. A second extraction would be a second answer to "what does
|
|
598
|
+
* this tag return", and the two would drift the first time a descriptor field
|
|
599
|
+
* is renamed — the shape this repo already has scars from.
|
|
600
|
+
*/
|
|
601
|
+
export declare type LoaderQuery<Procedures = unknown> = Procedures extends ProcedureTypeMap ? <Tag extends keyof Procedures & string>(tag: Tag, ...input: Record<string, never> extends ProcedureInput<Procedures[Tag]> ? [input?: ProcedureInput<Procedures[Tag]>] : [input: ProcedureInput<Procedures[Tag]>]) => Promise<ProcedureOutput<Procedures[Tag]>> : <T = unknown>(tag: string, input?: Record<string, unknown>) => Promise<T>;
|
|
573
602
|
|
|
574
603
|
/** The URL path a logical page takes for a given locale under URL-prefix
|
|
575
604
|
* routing: the default locale keeps the bare path, every other locale gets a
|
package/dist/ssr.d.ts
CHANGED
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { ComponentType } from 'react';
|
|
2
2
|
import { PipeableStream } from 'react-dom/server';
|
|
3
3
|
import { PreloadSeed } from '@voltro/client';
|
|
4
|
+
import { ProcedureInput } from '@voltro/client';
|
|
5
|
+
import { ProcedureOutput } from '@voltro/client';
|
|
6
|
+
import { ProcedureTypeMap } from '@voltro/client';
|
|
4
7
|
import { ReactNode } from 'react';
|
|
5
8
|
import { seedPreloadedSubscription } from '@voltro/client';
|
|
6
9
|
import { StoreSeed } from '@voltro/client';
|
|
@@ -207,7 +210,7 @@ declare type InteractiveMode = 'full' | 'islands' | 'none';
|
|
|
207
210
|
/** True for exactly the object {@link defer} returns. */
|
|
208
211
|
export declare const isDeferredLoaderResult: (value: unknown) => value is DeferredLoaderResult;
|
|
209
212
|
|
|
210
|
-
declare interface LoaderContext {
|
|
213
|
+
declare interface LoaderContext<Procedures = unknown> {
|
|
211
214
|
readonly params: Readonly<Record<string, string>>;
|
|
212
215
|
readonly pathname: string;
|
|
213
216
|
/**
|
|
@@ -248,11 +251,37 @@ declare interface LoaderContext {
|
|
|
248
251
|
* Present ONLY when the loader runs server-side (`voltro start` /
|
|
249
252
|
* `voltro dev` SSR). `undefined` for client-side loader invocations —
|
|
250
253
|
* in the browser, use `useSubscription` in the component for live data
|
|
251
|
-
* instead; the loader's `query` is for SSR first-paint + `meta`.
|
|
252
|
-
|
|
254
|
+
* instead; the loader's `query` is for SSR first-paint + `meta`.
|
|
255
|
+
*
|
|
256
|
+
* UNTYPED by default, and typed by naming your app's procedure map — the
|
|
257
|
+
* same map `createHooks` already takes, and the same extraction, so the two
|
|
258
|
+
* cannot disagree about what a tag returns:
|
|
259
|
+
*
|
|
260
|
+
* import type { AppProcedures } from '<your-api>/rpcGroup'
|
|
261
|
+
*
|
|
262
|
+
* export const loader = async ({ query }: LoaderContext<AppProcedures>) => {
|
|
263
|
+
* const rows = await query?.('bookmarks.list', { limit: 100 })
|
|
264
|
+
* // ^ inferred from the descriptor, and an unknown tag is an error
|
|
265
|
+
* }
|
|
266
|
+
*
|
|
267
|
+
* The default parameter keeps every existing loader compiling: with no map
|
|
268
|
+
* the signature is the old `<T = unknown>(tag: string, …)`. It is opt-in for
|
|
269
|
+
* the same reason `createHooks<AppProcedures>` is — the framework cannot
|
|
270
|
+
* import an app's generated file, so the app has to name it. */
|
|
271
|
+
readonly query?: LoaderQuery<Procedures>;
|
|
253
272
|
}
|
|
254
273
|
|
|
255
|
-
declare type LoaderFn<T = unknown> = (ctx: LoaderContext) => Promise<T> | T;
|
|
274
|
+
declare type LoaderFn<T = unknown, Procedures = unknown> = (ctx: LoaderContext<Procedures>) => Promise<T> | T;
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The loader's server-side `query`, typed by a procedure map when one is given.
|
|
278
|
+
*
|
|
279
|
+
* Reuses `ProcedureInput` / `ProcedureOutput` from `@voltro/client` rather than
|
|
280
|
+
* re-deriving them. A second extraction would be a second answer to "what does
|
|
281
|
+
* this tag return", and the two would drift the first time a descriptor field
|
|
282
|
+
* is renamed — the shape this repo already has scars from.
|
|
283
|
+
*/
|
|
284
|
+
declare type LoaderQuery<Procedures = unknown> = Procedures extends ProcedureTypeMap ? <Tag extends keyof Procedures & string>(tag: Tag, ...input: Record<string, never> extends ProcedureInput<Procedures[Tag]> ? [input?: ProcedureInput<Procedures[Tag]>] : [input: ProcedureInput<Procedures[Tag]>]) => Promise<ProcedureOutput<Procedures[Tag]>> : <T = unknown>(tag: string, input?: Record<string, unknown>) => Promise<T>;
|
|
256
285
|
|
|
257
286
|
declare interface PageDescriptor<TLoaderData = unknown> {
|
|
258
287
|
/** URL pattern, e.g. `/`, `/about`, `/users/[id]`, `/docs/[...slug]`. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@voltro/web",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.36.0",
|
|
4
4
|
"description": "The Voltro web framework — file-based routing, render modes (SSR / SSG / islands), the page-export contract, data hooks, and the browser mount.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"voltro",
|
|
@@ -53,8 +53,8 @@
|
|
|
53
53
|
"node": ">=24.0.0"
|
|
54
54
|
},
|
|
55
55
|
"dependencies": {
|
|
56
|
-
"@voltro/client": "0.
|
|
57
|
-
"@voltro/ui": "0.
|
|
56
|
+
"@voltro/client": "0.36.0",
|
|
57
|
+
"@voltro/ui": "0.36.0"
|
|
58
58
|
},
|
|
59
59
|
"peerDependencies": {
|
|
60
60
|
"@effect/platform": "^0.97.0",
|