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
69 changes: 69 additions & 0 deletions .changeset/banner-dsn-connection-display.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
---
"@objectstack/cli": patch
---

fix(cli): the startup banner reads a DSN-declared datasource, and stops printing credentials (#3793)

`OS_DATABASE_URL="postgres://u:p@127.0.0.1:59437/nope" os serve` printed:

```
Driver: SqlDriver(pg) → (unknown)
```

The driver was identified; the address was not. The banner exists so a
developer sees at a glance which database they are on, and it went blank
exactly when the database is unreachable and the address is the one thing worth
reading.

`describeRegisteredDriver` knew three of the four shapes a driver's
`config.connection` arrives in — a DSN string, `{ filename }` (sqlite), and
discrete `{ host, port, database }` — but not `{ connectionString }`, which is
what `defaultDatasourceDriverFactory` builds for a pg datasource declared with
`config.url` / `config.connectionString`. So *any* DSN-declared datasource read
`(unknown)`, while the same datasource spelled out in discrete fields read
fine. (`driver.config` keeps the shape its author passed — pinned by a
driver-sql test — so #3791's knex-side `{ connectionString, connectionTimeoutMillis }`
rewrite neither caused nor worsened this.)

Chasing that turned up two more `(unknown)`s with the same cause — the reader
returned as soon as a driver had a `config` at all, so it could only ever
succeed for a `SqlDriver`:

- **MongoDB** keeps its DSN in a top-level `config.url`, and `MongoDBDriver`
*does* have a `config`, so the "mongo/turso expose the URL on the instance"
arm below it was unreachable — every mongo boot banner read `(unknown)`.
- **In-memory** `config` is `{}` when none was passed, which is truthy, so the
`(in-memory)` arm was unreachable too — a preset-wired memory driver
bannered as `com.objectstack.driver.memory → (unknown)`.

Both are now read by shape rather than by which arm matched first. A related
latent one is fixed alongside: `SqliteWasmDriver` passes knex a dialect *class*
as `client`, and the label interpolated it — `SqlDriver(${cfg.client})` would
have pasted the class source into the banner. The label only interpolates a
string `client` now, and falls back to the driver's constructor name.

Both halves of the original bug are now one renderer,
`utils/connection-display.ts`:

- **`describeDriverConnection(config)`** knows all four `connection` shapes,
plus the `{ uri }` / `{ url }` mongo-family spellings, applies the same field
precedence to a top-level address, and returns `undefined` — not a guess —
for a function-valued `connection` the host builds per pool checkout.
- **`redactConnectionUrl(value)`** drops credentials rather than masking them.
The previous `//user:****@` mask left two holes: a password supplied with no
username (`postgres://:secret@host/db`) did not match its regex and printed
verbatim, and a Turso DSN carries its secret in the query string
(`libsql://….turso.io?authToken=…`), which was never touched. Userinfo *and*
query are now dropped; non-URL values (`:memory:`, a sqlite path,
`(in-memory)`) pass through untouched, and the function is idempotent.

Three byte-identical copies of the old mask (`serve` / `start` / `dev`) and a
fourth variant in `schema-migrate` collapse into that one helper. The
`schema-migrate` copy had the same DSN blindness with higher stakes: it feeds
the `Apply N change(s) to …?` confirm, where a `{ connectionString }` config
rendered as a bare `pg` — an operator was asked to approve a destructive
migration against an unnamed database.

Banner/prompt output changes accordingly — `postgres://admin:hunter2@db:5432/app`
was shown as `postgres://admin:****@db:5432/app` and is now
`postgres://db:5432/app`. Display only; no configuration or API surface moves.
11 changes: 2 additions & 9 deletions packages/cli/src/commands/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { printHeader, printKV, printStep, printError } from '../utils/format.js';
import { redactConnectionUrl } from '../utils/connection-display.js';
import { readEnvWithDeprecation, isMcpServerEnabled } from '@objectstack/types';

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

const port = flags.port ?? readEnvWithDeprecation('OS_PORT', 'PORT', { silent: true });
const binPath = process.argv[1];
Expand Down Expand Up @@ -500,11 +501,3 @@ export default class Dev extends Command {
});
}
}

function redactDbUrl(url: string): string {
try {
return url.replace(/(\/\/[^/@:]+):[^/@]+@/, '$1:****@');
} catch {
return url;
}
}
142 changes: 142 additions & 0 deletions packages/cli/src/commands/serve-driver-banner.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,142 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// framework#3793 — the startup banner's `Driver:` row.
//
// The banner exists so a developer sees at a glance which database they are
// talking to. When the driver was wired by an app preset or by
// `EnvironmentKernelFactory` rather than by serve's own `OS_DATABASE_URL`
// fallback, that row comes from probing the registered driver — and for a
// datasource declared with a DSN it read `SqlDriver(pg) → (unknown)`: the
// right driver, no address, at exactly the moment (an unreachable database)
// when the address is what you need.

import { describe, it, expect } from 'vitest';
import { describeRegisteredDriver } from './serve.js';

/** A kernel whose `getService` throws for anything not registered, like the real one. */
function fakeKernel(services: Record<string, unknown>) {
return {
getService(name: string) {
if (!(name in services)) throw new Error(`Service '${name}' not found`);
return services[name];
},
};
}

describe('describeRegisteredDriver', () => {
it('reads a DSN-declared pg datasource — the #3793 (unknown)', () => {
// Exactly what `defaultDatasourceDriverFactory` hands `new SqlDriver(...)`
// for a datasource whose config is `{ url }` / `{ connectionString }`.
const kernel = fakeKernel({
'driver.com.objectstack.driver.sql': {
config: {
client: 'pg',
connection: { connectionString: 'postgres://u:p@127.0.0.1:59437/nope' },
pool: { min: 0, max: 5 },
},
},
});
expect(describeRegisteredDriver(kernel)).toEqual({
label: 'SqlDriver(pg)',
url: 'postgres://127.0.0.1:59437/nope',
});
});

it('keeps reading the shapes it already knew', () => {
expect(describeRegisteredDriver(fakeKernel({
'driver.sql': { config: { client: 'pg', connection: 'postgres://u:p@host:5432/app' } },
}))).toEqual({ label: 'SqlDriver(pg)', url: 'postgres://host:5432/app' });

expect(describeRegisteredDriver(fakeKernel({
'driver.sql': { config: { client: 'better-sqlite3', connection: { filename: './data.db' } } },
}))).toEqual({ label: 'SqlDriver(better-sqlite3)', url: './data.db' });

expect(describeRegisteredDriver(fakeKernel({
'driver.sql': { config: { client: 'pg', connection: { host: 'db.example.com', port: 5432, database: 'app' } } },
}))).toEqual({ label: 'SqlDriver(pg)', url: 'db.example.com:5432/app' });
});

it('never puts credentials in the banner', () => {
for (const connection of [
'postgres://admin:hunter2@db.example.com:5432/app',
{ connectionString: 'postgres://admin:hunter2@db.example.com:5432/app' },
{ host: 'db.example.com', port: 5432, database: 'app', user: 'admin', password: 'hunter2' },
]) {
const probe = describeRegisteredDriver(fakeKernel({
'driver.sql': { config: { client: 'pg', connection } },
}));
expect(probe?.url).not.toContain('hunter2');
}
});

it('reads the top-level url a MongoDBDriver keeps on its config', () => {
// Every driver has a `config` property (MongoDBDriver's is `{ url, … }`,
// InMemoryDriver's is `{}`), so the old "has a config → it's a SqlDriver"
// early return meant *every* mongo boot banner read `(unknown)`.
const kernel = fakeKernel({
'driver.com.objectstack.driver.mongodb': {
constructor: { name: 'MongoDBDriver' },
name: 'com.objectstack.driver.mongodb',
config: { url: 'mongodb://admin:hunter2@cluster0.mongodb.net/app', database: 'app' },
},
});
expect(describeRegisteredDriver(kernel)).toEqual({
label: 'MongoDBDriver',
url: 'mongodb://cluster0.mongodb.net/app',
});
});

it('redacts the URL a driver exposes on the instance instead', () => {
const kernel = fakeKernel({
'driver.com.objectstack.driver.turso': {
constructor: { name: 'TursoDriver' },
url: 'libsql://app-org.turso.io?authToken=secret-jwt',
},
});
expect(describeRegisteredDriver(kernel)).toEqual({
label: 'TursoDriver',
url: 'libsql://app-org.turso.io',
});
});

it('still says (unknown) when the config genuinely carries no address', () => {
const kernel = fakeKernel({
// knex lets the host build a connection per pool checkout; there is
// nothing to read until it is called.
'driver.sql': { config: { client: 'pg', connection: () => ({ host: 'h' }) } },
});
expect(describeRegisteredDriver(kernel)).toEqual({ label: 'SqlDriver(pg)', url: '(unknown)' });
});

it('labels an in-memory driver "(in-memory)", not "(unknown)"', () => {
const kernel = fakeKernel({
// InMemoryDriver always has a `config` — `{}` when none was passed.
'driver.memory': {
constructor: { name: 'InMemoryDriver' },
name: 'com.objectstack.driver.memory',
config: {},
},
});
expect(describeRegisteredDriver(kernel)).toEqual({ label: 'InMemoryDriver', url: '(in-memory)' });
});

it('does not paste a Client class into the label (SqliteWasmDriver)', () => {
// SqliteWasmDriver passes knex a dialect *class* as `client`; interpolating
// it would put the class source in the banner.
class Client_WasmSqlite {}
const kernel = fakeKernel({
'driver.sql': {
constructor: { name: 'SqliteWasmDriver' },
config: { client: Client_WasmSqlite, connection: { filename: './app.wasm.db' } },
},
});
expect(describeRegisteredDriver(kernel)).toEqual({
label: 'SqliteWasmDriver',
url: './app.wasm.db',
});
});

it('returns null when no known driver is registered', () => {
expect(describeRegisteredDriver(fakeKernel({}))).toBeNull();
});
});
72 changes: 33 additions & 39 deletions packages/cli/src/commands/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import { PLATFORM_CAPABILITY_TOKENS } from '@objectstack/spec/kernel';
import { missingProviderMessage } from '../utils/capability-preflight.js';
import { resolveObjectStackHome } from '@objectstack/runtime';
import { LOG_LEVELS, resolveLogLevel, readLogLevelEnv } from '../utils/log-level.js';
import { redactConnectionUrl, describeDriverConnection } from '../utils/connection-display.js';
import {
printHeader,
printKV,
Expand Down Expand Up @@ -544,18 +545,13 @@ export default class Serve extends Command {
};
const trackPlugin = (name: string) => { loadedPlugins.push(shortPluginName(name)); };

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

// Save original console/stdout methods — we'll suppress noise during boot
const originalConsoleLog = console.log;
Expand Down Expand Up @@ -2370,7 +2366,7 @@ export default class Serve extends Command {
// never accept a request — shutdown immediately so the deploy
// pipeline can move on.
if (process.env.OS_MIGRATE_AND_EXIT === '1') {
console.log(chalk.green(`✓ Migration complete (${loadedPlugins.length} plugins started against ${redactDbUrl(resolvedDatabaseUrl) || 'configured DB'})`));
console.log(chalk.green(`✓ Migration complete (${loadedPlugins.length} plugins started against ${resolvedDatabaseUrl ? redactConnectionUrl(resolvedDatabaseUrl) : 'configured DB'})`));
try {
await kernel.shutdown();
} catch (err: any) {
Expand Down Expand Up @@ -2443,7 +2439,7 @@ export default class Serve extends Command {
uiEnabled: enableUI,
consolePath: loadedPlugins.includes('ConsoleUI') ? CONSOLE_PATH : undefined,
driverLabel: resolvedDriverLabel,
databaseUrl: redactDbUrl(resolvedDatabaseUrl),
databaseUrl: resolvedDatabaseUrl ? redactConnectionUrl(resolvedDatabaseUrl) : undefined,
multiTenant: resolveMultiOrgEnabled(),
seededAdmin,
automation: automationSummary,
Expand Down Expand Up @@ -2508,8 +2504,13 @@ export default class Serve extends Command {
* (e.g. when the example app's preset or `EnvironmentKernelFactory` wired
* it). Returns `null` when nothing matches; the caller treats that as
* "no driver info available" and skips the line.
*
* Reading the address out of a driver is `describeDriverConnection`'s job
* (#3793) — it is the one place that knows every shape a driver config
* arrives in, so a DSN-declared datasource no longer falls through to
* `(unknown)`, and no shape prints credentials.
*/
function describeRegisteredDriver(kernel: any): { label: string; url: string } | null {
export function describeRegisteredDriver(kernel: any): { label: string; url: string } | null {
const candidates = [
'driver.com.objectstack.driver.sql',
'driver.com.objectstack.driver.mongodb',
Expand All @@ -2522,33 +2523,26 @@ function describeRegisteredDriver(kernel: any): { label: string; url: string } |
try { driver = kernel?.getService?.(name); } catch { /* not registered */ }
if (!driver) continue;

// SqlDriver: `{ client, connection: string | { filename, host, ... } }`
const cfg = driver.config;
if (cfg) {
const client = cfg.client;
const conn = cfg.connection;
let url = '';
if (typeof conn === 'string') {
url = conn;
} else if (conn && typeof conn === 'object') {
url = conn.filename
?? (conn.host ? `${conn.host}${conn.port ? `:${conn.port}` : ''}${conn.database ? `/${conn.database}` : ''}` : '');
}
const label = client ? `SqlDriver(${client})` : (driver.name ?? 'SqlDriver');
return { label, url: url || '(unknown)' };
}

// MongoDB / Turso drivers expose the URL on the instance itself.
if (driver.url) {
const label = driver.constructor?.name ?? driver.name ?? 'Driver';
return { label, url: String(driver.url) };
}

// InMemoryDriver — no URL.
return {
label: driver.constructor?.name ?? driver.name ?? 'Driver',
url: '(in-memory)',
};
// `client` is a plain string for the stock knex dialects, but a Client
// *class* for SqliteWasmDriver — interpolating that would paste the whole
// class source into the banner.
const label = typeof cfg?.client === 'string'
? `SqlDriver(${cfg.client})`
: (driver.constructor?.name ?? driver.name ?? 'Driver');

// Read the address by shape, not by which branch matches first. Every
// driver here has a `config` property — SqlDriver's is knex-shaped,
// MongoDBDriver's holds a top-level `url`, InMemoryDriver's is `{}` — so
// returning early on "has a config" meant the mongo and in-memory arms
// below never ran and every non-SqlDriver boot banner read `(unknown)`.
// `driver.url` still covers a driver that keeps its DSN on the instance.
const url = describeDriverConnection(cfg)
?? (typeof driver.url === 'string' && driver.url ? redactConnectionUrl(driver.url) : undefined);

// A memory driver has no address to show — say so, rather than
// `(unknown)`, which reads as "we looked for one and failed".
return { label, url: url ?? (name.endsWith('.memory') ? '(in-memory)' : '(unknown)') };
}
return null;
}
Expand Down
11 changes: 2 additions & 9 deletions packages/cli/src/commands/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import fs from 'fs';
import os from 'os';
import path from 'path';
import { printHeader, printKV, printStep, printError } from '../utils/format.js';
import { redactConnectionUrl } from '../utils/connection-display.js';
import { readEnvWithDeprecation } from '@objectstack/types';

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

function redactDbUrl(url: string): string {
try {
return url.replace(/(\/\/[^/@:]+):[^/@]+@/, '$1:****@');
} catch {
return url;
}
}

/**
* Read the persisted AUTH_SECRET from `<home>/auth-secret`, or generate
* one on first run and persist it so subsequent restarts keep existing
Expand Down
Loading
Loading