From b5a94a7855789cb80d939b55416a28e3505e47aa Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 18:22:57 +0000 Subject: [PATCH 1/4] fix(cli): the connection label removes a password wherever the shared URL grammar reads one Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .../cli/src/utils/connection-display.test.ts | 78 +++++++++++++++++++ packages/cli/src/utils/connection-display.ts | 27 +++++-- 2 files changed, 98 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/utils/connection-display.test.ts b/packages/cli/src/utils/connection-display.test.ts index 020dde4d0d3..358b6780a7a 100644 --- a/packages/cli/src/utils/connection-display.test.ts +++ b/packages/cli/src/utils/connection-display.test.ts @@ -12,6 +12,7 @@ // shape leaks the credentials the DSN carries. import { describe, it, expect } from 'vitest'; +import { urlUserinfoPassword } from '@objectstack/spec/data'; import { redactConnectionUrl, describeDriverConnection, describeDriverSqliteFile } from './connection-display.js'; describe('redactConnectionUrl', () => { @@ -56,6 +57,83 @@ describe('redactConnectionUrl', () => { }); }); +// The label is a print-time reader of the same question the datasource doors +// answer: where does a URL carry a password? It asks the platform's ONE +// userinfo grammar (`@objectstack/spec/data`) rather than keeping its own, so +// the oracle below is that grammar's own password accessor. +describe('redactConnectionUrl — the password rule is the shared URL grammar', () => { + const PASSWORD = 'S3CRET-PW'; + + it('removes the password wherever the shared grammar reads one, with or without a literal `://`', () => { + // A scheme-relative authority. + expect(redactConnectionUrl(`//admin:${PASSWORD}@db.example.com:5432/app`)) + .toBe('//admin@db.example.com:5432/app'); + // A tab inside the `//`, which a URL parser deletes before it reads. + expect(redactConnectionUrl(`postgres:/\t/admin:${PASSWORD}@db.example.com/app`)) + .toBe('postgres:/\t/admin@db.example.com/app'); + }); + + it('removes the whole password up to the last `@` of the authority, as the shared grammar bounds it', () => { + expect(redactConnectionUrl('postgres://admin:S3C@RET-PW@h1:5432,h2:5432/app')) + .toBe('postgres://h1:5432,h2:5432/app'); + }); + + it('never prints a password the shared grammar reads, in any URL shape', () => { + const shapes = [ + `postgres://admin:${PASSWORD}@db.example.com:5432/app`, + `postgres://:${PASSWORD}@db.example.com/app`, + `mysql://admin:${PASSWORD}@db.example.com:3306/app`, + `libsql://admin:${PASSWORD}@app-org.turso.io?authToken=jwt`, + `postgres://admin:${PASSWORD}@h1:5432,h2:5432/app`, + `//admin:${PASSWORD}@db.example.com:5432/app`, + `postgres:/\t/admin:${PASSWORD}@db.example.com/app`, + ` \npostgres://admin:${PASSWORD}@db.example.com/app`, + // Special schemes without the `//`: the label prints exactly what the + // shared grammar reads here, so it follows that grammar's reading of + // these shapes without an edit of its own. + `https:admin:${PASSWORD}@db.example.com/app`, + `http:/admin:${PASSWORD}@db.example.com/app`, + `wss:\\admin:${PASSWORD}@db.example.com/app`, + ]; + const judged = shapes.filter((value) => urlUserinfoPassword(value) !== undefined); + // Non-vacuity floor: the first eight shapes carry a password on every + // reading of the grammar, so the property below is never checked over nothing. + expect(judged.length).toBeGreaterThanOrEqual(8); + for (const value of judged) { + expect(urlUserinfoPassword(value)).toBe(PASSWORD); + expect(redactConnectionUrl(value), value).not.toContain(PASSWORD); + } + }); + + it('keeps its output byte for byte for every value the shared grammar reads no password in', () => { + const controls: ReadonlyArray = [ + ['postgres://admin@db.example.com:5432/app', 'postgres://db.example.com:5432/app'], + ['postgres://admin:@db.example.com/app', 'postgres://db.example.com/app'], + ['postgres://db.example.com:5432/app?sslmode=require', 'postgres://db.example.com:5432/app'], + ['postgres://admin@h1:5432,h2:5432/app', 'postgres://h1:5432,h2:5432/app'], + ['libsql://app-org.turso.io?authToken=secret-jwt', 'libsql://app-org.turso.io'], + ['https://db.example.com/app', 'https://db.example.com/app'], + ['file:./data/app.db', 'file:./data/app.db'], + ['file:///var/lib/app.db', 'file:///var/lib/app.db'], + ['sqlite:/var/lib/app.db', 'sqlite:/var/lib/app.db'], + ['//admin@db.example.com/app', '//admin@db.example.com/app'], + [':memory:', ':memory:'], + ['(in-memory)', '(in-memory)'], + ]; + for (const [value, printed] of controls) { + expect(urlUserinfoPassword(value), value).toBeUndefined(); + expect(redactConnectionUrl(value), value).toBe(printed); + } + }); + + it('keeps its output byte for byte for a password the URL parser already removed', () => { + expect(redactConnectionUrl(`postgres://admin:${PASSWORD}@db.example.com:5432/app`)) + .toBe('postgres://db.example.com:5432/app'); + expect(redactConnectionUrl(`libsql://admin:${PASSWORD}@app-org.turso.io?authToken=jwt`)) + .toBe('libsql://app-org.turso.io'); + }); +}); + describe('describeDriverConnection', () => { it('reads a DSN string', () => { expect(describeDriverConnection({ client: 'pg', connection: 'postgres://u:p@host:5432/app' })) diff --git a/packages/cli/src/utils/connection-display.ts b/packages/cli/src/utils/connection-display.ts index 66ca3a885ea..ce9bfb3eb09 100644 --- a/packages/cli/src/utils/connection-display.ts +++ b/packages/cli/src/utils/connection-display.ts @@ -24,18 +24,31 @@ * that the driver label and the host don't already say. */ +import { redactUrlPassword } from '@objectstack/spec/data'; + /** * Strip credentials from a connection string. * - * Values that aren't URLs — a sqlite path, `:memory:`, the `(in-memory)` - * marker — pass through untouched, as does anything the URL parser rejects - * (minus its userinfo segment). Idempotent, so a call site that can't tell - * whether its input was already redacted may apply it defensively. + * The password goes first, and by the platform's ONE userinfo grammar + * (`redactUrlPassword` from `@objectstack/spec/data`, the datasource read + * door's redactor): wherever that grammar reads a password — the string judged + * as a URL parser cleans it — the label drops it, so the label + * cannot print a password the doors would refuse or redact. No second + * userinfo grammar lives here; when the shared one reads a userinfo in more + * shapes, this label follows without an edit. + * + * Then the display shaping, unchanged: a value carrying `://` loses its + * username and query string through the URL parser, or its userinfo segment + * by hand when the parser rejects it. Values that aren't URLs — a sqlite path, + * `:memory:`, the `(in-memory)` marker — pass through untouched unless the + * shared grammar read a password in them. Idempotent, so a call site that + * can't tell whether its input was already redacted may apply it defensively. */ export function redactConnectionUrl(value: string): string { - if (!value.includes('://')) return value; + const withoutPassword = redactUrlPassword(value); + if (!withoutPassword.includes('://')) return withoutPassword; try { - const url = new URL(value); + const url = new URL(withoutPassword); url.username = ''; url.password = ''; // Query params are where tokens hide (`libsql://….turso.io?authToken=…`), @@ -45,7 +58,7 @@ export function redactConnectionUrl(value: string): string { } catch { // Not parseable (a driver-specific dialect, a malformed URL). Drop the // userinfo segment by hand rather than printing the whole thing. - return value.replace(/:\/\/[^@/]*@/, '://'); + return withoutPassword.replace(/:\/\/[^@/]*@/, '://'); } } From 4855c1cb8c209c1f58dc4e21727be2df41a20435 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 18:35:59 +0000 Subject: [PATCH 2/4] chore(changeset): cli connection label follows the shared URL grammar Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .changeset/cli-connection-label-shared-url-grammar.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/cli-connection-label-shared-url-grammar.md diff --git a/.changeset/cli-connection-label-shared-url-grammar.md b/.changeset/cli-connection-label-shared-url-grammar.md new file mode 100644 index 00000000000..e044547eb69 --- /dev/null +++ b/.changeset/cli-connection-label-shared-url-grammar.md @@ -0,0 +1,11 @@ +--- +'@objectstack/cli': patch +--- + +fix(cli): a connection label printed by the CLI removes a password wherever the URL rules read one + +Clause-②: no +Metadata-protocol: none + +- **One userinfo grammar.** The database label that `os dev`, `os start` and `os serve` print, and the migrate and resync confirms, now remove a URL password through the same shared function the datasource doors use (`redactUrlPassword` from `@objectstack/spec/data`). Wherever those rules read a password, the label drops it. When the shared rules read a userinfo in more shapes, the label follows with no edit of its own. +- **Unchanged.** Every value the shared rules read no password in prints exactly as before: `postgres://`, `libsql://` and `file:` values, values with no userinfo, a username-only userinfo, and everything the URL parser already cleaned. From 07d421dad312ba1c57c59d9e07409e21ab78f3cc Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 19:49:59 +0000 Subject: [PATCH 3/4] fix(cli): the connection label removes a credential query pair through the shared query-pair judge Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .../cli/src/utils/connection-display.test.ts | 80 +++++++++++++++++-- packages/cli/src/utils/connection-display.ts | 28 ++++--- 2 files changed, 88 insertions(+), 20 deletions(-) diff --git a/packages/cli/src/utils/connection-display.test.ts b/packages/cli/src/utils/connection-display.test.ts index 358b6780a7a..bb259c48089 100644 --- a/packages/cli/src/utils/connection-display.test.ts +++ b/packages/cli/src/utils/connection-display.test.ts @@ -12,7 +12,11 @@ // shape leaks the credentials the DSN carries. import { describe, it, expect } from 'vitest'; -import { urlUserinfoPassword } from '@objectstack/spec/data'; +import { + CREDENTIAL_URL_QUERY_PARAM_NAMES, + urlCredentialQueryParams, + urlUserinfoPassword, +} from '@objectstack/spec/data'; import { redactConnectionUrl, describeDriverConnection, describeDriverSqliteFile } from './connection-display.js'; describe('redactConnectionUrl', () => { @@ -73,6 +77,15 @@ describe('redactConnectionUrl — the password rule is the shared URL grammar', .toBe('postgres:/\t/admin@db.example.com/app'); }); + it('removes the password where the shared grammar reads a special scheme\'s authority without a `//`', () => { + expect(redactConnectionUrl(`https:admin:${PASSWORD}@db.example.com/app`)) + .toBe('https:admin@db.example.com/app'); + expect(redactConnectionUrl(`http:/admin:${PASSWORD}@db.example.com/app`)) + .toBe('http:/admin@db.example.com/app'); + expect(redactConnectionUrl(`wss:\\admin:${PASSWORD}@db.example.com/app`)) + .toBe('wss:\\admin@db.example.com/app'); + }); + it('removes the whole password up to the last `@` of the authority, as the shared grammar bounds it', () => { expect(redactConnectionUrl('postgres://admin:S3C@RET-PW@h1:5432,h2:5432/app')) .toBe('postgres://h1:5432,h2:5432/app'); @@ -88,17 +101,15 @@ describe('redactConnectionUrl — the password rule is the shared URL grammar', `//admin:${PASSWORD}@db.example.com:5432/app`, `postgres:/\t/admin:${PASSWORD}@db.example.com/app`, ` \npostgres://admin:${PASSWORD}@db.example.com/app`, - // Special schemes without the `//`: the label prints exactly what the - // shared grammar reads here, so it follows that grammar's reading of - // these shapes without an edit of its own. + // Special schemes without the `//`, read as a URL parser reads them. `https:admin:${PASSWORD}@db.example.com/app`, `http:/admin:${PASSWORD}@db.example.com/app`, `wss:\\admin:${PASSWORD}@db.example.com/app`, ]; const judged = shapes.filter((value) => urlUserinfoPassword(value) !== undefined); - // Non-vacuity floor: the first eight shapes carry a password on every - // reading of the grammar, so the property below is never checked over nothing. - expect(judged.length).toBeGreaterThanOrEqual(8); + // Non-vacuity floor: the shared grammar reads a password in every shape + // listed, so the property below is never checked over nothing. + expect(judged.length).toBe(shapes.length); for (const value of judged) { expect(urlUserinfoPassword(value)).toBe(PASSWORD); expect(redactConnectionUrl(value), value).not.toContain(PASSWORD); @@ -134,6 +145,61 @@ describe('redactConnectionUrl — the password rule is the shared URL grammar', }); }); +// The query half of the same rule: a credential query pair is judged by the +// shared query-pair judge and its one list of names, on every path the label +// takes — a value with no `://`, the URL parser's, and the hand fallback for a +// value the parser rejects. +describe('redactConnectionUrl — the credential query rule is the shared query-pair judge', () => { + const TOKEN = 'T0KEN-VALUE'; + + it('removes a credential query pair from a value with no `://`', () => { + expect(redactConnectionUrl(`libsql:app-org.turso.io?authToken=${TOKEN}`)) + .toBe('libsql:app-org.turso.io'); + expect(redactConnectionUrl(`file:local.db?authToken=${TOKEN}&mode=ro`)) + .toBe('file:local.db?mode=ro'); + }); + + it('removes a credential query pair on the hand fallback for a value the URL parser rejects', () => { + expect(redactConnectionUrl(`postgres://admin@h1:5432,h2:5432/app?password=${TOKEN}&sslmode=require`)) + .toBe('postgres://h1:5432,h2:5432/app?sslmode=require'); + }); + + it('removes a credential query pair on the URL parser path', () => { + expect(redactConnectionUrl(`postgres://db.example.com/app?PASSWORD=${TOKEN}`)) + .toBe('postgres://db.example.com/app'); + expect(redactConnectionUrl(`libsql://app-org.turso.io?authToken=${TOKEN}`)) + .toBe('libsql://app-org.turso.io'); + }); + + it('never prints a credential query value the shared judge reads, on any path', () => { + const shapes = [ + `libsql:app-org.turso.io?authToken=${TOKEN}`, + `file:local.db?mode=ro&AUTHTOKEN=${TOKEN}`, + `file:local.db?auth%54oken=${TOKEN}`, + `postgres://admin@h1:5432,h2:5432/app?password=${TOKEN}`, + `postgres://db.example.com/app?password=${TOKEN}`, + `libsql://app-org.turso.io?authToken=${TOKEN}#frag`, + ]; + for (const value of shapes) { + expect(urlCredentialQueryParams(value, CREDENTIAL_URL_QUERY_PARAM_NAMES), value).not.toEqual([]); + expect(redactConnectionUrl(value), value).not.toContain(TOKEN); + } + }); + + it('keeps its output byte for byte for every value with no credential query pair', () => { + const controls: ReadonlyArray = [ + ['postgres://admin@h1:5432,h2:5432/app?sslmode=require', 'postgres://h1:5432,h2:5432/app?sslmode=require'], + ['file:local.db?mode=ro', 'file:local.db?mode=ro'], + ['libsql:app-org.turso.io?authToken=', 'libsql:app-org.turso.io?authToken='], + ['postgres://db.example.com:5432/app?sslmode=require', 'postgres://db.example.com:5432/app'], + ]; + for (const [value, printed] of controls) { + expect(urlCredentialQueryParams(value, CREDENTIAL_URL_QUERY_PARAM_NAMES), value).toEqual([]); + expect(redactConnectionUrl(value), value).toBe(printed); + } + }); +}); + describe('describeDriverConnection', () => { it('reads a DSN string', () => { expect(describeDriverConnection({ client: 'pg', connection: 'postgres://u:p@host:5432/app' })) diff --git a/packages/cli/src/utils/connection-display.ts b/packages/cli/src/utils/connection-display.ts index ce9bfb3eb09..12a5a8ad60b 100644 --- a/packages/cli/src/utils/connection-display.ts +++ b/packages/cli/src/utils/connection-display.ts @@ -24,31 +24,33 @@ * that the driver label and the host don't already say. */ -import { redactUrlPassword } from '@objectstack/spec/data'; +import { redactUrlCredentials } from '@objectstack/spec/data'; /** * Strip credentials from a connection string. * - * The password goes first, and by the platform's ONE userinfo grammar - * (`redactUrlPassword` from `@objectstack/spec/data`, the datasource read - * door's redactor): wherever that grammar reads a password — the string judged - * as a URL parser cleans it — the label drops it, so the label - * cannot print a password the doors would refuse or redact. No second - * userinfo grammar lives here; when the shared one reads a userinfo in more - * shapes, this label follows without an edit. + * Credentials go first, and by the platform's ONE URL credential grammar + * (`redactUrlCredentials` from `@objectstack/spec/data`, the composite the + * datasource read door applies): wherever that grammar reads a userinfo + * password or a credential query pair — the string judged as a URL parser + * cleans it, the pair judged by the shared query-pair judge and its one list + * of credential names — the label drops it, so the label cannot print a + * credential the doors would refuse or redact. No second grammar and no second + * name list live here; when the shared ones read more, this label follows + * without an edit. * * Then the display shaping, unchanged: a value carrying `://` loses its * username and query string through the URL parser, or its userinfo segment * by hand when the parser rejects it. Values that aren't URLs — a sqlite path, * `:memory:`, the `(in-memory)` marker — pass through untouched unless the - * shared grammar read a password in them. Idempotent, so a call site that + * shared grammar read a credential in them. Idempotent, so a call site that * can't tell whether its input was already redacted may apply it defensively. */ export function redactConnectionUrl(value: string): string { - const withoutPassword = redactUrlPassword(value); - if (!withoutPassword.includes('://')) return withoutPassword; + const withoutCredentials = redactUrlCredentials(value); + if (!withoutCredentials.includes('://')) return withoutCredentials; try { - const url = new URL(withoutPassword); + const url = new URL(withoutCredentials); url.username = ''; url.password = ''; // Query params are where tokens hide (`libsql://….turso.io?authToken=…`), @@ -58,7 +60,7 @@ export function redactConnectionUrl(value: string): string { } catch { // Not parseable (a driver-specific dialect, a malformed URL). Drop the // userinfo segment by hand rather than printing the whole thing. - return withoutPassword.replace(/:\/\/[^@/]*@/, '://'); + return withoutCredentials.replace(/:\/\/[^@/]*@/, '://'); } } From 1e012660900c3768b722dde27d5343c959bb498f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 4 Nov 2026 19:59:11 +0000 Subject: [PATCH 4/4] chore(changeset): the cli connection label also follows the shared query-pair judge Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj Co-authored-by: Claude --- .changeset/cli-connection-label-shared-url-grammar.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.changeset/cli-connection-label-shared-url-grammar.md b/.changeset/cli-connection-label-shared-url-grammar.md index e044547eb69..cf4484ecbd4 100644 --- a/.changeset/cli-connection-label-shared-url-grammar.md +++ b/.changeset/cli-connection-label-shared-url-grammar.md @@ -2,10 +2,10 @@ '@objectstack/cli': patch --- -fix(cli): a connection label printed by the CLI removes a password wherever the URL rules read one +fix(cli): a connection label printed by the CLI removes a password or a credential query pair wherever the URL rules read one Clause-②: no Metadata-protocol: none -- **One userinfo grammar.** The database label that `os dev`, `os start` and `os serve` print, and the migrate and resync confirms, now remove a URL password through the same shared function the datasource doors use (`redactUrlPassword` from `@objectstack/spec/data`). Wherever those rules read a password, the label drops it. When the shared rules read a userinfo in more shapes, the label follows with no edit of its own. -- **Unchanged.** Every value the shared rules read no password in prints exactly as before: `postgres://`, `libsql://` and `file:` values, values with no userinfo, a username-only userinfo, and everything the URL parser already cleaned. +- **One credential grammar.** The database label that `os dev`, `os start` and `os serve` print, and the migrate and resync confirms, now remove URL credentials through the same shared function the datasource read door applies (`redactUrlCredentials` from `@objectstack/spec/data`): the userinfo password, and every credential query pair judged by the shared query-pair judge and its one list of names. Wherever those rules read a credential, on every path the label takes, the label drops it. When the shared rules read more, the label follows with no edit of its own. +- **Unchanged.** Every value the shared rules read no credential in prints exactly as before: `postgres://`, `libsql://` and `file:` values, values with no userinfo, a username-only userinfo, and everything the URL parser already cleaned.