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
70 changes: 70 additions & 0 deletions .changeset/datasource-config-postgres-url-unparseable-refused.md
Original file line numberDiff line numberDiff line change
@@ -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.

<!-- adr-0087: registered datasource-config-postgres-url-unparseable-refused -->
2 changes: 1 addition & 1 deletion content/docs/references/data/driver-postgres.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |
Expand Down
1 change: 1 addition & 0 deletions packages/spec/package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -246,6 +246,7 @@
"vitest": "^4.1.10"
},
"dependencies": {
"pg-connection-string": "^2.14.0",
"zod": "^4.4.3"
},
"peerDependencies": {
Expand Down
158 changes: 158 additions & 0 deletions packages/spec/src/data/driver/postgres.test.ts
Original file line numberDiff line numberDiff line change
@@ -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', () => {
Expand DownExpand Up@@ -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);
});
});
Loading
Loading