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
21 changes: 21 additions & 0 deletions .changeset/23764-unsupported-scheme-no-url.md
Original file line number Diff line number Diff line change
@@ -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`.
207 changes: 207 additions & 0 deletions packages/runtime/src/standalone-stack-url-refusal-credentials.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,207 @@
// 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 `<scheme>://`: 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-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));
// 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);
}
}

/** 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);
},
);
});
23 changes: 22 additions & 1 deletion packages/runtime/src/standalone-stack.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
42 changes: 35 additions & 7 deletions packages/runtime/src/standalone-stack.ts
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,21 @@ export interface StandaloneStackResult {

type ResolvedDriverKind = z.infer<typeof StandaloneDatabaseDriverSchema>;

/**
* The `<scheme>://` 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
Expand Down Expand Up @@ -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: ` +
Expand Down Expand Up @@ -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)}`);
}

Expand All @@ -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.`
);
}
Expand Down
Loading