Skip to content

Commit 2a64b50

Browse files
committed
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 <noreply@anthropic.com>
1 parent ec44cf6 commit 2a64b50

3 files changed

Lines changed: 262 additions & 8 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// The standalone host's URL refusals name the URL's SCHEME, never the URL.
4+
//
5+
// A database URL is where postgres / mysql / mongodb credentials ride
6+
// (`user:password@host`, `?authToken=…`), and each refusal below leaves the
7+
// process as an error message that the CLI prints to the console and a
8+
// deployment's boot log keeps. Every one of them used to interpolate the whole
9+
// URL, so a password in `OS_DATABASE_URL` reached the log whenever the URL was
10+
// refused. The cases drive each refusal with a placeholder-credential URL and
11+
// hold three things:
12+
//
13+
// 1. the refusal still happens where it did, as a plain `Error`, and still
14+
// tells the operator what to do (the scheme, the supported list or the
15+
// remedy);
16+
// 2. no own property of the thrown error (message, stack, anything else)
17+
// carries the username, the password or the query token;
18+
// 3. CONTROL: every scheme the host supports still resolves to the same
19+
// driver kind, and a credential-bearing URL that RESOLVES keeps its
20+
// credential in `url`. The redaction belongs to the message, never to
21+
// the connection.
22+
23+
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
24+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
25+
import { tmpdir } from 'node:os';
26+
import { join } from 'node:path';
27+
import { resolveStandaloneDatabase } from './standalone-stack.js';
28+
29+
// Placeholders only: nothing here is, or looks like, a real secret.
30+
const USER = 'PLACEHOLDER-USER';
31+
const SECRET = 'PLACEHOLDER-SECRET';
32+
const TOKEN = 'PLACEHOLDER-TOKEN';
33+
/** Everything after `<scheme>://`: a userinfo with a password, a host, a path and a token query. */
34+
const CREDENTIALED_TAIL = `${USER}:${SECRET}@127.0.0.1:6399/0?authToken=${TOKEN}`;
35+
36+
/** Env keys these tests write; restored after every case. */
37+
const ENV_KEYS = [
38+
'OS_DATABASE_URL',
39+
'DATABASE_URL',
40+
'TURSO_DATABASE_URL',
41+
'OS_DATABASE_DRIVER',
42+
'OS_HOME',
43+
'OS_ARTIFACT_PATH',
44+
] as const;
45+
const ORIGINAL_ENV: Record<string, string | undefined> = Object.fromEntries(
46+
ENV_KEYS.map((k) => [k, process.env[k]]),
47+
);
48+
49+
let root: string;
50+
beforeEach(() => {
51+
for (const key of ENV_KEYS) delete process.env[key];
52+
root = mkdtempSync(join(tmpdir(), 'os-url-refusal-credentials-'));
53+
// Every rung that could answer before the one under test points at this
54+
// empty directory, so no developer state leaks into a case.
55+
process.env.OS_HOME = root;
56+
});
57+
afterEach(() => {
58+
try { rmSync(root, { recursive: true, force: true }); } catch { /* noop */ }
59+
for (const key of ENV_KEYS) {
60+
const original = ORIGINAL_ENV[key];
61+
if (original === undefined) delete process.env[key];
62+
else process.env[key] = original;
63+
}
64+
});
65+
66+
/** The error `run` throws; fails the case when it throws nothing. */
67+
function refusalOf(run: () => unknown): Error {
68+
try {
69+
run();
70+
} catch (err) {
71+
return err as Error;
72+
}
73+
throw new Error('expected a refusal, but the URL resolved');
74+
}
75+
76+
/** Same refusal class as before the change, and no credential anywhere on it. */
77+
function expectRefusedWithoutCredentials(err: Error): void {
78+
expect(err.constructor).toBe(Error);
79+
expect(err.cause).toBeUndefined();
80+
const everything = JSON.stringify(err, Object.getOwnPropertyNames(err));
81+
expect(everything).toContain(err.message);
82+
for (const placeholder of [USER, SECRET, TOKEN]) {
83+
expect(everything, `the refusal carries ${placeholder}`).not.toContain(placeholder);
84+
}
85+
}
86+
87+
/** A compiled artifact whose declared default datasource carries `url`. */
88+
function artifactDeclaring(driver: string, url: string): string {
89+
const artifactPath = join(root, 'objectstack.json');
90+
writeFileSync(artifactPath, JSON.stringify({
91+
datasources: [{ name: 'primary', driver, config: { url } }],
92+
datasourceMapping: [{ default: true, datasource: 'primary' }],
93+
}));
94+
return artifactPath;
95+
}
96+
97+
describe('the unsupported-scheme refusal names the scheme, never the URL', () => {
98+
const url = `redis://${CREDENTIALED_TAIL}`;
99+
100+
it.each([
101+
['the databaseUrl option (--database)', () => resolveStandaloneDatabase({ databaseUrl: url })],
102+
['OS_DATABASE_URL', () => {
103+
process.env.OS_DATABASE_URL = url;
104+
return resolveStandaloneDatabase({ projectRoot: root });
105+
}],
106+
['a declared default datasource', () =>
107+
resolveStandaloneDatabase({ projectRoot: root, artifactPath: artifactDeclaring('redis', url) })],
108+
])('via %s', (_door, run) => {
109+
const err = refusalOf(run);
110+
expect(err.message).toContain('Unsupported database URL scheme "redis://"');
111+
// Still actionable: the supported list is intact.
112+
expect(err.message).toContain('postgres://');
113+
expect(err.message).toContain('libsql://');
114+
expectRefusedWithoutCredentials(err);
115+
});
116+
117+
it('a plain https:// URL (not a Turso host) is refused the same way', () => {
118+
const err = refusalOf(() => resolveStandaloneDatabase({ databaseUrl: `https://${CREDENTIALED_TAIL}` }));
119+
expect(err.message).toContain('Unsupported database URL scheme "https://"');
120+
expectRefusedWithoutCredentials(err);
121+
});
122+
});
123+
124+
describe('the retired in-memory engine refusal names the scheme, never the URL', () => {
125+
it.each([
126+
['memory://', 'the databaseUrl option', (u: string) => resolveStandaloneDatabase({ databaseUrl: u }), 'A database URL with the'],
127+
['mingo://', 'the databaseUrl option', (u: string) => resolveStandaloneDatabase({ databaseUrl: u }), 'A database URL with the'],
128+
['memory://', 'OS_DATABASE_URL', (u: string) => {
129+
process.env.OS_DATABASE_URL = u;
130+
return resolveStandaloneDatabase({ projectRoot: root });
131+
}, 'An OS_DATABASE_URL with the'],
132+
])('%s via %s', (scheme, _door, run, opening) => {
133+
const err = refusalOf(() => run(`${scheme}${CREDENTIALED_TAIL}`));
134+
expect(err.message).toContain(`${opening} "${scheme}" scheme selects the in-memory (mingo) engine`);
135+
// Still actionable: the two SQLite replacements.
136+
expect(err.message).toContain('--fresh');
137+
expect(err.message).toContain(':memory:');
138+
expectRefusedWithoutCredentials(err);
139+
});
140+
141+
it('via a declared datasource whose url names the engine', () => {
142+
const err = refusalOf(() => resolveStandaloneDatabase({
143+
projectRoot: root,
144+
artifactPath: artifactDeclaring('postgres', `memory://${CREDENTIALED_TAIL}`),
145+
}));
146+
expect(err.message).toContain('"primary"');
147+
expect(err.message).toContain('URL scheme "memory://"');
148+
expectRefusedWithoutCredentials(err);
149+
});
150+
});
151+
152+
describe('the sqlite non-path refusal names the scheme, never the URL', () => {
153+
it.each([
154+
['the databaseDriver option', () =>
155+
resolveStandaloneDatabase({ databaseDriver: 'sqlite', databaseUrl: `postgres://${CREDENTIALED_TAIL}` })],
156+
['OS_DATABASE_DRIVER', () => {
157+
process.env.OS_DATABASE_DRIVER = 'sqlite';
158+
return resolveStandaloneDatabase({ databaseUrl: `postgres://${CREDENTIALED_TAIL}` });
159+
}],
160+
])('an explicit sqlite selection over a server URL, via %s', (_door, run) => {
161+
const err = refusalOf(run);
162+
expect(err.message).toContain('sqlite driver was selected but the URL does not look like a file path');
163+
expect(err.message).toContain('its scheme is "postgres://"');
164+
expect(err.message).toContain('file:/path/to/db.sqlite');
165+
expectRefusedWithoutCredentials(err);
166+
});
167+
168+
it('a file: URL that names no file is still refused, saying so', () => {
169+
const err = refusalOf(() => resolveStandaloneDatabase({ databaseUrl: 'file:' }));
170+
expect(err.message).toContain('it names no file after "file:"');
171+
expect(err.constructor).toBe(Error);
172+
});
173+
});
174+
175+
describe('CONTROL: what resolves is unchanged, credentials included', () => {
176+
// Every arm of the URL detection, as it classified before the change.
177+
it.each([
178+
['postgres://h/db', 'postgres'],
179+
['postgresql://h/db', 'postgres'],
180+
['pg://h/db', 'postgres'],
181+
['mysql://h/db', 'mysql'],
182+
['mysql2://h/db', 'mysql'],
183+
['mongodb://h/db', 'mongodb'],
184+
['mongodb+srv://h/db', 'mongodb'],
185+
['libsql://my-db.turso.io', 'turso'],
186+
['https://my-db.turso.io', 'turso'],
187+
['wasm-sqlite:///tmp/app.db', 'sqlite-wasm'],
188+
['/tmp/app.wasm.db', 'sqlite-wasm'],
189+
['file:/tmp/app.db', 'sqlite'],
190+
['sqlite:///tmp/app.db', 'sqlite'],
191+
['/tmp/app.db', 'sqlite'],
192+
['data/app', 'sqlite'],
193+
[':memory:', 'sqlite'],
194+
])('%s → %s', (url, driver) => {
195+
expect(resolveStandaloneDatabase({ databaseUrl: url }).driver).toBe(driver);
196+
});
197+
198+
it.each(['postgres', 'mysql', 'mongodb'])(
199+
'a credential-bearing %s URL resolves with its credential intact (the driver needs it)',
200+
(scheme) => {
201+
const url = `${scheme}://${CREDENTIALED_TAIL}`;
202+
expect(resolveStandaloneDatabase({ databaseUrl: url }).url).toBe(url);
203+
},
204+
);
205+
});

‎packages/runtime/src/standalone-stack.test.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -357,11 +357,32 @@ describe('createStandaloneStack — default datasource declared, built via the s
357357
(e: unknown) => e as Error,
358358
);
359359
expect(err, 'createStandaloneStack accepted memory://').toBeInstanceOf(Error);
360-
expect(err!.message).toContain('"memory://default-driver"');
360+
// The scheme, never the URL: what follows `//` is where a credential rides
361+
// (standalone-stack-url-refusal-credentials.test.ts holds that per door).
362+
expect(err!.message).toContain('"memory://" scheme');
363+
expect(err!.message).not.toContain('default-driver');
361364
expect(err!.message).toContain('--fresh');
362365
expect(err!.message).toContain(':memory:');
363366
}, BOOT_TIMEOUT);
364367

368+
// The boot's own door to the unsupported-scheme refusal: refused before a
369+
// definition exists, naming the scheme, and no credential in the URL reaches
370+
// the error the CLI prints.
371+
it('an unsupported scheme → refused before any definition is declared, naming the scheme and never the URL', async () => {
372+
const err = await createStandaloneStack({
373+
databaseUrl: 'redis://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN',
374+
}).then(
375+
() => null,
376+
(e: unknown) => e as Error,
377+
);
378+
expect(err, 'createStandaloneStack accepted redis://').toBeInstanceOf(Error);
379+
expect(err!.message).toContain('Unsupported database URL scheme "redis://"');
380+
const everything = JSON.stringify(err, Object.getOwnPropertyNames(err));
381+
for (const placeholder of ['PLACEHOLDER-USER', 'PLACEHOLDER-SECRET', 'PLACEHOLDER-TOKEN']) {
382+
expect(everything).not.toContain(placeholder);
383+
}
384+
}, BOOT_TIMEOUT);
385+
365386
it(':memory: → declares driver "sqlite" on SQLite\'s in-memory database; factory builds SqlDriver that round-trips', async () => {
366387
const r = await definitionRoundTrip({ databaseUrl: ':memory:' });
367388
expect(r.driverId).toBe('sqlite');

‎packages/runtime/src/standalone-stack.ts‎

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,21 @@ export interface StandaloneStackResult {
396396

397397
type ResolvedDriverKind = z.infer<typeof StandaloneDatabaseDriverSchema>;
398398

399+
/**
400+
* The `<scheme>://` a database URL opens with, or `undefined` when it has none.
401+
*
402+
* Every refusal in this host that is about a URL names THIS, never the URL.
403+
* The URL is where the postgres / mysql / mongodb credentials ride
404+
* (`user:password@host`, `?authToken=…`), and these refusals are printed to
405+
* the console and the boot log on the way out, which a deployment may ship to
406+
* a log aggregator. A scheme is letters, digits and `+ - .` up to the first
407+
* `://`, so it cannot hold a userinfo, a host, a path or a query: naming it
408+
* cannot echo a credential, whatever the rest of the URL carries.
409+
*/
410+
function urlSchemeOf(dbUrl: string): string | undefined {
411+
return /^[a-z][a-z0-9+.-]*:\/\//i.exec(dbUrl)?.[0];
412+
}
413+
399414
function detectDriverFromUrl(dbUrl: string): ResolvedDriverKind {
400415
// The in-memory (mingo) engine's two schemes — character-for-character the
401416
// CLI's `inferDriverTypeFromUrl` regex, for the reason the mysql and turso
@@ -428,9 +443,11 @@ function detectDriverFromUrl(dbUrl: string): ResolvedDriverKind {
428443
if (/\.wasm\.db$/i.test(dbUrl)) return 'sqlite-wasm';
429444
if (/^file:/i.test(dbUrl)) return 'sqlite';
430445
// Bare path without a scheme — treat as a sqlite file path.
431-
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(dbUrl)) return 'sqlite';
446+
const scheme = urlSchemeOf(dbUrl);
447+
if (!scheme) return 'sqlite';
448+
// Names the scheme, never the URL ({@link urlSchemeOf}).
432449
throw new Error(
433-
`[StandaloneStack] Unsupported database URL scheme: ${dbUrl}. ` +
450+
`[StandaloneStack] Unsupported database URL scheme "${scheme}". ` +
434451
`Supported schemes: postgres://, pg://, mysql://, mysql2://, ` +
435452
`mongodb://, mongodb+srv://, ` +
436453
`libsql:// (optional @objectstack/driver-turso), file: ` +
@@ -536,11 +553,16 @@ function refuseRetiredMemoryEngine(
536553
resolution: { source: ProjectDatabaseUrlSource; datasourceName?: string },
537554
): BootDriverKind {
538555
if (driver !== 'memory') return driver;
556+
// The URL's scheme, never the URL ({@link urlSchemeOf}): `memory://` and
557+
// `mingo://` accept anything after the `//`, a `user:password@` included.
558+
// `driver` is `memory` only when {@link detectDriverFromUrl} matched one of
559+
// the two, so there is always a scheme to name.
560+
const scheme = urlSchemeOf(url) ?? '';
539561
const selection = resolution.source === 'config-datasource'
540-
? `The project's default datasource "${resolution.datasourceName ?? 'default'}" (driver: memory, URL "${url}")`
562+
? `The project's default datasource "${resolution.datasourceName ?? 'default'}" (driver: memory, URL scheme "${scheme}")`
541563
: resolution.source === 'env'
542-
? `OS_DATABASE_URL "${url}"`
543-
: `The database URL "${url}"`;
564+
? `An OS_DATABASE_URL with the "${scheme}" scheme`
565+
: `A database URL with the "${scheme}" scheme`;
544566
throw new Error(`[StandaloneStack] ${retiredMemoryEngineMessage(selection)}`);
545567
}
546568

@@ -552,9 +574,15 @@ function sqliteFilenameFromUrl(dbUrl: string, kind: 'sqlite' | 'sqlite-wasm'): s
552574
.replace(/^file:(\/\/)?/i, '') || ':memory:';
553575
}
554576
const filename = dbUrl.replace(/^file:(\/\/)?/, '');
555-
if (!filename || /^[a-z][a-z0-9+.-]*:\/\//i.test(filename)) {
577+
const scheme = urlSchemeOf(filename);
578+
if (!filename || scheme) {
579+
// Names the scheme, never the URL ({@link urlSchemeOf}). This is the
580+
// refusal an explicit `sqlite` selection meets over a server URL
581+
// (`OS_DATABASE_DRIVER=sqlite` beside a `postgres://user:password@…`),
582+
// so the URL it would otherwise echo is exactly the credentialed kind.
583+
const why = scheme ? `its scheme is "${scheme}"` : 'it names no file after "file:"';
556584
throw new Error(
557-
`[StandaloneStack] sqlite driver was selected but the URL does not look like a file path: "${dbUrl}". ` +
585+
`[StandaloneStack] sqlite driver was selected but the URL does not look like a file path (${why}). ` +
558586
`Use file:/path/to/db.sqlite, or set OS_DATABASE_DRIVER explicitly.`
559587
);
560588
}

0 commit comments

Comments
 (0)