Skip to content

Commit 0145680

Browse files
os-muskclaude
andauthored
fix(driver-turso)!: refuse timeout beside an UPPERCASE WSS:// url in forced remote mode (#16848)
* fix(driver-turso)!: fold case in ridesWebSocketTransport so an UPPERCASE WSS:// url with a timeout is refused too `@libsql/client` routes on a scheme `expandConfig` has already lowercased, so `WSS://` reaches the WebSocket arm — which carries no window — while the driver's literal-prefix predicate did not, leaving new TursoDriver({ url: 'WSS://…', mode: 'remote', timeout: 30000 }) constructing with a window that reaches nothing. Fold case in the window predicate only; `detectMode` stays case-sensitive on purpose and the docblock now says why, so the uppercase -> 'local' fall-through is not deleted as a tidy-up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg * test(driver-turso): use split/join instead of replaceAll in the uppercase-scheme pin `@objectstack/driver-turso`'s tsconfig lib target does not carry `String.prototype.replaceAll` (TS2550) -- which is also the proof that this package's typecheck program really does reach the new `*.test.ts`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ce8bfc9 commit 0145680

3 files changed

Lines changed: 243 additions & 6 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
"@objectstack/driver-turso": minor
3+
---
4+
5+
fix(driver-turso)!: `timeout` beside an UPPERCASE `WSS://` / `WS://` url in forced remote mode is refused at construction, closing the last corner of the same gap (ADR-0049 enforce-or-remove)
6+
7+
<!-- adr-0087: not-required (no-migration-prescription) An accept-set narrowing performed at the driver constructor: no key, spec symbol, Zod schema, object definition or stored representation is added, removed or renamed — `TursoDriverConfig.timeout`, `url` and `mode` keep their names and types, and `TursoConfigSchema` is untouched. What moves is which CONFIGURATIONS `new TursoDriver()` accepts — one predicate now compares the url's scheme case-insensitively, exactly as `@libsql/client` itself does before routing — so `objectstack migrate meta` has nothing to visit and there is no tombstone to mint. The refusal is the one the lowercase spelling already produces, naming the key, the scheme it met and both ways out; which of the two an author wants is authoring intent no ledger line can decide. -->
8+
9+
The refusal that closed `timeout` beside a `wss://` / `ws://` url matched the two schemes **literally**, so one composition still constructed with a window that reaches nothing:
10+
11+
```ts
12+
new TursoDriver({ url: 'WSS://db.example.turso.io', mode: 'remote', timeout: 30000 })
13+
```
14+
15+
Reading `@libsql/client`'s routing switch alone says that cannot happen — the switch really does match the literal lowercase (`lib-esm/node.js`: `config.scheme === "wss" || config.scheme === "ws"`). But the switch never sees the url as the author spelled it. The node entry is `_createClient(expandConfig(config, true))`, and `expandConfig` has already lowercased the scheme by then — `@libsql/core@0.17.4`, `lib-esm/config.js`: `const originalUriScheme = uri.scheme.toLowerCase();`. Executed against that version: `expandConfig({ url: 'WSS://db.example.turso.io' }, true).scheme === 'wss'`, and `'Ws://127.0.0.1:8080'``'ws'`. So an uppercase `WSS://` url does reach the WebSocket client, which takes no `fetch` and no timeout option of its own — the driver constructed, connected, and ran unbounded.
16+
17+
**BREAKING** accept-set narrowing on a published driver option, shipped as `minor` under the repo's launch-window convention for breaking changes (`scripts/check-changeset-no-major.mjs`). **The constructor now refuses a configuration it accepted before**: a non-zero `timeout` beside an uppercase-or-mixed-case `wss://` / `ws://` `url` in remote mode throws at `new TursoDriver()` — ahead of the Knex base and of any client, so no half-built driver exists — with the ADR-0112 envelope `code: 'VALIDATION_ERROR'`, `status: 400`, and **the same message the lowercase spelling already produced**, echoing the scheme in the caller's own casing so an operator can grep their config for what they actually typed.
18+
19+
**The explicit `mode: 'remote'` is load-bearing.** Without it an uppercase url falls through `TursoDriver.detectMode` to `'local'` — behaviour that predates the refusal entirely and is **unchanged here**. Only the window predicate folds case; the mode detector is deliberately left case-sensitive, and the code says so at the predicate, because folding it there too would delete that fall-through: a mode-detection change on a published driver, which must be argued on its own rather than slipped in as a tidy-up.
20+
21+
**What stays accepted — the refusal is no wider than the gap**, pinned by controls:
22+
23+
- an uppercase url with **no** explicit `mode` still detects as `'local'`, with or without a `timeout`;
24+
- the uppercase WebSocket url with no `timeout`, or with `timeout: 0` (the documented "no bound");
25+
- `https://` / `HTTPS://` / `LIBSQL://` / `HTTP://` remote urls **with** a window — the HTTP arm is bounded, so every casing of every HTTP-side scheme keeps the key;
26+
- the existing lowercase refusals, unchanged in code, message and envelope.
27+
28+
**What an affected author does.** Unchanged from the lowercase case, and the refusal text says it: keep the window and spell the url `libsql://` or `https://` (bounded — the client resolves `libsql://` to HTTPS), or drop the window and run the WebSocket remote unbounded, as it always did.
29+
30+
Blast radius, measured on this tree: no in-repo deployment, example, test or doc pairs an uppercase remote scheme with a window; the host boot path (`OS_DATABASE_URL`) forwards only `url` and `authToken`, and the datasource seam's `buildTursoDriverConfig` normalises no casing either — so the pair is reachable in principle from both and is not observed in this repository. Whether any out-of-repo deployment spells a Turso url with an uppercase scheme is NOT measured and is not claimed to be zero.
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `TursoDriverConfig.timeout` beside an UPPERCASE `WSS://` / `WS://` url in
5+
* forced remote mode is refused at construction, exactly as the lowercase pair
6+
* already is — the last corner of the same ADR-0049 gap.
7+
*
8+
* # Why the corner was open (the upstream step the first reading missed)
9+
*
10+
* `@libsql/client`'s routing switch really does match the literal lowercase
11+
* (`lib-esm/node.js`: `config.scheme === "wss" || config.scheme === "ws"`), and
12+
* reading only that switch says an uppercase url can never reach the WebSocket
13+
* arm. It can: `expandConfig` runs BEFORE the switch and has already lowercased
14+
* the scheme, so the switch never sees the original casing. Measured against
15+
* `@libsql/core@0.17.4`, whose `lib-esm/config.js` does it on one line —
16+
* `const originalUriScheme = uri.scheme.toLowerCase();`:
17+
*
18+
* ```
19+
* expandConfig({ url: 'WSS://db.example.turso.io' }, true).scheme === 'wss'
20+
* expandConfig({ url: 'Ws://127.0.0.1:8080' }, true).scheme === 'ws'
21+
* ```
22+
*
23+
* (and the control that makes those two a reading rather than a coincidence:
24+
* `'LIBSQL://db.example.turso.io'` expands to `'https'`, so the same call is
25+
* observably capable of answering something other than the input's own letters.)
26+
* `@libsql/client@0.17.4`'s node entry is `_createClient(expandConfig(config,
27+
* true))`, so that lowercased scheme IS what the switch reads. An uppercase
28+
* `WSS://` url therefore reaches the WebSocket client, which has no window seam
29+
* at all — the reading `refuseWebSocketTimeout` already stands on.
30+
*
31+
* A correct local observation plus a missed upstream step yields a wrong
32+
* conclusion, and the observation itself survives re-checking; that is why this
33+
* pin spells the chain out rather than asserting the outcome alone.
34+
*
35+
* # What this file pins
36+
*
37+
* The refusal reaching the uppercase spellings, as the ADR-0112 envelope
38+
* (`code` + `status`) and as THE SAME MESSAGE the lowercase refusal produces —
39+
* asserted by construction, not by re-typing the text: the uppercase message
40+
* must equal the lowercase one with the echoed scheme swapped. The echo is the
41+
* one deliberate difference, and it is deliberate because an operator greps the
42+
* config for what they actually typed.
43+
*
44+
* And the three controls the refusal must not eat:
45+
*
46+
* 1. `detectMode` is untouched — an uppercase url with NO explicit mode still
47+
* falls through to `'local'`, the pre-existing behaviour this card
48+
* deliberately does not change. (No pin held this before; the scope ruling
49+
* on this card requires one, so it is written here rather than assumed.)
50+
* 2. The existing lowercase `wss://` / `ws://` refusals still fire
51+
* (`turso-driver-ws-timeout-refusal.test.ts` is the primary pin; repeated
52+
* here as the immediate neighbour of the widened predicate).
53+
* 3. `https://` remote + `timeout` is still ACCEPTED, in every casing. An
54+
* implementation that refused `timeout` on every remote would turn this
55+
* file green while deleting the whole option.
56+
*
57+
* # Reverse verification — direction predicted before it was run
58+
*
59+
* Restore `ridesWebSocketTransport` to its literal-prefix form and the refusal
60+
* cases go RED (the constructor returns a driver with `transportMode:
61+
* 'remote'`, and there is no envelope to read); all three controls stay GREEN,
62+
* because each describes behaviour that is identical before and after.
63+
* Measured both ways — see the PR.
64+
*/
65+
66+
import { describe, expect, it } from 'vitest';
67+
import { createTursoDriver } from './index.js';
68+
import { TursoDriver } from './turso-driver.js';
69+
70+
type Refusal = Error & { code?: string; status?: number };
71+
72+
/** The error `build` threw, or `null` when it returned. */
73+
function refusalOf(build: () => unknown): Refusal | null {
74+
try {
75+
build();
76+
return null;
77+
} catch (error) {
78+
return error as Refusal;
79+
}
80+
}
81+
82+
const WINDOW_MS = 30_000;
83+
const HOST = 'db.example.turso.io';
84+
85+
describe('timeout beside an UPPERCASE WebSocket url in forced remote mode — refused at construction', () => {
86+
it.each([
87+
['WSS://', `WSS://${HOST}`],
88+
['Wss://', `Wss://${HOST}`],
89+
['WS://', 'WS://127.0.0.1:8080'],
90+
['Ws://', 'Ws://127.0.0.1:8080'],
91+
])('%s + explicit remote + timeout is refused as VALIDATION_ERROR / 400', (scheme, url) => {
92+
const refusal = refusalOf(() => new TursoDriver({ url, mode: 'remote', timeout: WINDOW_MS }));
93+
94+
expect(refusal).not.toBeNull();
95+
expect(refusal!.code).toBe('VALIDATION_ERROR');
96+
expect(refusal!.status).toBe(400);
97+
expect(refusal!.message).toContain('TursoDriverConfig.timeout');
98+
expect(refusal!.message).toContain(`${WINDOW_MS} ms`);
99+
// The scheme is echoed in the caller's own spelling.
100+
expect(refusal!.message).toContain(`\`${scheme}\``);
101+
// Both ways out survive.
102+
expect(refusal!.message).toContain('libsql://');
103+
expect(refusal!.message).toContain('omit `timeout`');
104+
});
105+
106+
it('is the SAME message the lowercase refusal produces, differing only in the echoed scheme', () => {
107+
const upper = refusalOf(() => new TursoDriver({ url: `WSS://${HOST}`, mode: 'remote', timeout: WINDOW_MS }));
108+
const lower = refusalOf(() => new TursoDriver({ url: `wss://${HOST}`, mode: 'remote', timeout: WINDOW_MS }));
109+
110+
expect(lower).not.toBeNull();
111+
expect(upper).not.toBeNull();
112+
expect(upper!.code).toBe(lower!.code);
113+
expect(upper!.status).toBe(lower!.status);
114+
expect(upper!.message).toBe(lower!.message.split('`wss://`').join('`WSS://`'));
115+
});
116+
117+
it('createTursoDriver() is the same constructor, and refuses the same pair', () => {
118+
const refusal = refusalOf(() => createTursoDriver({ url: `WSS://${HOST}`, mode: 'remote', timeout: WINDOW_MS }));
119+
120+
expect(refusal?.code).toBe('VALIDATION_ERROR');
121+
expect(refusal?.status).toBe(400);
122+
});
123+
});
124+
125+
describe('CONTROL 1 — detectMode is untouched: an uppercase url with no explicit mode is still local', () => {
126+
it.each([`WSS://${HOST}`, 'Ws://127.0.0.1:8080', `HTTPS://${HOST}`, `LIBSQL://${HOST}`])(
127+
'%s with no `mode` falls through to local, exactly as before this change',
128+
(url) => {
129+
const driver = new TursoDriver({ url });
130+
131+
expect(driver.transportMode).toBe('local');
132+
expect(driver.isRemote).toBe(false);
133+
},
134+
);
135+
136+
it('and it stays local WITH a timeout — the refusal is scoped to remote mode, so it cannot reach here', () => {
137+
const driver = new TursoDriver({ url: `WSS://${HOST}`, timeout: WINDOW_MS });
138+
139+
expect(driver.transportMode).toBe('local');
140+
expect(driver.getTursoConfig().timeout).toBe(WINDOW_MS);
141+
});
142+
});
143+
144+
describe('CONTROL 2 — the existing lowercase refusals still fire', () => {
145+
it.each([
146+
['wss://', `wss://${HOST}`],
147+
['ws://', 'ws://127.0.0.1:8080'],
148+
])('%s + timeout is still refused as VALIDATION_ERROR / 400', (_scheme, url) => {
149+
const refusal = refusalOf(() => new TursoDriver({ url, authToken: 'token', timeout: WINDOW_MS }));
150+
151+
expect(refusal?.code).toBe('VALIDATION_ERROR');
152+
expect(refusal?.status).toBe(400);
153+
});
154+
});
155+
156+
describe('CONTROL 3 — https:// remote + timeout stays ACCEPTED, in every casing', () => {
157+
it.each([`https://${HOST}`, `HTTPS://${HOST}`, `Https://${HOST}`, `LIBSQL://${HOST}`, `HTTP://127.0.0.1:8080`])(
158+
'%s + explicit remote + timeout constructs and keeps the window — the HTTP arm IS bounded',
159+
(url) => {
160+
const driver = new TursoDriver({ url, mode: 'remote', authToken: 'token', timeout: WINDOW_MS });
161+
162+
expect(driver.transportMode).toBe('remote');
163+
expect(driver.getTursoConfig().timeout).toBe(WINDOW_MS);
164+
},
165+
);
166+
167+
it('an uppercase WebSocket url with NO timeout still constructs as remote when mode is forced', () => {
168+
const driver = new TursoDriver({ url: `WSS://${HOST}`, mode: 'remote', authToken: 'token' });
169+
170+
expect(driver.transportMode).toBe('remote');
171+
expect(driver.getTursoConfig().timeout).toBeUndefined();
172+
});
173+
174+
it('`timeout: 0` is the documented "no bound" and is not refused on an uppercase url either', () => {
175+
const driver = new TursoDriver({ url: `WSS://${HOST}`, mode: 'remote', authToken: 'token', timeout: 0 });
176+
177+
expect(driver.transportMode).toBe('remote');
178+
expect(driver.getTursoConfig().timeout).toBe(0);
179+
});
180+
});

packages/drivers/driver-turso/src/turso-driver.ts

Lines changed: 33 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -339,14 +339,41 @@ function timeoutWindow(config: TursoDriverConfig): number | undefined {
339339
/**
340340
* Whether a remote url rides `@libsql/client`'s WebSocket transport.
341341
*
342-
* The client routes on the literal scheme (`lib-esm/node.js`: `wss` / `ws` →
343-
* its ws client, `https` / `http` → its HTTP client); `libsql://` is expanded
344-
* by `@libsql/core` before that switch, and the entry this driver imports
345-
* expands it to HTTPS. So these two spellings are the whole population that
346-
* reaches the WebSocket arm from this driver.
342+
* The client's routing switch matches the literal lowercase (`lib-esm/node.js`:
343+
* `wss` / `ws` → its ws client, `https` / `http` → its HTTP client), but it
344+
* never sees the url as the author spelled it: the node entry is
345+
* `_createClient(expandConfig(config, true))`, and `expandConfig` has ALREADY
346+
* lowercased the scheme by then — `@libsql/core@0.17.4`,
347+
* `lib-esm/config.js`: `const originalUriScheme = uri.scheme.toLowerCase();`.
348+
* Executed against that version:
349+
* `expandConfig({ url: 'WSS://db.example.turso.io' }, true).scheme === 'wss'`
350+
* and `'Ws://127.0.0.1:8080'` → `'ws'`; the control that makes those a reading
351+
* is `'LIBSQL://…'` → `'https'`, the same call answering something other than
352+
* the input's own letters. (`libsql://` is expanded before the switch too, and
353+
* the entry this driver imports expands it to HTTPS.)
354+
*
355+
* ⇒ Case is folded HERE so this predicate agrees with the client it hands the
356+
* url to. Reading the switch alone says an uppercase `WSS://` cannot reach the
357+
* WebSocket arm; it can, and a window beside it would be accepted and never
358+
* delivered — the corner {@link refuseWebSocketTimeout} exists to close. Only
359+
* the comparison is folded: the url itself is passed on exactly as authored, so
360+
* the refusal message echoes the operator's own spelling and stays greppable
361+
* against their config.
362+
*
363+
* ⚠️ DELIBERATE INCONSISTENCY, and it is deliberate: `TursoDriver.detectMode`
364+
* matches the same two schemes CASE-SENSITIVELY and is left that way. Folding
365+
* case there as well would delete its uppercase → `'local'` fall-through — a
366+
* mode-detection change on a published driver that predates this refusal
367+
* entirely and is out of scope here; it must be argued on its own, not slipped
368+
* in as a tidy-up. So the two readers of one url disagree on purpose: this one
369+
* answers "does the WINDOW reach anything", `detectMode` answers "which
370+
* transport is this", and only the first question is settled by the scheme the
371+
* libsql client will actually route on. ⛔ Do not "unify" them without that
372+
* argument.
347373
*/
348374
function ridesWebSocketTransport(url: string): boolean {
349-
return url.startsWith('wss://') || url.startsWith('ws://');
375+
const scheme = url.toLowerCase();
376+
return scheme.startsWith('wss://') || scheme.startsWith('ws://');
350377
}
351378

352379
/**

0 commit comments

Comments
 (0)