diff --git a/.changeset/datasource-config-postgres-url-unparseable-refused.md b/.changeset/datasource-config-postgres-url-unparseable-refused.md new file mode 100644 index 0000000000..2d6af650d0 --- /dev/null +++ b/.changeset/datasource-config-postgres-url-unparseable-refused.md @@ -0,0 +1,70 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): refuse a postgres `config.url` that `pg` itself cannot parse at publish (#9091) + +**BREAKING** accept-set narrowing, landing after the v17.0.0 cut (the lockstep +launch-window convention ships it as `minor`, like the sibling refusals #8337, +#9040 and #9041; the migration prescription is registered under protocol major +18, where `os migrate meta` users will look). + +`PostgresConfigSchema.url`'s own describe text documents the postgres URL +grammar (`postgresql://[user@][host][:port][/dbname][?params]`) and, until now, +enforced none of it: the value was only string-scanned for credentials +(#8082/#8337) and placeholders (#8336). That leniency is deliberate at the +SHARED helper — its refusal to parse is load-bearing for mongo's +multi-host/`+srv` forms (#8696) — but for postgres it amounted to no check at +all. Measured on `pg@8.22.0`: both `pg-connection-string`'s `parse` and `pg`'s +`ConnectionParameters` throw `TypeError [ERR_INVALID_URL]` on +`postgresql://app@h1:5432,h2:5433/app` (node-postgres does not implement +libpq's multi-host DSN), yet the schema accepted that exact value — the +operator discovered the datasource could never connect only at connect time, +via a bare `Invalid URL` whose `input` field `pg` redacts. + +The schema now asks `pg`'s own grammar at publish — a per-driver `superRefine` +on the postgres `url` runs `parse` from `pg-connection-string` (the parser `pg` +itself uses; now a dependency of `@objectstack/spec`) — and refuses, at the +value's path: + +- anything `parse` throws on (multi-host DSNs, non-numeric ports, malformed + percent-escapes), with the parser's own message quoted; +- a scheme-less non-URL, which `parse` only "accepts" by resolving it against + its placeholder base (`postgres://base`) — pg would connect to the literal + host `base` with the authored text as the database name; +- the fs-reading query parameters `?sslcert=` / `?sslkey=` / `?sslrootcert=`, + which make `parse` itself call `fs.readFileSync` — a publish verdict must + not depend on the validating host's filesystem, and certificate material + already has its declared home in the datasource-level `ssl` block (the same + prescription the config-level `ca`/`cert`/`key` keys carry). + +Every measured shape `pg` genuinely opens stays accepted byte-identically: +single-host URLs (credential-free ones included), the empty-host libpq forms +(`postgresql:///db`, `postgresql://user@/db`), unix-socket spellings (a +leading-`/` path, `socket:`, a percent-encoded socket host), IPv6 hosts, and +non-credential/non-fs query parameters. Mongo, mysql and turso URLs are +untouched — the shared helpers keep refusing to parse, per-driver by design. + +## FROM → TO + +```yaml +# before — parsed green; `pg` then threw a redacted `Invalid URL` at connect +driver: postgres +config: + url: postgresql://app@h1:5432,h2:5433/app + +# after — point the URL at a single host (or a proxy/pooler in front of the +# cluster); `pg` does not implement libpq's multi-host DSN, so no spelling of +# it can connect +driver: postgres +config: + url: postgresql://app@h1:5432/app +``` + +There is deliberately no automatic rewrite: a URL `pg` cannot parse does not +carry enough structure to say which single host the author meant (a multi-host +DSN names several on purpose), so the choice of target is the author's. +Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through +this publish door and are unaffected by construction. + + diff --git a/content/docs/references/data/driver-postgres.mdx b/content/docs/references/data/driver-postgres.mdx index 0bc1a3d33e..2b476ed192 100644 --- a/content/docs/references/data/driver-postgres.mdx +++ b/content/docs/references/data/driver-postgres.mdx @@ -40,7 +40,7 @@ PostgreSQL connection configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **url** | `string` | optional | Connection URI (supersedes the discrete fields; must not embed a password — bind the secret instead) | +| **url** | `string` | optional | Connection URI (supersedes the discrete fields; must be a URL `pg` can parse; must not embed a password — bind the secret instead) | | **host** | `string` | optional (default: `"localhost"`) | Host address | | **port** | `integer` | optional (default: `5432`) | Port number | | **database** | `string` | optional | Database name | diff --git a/packages/spec/package.json b/packages/spec/package.json index de9b333e0c..64b6e2f30d 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -246,6 +246,7 @@ "vitest": "^4.1.10" }, "dependencies": { + "pg-connection-string": "^2.14.0", "zod": "^4.4.3" }, "peerDependencies": { diff --git a/packages/spec/src/data/driver/postgres.test.ts b/packages/spec/src/data/driver/postgres.test.ts index 26a776725c..ece5d4993a 100644 --- a/packages/spec/src/data/driver/postgres.test.ts +++ b/packages/spec/src/data/driver/postgres.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from 'vitest'; +import { DatasourceSchema } from '../datasource.zod'; +import { MongoConfigSchema } from './mongo.zod'; import { PostgresConfigSchema } from './postgres.zod'; describe('PostgresConfigSchema', () => { @@ -192,3 +194,159 @@ describe('PostgresConfigSchema', () => { .toThrow(); }); }); + +/** + * #9091 — a `url` that `pg` itself cannot parse is refused at publish. + * + * The describe text always documented the postgres URL grammar; until #9091 + * the value was only string-scanned (credentials #8082/#8337, placeholders + * #8336) because the SHARED helper's refusal to parse is load-bearing for + * mongo's multi-host/`+srv` forms (#8696). The parse question is asked + * per-driver, of `pg`'s own parser (`pg-connection-string`). + * + * Envelope note (the standing minimum for rejection pins): the zod issue's + * `code` and its (re-pathed) location are the whole envelope at this layer — + * `status` does not exist here; the publish door wraps every schema refusal + * uniformly (metadata-protocol's `422 INVALID_METADATA`, whose `issues[]` + * carry these zod codes verbatim). + */ +describe('PostgresConfigSchema.url pg-grammar enforcement (#9091)', () => { + it("refuses libpq's multi-host DSN — the form `pg` measurably cannot open", () => { + // Measured on pg@8.22.0 / pg-connection-string@2.14.0: both `parse` and + // `ConnectionParameters` throw `TypeError [ERR_INVALID_URL]` on this exact + // value. It parsed green here until #9091. + const result = PostgresConfigSchema.safeParse({ + url: 'postgresql://app@h1:5432,h2:5433/app', + }); + + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'url'); + expect(issue, 'refusal must land at `url`').toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain('not a connection URL `pg` can open'); + // The message names the common cause and its working replacements. + expect(issue!.message).toContain('multi-host'); + // The runtime-DSN carve-out, stated rather than implied (family convention). + expect(issue!.message).toContain('OS_DATABASE_URL'); + }); + + it('re-paths the refusal at `config.url` through the datasource door', () => { + const result = DatasourceSchema.safeParse({ + name: 'warehouse', + driver: 'postgres', + config: { url: 'postgresql://app@h1:5432,h2:5433/app' }, + }); + + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'config.url'); + expect(issue, 'refusal must be re-pathed at `config.url`').toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain('not a connection URL `pg` can open'); + }); + + it('refuses a non-numeric port — `pg` throws ERR_INVALID_URL on it', () => { + const result = PostgresConfigSchema.safeParse({ + url: 'postgresql://db.example.com:notaport/app', + }); + + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'url'); + expect(issue).toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain('not a connection URL `pg` can open'); + }); + + it('refuses a scheme-less non-URL — `pg` would resolve it against a placeholder host', () => { + // `pg-connection-string` parses these via `new URL(str, 'postgres://base')`, + // so they do NOT throw: pg would connect to the literal host `base` with + // the authored text as the database name. Structurally unusable, refused. + for (const url of ['not a url at all', 'host=localhost dbname=app']) { + const result = PostgresConfigSchema.safeParse({ url }); + + expect(result.success, url).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'url'); + expect(issue, `refusal for ${url} must land at \`url\``).toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain('no scheme'); + expect(issue!.message).toContain('`base`'); + } + }); + + it('refuses the fs-reading query parameters, pointing at the datasource-level `ssl` block', () => { + // `?sslcert=`/`?sslkey=`/`?sslrootcert=` make `parse` itself call + // `fs.readFileSync` — a publish verdict must not depend on the validating + // host's filesystem, and certificate material already has its declared + // home (the same prescription the config-level `ca`/`cert`/`key` keys + // carry). + const result = PostgresConfigSchema.safeParse({ + url: 'postgresql://db.example.com/app?sslcert=/etc/ssl/client.pem', + }); + + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'url'); + expect(issue).toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain('?sslcert='); + expect(issue!.message).toContain('datasource-level `ssl` block'); + }); + + it('mirrors `pg` exactly on the fs-param boundary: exact-case, non-empty value', () => { + // Measured: `?SSLCERT=` is copied into the parsed config and read by + // nothing (no fs touch), and an empty `?sslcert=` is falsy at the + // parser's guard (no fs touch) — refusing either would narrow past what + // `pg` does. Both stay accepted. + for (const url of [ + 'postgresql://db.example.com/app?SSLCERT=/etc/ssl/client.pem', + 'postgresql://db.example.com/app?sslcert=', + ]) { + const result = PostgresConfigSchema.safeParse({ url }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + } + }); + + it('reports the parse refusal ALONGSIDE the credential refusal on a value violating both', () => { + // Composition pin: independent superRefines judge one value, each + // reporting its own finding (#8082 userinfo + #9091 grammar here). + const result = PostgresConfigSchema.safeParse({ + url: 'postgresql://user:pass@h1:5432,h2:5433/app', + }); + + expect(result.success).toBe(false); + const messages = result.error!.issues + .filter((i) => i.path.join('.') === 'url') + .map((i) => i.message); + expect(messages.some((m) => m.includes('embeds a password'))).toBe(true); + expect(messages.some((m) => m.includes('not a connection URL `pg` can open'))).toBe(true); + }); + + it('accepts every measured shape `pg` genuinely opens', () => { + for (const url of [ + // The documented single-host forms, credential-free ones included. + 'postgresql://db.example.com/app', + 'postgresql://user@db.example.com:5432/production', + 'postgres://host/db', + // Empty-host libpq forms (default socket/localhost). + 'postgresql:///dbname', + 'postgresql://user@/mydb', + // Unix-socket spellings: leading-`/` path, `socket:`, encoded host. + '/var/run/postgresql', + 'socket:/var/run/postgresql?db=app', + 'postgresql://%2Fvar%2Frun%2Fpostgresql/mydb', + // IPv6 host and non-credential, non-fs query parameters. + 'postgresql://user@[2001:db8::1]:5432/db', + 'postgresql://db.example.com/app?application_name=objectstack', + ]) { + const result = PostgresConfigSchema.safeParse({ url, database: 'app' }); + expect(result.success, `${url}: ${JSON.stringify(result.error?.issues)}`).toBe(true); + } + }); + + it("leaves mongo's multi-host form untouched — the shared helper's leniency it must keep (#8696)", () => { + // The #9091 parse check is per-driver BY DESIGN: for mongo the multi-host + // DSN is a real, working, documented shape. Pin that it still parses. + const result = MongoConfigSchema.safeParse({ + url: 'mongodb://app@h1:27017,h2:27017/app', + }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); +}); diff --git a/packages/spec/src/data/driver/postgres.zod.ts b/packages/spec/src/data/driver/postgres.zod.ts index 71f67b33fd..fe283e6a9e 100644 --- a/packages/spec/src/data/driver/postgres.zod.ts +++ b/packages/spec/src/data/driver/postgres.zod.ts @@ -13,6 +13,7 @@ * block, which the factory now honours for every SQL driver. */ +import { parse as parsePostgresUrl } from 'pg-connection-string'; import { z } from 'zod'; import { lazySchema } from '../../shared/lazy-schema'; @@ -31,6 +32,164 @@ import { SSL_DETAIL_BELONGS_ON_DATASOURCE, } from './common.zod'; +/** + * Refusal prescription for a `url` that `pg` itself cannot parse (#9091). + * + * The gap this closes: the describe text below documents a grammar + * (`postgresql://[user@][host][:port][/dbname][?params]`) that nothing + * enforced. The shared `credentialFreeUrl` / `placeholderFree` checks are + * string-boundary scans by design — their refusal to parse is load-bearing + * for mongo's multi-host and `+srv` forms (#8696), so the parse question is + * asked HERE, per-driver, of the postgres client's own grammar: `parse` from + * `pg-connection-string@2.14.0`, the parser `pg@8.22.0` itself runs a + * connection string through (`ConnectionParameters`). What that parser + * throws on (measured: libpq's multi-host `h1:5432,h2:5433` form — + * `ERR_INVALID_URL`; a non-numeric port; a malformed percent-escape) used to + * parse green at publish and then fail at connect with a bare `Invalid URL` + * whose own `input` field `pg` redacts — an error naming neither the value + * nor the datasource. Same posture as #8873's runtime arm: ask `pg`'s + * grammar, never re-model it. + */ +const PG_UNPARSEABLE_URL_REFUSED = (key: string, detail: string): string => + `this \`${key}\` is not a connection URL \`pg\` can open — \`pg-connection-string\` (the ` + + `parser \`pg\` itself uses) refuses it: ${detail}. The datasource would publish green and ` + + 'then fail at connect time with an error that names neither the value nor the datasource. ' + + 'Expected format: `postgresql://[user@][host][:port][/dbname][?params]`. Note that `pg` ' + + "does not implement libpq's multi-host form (`host1:port1,host2:port2`) — a multi-host " + + 'DSN fails exactly this way; point the URL at a single host (or a proxy in front of the ' + + 'cluster) instead. Runtime-environment DSNs (`OS_DATABASE_URL` and friends) do not pass ' + + 'through this publish door and are unaffected.'; + +/** + * Refusal for a value `pg` "parses" only by resolving it against its + * placeholder base URL (#9091 — the structurally-unusable half). + * + * `pg-connection-string` parses via `new URL(str, 'postgres://base')`, so a + * value that is not an absolute URL at all (`not a url`, a libpq + * keyword/value string like `host=x dbname=y`) does not throw — it resolves + * RELATIVE to the base, and the client then connects to the literal host + * `base` with the whole authored value as the database name. That is a + * "successful" parse of a configuration the author never wrote, so it is + * refused as unusable rather than accepted as what `pg` happens to do. + */ +const PG_RELATIVE_URL_REFUSED = (key: string): string => + `this \`${key}\` is not a URL: it has no scheme, so \`pg\` would parse it only by resolving ` + + 'it against an internal placeholder base and then connect to the literal host `base` — a ' + + 'host that was never named — with the authored text as the database name. Write a real ' + + 'connection URL: `postgresql://[user@][host][:port][/dbname][?params]` (a unix-socket ' + + 'path starting with `/` is also accepted). Runtime-environment DSNs (`OS_DATABASE_URL` ' + + 'and friends) do not pass through this publish door and are unaffected.'; + +/** + * Refusal for the query parameters that make `pg`'s parser READ THE LOCAL + * FILESYSTEM (#9091): `?sslcert=` / `?sslkey=` / `?sslrootcert=` are file + * PATHS that `pg-connection-string` opens with `fs.readFileSync` during + * `parse` itself. Publish-time validation must not read the validating + * server's filesystem (the verdict would depend on which machine validates, + * and the parse would become a file-existence oracle), and certificate + * material already has a declared home this schema names for the config-level + * `ca`/`cert`/`key` keys: the datasource-level `ssl` block. Same prescription, + * one syntax over. + */ +const PG_FS_QUERY_PARAM_REFUSED = (key: string, param: string): string => + `this \`${key}\` carries \`?${param}=\` in its query string — a file path that \`pg\` reads ` + + 'from the local filesystem while parsing the URL, so it cannot be judged (or safely ' + + 'parsed) at publish: whether the file exists is a fact about the connect-time host, not ' + + `about the datasource. ${SSL_DETAIL_BELONGS_ON_DATASOURCE}`; + +/** + * The query parameters `pg-connection-string@2.14.0`'s `parse` resolves into + * `fs.readFileSync` calls (measured — see {@link PG_FS_QUERY_PARAM_REFUSED}). + * Matched EXACT-CASE on the percent-decoded key, mirroring the parser's own + * `config.sslcert` property reads off WHATWG `URLSearchParams` (measured: + * `?SSLCERT=` is copied into the config and read by nothing — no fs touch, no + * refusal), and only with a non-empty value (an empty value is falsy at the + * parser's `if (config.sslcert)` guard — no fs touch either). + */ +const PG_FS_QUERY_PARAMS: readonly string[] = ['sslcert', 'sslkey', 'sslrootcert']; + +/** The {@link PG_FS_QUERY_PARAMS} an authored URL-ish string carries with a non-empty value. */ +function pgFileReadingQueryParams(value: string): string[] { + const hashIdx = value.indexOf('#'); + const head = hashIdx === -1 ? value : value.slice(0, hashIdx); + const queryIdx = head.indexOf('?'); + if (queryIdx === -1) return []; + const found: string[] = []; + for (const pair of head.slice(queryIdx + 1).split('&')) { + const splitIdx = pair.indexOf('='); + if (splitIdx < 0 || splitIdx === pair.length - 1) continue; + let key = pair.slice(0, splitIdx).replace(/\+/g, ' '); + try { + key = decodeURIComponent(key); + } catch { + // A malformed escape cannot spell a declared name once decoding fails + // (and `parse` itself throws on it before reaching the fs branch). + } + const match = PG_FS_QUERY_PARAMS.find((param) => param === key); + if (match !== undefined && !found.includes(match)) found.push(match); + } + return found; +} + +/** Can WHATWG `URL` parse this string as an ABSOLUTE URL (no base)? */ +function isAbsoluteUrl(value: string): boolean { + try { + new URL(value); + return true; + } catch { + return false; + } +} + +/** + * Did `parse` succeed only by resolving the value against its placeholder + * base? Mirrors the parser's own preprocessing (space/percent re-encoding, + * then the `@/` → `@___DUMMY___/` empty-host retry — the retry form, + * `postgresql://user@/db`, is libpq's real empty-host-with-userinfo spelling + * and stays accepted) so the two cannot disagree about which branch ran. + */ +function pgParsedRelativeToBase(value: string): boolean { + const str = / |%[^a-f0-9]|%[a-f0-9][^a-f0-9]/i.test(value) + ? encodeURI(value).replace(/%25(\d\d)/g, '%$1') + : value; + return !isAbsoluteUrl(str) && !isAbsoluteUrl(str.replace('@/', '@___DUMMY___/')); +} + +/** + * Attach the #9091 pg-grammar refusal to the postgres `url` key — per-driver + * by design (see {@link PG_UNPARSEABLE_URL_REFUSED}; the shared helpers must + * keep refusing to parse for mongo's sake, #8696). Composes with + * `credentialFreeUrl` (#8082/#8337) and `placeholderFree` (#8336) the same + * way those compose with each other: independent `superRefine`s judging one + * value, each reporting its own finding. + */ +function pgParseableUrl(schema: S, key: string) { + return schema.superRefine((value, ctx) => { + if (typeof value !== 'string') return; + // A leading `/` is the unix-socket path form: `parse` short-circuits on + // it before any URL or query handling and cannot refuse it. + if (value.startsWith('/')) return; + // Refused BEFORE `parse` so the fs-reading branch is never reached here. + const fsParams = pgFileReadingQueryParams(value); + if (fsParams.length > 0) { + for (const param of fsParams) { + ctx.addIssue({ code: 'custom', message: PG_FS_QUERY_PARAM_REFUSED(key, param) }); + } + return; + } + try { + parsePostgresUrl(value); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + ctx.addIssue({ code: 'custom', message: PG_UNPARSEABLE_URL_REFUSED(key, detail) }); + return; + } + if (pgParsedRelativeToBase(value)) { + ctx.addIssue({ code: 'custom', message: PG_RELATIVE_URL_REFUSED(key) }); + } + }); +} + /** Prescription for a pool knob written inside `config` instead of `pool`. */ const poolBelongsOnDatasource = (key: string, canonical: string) => `\`${key}\` is not driver config — connection pooling is configured once for every driver in ` @@ -96,17 +255,26 @@ export const PostgresConfigSchema = lazySchema(() => strictObject( * config, so `?password=` is honoured (it even wins over userinfo — * measured; see `CREDENTIAL_URL_QUERY_PARAMS` in common.zod.ts) and is * refused the same way; non-credential parameters (`?sslmode=` and - * friends) stay writable. Placeholder-free since #8336: a `${…}` span anywhere in + * friends) stay writable, except the fs-reading trio (`?sslcert=` / + * `?sslkey=` / `?sslrootcert=`) — refused since #9091, certificate + * material lives in the datasource-level `ssl` block. Placeholder-free since #8336: a `${…}` span anywhere in * the value is refused — placeholders in authored metadata are resolved by * nothing. Runtime-environment DSNs (`OS_DATABASE_URL`) never pass * through this schema and are unaffected. + * Since #9091 the documented format is ENFORCED by asking `pg`'s own + * parser (`pg-connection-string`): a URL `pg` cannot parse — libpq's + * multi-host form, a non-numeric port, a scheme-less non-URL — is refused + * at publish instead of exploding at connect with a redacted `Invalid URL`. * Format: `postgresql://[user@][host][:port][/dbname][?params]` */ - url: placeholderFree( - credentialFreeUrl(z.string(), 'url', CREDENTIAL_URL_QUERY_PARAMS.postgres), + url: pgParseableUrl( + placeholderFree( + credentialFreeUrl(z.string(), 'url', CREDENTIAL_URL_QUERY_PARAMS.postgres), + 'url', + ), 'url', ).optional() - .describe('Connection URI (supersedes the discrete fields; must not embed a password — bind the secret instead)') + .describe('Connection URI (supersedes the discrete fields; must be a URL `pg` can parse; must not embed a password — bind the secret instead)') .meta({ title: 'Connection URL' }), /** Hostname or IP address. Placeholder-free since #8336. */ diff --git a/packages/spec/src/migrations/entries/semantic/18.datasource-config-postgres-url-unparseable-refused.ts b/packages/spec/src/migrations/entries/semantic/18.datasource-config-postgres-url-unparseable-refused.ts new file mode 100644 index 0000000000..93a2d63dd2 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.datasource-config-postgres-url-unparseable-refused.ts @@ -0,0 +1,49 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'datasource-config-postgres-url-unparseable-refused', + surface: 'datasource.config.url (postgres) — connection URLs the `pg` client cannot parse ' + + "(libpq's multi-host `h1:5432,h2:5433` form, a non-numeric port, a scheme-less non-URL, " + + 'a malformed percent-escape), plus the filesystem-reading query parameters ' + + '`?sslcert=` / `?sslkey=` / `?sslrootcert=`', + replacement: 'a single-host URL `pg` itself parses — ' + + '`postgresql://[user@][host][:port][/dbname][?params]` (unix-socket forms stay accepted: ' + + 'a leading-`/` path, `socket:`, or a percent-encoded socket host). For a multi-host ' + + 'cluster, point the URL at one node or at a proxy/pooler in front of the cluster — ' + + '`pg` does not implement libpq\'s multi-host DSN, so no spelling of it can connect. For ' + + 'certificate material, use the datasource-level `ssl` block (`ssl: { ca: …, cert: …, ' + + 'key: … }` next to `driver`) instead of file-path query parameters', + reason: + "`PostgresConfigSchema.url`'s own describe text documents the postgres URL grammar, but " + + 'until protocol 18 the value was only string-scanned for credentials (#8082/#8337) and ' + + 'placeholders (#8336) — deliberately so at the SHARED helper, whose refusal to parse is ' + + "load-bearing for mongo's multi-host/`+srv` forms (#8696). For postgres that leniency " + + 'was no check at all: `pg@8.22.0` does not implement libpq\'s multi-host DSN — both ' + + "`pg-connection-string`'s `parse` and `pg`'s `ConnectionParameters` throw " + + '`TypeError [ERR_INVALID_URL]` on `postgresql://app@h1:5432,h2:5433/app` (measured) — ' + + 'so an operator could publish exactly that URL, see it saved, and discover only at ' + + 'connect time that it can never open a connection, via a bare `Invalid URL` whose ' + + '`input` field `pg` redacts. The refusal now asks the same grammar one door up: ' + + '`parse` from `pg-connection-string` (the parser `pg` itself uses) runs at publish, ' + + 'per-driver, and what it throws on is refused with the value\'s path named. Two ' + + 'adjacent shapes are refused as structurally unusable rather than parse-refused, both ' + + 'measured: a scheme-less value "parses" only by resolving against the parser\'s ' + + 'placeholder base (`postgres://base`), i.e. `pg` would connect to the literal host ' + + '`base` with the authored text as the database name; and `?sslcert=`/`?sslkey=`/' + + '`?sslrootcert=` make `parse` itself call `fs.readFileSync`, so the verdict would ' + + 'depend on the validating host\'s filesystem — certificate material already has its ' + + 'declared home in the datasource-level `ssl` block. There is no mechanical rewrite: a ' + + 'URL `pg` cannot parse does not carry enough structure to say which single host the ' + + 'author meant (a multi-host DSN names several on purpose), so the choice of target is ' + + "the author's. Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass " + + 'through the publish door and are unaffected by construction.', + acceptanceCriteria: + 'Every postgres datasource parses with a `config.url` that `pg-connection-string` ' + + 'parses without throwing, that carries a scheme (or is a unix-socket path), and that ' + + 'carries no `?sslcert=`/`?sslkey=`/`?sslrootcert=` query parameter; each affected ' + + 'datasource still connects to the intended single host; certificate material, where ' + + 'needed, lives in the datasource-level `ssl` block; mongo/mysql/turso datasources are ' + + 'byte-identical before and after (their URL checks are unchanged).', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 2d228076ef..369d2c7a25 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -5033,6 +5033,51 @@ const step18: MigrationStep = { 'the connection form) with the username in its URL, and still connects; no passthrough ' + 'credential remains in any stored `sys_metadata` row or authored source.', }, + { + id: 'datasource-config-postgres-url-unparseable-refused', + surface: 'datasource.config.url (postgres) — connection URLs the `pg` client cannot parse ' + + "(libpq's multi-host `h1:5432,h2:5433` form, a non-numeric port, a scheme-less non-URL, " + + 'a malformed percent-escape), plus the filesystem-reading query parameters ' + + '`?sslcert=` / `?sslkey=` / `?sslrootcert=`', + replacement: 'a single-host URL `pg` itself parses — ' + + '`postgresql://[user@][host][:port][/dbname][?params]` (unix-socket forms stay accepted: ' + + 'a leading-`/` path, `socket:`, or a percent-encoded socket host). For a multi-host ' + + 'cluster, point the URL at one node or at a proxy/pooler in front of the cluster — ' + + '`pg` does not implement libpq\'s multi-host DSN, so no spelling of it can connect. For ' + + 'certificate material, use the datasource-level `ssl` block (`ssl: { ca: …, cert: …, ' + + 'key: … }` next to `driver`) instead of file-path query parameters', + reason: + "`PostgresConfigSchema.url`'s own describe text documents the postgres URL grammar, but " + + 'until protocol 18 the value was only string-scanned for credentials (#8082/#8337) and ' + + 'placeholders (#8336) — deliberately so at the SHARED helper, whose refusal to parse is ' + + "load-bearing for mongo's multi-host/`+srv` forms (#8696). For postgres that leniency " + + 'was no check at all: `pg@8.22.0` does not implement libpq\'s multi-host DSN — both ' + + "`pg-connection-string`'s `parse` and `pg`'s `ConnectionParameters` throw " + + '`TypeError [ERR_INVALID_URL]` on `postgresql://app@h1:5432,h2:5433/app` (measured) — ' + + 'so an operator could publish exactly that URL, see it saved, and discover only at ' + + 'connect time that it can never open a connection, via a bare `Invalid URL` whose ' + + '`input` field `pg` redacts. The refusal now asks the same grammar one door up: ' + + '`parse` from `pg-connection-string` (the parser `pg` itself uses) runs at publish, ' + + 'per-driver, and what it throws on is refused with the value\'s path named. Two ' + + 'adjacent shapes are refused as structurally unusable rather than parse-refused, both ' + + 'measured: a scheme-less value "parses" only by resolving against the parser\'s ' + + 'placeholder base (`postgres://base`), i.e. `pg` would connect to the literal host ' + + '`base` with the authored text as the database name; and `?sslcert=`/`?sslkey=`/' + + '`?sslrootcert=` make `parse` itself call `fs.readFileSync`, so the verdict would ' + + 'depend on the validating host\'s filesystem — certificate material already has its ' + + 'declared home in the datasource-level `ssl` block. There is no mechanical rewrite: a ' + + 'URL `pg` cannot parse does not carry enough structure to say which single host the ' + + 'author meant (a multi-host DSN names several on purpose), so the choice of target is ' + + "the author's. Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass " + + 'through the publish door and are unaffected by construction.', + acceptanceCriteria: + 'Every postgres datasource parses with a `config.url` that `pg-connection-string` ' + + 'parses without throwing, that carries a scheme (or is a unix-socket path), and that ' + + 'carries no `?sslcert=`/`?sslkey=`/`?sslrootcert=` query parameter; each affected ' + + 'datasource still connects to the intended single host; certificate material, where ' + + 'needed, lives in the datasource-level `ssl` block; mongo/mysql/turso datasources are ' + + 'byte-identical before and after (their URL checks are unchanged).', + }, { id: 'datasource-config-url-query-credential-refused', surface: 'datasource.config.url / datasource.config.syncUrl (turso) and ' + diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7862ce4468..fd2c668c69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2574,6 +2574,9 @@ importers: packages/spec: dependencies: + pg-connection-string: + specifier: ^2.14.0 + version: 2.14.0 zod: specifier: ^4.4.3 version: 4.4.3