From 99e02c2859c81053ea7d83b06341e743b2273953 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 10:04:41 +0000 Subject: [PATCH 1/2] fix(driver-turso)!: fold case in ridesWebSocketTransport so an UPPERCASE WSS:// url with a timeout is refused too MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@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 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- ...rso-uppercase-ws-scheme-timeout-refusal.md | 30 +++ ...ppercase-ws-scheme-timeout-refusal.test.ts | 180 ++++++++++++++++++ .../drivers/driver-turso/src/turso-driver.ts | 39 +++- 3 files changed, 243 insertions(+), 6 deletions(-) create mode 100644 .changeset/driver-turso-uppercase-ws-scheme-timeout-refusal.md create mode 100644 packages/drivers/driver-turso/src/turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts diff --git a/.changeset/driver-turso-uppercase-ws-scheme-timeout-refusal.md b/.changeset/driver-turso-uppercase-ws-scheme-timeout-refusal.md new file mode 100644 index 0000000000..671c3c0bf2 --- /dev/null +++ b/.changeset/driver-turso-uppercase-ws-scheme-timeout-refusal.md @@ -0,0 +1,30 @@ +--- +"@objectstack/driver-turso": minor +--- + +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) + + + +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: + +```ts +new TursoDriver({ url: 'WSS://db.example.turso.io', mode: 'remote', timeout: 30000 }) +``` + +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. + +**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. + +**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. + +**What stays accepted — the refusal is no wider than the gap**, pinned by controls: + +- an uppercase url with **no** explicit `mode` still detects as `'local'`, with or without a `timeout`; +- the uppercase WebSocket url with no `timeout`, or with `timeout: 0` (the documented "no bound"); +- `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; +- the existing lowercase refusals, unchanged in code, message and envelope. + +**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. + +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. diff --git a/packages/drivers/driver-turso/src/turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts new file mode 100644 index 0000000000..4db527487a --- /dev/null +++ b/packages/drivers/driver-turso/src/turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts @@ -0,0 +1,180 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * `TursoDriverConfig.timeout` beside an UPPERCASE `WSS://` / `WS://` url in + * forced remote mode is refused at construction, exactly as the lowercase pair + * already is — the last corner of the same ADR-0049 gap. + * + * # Why the corner was open (the upstream step the first reading missed) + * + * `@libsql/client`'s routing switch really does match the literal lowercase + * (`lib-esm/node.js`: `config.scheme === "wss" || config.scheme === "ws"`), and + * reading only that switch says an uppercase url can never reach the WebSocket + * arm. It can: `expandConfig` runs BEFORE the switch and has already lowercased + * the scheme, so the switch never sees the original casing. Measured against + * `@libsql/core@0.17.4`, whose `lib-esm/config.js` does it on one line — + * `const originalUriScheme = uri.scheme.toLowerCase();`: + * + * ``` + * expandConfig({ url: 'WSS://db.example.turso.io' }, true).scheme === 'wss' + * expandConfig({ url: 'Ws://127.0.0.1:8080' }, true).scheme === 'ws' + * ``` + * + * (and the control that makes those two a reading rather than a coincidence: + * `'LIBSQL://db.example.turso.io'` expands to `'https'`, so the same call is + * observably capable of answering something other than the input's own letters.) + * `@libsql/client@0.17.4`'s node entry is `_createClient(expandConfig(config, + * true))`, so that lowercased scheme IS what the switch reads. An uppercase + * `WSS://` url therefore reaches the WebSocket client, which has no window seam + * at all — the reading `refuseWebSocketTimeout` already stands on. + * + * A correct local observation plus a missed upstream step yields a wrong + * conclusion, and the observation itself survives re-checking; that is why this + * pin spells the chain out rather than asserting the outcome alone. + * + * # What this file pins + * + * The refusal reaching the uppercase spellings, as the ADR-0112 envelope + * (`code` + `status`) and as THE SAME MESSAGE the lowercase refusal produces — + * asserted by construction, not by re-typing the text: the uppercase message + * must equal the lowercase one with the echoed scheme swapped. The echo is the + * one deliberate difference, and it is deliberate because an operator greps the + * config for what they actually typed. + * + * And the three controls the refusal must not eat: + * + * 1. `detectMode` is untouched — an uppercase url with NO explicit mode still + * falls through to `'local'`, the pre-existing behaviour this card + * deliberately does not change. (No pin held this before; the scope ruling + * on this card requires one, so it is written here rather than assumed.) + * 2. The existing lowercase `wss://` / `ws://` refusals still fire + * (`turso-driver-ws-timeout-refusal.test.ts` is the primary pin; repeated + * here as the immediate neighbour of the widened predicate). + * 3. `https://` remote + `timeout` is still ACCEPTED, in every casing. An + * implementation that refused `timeout` on every remote would turn this + * file green while deleting the whole option. + * + * # Reverse verification — direction predicted before it was run + * + * Restore `ridesWebSocketTransport` to its literal-prefix form and the refusal + * cases go RED (the constructor returns a driver with `transportMode: + * 'remote'`, and there is no envelope to read); all three controls stay GREEN, + * because each describes behaviour that is identical before and after. + * Measured both ways — see the PR. + */ + +import { describe, expect, it } from 'vitest'; +import { createTursoDriver } from './index.js'; +import { TursoDriver } from './turso-driver.js'; + +type Refusal = Error & { code?: string; status?: number }; + +/** The error `build` threw, or `null` when it returned. */ +function refusalOf(build: () => unknown): Refusal | null { + try { + build(); + return null; + } catch (error) { + return error as Refusal; + } +} + +const WINDOW_MS = 30_000; +const HOST = 'db.example.turso.io'; + +describe('timeout beside an UPPERCASE WebSocket url in forced remote mode — refused at construction', () => { + it.each([ + ['WSS://', `WSS://${HOST}`], + ['Wss://', `Wss://${HOST}`], + ['WS://', 'WS://127.0.0.1:8080'], + ['Ws://', 'Ws://127.0.0.1:8080'], + ])('%s + explicit remote + timeout is refused as VALIDATION_ERROR / 400', (scheme, url) => { + const refusal = refusalOf(() => new TursoDriver({ url, mode: 'remote', timeout: WINDOW_MS })); + + expect(refusal).not.toBeNull(); + expect(refusal!.code).toBe('VALIDATION_ERROR'); + expect(refusal!.status).toBe(400); + expect(refusal!.message).toContain('TursoDriverConfig.timeout'); + expect(refusal!.message).toContain(`${WINDOW_MS} ms`); + // The scheme is echoed in the caller's own spelling. + expect(refusal!.message).toContain(`\`${scheme}\``); + // Both ways out survive. + expect(refusal!.message).toContain('libsql://'); + expect(refusal!.message).toContain('omit `timeout`'); + }); + + it('is the SAME message the lowercase refusal produces, differing only in the echoed scheme', () => { + const upper = refusalOf(() => new TursoDriver({ url: `WSS://${HOST}`, mode: 'remote', timeout: WINDOW_MS })); + const lower = refusalOf(() => new TursoDriver({ url: `wss://${HOST}`, mode: 'remote', timeout: WINDOW_MS })); + + expect(lower).not.toBeNull(); + expect(upper).not.toBeNull(); + expect(upper!.code).toBe(lower!.code); + expect(upper!.status).toBe(lower!.status); + expect(upper!.message).toBe(lower!.message.replaceAll('`wss://`', '`WSS://`')); + }); + + it('createTursoDriver() is the same constructor, and refuses the same pair', () => { + const refusal = refusalOf(() => createTursoDriver({ url: `WSS://${HOST}`, mode: 'remote', timeout: WINDOW_MS })); + + expect(refusal?.code).toBe('VALIDATION_ERROR'); + expect(refusal?.status).toBe(400); + }); +}); + +describe('CONTROL 1 — detectMode is untouched: an uppercase url with no explicit mode is still local', () => { + it.each([`WSS://${HOST}`, 'Ws://127.0.0.1:8080', `HTTPS://${HOST}`, `LIBSQL://${HOST}`])( + '%s with no `mode` falls through to local, exactly as before this change', + (url) => { + const driver = new TursoDriver({ url }); + + expect(driver.transportMode).toBe('local'); + expect(driver.isRemote).toBe(false); + }, + ); + + it('and it stays local WITH a timeout — the refusal is scoped to remote mode, so it cannot reach here', () => { + const driver = new TursoDriver({ url: `WSS://${HOST}`, timeout: WINDOW_MS }); + + expect(driver.transportMode).toBe('local'); + expect(driver.getTursoConfig().timeout).toBe(WINDOW_MS); + }); +}); + +describe('CONTROL 2 — the existing lowercase refusals still fire', () => { + it.each([ + ['wss://', `wss://${HOST}`], + ['ws://', 'ws://127.0.0.1:8080'], + ])('%s + timeout is still refused as VALIDATION_ERROR / 400', (_scheme, url) => { + const refusal = refusalOf(() => new TursoDriver({ url, authToken: 'token', timeout: WINDOW_MS })); + + expect(refusal?.code).toBe('VALIDATION_ERROR'); + expect(refusal?.status).toBe(400); + }); +}); + +describe('CONTROL 3 — https:// remote + timeout stays ACCEPTED, in every casing', () => { + it.each([`https://${HOST}`, `HTTPS://${HOST}`, `Https://${HOST}`, `LIBSQL://${HOST}`, `HTTP://127.0.0.1:8080`])( + '%s + explicit remote + timeout constructs and keeps the window — the HTTP arm IS bounded', + (url) => { + const driver = new TursoDriver({ url, mode: 'remote', authToken: 'token', timeout: WINDOW_MS }); + + expect(driver.transportMode).toBe('remote'); + expect(driver.getTursoConfig().timeout).toBe(WINDOW_MS); + }, + ); + + it('an uppercase WebSocket url with NO timeout still constructs as remote when mode is forced', () => { + const driver = new TursoDriver({ url: `WSS://${HOST}`, mode: 'remote', authToken: 'token' }); + + expect(driver.transportMode).toBe('remote'); + expect(driver.getTursoConfig().timeout).toBeUndefined(); + }); + + it('`timeout: 0` is the documented "no bound" and is not refused on an uppercase url either', () => { + const driver = new TursoDriver({ url: `WSS://${HOST}`, mode: 'remote', authToken: 'token', timeout: 0 }); + + expect(driver.transportMode).toBe('remote'); + expect(driver.getTursoConfig().timeout).toBe(0); + }); +}); diff --git a/packages/drivers/driver-turso/src/turso-driver.ts b/packages/drivers/driver-turso/src/turso-driver.ts index 985c541795..7527c78a1a 100644 --- a/packages/drivers/driver-turso/src/turso-driver.ts +++ b/packages/drivers/driver-turso/src/turso-driver.ts @@ -339,14 +339,41 @@ function timeoutWindow(config: TursoDriverConfig): number | undefined { /** * Whether a remote url rides `@libsql/client`'s WebSocket transport. * - * The client routes on the literal scheme (`lib-esm/node.js`: `wss` / `ws` → - * its ws client, `https` / `http` → its HTTP client); `libsql://` is expanded - * by `@libsql/core` before that switch, and the entry this driver imports - * expands it to HTTPS. So these two spellings are the whole population that - * reaches the WebSocket arm from this driver. + * The client's routing switch matches the literal lowercase (`lib-esm/node.js`: + * `wss` / `ws` → its ws client, `https` / `http` → its HTTP client), but it + * 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'`; the control that makes those a reading + * is `'LIBSQL://…'` → `'https'`, the same call answering something other than + * the input's own letters. (`libsql://` is expanded before the switch too, and + * the entry this driver imports expands it to HTTPS.) + * + * ⇒ Case is folded HERE so this predicate agrees with the client it hands the + * url to. Reading the switch alone says an uppercase `WSS://` cannot reach the + * WebSocket arm; it can, and a window beside it would be accepted and never + * delivered — the corner {@link refuseWebSocketTimeout} exists to close. Only + * the comparison is folded: the url itself is passed on exactly as authored, so + * the refusal message echoes the operator's own spelling and stays greppable + * against their config. + * + * ⚠️ DELIBERATE INCONSISTENCY, and it is deliberate: `TursoDriver.detectMode` + * matches the same two schemes CASE-SENSITIVELY and is left that way. Folding + * case there as well would delete its uppercase → `'local'` fall-through — a + * mode-detection change on a published driver that predates this refusal + * entirely and is out of scope here; it must be argued on its own, not slipped + * in as a tidy-up. So the two readers of one url disagree on purpose: this one + * answers "does the WINDOW reach anything", `detectMode` answers "which + * transport is this", and only the first question is settled by the scheme the + * libsql client will actually route on. ⛔ Do not "unify" them without that + * argument. */ function ridesWebSocketTransport(url: string): boolean { - return url.startsWith('wss://') || url.startsWith('ws://'); + const scheme = url.toLowerCase(); + return scheme.startsWith('wss://') || scheme.startsWith('ws://'); } /** From 011101865aa0baa36331d3fae6d137f65f02ef84 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 8 Sep 2026 10:07:15 +0000 Subject: [PATCH 2/2] 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 Claude-Session: https://claude.ai/code/session_01ADLdAs2pVcH17h9tZKWMBg --- .../turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/drivers/driver-turso/src/turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts b/packages/drivers/driver-turso/src/turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts index 4db527487a..0aee2cafd0 100644 --- a/packages/drivers/driver-turso/src/turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts +++ b/packages/drivers/driver-turso/src/turso-driver-uppercase-ws-scheme-timeout-refusal.test.ts @@ -111,7 +111,7 @@ describe('timeout beside an UPPERCASE WebSocket url in forced remote mode — re expect(upper).not.toBeNull(); expect(upper!.code).toBe(lower!.code); expect(upper!.status).toBe(lower!.status); - expect(upper!.message).toBe(lower!.message.replaceAll('`wss://`', '`WSS://`')); + expect(upper!.message).toBe(lower!.message.split('`wss://`').join('`WSS://`')); }); it('createTursoDriver() is the same constructor, and refuses the same pair', () => {