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
18 changes: 18 additions & 0 deletions .changeset/23792-memory-url-refusal-no-url.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@objectstack/cli': patch
---

fix(cli): `os serve`'s refusal of a `memory://` or `mingo://` `OS_DATABASE_URL` names the URL's scheme and never prints the URL, so a password or token in it no longer reaches the console

Clause-②: no

This is the `os serve` path a host config takes (one whose `plugins` hold plugin instances), and the one `OS_MODE=off|none|legacy` takes. `os dev` and `os start` reach it through their `serve` child. Both schemes name the retired in-memory (mingo) engine, and both accept anything after the `//`, a `user:password@` and a query string included. The refusal interpolated the whole URL, and the CLI prints it as it exits 1.

With `OS_DATABASE_URL=memory://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN`:

- FROM: `✗ OS_DATABASE_URL "memory://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN" selects the in-memory (mingo) engine, …`
- TO: `✗ An OS_DATABASE_URL with the "memory://" scheme selects the in-memory (mingo) engine, …`

`mingo://` reads the same way, with `"mingo://"`. These are the runtime host's words for the same URL (`os dev` / `os start` over a plain config, and `os migrate`), so the two hosts print one sentence. The rest of the sentence, which names the SQLite replacements (`os dev --fresh`, and the URL `:memory:`), is unchanged.

What does not change: both schemes are still refused, with the same error class and exit code, in dev and production; every other URL resolves as before, to the same driver.
22 changes: 22 additions & 0 deletions packages/cli/src/utils/driver-vocabulary-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,28 @@ describe('driver vocabulary parity: `os start` and `os migrate` answer alike (#6
},
);

// The URL door's refusal is printed on the way out, and both schemes take a
// `user:password@` and an `?authToken=`. Both hosts name the SCHEME, never the
// URL, in the same words: the CLI host's refusal is the runtime host's minus
// its `[StandaloneStack]` tag. Placeholder credentials only.
it.each(['memory://', 'mingo://'])(
'both hosts refuse a credentialed %s OS_DATABASE_URL in the same words, and neither prints the credentials',
(scheme) => {
const url = `${scheme}PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN`;
const cli = refusalMessage(() => resolveDriverType(undefined, url));
process.env.OS_DATABASE_URL = url;
const standalone = refusalMessage(() => resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' }));
for (const [host, message] of [['os start', cli], ['os migrate', standalone]] as const) {
expect(message, `${host} accepted ${scheme}…`).not.toBeNull();
expect(message, host).toContain(`"${scheme}"`);
for (const placeholder of ['PLACEHOLDER-USER', 'PLACEHOLDER-SECRET', 'PLACEHOLDER-TOKEN']) {
expect(message, `${host} prints ${placeholder}`).not.toContain(placeholder);
}
}
expect(cli).toBe(standalone!.replace(/^\[StandaloneStack\] /, ''));
},
);

// The control on the URL door: the replacement the refusals name is accepted
// by both hosts, as SQLite — so the refusal is scoped to the engine, not to
// the word "memory".
Expand Down
65 changes: 60 additions & 5 deletions packages/cli/src/utils/storage-driver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,15 @@ describe('inferDriverTypeFromUrl', () => {

// The in-memory (mingo) engine's schemes are REFUSED at inference: the engine
// is no longer a boot store. Asserted on the error's TYPE and on what it names
// (the URL and the two SQLite replacements), never on its prose.
it.each(['memory://', 'memory://ignored-host', 'mingo://', 'MINGO://x'])(
'refuses %s at inference, naming the SQLite replacements',
(url) => {
// (the variable, the scheme and the two SQLite replacements), never on its prose.
it.each([
['memory://', 'memory://'],
['memory://ignored-host', 'memory://'],
['mingo://', 'mingo://'],
['MINGO://x', 'MINGO://'],
])(
'refuses %s at inference, naming its scheme and the SQLite replacements',
(url, scheme) => {
let err: unknown;
try {
inferDriverTypeFromUrl(url);
Expand All @@ -75,12 +80,62 @@ describe('inferDriverTypeFromUrl', () => {
const refusal = err as UnsupportedDriverError;
// Not a kind this resolver produces — the allowlist pin's oracle reads this.
expect(refusal.recognized).toBe(false);
expect(refusal.message).toContain(`"${url}"`);
expect(refusal.driverType).toBe(scheme);
expect(refusal.message).toContain('OS_DATABASE_URL');
expect(refusal.message).toContain(`"${scheme}"`);
expect(refusal.message).toContain('--fresh');
expect(refusal.message).toContain(':memory:');
},
);

// The refusal is printed to the console on the way out, and both schemes take
// anything after the `//`: a `user:password@` and an `?authToken=` ride along.
// It names the scheme, never the URL — in the message AND in the field a
// caller may log. Placeholder credentials only.
const PLACEHOLDERS = ['PLACEHOLDER-USER', 'PLACEHOLDER-SECRET', 'PLACEHOLDER-TOKEN'] as const;
const CREDENTIALED_TAIL = 'PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN';
it.each(['memory://', 'mingo://'])(
'refuses a %s URL carrying a user, password and token, naming the scheme and none of the three',
(scheme) => {
const url = `${scheme}${CREDENTIALED_TAIL}`;
let err: unknown;
try {
inferDriverTypeFromUrl(url);
} catch (e) {
err = e;
}
expect(err, `inferDriverTypeFromUrl accepted ${scheme}…`).toBeInstanceOf(UnsupportedDriverError);
const refusal = err as UnsupportedDriverError;
expect(refusal.recognized).toBe(false);
expect(refusal.driverType).toBe(scheme);
expect(refusal.message).toContain(`"${scheme}"`);
expect(refusal.message).toContain('--fresh');
expect(refusal.message).toContain(':memory:');
for (const placeholder of PLACEHOLDERS) {
expect(refusal.message, `the refusal prints ${placeholder}`).not.toContain(placeholder);
expect(refusal.driverType, `driverType carries ${placeholder}`).not.toContain(placeholder);
}
expect(refusal.message).not.toContain('127.0.0.1');
},
);

// CONTROL: the same credentialed tail under every other scheme classifies as
// before — the redaction is scoped to the retired engine's arm, not to URLs
// that carry credentials.
it.each([
['postgres://', 'postgres'],
['postgresql://', 'postgres'],
['pg://', 'postgres'],
['mysql://', 'mysql'],
['mysql2://', 'mysql'],
['mongodb://', 'mongodb'],
['mongodb+srv://', 'mongodb'],
['libsql://', 'turso'],
['wasm-sqlite://', 'sqlite-wasm'],
])('classifies a %s URL carrying the same user, password and token as %s', (scheme, kind) => {
expect(inferDriverTypeFromUrl(`${scheme}${CREDENTIALED_TAIL}`)).toBe(kind);
});

// The sqlite `:memory:` PSEUDO-FILE is SQLite's own in-memory mode — NOT the
// mingo engine. It stays `sqlite`, and it is the replacement the refusal names.
it('keeps sqlite `:memory:` mapped to sqlite (the replacement, distinct from memory://)', () => {
Expand Down
16 changes: 13 additions & 3 deletions packages/cli/src/utils/storage-driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,10 +328,20 @@ export function inferDriverTypeFromUrl(url: string | undefined): string {
// the same URLs. Kept ABOVE the sqlite test so it is not shadowed, and
// deliberately distinct from sqlite's `:memory:` pseudo-file below, which is
// the replacement this refusal names.
if (/^(memory|mingo):\/\//i.test(u)) {
//
// The refusal names the SCHEME, never the URL: both schemes accept anything
// after the `//`, a `user:password@` and a `?authToken=` included, and this
// sentence is printed to the console on the way out. The subject is the
// runtime host's own (`refuseRetiredMemoryEngine` in
// `packages/runtime/src/standalone-stack.ts`, module-local there, so written
// out here), and `driver-vocabulary-parity.test.ts` holds the two equal.
// The scheme is also what the error carries as `driverType`, as the
// unrecognised-scheme refusal below does.
const retiredScheme = /^(memory|mingo):\/\//i.exec(u)?.[0];
if (retiredScheme) {
throw new UnsupportedDriverError(
u,
retiredMemoryEngineMessage(`OS_DATABASE_URL "${u}"`),
retiredScheme,
retiredMemoryEngineMessage(`An OS_DATABASE_URL with the "${retiredScheme}" scheme`),
// Not a kind this resolver can produce — `database-driver-allowlist.pin`
// reads `recognized` to tell the two apart.
{ recognized: false },
Expand Down
Loading