From 2a64b505ca3824e0ebe7fa1f0197df1a63b997e9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:11:21 +0000 Subject: [PATCH 1/3] fix(runtime): standalone URL refusals name the scheme, never the URL The standalone host's unsupported-scheme refusal interpolated the whole database URL, so a password or token in OS_DATABASE_URL reached the console and the boot log. The retired in-memory engine refusal and the sqlite non-path refusal did the same. All three now name only the URL's scheme, which cannot hold a userinfo, host, path or query. Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- ...lone-stack-url-refusal-credentials.test.ts | 205 ++++++++++++++++++ packages/runtime/src/standalone-stack.test.ts | 23 +- packages/runtime/src/standalone-stack.ts | 42 +++- 3 files changed, 262 insertions(+), 8 deletions(-) create mode 100644 packages/runtime/src/standalone-stack-url-refusal-credentials.test.ts diff --git a/packages/runtime/src/standalone-stack-url-refusal-credentials.test.ts b/packages/runtime/src/standalone-stack-url-refusal-credentials.test.ts new file mode 100644 index 00000000000..202d71f9ad6 --- /dev/null +++ b/packages/runtime/src/standalone-stack-url-refusal-credentials.test.ts @@ -0,0 +1,205 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// The standalone host's URL refusals name the URL's SCHEME, never the URL. +// +// A database URL is where postgres / mysql / mongodb credentials ride +// (`user:password@host`, `?authToken=…`), and each refusal below leaves the +// process as an error message that the CLI prints to the console and a +// deployment's boot log keeps. Every one of them used to interpolate the whole +// URL, so a password in `OS_DATABASE_URL` reached the log whenever the URL was +// refused. The cases drive each refusal with a placeholder-credential URL and +// hold three things: +// +// 1. the refusal still happens where it did, as a plain `Error`, and still +// tells the operator what to do (the scheme, the supported list or the +// remedy); +// 2. no own property of the thrown error (message, stack, anything else) +// carries the username, the password or the query token; +// 3. CONTROL: every scheme the host supports still resolves to the same +// driver kind, and a credential-bearing URL that RESOLVES keeps its +// credential in `url`. The redaction belongs to the message, never to +// the connection. + +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'; +/** Everything after `://`: 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 = 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-url-refusal-credentials-')); + // 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'); +} + +/** Same refusal class as before the change, and no credential anywhere on it. */ +function expectRefusedWithoutCredentials(err: Error): void { + expect(err.constructor).toBe(Error); + expect(err.cause).toBeUndefined(); + const everything = JSON.stringify(err, Object.getOwnPropertyNames(err)); + expect(everything).toContain(err.message); + for (const placeholder of [USER, SECRET, TOKEN]) { + expect(everything, `the refusal carries ${placeholder}`).not.toContain(placeholder); + } +} + +/** A compiled artifact whose declared default datasource carries `url`. */ +function artifactDeclaring(driver: string, url: string): string { + const artifactPath = join(root, 'objectstack.json'); + writeFileSync(artifactPath, JSON.stringify({ + datasources: [{ name: 'primary', driver, config: { url } }], + datasourceMapping: [{ default: true, datasource: 'primary' }], + })); + return artifactPath; +} + +describe('the unsupported-scheme refusal names the scheme, never the URL', () => { + const url = `redis://${CREDENTIALED_TAIL}`; + + it.each([ + ['the databaseUrl option (--database)', () => resolveStandaloneDatabase({ databaseUrl: url })], + ['OS_DATABASE_URL', () => { + process.env.OS_DATABASE_URL = url; + return resolveStandaloneDatabase({ projectRoot: root }); + }], + ['a declared default datasource', () => + resolveStandaloneDatabase({ projectRoot: root, artifactPath: artifactDeclaring('redis', url) })], + ])('via %s', (_door, run) => { + const err = refusalOf(run); + expect(err.message).toContain('Unsupported database URL scheme "redis://"'); + // Still actionable: the supported list is intact. + expect(err.message).toContain('postgres://'); + expect(err.message).toContain('libsql://'); + expectRefusedWithoutCredentials(err); + }); + + it('a plain https:// URL (not a Turso host) is refused the same way', () => { + const err = refusalOf(() => resolveStandaloneDatabase({ databaseUrl: `https://${CREDENTIALED_TAIL}` })); + expect(err.message).toContain('Unsupported database URL scheme "https://"'); + expectRefusedWithoutCredentials(err); + }); +}); + +describe('the retired in-memory engine refusal names the scheme, never the URL', () => { + it.each([ + ['memory://', 'the databaseUrl option', (u: string) => resolveStandaloneDatabase({ databaseUrl: u }), 'A database URL with the'], + ['mingo://', 'the databaseUrl option', (u: string) => resolveStandaloneDatabase({ databaseUrl: u }), 'A database URL with the'], + ['memory://', 'OS_DATABASE_URL', (u: string) => { + process.env.OS_DATABASE_URL = u; + return resolveStandaloneDatabase({ projectRoot: root }); + }, 'An OS_DATABASE_URL with the'], + ])('%s via %s', (scheme, _door, run, opening) => { + const err = refusalOf(() => run(`${scheme}${CREDENTIALED_TAIL}`)); + expect(err.message).toContain(`${opening} "${scheme}" scheme selects the in-memory (mingo) engine`); + // Still actionable: the two SQLite replacements. + expect(err.message).toContain('--fresh'); + expect(err.message).toContain(':memory:'); + expectRefusedWithoutCredentials(err); + }); + + it('via a declared datasource whose url names the engine', () => { + const err = refusalOf(() => resolveStandaloneDatabase({ + projectRoot: root, + artifactPath: artifactDeclaring('postgres', `memory://${CREDENTIALED_TAIL}`), + })); + expect(err.message).toContain('"primary"'); + expect(err.message).toContain('URL scheme "memory://"'); + expectRefusedWithoutCredentials(err); + }); +}); + +describe('the sqlite non-path refusal names the scheme, never the URL', () => { + it.each([ + ['the databaseDriver option', () => + resolveStandaloneDatabase({ databaseDriver: 'sqlite', databaseUrl: `postgres://${CREDENTIALED_TAIL}` })], + ['OS_DATABASE_DRIVER', () => { + process.env.OS_DATABASE_DRIVER = 'sqlite'; + return resolveStandaloneDatabase({ databaseUrl: `postgres://${CREDENTIALED_TAIL}` }); + }], + ])('an explicit sqlite selection over a server URL, via %s', (_door, run) => { + const err = refusalOf(run); + expect(err.message).toContain('sqlite driver was selected but the URL does not look like a file path'); + expect(err.message).toContain('its scheme is "postgres://"'); + expect(err.message).toContain('file:/path/to/db.sqlite'); + expectRefusedWithoutCredentials(err); + }); + + it('a file: URL that names no file is still refused, saying so', () => { + const err = refusalOf(() => resolveStandaloneDatabase({ databaseUrl: 'file:' })); + expect(err.message).toContain('it names no file after "file:"'); + expect(err.constructor).toBe(Error); + }); +}); + +describe('CONTROL: what resolves is unchanged, credentials included', () => { + // Every arm of the URL detection, as it classified before the change. + it.each([ + ['postgres://h/db', 'postgres'], + ['postgresql://h/db', 'postgres'], + ['pg://h/db', 'postgres'], + ['mysql://h/db', 'mysql'], + ['mysql2://h/db', 'mysql'], + ['mongodb://h/db', 'mongodb'], + ['mongodb+srv://h/db', 'mongodb'], + ['libsql://my-db.turso.io', 'turso'], + ['https://my-db.turso.io', 'turso'], + ['wasm-sqlite:///tmp/app.db', 'sqlite-wasm'], + ['/tmp/app.wasm.db', 'sqlite-wasm'], + ['file:/tmp/app.db', 'sqlite'], + ['sqlite:///tmp/app.db', 'sqlite'], + ['/tmp/app.db', 'sqlite'], + ['data/app', 'sqlite'], + [':memory:', 'sqlite'], + ])('%s → %s', (url, driver) => { + expect(resolveStandaloneDatabase({ databaseUrl: url }).driver).toBe(driver); + }); + + it.each(['postgres', 'mysql', 'mongodb'])( + 'a credential-bearing %s URL resolves with its credential intact (the driver needs it)', + (scheme) => { + const url = `${scheme}://${CREDENTIALED_TAIL}`; + expect(resolveStandaloneDatabase({ databaseUrl: url }).url).toBe(url); + }, + ); +}); diff --git a/packages/runtime/src/standalone-stack.test.ts b/packages/runtime/src/standalone-stack.test.ts index 3fee5c20583..95f93f97552 100644 --- a/packages/runtime/src/standalone-stack.test.ts +++ b/packages/runtime/src/standalone-stack.test.ts @@ -357,11 +357,32 @@ describe('createStandaloneStack — default datasource declared, built via the s (e: unknown) => e as Error, ); expect(err, 'createStandaloneStack accepted memory://').toBeInstanceOf(Error); - expect(err!.message).toContain('"memory://default-driver"'); + // The scheme, never the URL: what follows `//` is where a credential rides + // (standalone-stack-url-refusal-credentials.test.ts holds that per door). + expect(err!.message).toContain('"memory://" scheme'); + expect(err!.message).not.toContain('default-driver'); expect(err!.message).toContain('--fresh'); expect(err!.message).toContain(':memory:'); }, BOOT_TIMEOUT); + // The boot's own door to the unsupported-scheme refusal: refused before a + // definition exists, naming the scheme, and no credential in the URL reaches + // the error the CLI prints. + it('an unsupported scheme → refused before any definition is declared, naming the scheme and never the URL', async () => { + const err = await createStandaloneStack({ + databaseUrl: 'redis://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN', + }).then( + () => null, + (e: unknown) => e as Error, + ); + expect(err, 'createStandaloneStack accepted redis://').toBeInstanceOf(Error); + expect(err!.message).toContain('Unsupported database URL scheme "redis://"'); + const everything = JSON.stringify(err, Object.getOwnPropertyNames(err)); + for (const placeholder of ['PLACEHOLDER-USER', 'PLACEHOLDER-SECRET', 'PLACEHOLDER-TOKEN']) { + expect(everything).not.toContain(placeholder); + } + }, BOOT_TIMEOUT); + it(':memory: → declares driver "sqlite" on SQLite\'s in-memory database; factory builds SqlDriver that round-trips', async () => { const r = await definitionRoundTrip({ databaseUrl: ':memory:' }); expect(r.driverId).toBe('sqlite'); diff --git a/packages/runtime/src/standalone-stack.ts b/packages/runtime/src/standalone-stack.ts index fc8fb2beee0..787073f6c5e 100644 --- a/packages/runtime/src/standalone-stack.ts +++ b/packages/runtime/src/standalone-stack.ts @@ -396,6 +396,21 @@ export interface StandaloneStackResult { type ResolvedDriverKind = z.infer; +/** + * The `://` a database URL opens with, or `undefined` when it has none. + * + * Every refusal in this host that is about a URL names THIS, never the URL. + * The URL is where the postgres / mysql / mongodb credentials ride + * (`user:password@host`, `?authToken=…`), and these refusals are printed to + * the console and the boot log on the way out, which a deployment may ship to + * a log aggregator. A scheme is letters, digits and `+ - .` up to the first + * `://`, so it cannot hold a userinfo, a host, a path or a query: naming it + * cannot echo a credential, whatever the rest of the URL carries. + */ +function urlSchemeOf(dbUrl: string): string | undefined { + return /^[a-z][a-z0-9+.-]*:\/\//i.exec(dbUrl)?.[0]; +} + function detectDriverFromUrl(dbUrl: string): ResolvedDriverKind { // The in-memory (mingo) engine's two schemes — character-for-character the // CLI's `inferDriverTypeFromUrl` regex, for the reason the mysql and turso @@ -428,9 +443,11 @@ function detectDriverFromUrl(dbUrl: string): ResolvedDriverKind { if (/\.wasm\.db$/i.test(dbUrl)) return 'sqlite-wasm'; if (/^file:/i.test(dbUrl)) return 'sqlite'; // Bare path without a scheme — treat as a sqlite file path. - if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(dbUrl)) return 'sqlite'; + const scheme = urlSchemeOf(dbUrl); + if (!scheme) return 'sqlite'; + // Names the scheme, never the URL ({@link urlSchemeOf}). throw new Error( - `[StandaloneStack] Unsupported database URL scheme: ${dbUrl}. ` + + `[StandaloneStack] Unsupported database URL scheme "${scheme}". ` + `Supported schemes: postgres://, pg://, mysql://, mysql2://, ` + `mongodb://, mongodb+srv://, ` + `libsql:// (optional @objectstack/driver-turso), file: ` + @@ -536,11 +553,16 @@ function refuseRetiredMemoryEngine( resolution: { source: ProjectDatabaseUrlSource; datasourceName?: string }, ): BootDriverKind { if (driver !== 'memory') return driver; + // The URL's scheme, never the URL ({@link urlSchemeOf}): `memory://` and + // `mingo://` accept anything after the `//`, a `user:password@` included. + // `driver` is `memory` only when {@link detectDriverFromUrl} matched one of + // the two, so there is always a scheme to name. + const scheme = urlSchemeOf(url) ?? ''; const selection = resolution.source === 'config-datasource' - ? `The project's default datasource "${resolution.datasourceName ?? 'default'}" (driver: memory, URL "${url}")` + ? `The project's default datasource "${resolution.datasourceName ?? 'default'}" (driver: memory, URL scheme "${scheme}")` : resolution.source === 'env' - ? `OS_DATABASE_URL "${url}"` - : `The database URL "${url}"`; + ? `An OS_DATABASE_URL with the "${scheme}" scheme` + : `A database URL with the "${scheme}" scheme`; throw new Error(`[StandaloneStack] ${retiredMemoryEngineMessage(selection)}`); } @@ -552,9 +574,15 @@ function sqliteFilenameFromUrl(dbUrl: string, kind: 'sqlite' | 'sqlite-wasm'): s .replace(/^file:(\/\/)?/i, '') || ':memory:'; } const filename = dbUrl.replace(/^file:(\/\/)?/, ''); - if (!filename || /^[a-z][a-z0-9+.-]*:\/\//i.test(filename)) { + const scheme = urlSchemeOf(filename); + if (!filename || scheme) { + // Names the scheme, never the URL ({@link urlSchemeOf}). This is the + // refusal an explicit `sqlite` selection meets over a server URL + // (`OS_DATABASE_DRIVER=sqlite` beside a `postgres://user:password@…`), + // so the URL it would otherwise echo is exactly the credentialed kind. + const why = scheme ? `its scheme is "${scheme}"` : 'it names no file after "file:"'; throw new Error( - `[StandaloneStack] sqlite driver was selected but the URL does not look like a file path: "${dbUrl}". ` + + `[StandaloneStack] sqlite driver was selected but the URL does not look like a file path (${why}). ` + `Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.` ); } From 1d02ffc4d79187095f636bf89d5c2ec482b810c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:12:24 +0000 Subject: [PATCH 2/3] test(runtime): serialisation control reads the JSON-encoded message Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .../src/standalone-stack-url-refusal-credentials.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/runtime/src/standalone-stack-url-refusal-credentials.test.ts b/packages/runtime/src/standalone-stack-url-refusal-credentials.test.ts index 202d71f9ad6..38b5fe2ee07 100644 --- a/packages/runtime/src/standalone-stack-url-refusal-credentials.test.ts +++ b/packages/runtime/src/standalone-stack-url-refusal-credentials.test.ts @@ -78,7 +78,9 @@ function expectRefusedWithoutCredentials(err: Error): void { expect(err.constructor).toBe(Error); expect(err.cause).toBeUndefined(); const everything = JSON.stringify(err, Object.getOwnPropertyNames(err)); - expect(everything).toContain(err.message); + // Positive control: the serialisation really holds the message, so the + // negative reads below read something. + expect(everything).toContain(JSON.stringify(err.message)); for (const placeholder of [USER, SECRET, TOKEN]) { expect(everything, `the refusal carries ${placeholder}`).not.toContain(placeholder); } From c61b1366e1a36689f3400fc5762aa2ed7f56cae9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 02:20:46 +0000 Subject: [PATCH 3/3] chore(changeset): runtime patch for the scheme-only URL refusals Claude-Session: https://claude.ai/code/session_01Q9PZ5Pzqcus4woVvTZwWNU Co-authored-by: Claude --- .changeset/23764-unsupported-scheme-no-url.md | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 .changeset/23764-unsupported-scheme-no-url.md diff --git a/.changeset/23764-unsupported-scheme-no-url.md b/.changeset/23764-unsupported-scheme-no-url.md new file mode 100644 index 00000000000..6f549346e15 --- /dev/null +++ b/.changeset/23764-unsupported-scheme-no-url.md @@ -0,0 +1,21 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): the standalone host's database-URL refusals name the URL's scheme and never print the URL, so a password or token in `OS_DATABASE_URL` no longer reaches the console or the boot log + +Clause-②: no + +The standalone host is what `os dev`, `os start` and `os serve` boot over a plain config, and what `os migrate` boots. Its URL refusals interpolated the whole database URL, and the CLI prints that message as it exits. A URL is where postgres / mysql / mongodb credentials ride, so whenever such a URL was refused, the password and any token in its query string were printed. Each refusal now names only the scheme (the letters before `://`), which cannot hold a username, a password, a host, a path or a query. + +- **An unsupported scheme.** With `OS_DATABASE_URL=redis://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN`: + - FROM: `[StandaloneStack] Unsupported database URL scheme: redis://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN. Supported schemes: …` + - TO: `[StandaloneStack] Unsupported database URL scheme "redis://". Supported schemes: …`. The list of supported schemes is unchanged. `os migrate … --json` carries the same text in its `error` field. +- **A `memory://` or `mingo://` URL (the retired in-memory engine).** + - FROM: `The database URL "memory://…"` (or `OS_DATABASE_URL "memory://…"`) followed by the full URL. + - TO: `A database URL with the "memory://" scheme` (or `An OS_DATABASE_URL with the "memory://" scheme`). A declared default datasource reads `(driver: memory, URL scheme "memory://")`. The rest of the sentence, which names the SQLite replacements, is unchanged. +- **An explicit `sqlite` driver over a URL that is not a file path** (for example `OS_DATABASE_DRIVER=sqlite` beside a `postgres://…` URL). + - FROM: `sqlite driver was selected but the URL does not look like a file path: "postgres://PLACEHOLDER-USER:PLACEHOLDER-SECRET@…"` + - TO: `sqlite driver was selected but the URL does not look like a file path (its scheme is "postgres://")`. A bare `file:` now reads `(it names no file after "file:")`. + +What does not change: which URLs resolve, and to which driver; the URL handed to the driver, which keeps its credentials; and where each refusal is raised, with the same error class. Each message keeps the phrase that identifies it: `Unsupported database URL scheme`, `selects the in-memory (mingo) engine`, `does not look like a file path`.