Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions .changeset/23765-opaque-scheme-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
'@objectstack/runtime': patch
---

fix(runtime): the standalone host refuses an `OS_DATABASE_URL` with an opaque scheme (`jdbc:postgresql://…`, `redis:h`), and a `.wasm.db` ending no longer turns an unrecognised scheme into a SQLite file

Clause-②: no

This is the runtime's standalone host: `os dev`, `os start` and `os serve` over a plain config or an artifact, every `os migrate` subcommand, and every embedder of `createStandaloneStack`.

- **Before, an opaque scheme (two or more characters and a `:` with no `//`).** `OS_DATABASE_URL=jdbc:postgresql://127.0.0.1:5999/db os serve --dev` printed `✓ Server is ready` and opened a SQLite file at a path made of the URL, creating the directories `./jdbc:postgresql:/127.0.0.1:5999/`. An operator who believed they were on Postgres wrote to that local file. This broke the host's own rule that an unknown URL scheme is refused and never falls back to SQLite.
- **Before, a `.wasm.db` ending.** It was tested ahead of every scheme check, so `OS_DATABASE_URL=redis://h/x.wasm.db` booted on a WASM SQLite file written under `./redis:/h/`.
- **After.** Both shapes get the refusal an unrecognised `<scheme>://` already got, unchanged: `[StandaloneStack] Unsupported database URL scheme "jdbc:". Supported schemes: …`. The boot stops before any plugin starts and creates nothing on disk. The message names only the scheme, never the URL, so credentials in the URL are not printed. A `*.wasm.db` name still selects the WASM engine for a `file:` URL or a path with no scheme. The CLI's legacy `os serve` path already refuses these values, so the two hosts now give the same answer.
- **A one-letter drive is a path, not a scheme** (`C:\data\app.db`, `C:/data/app.db`). A file name whose first segment contains a colon now reads as a scheme, as it does on the CLI path: `backup:2026.db` was a SQLite file and is now refused. Write `./backup:2026.db` or `file:backup:2026.db` for that file.

`postgres://`, `pg://`, `mysql://`, `mongodb://`, `libsql://`, `wasm-sqlite://`, `file:`, `sqlite:`, `:memory:` and paths with no scheme resolve as before. An explicit `OS_DATABASE_DRIVER` still wins over the URL, which is then not classified.
38 changes: 13 additions & 25 deletions packages/cli/src/utils/driver-vocabulary-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,9 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro
['data/app.sqlite3', 'sqlite'],
['./data/app', 'sqlite'],
['/var/lib/objectstack/app', 'sqlite'],
// A single letter and a `:` is a Windows drive, not an opaque scheme.
['C:\\data\\app.db', 'sqlite'],
['C:/data/app.db', 'sqlite'],
])('both hosts ACCEPT %s as %s, and the CLI builds it from that URL in dev and prod', (url, kind) => {
expect(runtimeUrlVerdict(url), `the runtime host refused ${url}`).toEqual({ accepted: true, driverId: kind });
for (const isDev of [false, true]) {
Expand All @@ -487,41 +490,26 @@ describe('the OS_DATABASE_URL door: both hosts, one verdict per URL, dev and pro
}
});

// A `<scheme>://` neither host recognises: refused by both, the CLI's refusal
// being the typed one serve.ts makes fatal, in dev as in production.
// A scheme neither host recognises: refused by both, naming the same scheme,
// the CLI's refusal being the typed one serve.ts makes fatal, in dev as in
// production. That is an unrecognised `<scheme>://`; an OPAQUE scheme — two
// or more characters and a `:`, no `//` (`jdbc:postgresql://…`, `redis:h`),
// which the runtime host used to open as a SQLite file at a path made of the
// URL; and any scheme a `*.wasm.db` file name used to rescue there (#23765).
it.each([
['redis://127.0.0.1:6399/0', 'redis://'],
['rediss://cache.example.com:6380', 'rediss://'],
['https://db.example.com/app', 'https://'],
['mssql://h/db', 'mssql://'],
['redis://h/app.db', 'redis://'],
])('both hosts REFUSE %s, in dev AND in prod', (url, scheme) => {
const runtime = runtimeUrlVerdict(url);
expect(runtime.accepted, `the runtime host accepted ${url}`).toBe(false);
for (const isDev of [false, true]) {
const cli = cliUrlVerdict(url, isDev);
expect(cli.accepted, `the CLI host (isDev=${isDev}) accepted ${url}`).toBe(false);
const error = (cli as { error: unknown }).error;
expect(error, `the CLI host (isDev=${isDev}) did not refuse ${url} by type`).toBeInstanceOf(UnsupportedDriverError);
expect((error as UnsupportedDriverError).recognized).toBe(false);
expect((error as Error).message).toContain(`"${scheme}"`);
expect((error as Error).message).toContain('OS_DATABASE_URL');
}
});

// An OPAQUE scheme — two or more characters and a `:`, no `//`
// (`jdbc:postgresql://…`, `redis:h`) — and any scheme a `*.wasm.db` file name
// used to rescue. The CLI host refuses them, typed, in dev and prod. The
// runtime host's answer is deliberately NOT pinned here: today it opens these
// as a SQLite file at a path made of the URL, an implementation gap against
// its own "unknown schemes throw" rule that is #23765's to close, so asserting
// either agreement or its current answer would pin that gap.
it.each([
['jdbc:postgresql://localhost:5432/db', 'jdbc:'],
['redis:cache', 'redis:'],
['redis://h/app.wasm.db', 'redis://'],
['jdbc:x/app.wasm.db', 'jdbc:'],
])('the CLI host REFUSES %s (scheme %s), in dev AND in prod', (url, scheme) => {
])('both hosts REFUSE %s, in dev AND in prod', (url, scheme) => {
const runtime = runtimeUrlVerdict(url);
expect(runtime.accepted, `the runtime host accepted ${url}`).toBe(false);
expect((runtime as { message: string }).message, `the runtime host's refusal of ${url}`).toContain(`"${scheme}"`);
for (const isDev of [false, true]) {
const cli = cliUrlVerdict(url, isDev);
expect(cli.accepted, `the CLI host (isDev=${isDev}) accepted ${url}`).toBe(false);
Expand Down
13 changes: 7 additions & 6 deletions packages/cli/src/utils/storage-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -295,8 +295,9 @@ function unsupportedUrlSchemeMessage(scheme: string): string {
* per URL: `pg://` is postgres, and a value with no scheme is a SQLite file
* path, whatever its extension. The runtime refuses every other
* `<scheme>://`, so refusing it here takes away nothing that host means to
* accept. The one difference is an OPAQUE scheme (`jdbc:…`, `redis:h`), which
* this host refuses and the runtime host still opens as a file (#23765).
* accept. An OPAQUE scheme (`jdbc:…`, `redis:h`) gets the same answer: both
* hosts refuse it, naming the scheme, and `driver-vocabulary-parity.test.ts`
* holds the two equal.
*/
export function inferDriverTypeFromUrl(url: string | undefined): string {
if (!url) return '';
Expand Down Expand Up @@ -353,10 +354,10 @@ export function inferDriverTypeFromUrl(url: string | undefined): string {
// than letting a name that ends `.db` reach SQLite, is what keeps
// `redis://h/x.db` refused, as the runtime host refuses it;
// - an OPAQUE scheme, two or more characters and a `:` with no `//`
// (`jdbc:postgresql://…`, `redis:h`). The runtime host's scheme-less arm
// opens these as a SQLite file at a path made of the URL. That is an
// implementation gap on that host (#23765), against its own rule that an
// unknown scheme never falls back to SQLite, so it is not copied here.
// (`jdbc:postgresql://…`, `redis:h`), rather than opening a SQLite file at
// a path made of the URL. The runtime host's `detectDriverFromUrl` refuses
// it by the same rule, and `driver-vocabulary-parity.test.ts` holds the
// two equal.
// A single letter and a `:` is a drive (`C:\data\app`, `C:/data/app`), not
// a scheme, and stays a path.
const scheme = /^[a-z][a-z0-9+.-]*:\/\//i.exec(u)?.[0] ?? /^[a-z][a-z0-9+.-]+:/i.exec(u)?.[0];
Expand Down
211 changes: 211 additions & 0 deletions packages/runtime/src/standalone-stack-opaque-scheme-refusal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// The standalone host refuses an OPAQUE scheme, and a `*.wasm.db` file name no
// longer rescues a scheme nothing recognises (#23765).
//
// The module header's rule is "Unknown URL schemes throw — we never silently
// fall back to sqlite". Two arms of `detectDriverFromUrl` broke it:
//
// - the scheme-less arm read "no `<scheme>://`" as "a file path", so an
// opaque scheme — two or more characters and a `:` with no `//`
// (`jdbc:postgresql://…`, `redis:h`) — opened a SQLite file at a path made
// of the URL: `OS_DATABASE_URL=jdbc:postgresql://127.0.0.1:5999/db` booted
// "ready" on `./jdbc:postgresql:/127.0.0.1:5999/db`;
// - the `.wasm.db` test ran ahead of every scheme check, so
// `redis://h/x.wasm.db` opened a WASM SQLite file under `./redis:/h/`.
//
// The cases hold three things:
//
// 1. both shapes meet the refusal an unrecognised `<scheme>://` already met,
// unchanged — same error class, same own properties, same words with only
// the scheme differing — through every door the URL arrives by, and its
// first sentence names the scheme;
// 2. the refusal names the scheme only: no own property of the thrown error
// carries the placeholder username, password or token in the URL;
// 3. CONTROL: a single letter and a `:` is a Windows drive and stays a path,
// and every file spelling resolves to the kind AND the file it did.

import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { resolveStandaloneDatabase } from './standalone-stack.js';

// Placeholders only: nothing here is, or looks like, a real secret.
const USER = 'PLACEHOLDER-USER';
const SECRET = 'PLACEHOLDER-SECRET';
const TOKEN = 'PLACEHOLDER-TOKEN';
/** A userinfo with a password, a host, a path and a token query. */
const CREDENTIALED_TAIL = `${USER}:${SECRET}@127.0.0.1:6399/0?authToken=${TOKEN}`;

/** Env keys these tests write; restored after every case. */
const ENV_KEYS = [
'OS_DATABASE_URL',
'DATABASE_URL',
'TURSO_DATABASE_URL',
'OS_DATABASE_DRIVER',
'OS_HOME',
'OS_ARTIFACT_PATH',
] as const;
const ORIGINAL_ENV: Record<string, string | undefined> = Object.fromEntries(
ENV_KEYS.map((k) => [k, process.env[k]]),
);

let root: string;
beforeEach(() => {
for (const key of ENV_KEYS) delete process.env[key];
root = mkdtempSync(join(tmpdir(), 'os-opaque-scheme-refusal-'));
// Every rung that could answer before the one under test points at this
// empty directory, so no developer state leaks into a case.
process.env.OS_HOME = root;
});
afterEach(() => {
try { rmSync(root, { recursive: true, force: true }); } catch { /* noop */ }
for (const key of ENV_KEYS) {
const original = ORIGINAL_ENV[key];
if (original === undefined) delete process.env[key];
else process.env[key] = original;
}
});

/** The error `run` throws; fails the case when it throws nothing. */
function refusalOf(run: () => unknown): Error {
try {
run();
} catch (err) {
return err as Error;
}
throw new Error('expected a refusal, but the URL resolved');
}

/** A compiled artifact whose declared default datasource carries `url`. */
function artifactDeclaring(url: string): string {
const artifactPath = join(root, 'objectstack.json');
writeFileSync(artifactPath, JSON.stringify({
datasources: [{ name: 'primary', driver: 'postgres', config: { url } }],
datasourceMapping: [{ default: true, datasource: 'primary' }],
}));
return artifactPath;
}

/** The three doors a database URL reaches this host by. */
const DOORS: ReadonlyArray<readonly [door: string, run: (url: string) => unknown]> = [
['the databaseUrl option (--database)', (url) => resolveStandaloneDatabase({ databaseUrl: url })],
['OS_DATABASE_URL', (url) => {
process.env.OS_DATABASE_URL = url;
return resolveStandaloneDatabase({ projectRoot: root });
}],
['a declared default datasource', (url) =>
resolveStandaloneDatabase({ projectRoot: root, artifactPath: artifactDeclaring(url) })],
];

/**
* The refusal an unrecognised `<scheme>://` has always met, as the reference
* the widened refusal is compared against — so the comparison pins sameness,
* never the prose.
*/
const REFERENCE_SCHEME = 'wat://';
function referenceRefusal(): Error {
return refusalOf(() => resolveStandaloneDatabase({ databaseUrl: `${REFERENCE_SCHEME}nope` }));
}

/** The unsupported-scheme refusal, unchanged, naming `scheme` and nothing of the URL. */
function expectUnsupportedSchemeRefusal(err: Error, scheme: string): void {
const reference = referenceRefusal();
// 1. The same refusal: class, own properties (a `code`, were one ever
// added, included) and words, with only the scheme differing.
expect(err.constructor).toBe(Error);
expect(err.constructor).toBe(reference.constructor);
expect(Object.getOwnPropertyNames(err).sort()).toEqual(Object.getOwnPropertyNames(reference).sort());
expect((err as { code?: unknown }).code).toBe((reference as { code?: unknown }).code);
expect(err.cause).toBeUndefined();
expect(err.message.startsWith(`[StandaloneStack] Unsupported database URL scheme "${scheme}". `),
`the first sentence does not name "${scheme}": ${err.message}`).toBe(true);
expect(err.message.replace(`"${scheme}"`, '"SCHEME"'))
.toBe(reference.message.replace(`"${REFERENCE_SCHEME}"`, '"SCHEME"'));
// 2. The scheme only. Positive control first: the serialisation really
// holds the message, so the negative reads below read something.
const everything = JSON.stringify(err, Object.getOwnPropertyNames(err));
expect(everything).toContain(JSON.stringify(err.message));
for (const placeholder of [USER, SECRET, TOKEN]) {
expect(everything, `the refusal carries ${placeholder}`).not.toContain(placeholder);
}
}

describe('an OPAQUE scheme is refused, naming the scheme, never the URL', () => {
// Two or more characters and a `:` with no `//`. The scheme ends at the
// FIRST `:`, so it cannot hold a userinfo, a host or a path.
const OPAQUE: ReadonlyArray<readonly [url: string, scheme: string]> = [
// The card's reproduction, and the same shape carrying credentials.
['jdbc:postgresql://127.0.0.1:5999/db', 'jdbc:'],
[`jdbc:postgresql://${CREDENTIALED_TAIL}`, 'jdbc:'],
['redis:h', 'redis:'],
[`redis:${CREDENTIALED_TAIL}`, 'redis:'],
// A scheme this host recognises is opaque too when its `//` is missing:
// it is not a file path either.
['libsql:db.example.turso.io', 'libsql:'],
// A file name whose FIRST segment holds a colon reads as a scheme, as on
// the CLI host; `./backup:2026.db` and `file:backup:2026.db` name that
// file (CONTROL below).
['backup:2026.db', 'backup:'],
];

for (const [door, run] of DOORS) {
it.each(OPAQUE)(`%s via ${door} → refused as %s`, (url, scheme) => {
expectUnsupportedSchemeRefusal(refusalOf(() => run(url)), scheme);
});
}
});

describe('a `*.wasm.db` file name no longer rescues a scheme nothing recognises', () => {
// It selects the WASM engine for a FILE only: a `file:` URL or a value with
// no scheme (the CONTROL below).
const RESCUED: ReadonlyArray<readonly [url: string, scheme: string]> = [
['redis://h/x.wasm.db', 'redis://'],
// The URL must END in `.wasm.db` for the old test to have rescued it, so
// the token rides in the path here, not in a trailing query.
[`redis://${USER}:${SECRET}@127.0.0.1:6399/${TOKEN}/x.wasm.db`, 'redis://'],
['https://example.com/app.wasm.db', 'https://'],
['jdbc:x/app.wasm.db', 'jdbc:'],
];

for (const [door, run] of DOORS) {
it.each(RESCUED)(`%s via ${door} → refused as %s`, (url, scheme) => {
expectUnsupportedSchemeRefusal(refusalOf(() => run(url)), scheme);
});
}
});

describe('CONTROL: a drive letter, a file: URL and a plain path open the file they did', () => {
it.each([
// A single letter and a `:` is a Windows drive, not a scheme.
['C:\\data\\x.db', 'sqlite', 'C:\\data\\x.db'],
['C:/data/x.db', 'sqlite', 'C:/data/x.db'],
['c:\\data\\x.wasm.db', 'sqlite-wasm', 'c:\\data\\x.wasm.db'],
['file:./x.db', 'sqlite', './x.db'],
['sqlite:./x.db', 'sqlite', './x.db'],
['./x.db', 'sqlite', './x.db'],
['./x.wasm.db', 'sqlite-wasm', './x.wasm.db'],
['file:./x.wasm.db', 'sqlite-wasm', './x.wasm.db'],
['wasm-sqlite:///tmp/x.db', 'sqlite-wasm', '/tmp/x.db'],
['data/app', 'sqlite', 'data/app'],
// A `:` past the first path segment is not a scheme either, and `file:`
// names a file whose own name holds one.
['./data/app:v2.db', 'sqlite', './data/app:v2.db'],
['./backup:2026.db', 'sqlite', './backup:2026.db'],
['file:backup:2026.db', 'sqlite', 'backup:2026.db'],
[':memory:', 'sqlite', null],
])('%s → %s, file %s', (url, driver, file) => {
const resolved = resolveStandaloneDatabase({ databaseUrl: url });
expect(resolved.driver).toBe(driver);
expect(resolved.sqliteFile).toBe(file);
});

it.each(['postgres', 'mysql', 'mongodb'])(
'a credential-bearing %s:// URL still resolves, credential intact',
(scheme) => {
const url = `${scheme}://${CREDENTIALED_TAIL}`;
expect(resolveStandaloneDatabase({ databaseUrl: url }).url).toBe(url);
},
);
});
27 changes: 22 additions & 5 deletions packages/runtime/src/standalone-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -440,12 +440,29 @@ function detectDriverFromUrl(dbUrl: string): ResolvedDriverKind {
if (/^libsql:\/\//i.test(dbUrl)) return 'turso';
if (/^https?:\/\//i.test(dbUrl) && /\.turso\./i.test(dbUrl)) return 'turso';
if (/^wasm-sqlite:\/\//i.test(dbUrl)) return 'sqlite-wasm';
if (/\.wasm\.db$/i.test(dbUrl)) return 'sqlite-wasm';
if (/^file:/i.test(dbUrl)) return 'sqlite';
// A `*.wasm.db` file name selects the WASM engine only for a FILE: a
// `file:` URL or a value with no scheme (both returns below). Tested ahead
// of the scheme checks, it rescued a scheme nothing recognises
// (`redis://h/x.wasm.db`) into a SQLite file at a path made of the URL.
const wasmFile = /\.wasm\.db$/i.test(dbUrl);
if (/^file:/i.test(dbUrl)) return wasmFile ? 'sqlite-wasm' : 'sqlite';
// What is left with a scheme is refused, by the header's rule:
// - an unrecognised `<scheme>://` (`redis://…`);
// - an OPAQUE scheme, two or more characters and a `:` with no `//`
// (`jdbc:postgresql://…`, `redis:h`). These used to read as a bare path
// and open a SQLite file at a path made of the URL
// (`./jdbc:postgresql:/127.0.0.1:5999/db`), the bogus directory the
// rule exists to prevent.
// A single letter and a `:` is a Windows drive (`C:\data\app.db`,
// `C:/data/app.db`), not a scheme, and stays a path. The CLI host draws the
// same line (`inferDriverTypeFromUrl` in `utils/storage-driver.ts`), and
// `driver-vocabulary-parity.test.ts` holds the two equal.
const scheme = urlSchemeOf(dbUrl) ?? /^[a-z][a-z0-9+.-]+:/i.exec(dbUrl)?.[0];
// Bare path without a scheme — treat as a sqlite file path.
const scheme = urlSchemeOf(dbUrl);
if (!scheme) return 'sqlite';
// Names the scheme, never the URL ({@link urlSchemeOf}).
if (!scheme) return wasmFile ? 'sqlite-wasm' : 'sqlite';
// Names the scheme, never the URL ({@link urlSchemeOf}). An opaque scheme
// ends at its FIRST `:`, so it cannot hold a userinfo, a host or a path
// either.
throw new Error(
`[StandaloneStack] Unsupported database URL scheme "${scheme}". ` +
`Supported schemes: postgres://, pg://, mysql://, mysql2://, ` +
Expand Down
Loading