Skip to content

Commit 66d456d

Browse files
fix(cli): the memory:// / mingo:// OS_DATABASE_URL refusal names the scheme, never the URL (#23840)
Fixes #23792 Clause-②: no ## What changes `inferDriverTypeFromUrl` in `packages/cli/src/utils/storage-driver.ts` refuses a `memory://` or `mingo://` `OS_DATABASE_URL` (the retired in-memory engine). Its refusal was built from the whole URL, `OS_DATABASE_URL "URL"`, and `os serve` prints that message as it exits 1. Both schemes accept anything after the `//`, so a `user:password@` and an `?authToken=` were printed to the console. The arm now names the scheme only, in the runtime host's words: `An OS_DATABASE_URL with the "memory://" scheme selects the in-memory (mingo) engine, ...`. The rest of the sentence (the SQLite replacements) comes from the shared `retiredMemoryEngineMessage`, unchanged. The `UnsupportedDriverError` it throws now carries the scheme as `driverType`, not the URL, as the unrecognised-scheme arm below it already does. Landing: the `memory` / `mingo` arm only, as the claim's file surface names it. No other arm, the `OS_DATABASE_DRIVER` / `--database-driver` arm, `packages/runtime` and the scheme parsing are untouched. ## Why the words are mirrored, not imported The runtime phrase is built inside `refuseRetiredMemoryEngine` in `packages/runtime/src/standalone-stack.ts`, and it is module-local: `@objectstack/runtime` exports `retiredMemoryEngineMessage` (the shared tail, already used here) but not the subject. The dispatch rules out a new export from `packages/runtime` on this card, so the CLI arm writes the subject out. A parity row in `driver-vocabulary-parity.test.ts` holds the two hosts' refusals equal for the same credentialed URL (the CLI's is the runtime's minus its `[StandaloneStack]` tag), so the copy cannot drift silently. ## Reproduction at the public door Built CLI, run from `examples/app-todo`, the URL carrying placeholder credentials only: ``` OS_MODE=off OS_DATABASE_URL='SCHEME://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN' node ../../packages/cli/bin/run.js serve -p PORT ``` | | `memory://` | `mingo://` | |:--|:--|:--| | base `1ecb08a1cc` | exit 1; `PLACEHOLDER-USER` / `-SECRET` / `-TOKEN` printed 1 / 1 / 1 time | exit 1; 1 / 1 / 1 | | this branch (CLI built from `3ddd364ad`) | exit 1; 0 / 0 / 0, and `127.0.0.1:6399` 0 | exit 1; 0 / 0 / 0, and `127.0.0.1:6399` 0 | - Before: `✗ OS_DATABASE_URL "memory://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN" selects the in-memory (mingo) engine, ...` - After: `✗ An OS_DATABASE_URL with the "memory://" scheme selects the in-memory (mingo) engine, ...` (and `"mingo://"` for the other scheme). ## Pins `packages/cli/src/utils/storage-driver.test.ts`: - **The placeholder pin.** A `memory://` and a `mingo://` URL carrying a placeholder user, password and token: the refusal is an `UnsupportedDriverError` with `recognized: false` and `driverType` equal to the scheme; its message names the scheme, `--fresh` and `:memory:`; neither the message nor `driverType` contains any of the three placeholders (nor the host). - **CONTROL.** The same credentialed tail under `postgres://`, `postgresql://`, `pg://`, `mysql://`, `mysql2://`, `mongodb://`, `mongodb+srv://`, `libsql://` and `wasm-sqlite://` classifies as before. The existing "maps each recognized URL scheme" and unrecognised-scheme rows are unchanged and green. - The existing memory-scheme row asserted the whole URL appeared in the message (`memory://ignored-host`, `MINGO://x`). It now asserts the scheme (`"memory://"`, `"MINGO://"`) and `driverType`. `packages/cli/src/utils/driver-vocabulary-parity.test.ts`: for a credentialed `memory://` and `mingo://` URL, both hosts refuse, name the scheme, print none of the placeholders, and the CLI's message equals the runtime's minus `[StandaloneStack] `. ## Tests (local) - `pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2` on `a1a28595e`: `Test Files 297 passed (297)`, `Tests 4447 passed (4447)`, lock `VERDICT command-exit 0`. It ran longer than one foreground call on this shared box (13m53s under the lock), so it was started detached under the lock while the derived gates ran in the foreground; its result was read from the lock's `VERDICT` line after it exited. - The two edited files alone, `--project unit` (both are unit-tier): `Test Files 2 passed (2)`, `Tests 162 passed (162)`. - `pnpm --filter @objectstack/cli typecheck` on `a1a28595e`: exit 0; `check:test-typecheck: OK` (the test layer compiles under `tsconfig.test.json`). - `integration` tier: declared to CI. The diff touches no integration-tier file and no spawn entry. ## Ablation (one-time, nothing kept) The tests import `./storage-driver.js` relatively, so they read the source and no rebuild is involved. Each leg through `node scripts/ablation-replace.mjs` (anchor hit 1 to 0, blob changed, then restored to the HEAD blob `74287da383` with `git diff HEAD` empty): 1. Subject back to the old `OS_DATABASE_URL "${u}"`: 6 failed / 156 passed. Red: the two placeholder pins, the two parity rows, and the `memory://ignored-host` / `MINGO://x` scheme rows, each on the "names the scheme" assertion. 2. Scheme kept but the URL appended (`... "${retiredScheme}" scheme (${u})`), so the placeholder assertions are the first to fail: 4 failed / 158 passed, all four on `not to contain 'PLACEHOLDER-USER'` (both placeholder pins, both parity rows). 3. `driverType` back to the URL (`u`): 4 failed / 158 passed, on `expected 'memory://PLACEHOLDER-USER:...' to be 'memory://'` and the two scheme rows. ## Gates - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` on `a1a28595e` (merge base `43567a8fc`, 4 paths, 121 changed lines): 64 commands. Reconciled with `--ran`: `64 derived famil(ies) accounted for — 62 run, 2 NOT-MEASURED`. - 62 exit 0. - NOT MEASURED: `check:dual-build-cjs-loads` and `check:i18n-coverage`, both exit 3 `PREREQUISITE NOT MET` (packages outside the CLI's build closure have no `dist/` here). CI's `Lint & Repo Gates` runs both. - `pnpm exec eslint --no-inline-config` over the three edited `.ts` files: exit 0. The repo-wide lint is CI's `Lint & Repo Gates`. - Control-byte self-scan of the four changed files: no match. ## Acceptance notes - **Other CLI sites printing the database URL (measured, none).** A grep of `packages/cli/src` (tests excluded) for the URL interpolated into a `throw`, `console.*`, `logger.*` or `print*` line finds only this arm. The startup banner, the migration line and `os start`'s `Database` line already pass the URL through `redactConnectionUrl`. - **Not touched:** the `OS_DATABASE_DRIVER` / `--database-driver` arm (it prints a driver name, not a URL), `packages/runtime`, and the scheme parsing; #23794 and #23796 remain open. - **Doc drift, noted and not filed (carrier: none).** The TSDoc of `retiredMemoryEngineMessage` in `packages/runtime/src/resolve-project-database.ts` still gives `The database URL "memory://"` as an example subject. The runtime host now says `A database URL with the "memory://" scheme`. Comment only; no behaviour reads it. - Placeholder credentials only, here and in the pins. The Tests section's account of the detached unit run was corrected by the `domain:cli` seat 2 PM (session `session_018t6a5rvqPRvEDMCVVW9kYT`) at review, from the dev report's deviation 1. Co-authored-by: Claude <noreply@anthropic.com>
1 parent ee0e115 commit 66d456d

4 files changed

Lines changed: 113 additions & 8 deletions

File tree

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
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
6+
7+
Clause-②: no
8+
9+
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.
10+
11+
With `OS_DATABASE_URL=memory://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN`:
12+
13+
- FROM: `✗ OS_DATABASE_URL "memory://PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN" selects the in-memory (mingo) engine, …`
14+
- TO: `✗ An OS_DATABASE_URL with the "memory://" scheme selects the in-memory (mingo) engine, …`
15+
16+
`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.
17+
18+
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.

‎packages/cli/src/utils/driver-vocabulary-parity.test.ts‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -257,6 +257,28 @@ describe('driver vocabulary parity: `os start` and `os migrate` answer alike (#6
257257
},
258258
);
259259

260+
// The URL door's refusal is printed on the way out, and both schemes take a
261+
// `user:password@` and an `?authToken=`. Both hosts name the SCHEME, never the
262+
// URL, in the same words: the CLI host's refusal is the runtime host's minus
263+
// its `[StandaloneStack]` tag. Placeholder credentials only.
264+
it.each(['memory://', 'mingo://'])(
265+
'both hosts refuse a credentialed %s OS_DATABASE_URL in the same words, and neither prints the credentials',
266+
(scheme) => {
267+
const url = `${scheme}PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN`;
268+
const cli = refusalMessage(() => resolveDriverType(undefined, url));
269+
process.env.OS_DATABASE_URL = url;
270+
const standalone = refusalMessage(() => resolveStandaloneDatabase({ artifactPath: '/nonexistent/objectstack.json' }));
271+
for (const [host, message] of [['os start', cli], ['os migrate', standalone]] as const) {
272+
expect(message, `${host} accepted ${scheme}…`).not.toBeNull();
273+
expect(message, host).toContain(`"${scheme}"`);
274+
for (const placeholder of ['PLACEHOLDER-USER', 'PLACEHOLDER-SECRET', 'PLACEHOLDER-TOKEN']) {
275+
expect(message, `${host} prints ${placeholder}`).not.toContain(placeholder);
276+
}
277+
}
278+
expect(cli).toBe(standalone!.replace(/^\[StandaloneStack\] /, ''));
279+
},
280+
);
281+
260282
// The control on the URL door: the replacement the refusals name is accepted
261283
// by both hosts, as SQLite — so the refusal is scoped to the engine, not to
262284
// the word "memory".

‎packages/cli/src/utils/storage-driver.test.ts‎

Lines changed: 60 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -61,10 +61,15 @@ describe('inferDriverTypeFromUrl', () => {
6161

6262
// The in-memory (mingo) engine's schemes are REFUSED at inference: the engine
6363
// is no longer a boot store. Asserted on the error's TYPE and on what it names
64-
// (the URL and the two SQLite replacements), never on its prose.
65-
it.each(['memory://', 'memory://ignored-host', 'mingo://', 'MINGO://x'])(
66-
'refuses %s at inference, naming the SQLite replacements',
67-
(url) => {
64+
// (the variable, the scheme and the two SQLite replacements), never on its prose.
65+
it.each([
66+
['memory://', 'memory://'],
67+
['memory://ignored-host', 'memory://'],
68+
['mingo://', 'mingo://'],
69+
['MINGO://x', 'MINGO://'],
70+
])(
71+
'refuses %s at inference, naming its scheme and the SQLite replacements',
72+
(url, scheme) => {
6873
let err: unknown;
6974
try {
7075
inferDriverTypeFromUrl(url);
@@ -75,12 +80,62 @@ describe('inferDriverTypeFromUrl', () => {
7580
const refusal = err as UnsupportedDriverError;
7681
// Not a kind this resolver produces — the allowlist pin's oracle reads this.
7782
expect(refusal.recognized).toBe(false);
78-
expect(refusal.message).toContain(`"${url}"`);
83+
expect(refusal.driverType).toBe(scheme);
84+
expect(refusal.message).toContain('OS_DATABASE_URL');
85+
expect(refusal.message).toContain(`"${scheme}"`);
7986
expect(refusal.message).toContain('--fresh');
8087
expect(refusal.message).toContain(':memory:');
8188
},
8289
);
8390

91+
// The refusal is printed to the console on the way out, and both schemes take
92+
// anything after the `//`: a `user:password@` and an `?authToken=` ride along.
93+
// It names the scheme, never the URL — in the message AND in the field a
94+
// caller may log. Placeholder credentials only.
95+
const PLACEHOLDERS = ['PLACEHOLDER-USER', 'PLACEHOLDER-SECRET', 'PLACEHOLDER-TOKEN'] as const;
96+
const CREDENTIALED_TAIL = 'PLACEHOLDER-USER:PLACEHOLDER-SECRET@127.0.0.1:6399/0?authToken=PLACEHOLDER-TOKEN';
97+
it.each(['memory://', 'mingo://'])(
98+
'refuses a %s URL carrying a user, password and token, naming the scheme and none of the three',
99+
(scheme) => {
100+
const url = `${scheme}${CREDENTIALED_TAIL}`;
101+
let err: unknown;
102+
try {
103+
inferDriverTypeFromUrl(url);
104+
} catch (e) {
105+
err = e;
106+
}
107+
expect(err, `inferDriverTypeFromUrl accepted ${scheme}…`).toBeInstanceOf(UnsupportedDriverError);
108+
const refusal = err as UnsupportedDriverError;
109+
expect(refusal.recognized).toBe(false);
110+
expect(refusal.driverType).toBe(scheme);
111+
expect(refusal.message).toContain(`"${scheme}"`);
112+
expect(refusal.message).toContain('--fresh');
113+
expect(refusal.message).toContain(':memory:');
114+
for (const placeholder of PLACEHOLDERS) {
115+
expect(refusal.message, `the refusal prints ${placeholder}`).not.toContain(placeholder);
116+
expect(refusal.driverType, `driverType carries ${placeholder}`).not.toContain(placeholder);
117+
}
118+
expect(refusal.message).not.toContain('127.0.0.1');
119+
},
120+
);
121+
122+
// CONTROL: the same credentialed tail under every other scheme classifies as
123+
// before — the redaction is scoped to the retired engine's arm, not to URLs
124+
// that carry credentials.
125+
it.each([
126+
['postgres://', 'postgres'],
127+
['postgresql://', 'postgres'],
128+
['pg://', 'postgres'],
129+
['mysql://', 'mysql'],
130+
['mysql2://', 'mysql'],
131+
['mongodb://', 'mongodb'],
132+
['mongodb+srv://', 'mongodb'],
133+
['libsql://', 'turso'],
134+
['wasm-sqlite://', 'sqlite-wasm'],
135+
])('classifies a %s URL carrying the same user, password and token as %s', (scheme, kind) => {
136+
expect(inferDriverTypeFromUrl(`${scheme}${CREDENTIALED_TAIL}`)).toBe(kind);
137+
});
138+
84139
// The sqlite `:memory:` PSEUDO-FILE is SQLite's own in-memory mode — NOT the
85140
// mingo engine. It stays `sqlite`, and it is the replacement the refusal names.
86141
it('keeps sqlite `:memory:` mapped to sqlite (the replacement, distinct from memory://)', () => {

‎packages/cli/src/utils/storage-driver.ts‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -328,10 +328,20 @@ export function inferDriverTypeFromUrl(url: string | undefined): string {
328328
// the same URLs. Kept ABOVE the sqlite test so it is not shadowed, and
329329
// deliberately distinct from sqlite's `:memory:` pseudo-file below, which is
330330
// the replacement this refusal names.
331-
if (/^(memory|mingo):\/\//i.test(u)) {
331+
//
332+
// The refusal names the SCHEME, never the URL: both schemes accept anything
333+
// after the `//`, a `user:password@` and a `?authToken=` included, and this
334+
// sentence is printed to the console on the way out. The subject is the
335+
// runtime host's own (`refuseRetiredMemoryEngine` in
336+
// `packages/runtime/src/standalone-stack.ts`, module-local there, so written
337+
// out here), and `driver-vocabulary-parity.test.ts` holds the two equal.
338+
// The scheme is also what the error carries as `driverType`, as the
339+
// unrecognised-scheme refusal below does.
340+
const retiredScheme = /^(memory|mingo):\/\//i.exec(u)?.[0];
341+
if (retiredScheme) {
332342
throw new UnsupportedDriverError(
333-
u,
334-
retiredMemoryEngineMessage(`OS_DATABASE_URL "${u}"`),
343+
retiredScheme,
344+
retiredMemoryEngineMessage(`An OS_DATABASE_URL with the "${retiredScheme}" scheme`),
335345
// Not a kind this resolver can produce — `database-driver-allowlist.pin`
336346
// reads `recognized` to tell the two apart.
337347
{ recognized: false },

0 commit comments

Comments
 (0)