Skip to content

Commit 4c0df37

Browse files
authored
fix(cli): the startup banner reads a DSN-declared datasource, and stops printing credentials (#3793) (#3819)
`describeRegisteredDriver` knew three of the four shapes a driver's `config.connection` arrives in, but not `{ connectionString }` — what `defaultDatasourceDriverFactory` builds for a pg datasource declared with a DSN — so the banner read `SqlDriver(pg) → (unknown)`. Two more `(unknown)`s had the same cause: the reader returned as soon as a driver had a `config` at all, so only a SqlDriver could ever succeed. MongoDBDriver keeps its DSN in a top-level `config.url` and does have a `config`; InMemoryDriver's `config` is `{}`, which is truthy. Both arms below were unreachable. A latent label bug goes with them: SqliteWasmDriver passes knex a dialect class as `client`, which the label would have interpolated. Both halves are now one renderer, `utils/connection-display.ts`. `redactConnectionUrl` drops credentials rather than masking them — the old `//user:****@` mask missed a password with no username and never touched the query string, where a Turso DSN keeps its authToken. Three byte-identical copies of that mask (serve / start / dev) and a fourth variant in `schema-migrate` collapse into the shared helper; the `schema-migrate` copy had the same DSN blindness feeding the `Apply N change(s) to …?` confirm. Closes #3793.
1 parent 0c8a22f commit 4c0df37

8 files changed

Lines changed: 485 additions & 74 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
"@objectstack/cli": patch
3+
---
4+
5+
fix(cli): the startup banner reads a DSN-declared datasource, and stops printing credentials (#3793)
6+
7+
`OS_DATABASE_URL="postgres://u:p@127.0.0.1:59437/nope" os serve` printed:
8+
9+
```
10+
Driver: SqlDriver(pg) → (unknown)
11+
```
12+
13+
The driver was identified; the address was not. The banner exists so a
14+
developer sees at a glance which database they are on, and it went blank
15+
exactly when the database is unreachable and the address is the one thing worth
16+
reading.
17+
18+
`describeRegisteredDriver` knew three of the four shapes a driver's
19+
`config.connection` arrives in — a DSN string, `{ filename }` (sqlite), and
20+
discrete `{ host, port, database }` — but not `{ connectionString }`, which is
21+
what `defaultDatasourceDriverFactory` builds for a pg datasource declared with
22+
`config.url` / `config.connectionString`. So *any* DSN-declared datasource read
23+
`(unknown)`, while the same datasource spelled out in discrete fields read
24+
fine. (`driver.config` keeps the shape its author passed — pinned by a
25+
driver-sql test — so #3791's knex-side `{ connectionString, connectionTimeoutMillis }`
26+
rewrite neither caused nor worsened this.)
27+
28+
Chasing that turned up two more `(unknown)`s with the same cause — the reader
29+
returned as soon as a driver had a `config` at all, so it could only ever
30+
succeed for a `SqlDriver`:
31+
32+
- **MongoDB** keeps its DSN in a top-level `config.url`, and `MongoDBDriver`
33+
*does* have a `config`, so the "mongo/turso expose the URL on the instance"
34+
arm below it was unreachable — every mongo boot banner read `(unknown)`.
35+
- **In-memory** `config` is `{}` when none was passed, which is truthy, so the
36+
`(in-memory)` arm was unreachable too — a preset-wired memory driver
37+
bannered as `com.objectstack.driver.memory → (unknown)`.
38+
39+
Both are now read by shape rather than by which arm matched first. A related
40+
latent one is fixed alongside: `SqliteWasmDriver` passes knex a dialect *class*
41+
as `client`, and the label interpolated it — `SqlDriver(${cfg.client})` would
42+
have pasted the class source into the banner. The label only interpolates a
43+
string `client` now, and falls back to the driver's constructor name.
44+
45+
Both halves of the original bug are now one renderer,
46+
`utils/connection-display.ts`:
47+
48+
- **`describeDriverConnection(config)`** knows all four `connection` shapes,
49+
plus the `{ uri }` / `{ url }` mongo-family spellings, applies the same field
50+
precedence to a top-level address, and returns `undefined` — not a guess —
51+
for a function-valued `connection` the host builds per pool checkout.
52+
- **`redactConnectionUrl(value)`** drops credentials rather than masking them.
53+
The previous `//user:****@` mask left two holes: a password supplied with no
54+
username (`postgres://:secret@host/db`) did not match its regex and printed
55+
verbatim, and a Turso DSN carries its secret in the query string
56+
(`libsql://….turso.io?authToken=…`), which was never touched. Userinfo *and*
57+
query are now dropped; non-URL values (`:memory:`, a sqlite path,
58+
`(in-memory)`) pass through untouched, and the function is idempotent.
59+
60+
Three byte-identical copies of the old mask (`serve` / `start` / `dev`) and a
61+
fourth variant in `schema-migrate` collapse into that one helper. The
62+
`schema-migrate` copy had the same DSN blindness with higher stakes: it feeds
63+
the `Apply N change(s) to …?` confirm, where a `{ connectionString }` config
64+
rendered as a bare `pg` — an operator was asked to approve a destructive
65+
migration against an unnamed database.
66+
67+
Banner/prompt output changes accordingly — `postgres://admin:hunter2@db:5432/app`
68+
was shown as `postgres://admin:****@db:5432/app` and is now
69+
`postgres://db:5432/app`. Display only; no configuration or API surface moves.

packages/cli/src/commands/dev.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import fs from 'fs';
88
import os from 'os';
99
import path from 'path';
1010
import { printHeader, printKV, printStep, printError } from '../utils/format.js';
11+
import { redactConnectionUrl } from '../utils/connection-display.js';
1112
import { readEnvWithDeprecation, isMcpServerEnabled } from '@objectstack/types';
1213

1314
/**
@@ -252,7 +253,7 @@ export default class Dev extends Command {
252253
};
253254
printKV('Environment ID', environmentId, '🎯');
254255
printKV('Artifact', isUrl ? artifactPath : path.relative(process.cwd(), artifactPath), '📦');
255-
if (effectiveDb) printKV('Database', redactDbUrl(effectiveDb), '🗄️');
256+
if (effectiveDb) printKV('Database', redactConnectionUrl(effectiveDb), '🗄️');
256257

257258
const port = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true });
258259
const binPath = process.argv[1];
@@ -500,11 +501,3 @@ export default class Dev extends Command {
500501
});
501502
}
502503
}
503-
504-
function redactDbUrl(url: string): string {
505-
try {
506-
return url.replace(/(\/\/[^/@:]+):[^/@]+@/, '$1:****@');
507-
} catch {
508-
return url;
509-
}
510-
}
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// framework#3793 — the startup banner's `Driver:` row.
4+
//
5+
// The banner exists so a developer sees at a glance which database they are
6+
// talking to. When the driver was wired by an app preset or by
7+
// `EnvironmentKernelFactory` rather than by serve's own `OS_DATABASE_URL`
8+
// fallback, that row comes from probing the registered driver — and for a
9+
// datasource declared with a DSN it read `SqlDriver(pg) → (unknown)`: the
10+
// right driver, no address, at exactly the moment (an unreachable database)
11+
// when the address is what you need.
12+
13+
import { describe, it, expect } from 'vitest';
14+
import { describeRegisteredDriver } from './serve.js';
15+
16+
/** A kernel whose `getService` throws for anything not registered, like the real one. */
17+
function fakeKernel(services: Record<string, unknown>) {
18+
return {
19+
getService(name: string) {
20+
if (!(name in services)) throw new Error(`Service '${name}' not found`);
21+
return services[name];
22+
},
23+
};
24+
}
25+
26+
describe('describeRegisteredDriver', () => {
27+
it('reads a DSN-declared pg datasource — the #3793 (unknown)', () => {
28+
// Exactly what `defaultDatasourceDriverFactory` hands `new SqlDriver(...)`
29+
// for a datasource whose config is `{ url }` / `{ connectionString }`.
30+
const kernel = fakeKernel({
31+
'driver.com.objectstack.driver.sql': {
32+
config: {
33+
client: 'pg',
34+
connection: { connectionString: 'postgres://u:p@127.0.0.1:59437/nope' },
35+
pool: { min: 0, max: 5 },
36+
},
37+
},
38+
});
39+
expect(describeRegisteredDriver(kernel)).toEqual({
40+
label: 'SqlDriver(pg)',
41+
url: 'postgres://127.0.0.1:59437/nope',
42+
});
43+
});
44+
45+
it('keeps reading the shapes it already knew', () => {
46+
expect(describeRegisteredDriver(fakeKernel({
47+
'driver.sql': { config: { client: 'pg', connection: 'postgres://u:p@host:5432/app' } },
48+
}))).toEqual({ label: 'SqlDriver(pg)', url: 'postgres://host:5432/app' });
49+
50+
expect(describeRegisteredDriver(fakeKernel({
51+
'driver.sql': { config: { client: 'better-sqlite3', connection: { filename: './data.db' } } },
52+
}))).toEqual({ label: 'SqlDriver(better-sqlite3)', url: './data.db' });
53+
54+
expect(describeRegisteredDriver(fakeKernel({
55+
'driver.sql': { config: { client: 'pg', connection: { host: 'db.example.com', port: 5432, database: 'app' } } },
56+
}))).toEqual({ label: 'SqlDriver(pg)', url: 'db.example.com:5432/app' });
57+
});
58+
59+
it('never puts credentials in the banner', () => {
60+
for (const connection of [
61+
'postgres://admin:hunter2@db.example.com:5432/app',
62+
{ connectionString: 'postgres://admin:hunter2@db.example.com:5432/app' },
63+
{ host: 'db.example.com', port: 5432, database: 'app', user: 'admin', password: 'hunter2' },
64+
]) {
65+
const probe = describeRegisteredDriver(fakeKernel({
66+
'driver.sql': { config: { client: 'pg', connection } },
67+
}));
68+
expect(probe?.url).not.toContain('hunter2');
69+
}
70+
});
71+
72+
it('reads the top-level url a MongoDBDriver keeps on its config', () => {
73+
// Every driver has a `config` property (MongoDBDriver's is `{ url, … }`,
74+
// InMemoryDriver's is `{}`), so the old "has a config → it's a SqlDriver"
75+
// early return meant *every* mongo boot banner read `(unknown)`.
76+
const kernel = fakeKernel({
77+
'driver.com.objectstack.driver.mongodb': {
78+
constructor: { name: 'MongoDBDriver' },
79+
name: 'com.objectstack.driver.mongodb',
80+
config: { url: 'mongodb://admin:hunter2@cluster0.mongodb.net/app', database: 'app' },
81+
},
82+
});
83+
expect(describeRegisteredDriver(kernel)).toEqual({
84+
label: 'MongoDBDriver',
85+
url: 'mongodb://cluster0.mongodb.net/app',
86+
});
87+
});
88+
89+
it('redacts the URL a driver exposes on the instance instead', () => {
90+
const kernel = fakeKernel({
91+
'driver.com.objectstack.driver.turso': {
92+
constructor: { name: 'TursoDriver' },
93+
url: 'libsql://app-org.turso.io?authToken=secret-jwt',
94+
},
95+
});
96+
expect(describeRegisteredDriver(kernel)).toEqual({
97+
label: 'TursoDriver',
98+
url: 'libsql://app-org.turso.io',
99+
});
100+
});
101+
102+
it('still says (unknown) when the config genuinely carries no address', () => {
103+
const kernel = fakeKernel({
104+
// knex lets the host build a connection per pool checkout; there is
105+
// nothing to read until it is called.
106+
'driver.sql': { config: { client: 'pg', connection: () => ({ host: 'h' }) } },
107+
});
108+
expect(describeRegisteredDriver(kernel)).toEqual({ label: 'SqlDriver(pg)', url: '(unknown)' });
109+
});
110+
111+
it('labels an in-memory driver "(in-memory)", not "(unknown)"', () => {
112+
const kernel = fakeKernel({
113+
// InMemoryDriver always has a `config` — `{}` when none was passed.
114+
'driver.memory': {
115+
constructor: { name: 'InMemoryDriver' },
116+
name: 'com.objectstack.driver.memory',
117+
config: {},
118+
},
119+
});
120+
expect(describeRegisteredDriver(kernel)).toEqual({ label: 'InMemoryDriver', url: '(in-memory)' });
121+
});
122+
123+
it('does not paste a Client class into the label (SqliteWasmDriver)', () => {
124+
// SqliteWasmDriver passes knex a dialect *class* as `client`; interpolating
125+
// it would put the class source in the banner.
126+
class Client_WasmSqlite {}
127+
const kernel = fakeKernel({
128+
'driver.sql': {
129+
constructor: { name: 'SqliteWasmDriver' },
130+
config: { client: Client_WasmSqlite, connection: { filename: './app.wasm.db' } },
131+
},
132+
});
133+
expect(describeRegisteredDriver(kernel)).toEqual({
134+
label: 'SqliteWasmDriver',
135+
url: './app.wasm.db',
136+
});
137+
});
138+
139+
it('returns null when no known driver is registered', () => {
140+
expect(describeRegisteredDriver(fakeKernel({}))).toBeNull();
141+
});
142+
});

packages/cli/src/commands/serve.ts

Lines changed: 33 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { PLATFORM_CAPABILITY_TOKENS } from '@objectstack/spec/kernel';
1414
import { missingProviderMessage } from '../utils/capability-preflight.js';
1515
import { resolveObjectStackHome } from '@objectstack/runtime';
1616
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
17+
import { redactConnectionUrl, describeDriverConnection } from '../utils/connection-display.js';
1718
import {
1819
printHeader,
1920
printKV,
@@ -544,18 +545,13 @@ export default class Serve extends Command {
544545
};
545546
const trackPlugin = (name: string) => { loadedPlugins.push(shortPluginName(name)); };
546547

547-
// Track resolved storage driver + redacted URL for the startup banner.
548+
// Track resolved storage driver + connection target for the startup banner.
549+
// The value lands here raw when it came from this command's own
550+
// OS_DATABASE_URL fallback and already-redacted when it came from probing a
551+
// registered driver, so it is redacted at print time — `redactConnectionUrl`
552+
// is idempotent, so the second pass over an already-clean URL is a no-op.
548553
let resolvedDriverLabel: string | undefined;
549554
let resolvedDatabaseUrl: string | undefined;
550-
const redactDbUrl = (url: string | undefined): string | undefined => {
551-
if (!url) return undefined;
552-
try {
553-
// Redact passwords inside connection URLs: protocol://user:****@host/db
554-
return url.replace(/(\/\/[^/@:]+):[^/@]+@/, '$1:****@');
555-
} catch {
556-
return url;
557-
}
558-
};
559555

560556
// Save original console/stdout methods — we'll suppress noise during boot
561557
const originalConsoleLog = console.log;
@@ -2369,7 +2365,7 @@ export default class Serve extends Command {
23692365
// never accept a request — shutdown immediately so the deploy
23702366
// pipeline can move on.
23712367
if (process.env.OS_MIGRATE_AND_EXIT === '1') {
2372-
console.log(chalk.green(`✓ Migration complete (${loadedPlugins.length} plugins started against ${redactDbUrl(resolvedDatabaseUrl) || 'configured DB'})`));
2368+
console.log(chalk.green(`✓ Migration complete (${loadedPlugins.length} plugins started against ${resolvedDatabaseUrl ? redactConnectionUrl(resolvedDatabaseUrl) : 'configured DB'})`));
23732369
try {
23742370
await kernel.shutdown();
23752371
} catch (err: any) {
@@ -2442,7 +2438,7 @@ export default class Serve extends Command {
24422438
uiEnabled: enableUI,
24432439
consolePath: loadedPlugins.includes('ConsoleUI') ? CONSOLE_PATH : undefined,
24442440
driverLabel: resolvedDriverLabel,
2445-
databaseUrl: redactDbUrl(resolvedDatabaseUrl),
2441+
databaseUrl: resolvedDatabaseUrl ? redactConnectionUrl(resolvedDatabaseUrl) : undefined,
24462442
multiTenant: resolveMultiOrgEnabled(),
24472443
seededAdmin,
24482444
automation: automationSummary,
@@ -2507,8 +2503,13 @@ export default class Serve extends Command {
25072503
* (e.g. when the example app's preset or `EnvironmentKernelFactory` wired
25082504
* it). Returns `null` when nothing matches; the caller treats that as
25092505
* "no driver info available" and skips the line.
2506+
*
2507+
* Reading the address out of a driver is `describeDriverConnection`'s job
2508+
* (#3793) — it is the one place that knows every shape a driver config
2509+
* arrives in, so a DSN-declared datasource no longer falls through to
2510+
* `(unknown)`, and no shape prints credentials.
25102511
*/
2511-
function describeRegisteredDriver(kernel: any): { label: string; url: string } | null {
2512+
export function describeRegisteredDriver(kernel: any): { label: string; url: string } | null {
25122513
const candidates = [
25132514
'driver.com.objectstack.driver.sql',
25142515
'driver.com.objectstack.driver.mongodb',
@@ -2521,33 +2522,26 @@ function describeRegisteredDriver(kernel: any): { label: string; url: string } |
25212522
try { driver = kernel?.getService?.(name); } catch { /* not registered */ }
25222523
if (!driver) continue;
25232524

2524-
// SqlDriver: `{ client, connection: string | { filename, host, ... } }`
25252525
const cfg = driver.config;
2526-
if (cfg) {
2527-
const client = cfg.client;
2528-
const conn = cfg.connection;
2529-
let url = '';
2530-
if (typeof conn === 'string') {
2531-
url = conn;
2532-
} else if (conn && typeof conn === 'object') {
2533-
url = conn.filename
2534-
?? (conn.host ? `${conn.host}${conn.port ? `:${conn.port}` : ''}${conn.database ? `/${conn.database}` : ''}` : '');
2535-
}
2536-
const label = client ? `SqlDriver(${client})` : (driver.name ?? 'SqlDriver');
2537-
return { label, url: url || '(unknown)' };
2538-
}
2539-
2540-
// MongoDB / Turso drivers expose the URL on the instance itself.
2541-
if (driver.url) {
2542-
const label = driver.constructor?.name ?? driver.name ?? 'Driver';
2543-
return { label, url: String(driver.url) };
2544-
}
2545-
2546-
// InMemoryDriver — no URL.
2547-
return {
2548-
label: driver.constructor?.name ?? driver.name ?? 'Driver',
2549-
url: '(in-memory)',
2550-
};
2526+
// `client` is a plain string for the stock knex dialects, but a Client
2527+
// *class* for SqliteWasmDriver — interpolating that would paste the whole
2528+
// class source into the banner.
2529+
const label = typeof cfg?.client === 'string'
2530+
? `SqlDriver(${cfg.client})`
2531+
: (driver.constructor?.name ?? driver.name ?? 'Driver');
2532+
2533+
// Read the address by shape, not by which branch matches first. Every
2534+
// driver here has a `config` property — SqlDriver's is knex-shaped,
2535+
// MongoDBDriver's holds a top-level `url`, InMemoryDriver's is `{}` — so
2536+
// returning early on "has a config" meant the mongo and in-memory arms
2537+
// below never ran and every non-SqlDriver boot banner read `(unknown)`.
2538+
// `driver.url` still covers a driver that keeps its DSN on the instance.
2539+
const url = describeDriverConnection(cfg)
2540+
?? (typeof driver.url === 'string' && driver.url ? redactConnectionUrl(driver.url) : undefined);
2541+
2542+
// A memory driver has no address to show — say so, rather than
2543+
// `(unknown)`, which reads as "we looked for one and failed".
2544+
return { label, url: url ?? (name.endsWith('.memory') ? '(in-memory)' : '(unknown)') };
25512545
}
25522546
return null;
25532547
}

packages/cli/src/commands/start.ts

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import fs from 'fs';
99
import os from 'os';
1010
import path from 'path';
1111
import { printHeader, printKV, printStep, printError } from '../utils/format.js';
12+
import { redactConnectionUrl } from '../utils/connection-display.js';
1213
import { readEnvWithDeprecation } from '@objectstack/types';
1314

1415
/**
@@ -209,7 +210,7 @@ export default class Start extends Command {
209210
} else {
210211
printKV('Artifact', 'none (empty kernel — install apps via the Console marketplace)', '📦');
211212
}
212-
printKV('Database', redactDbUrl(databaseUrl), '🗄️');
213+
printKV('Database', redactConnectionUrl(databaseUrl), '🗄️');
213214
printKV('Environment', environmentId, '🎯');
214215
// Resolve the port the child `serve` will actually bind, matching its
215216
// flag default (`--port` > $OS_PORT/$PORT > 3000). Using `flags.port`
@@ -325,14 +326,6 @@ function resolveArtifactSource(flagValue: string | undefined, homeDir: string):
325326
return undefined;
326327
}
327328

328-
function redactDbUrl(url: string): string {
329-
try {
330-
return url.replace(/(\/\/[^/@:]+):[^/@]+@/, '$1:****@');
331-
} catch {
332-
return url;
333-
}
334-
}
335-
336329
/**
337330
* Read the persisted AUTH_SECRET from `<home>/auth-secret`, or generate
338331
* one on first run and persist it so subsequent restarts keep existing

0 commit comments

Comments
 (0)