From f1ec0beccb81932fe37fb04af2124519b1998f13 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 13:56:25 +0000 Subject: [PATCH 1/2] =?UTF-8?q?feat(spec):=20refuse=20${=E2=80=A6}=20place?= =?UTF-8?q?holder=20syntax=20in=20connection-material=20driver=20config=20?= =?UTF-8?q?keys=20at=20publish=20(#8336)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #7990 census (measured during #8078) established that ${…} placeholders in authored datasource config are resolved by nothing — stored verbatim in sys_metadata, handed verbatim to the database client, failing at a distance with no error naming the placeholder. Two shipped refusal messages (#8078, #8082) had to warn around the broken escape. Maintainer-ruled direction 2 (2026-08-13): refuse the syntax loudly at publish; direction 1 (implement resolution) rejected — a capability with an env-exfiltration surface and no measured pull. - One shared value-level judgement in data/driver/common.zod.ts (UNRESOLVED_PLACEHOLDER_REFUSED / containsUnresolvedPlaceholder / placeholderFree / placeholderFreeDeep), the #8082 single-mechanism construction, applied to every connection-material string key the shared factory hands to the client: postgres url/host/database/username/schema/ applicationName, mysql url/host/database/username, mongo url/host/database/ username/authSource + the options passthrough (judged deep), turso url/syncUrl/encryptionKey, sqlite + sqlite-wasm filename. - Boundary: placeholder-by-intent — complete ${…} spans only; $VAR, {name}, unclosed ${ stay accepted; no-contract drivers stay unjudged (#4410 line). - The #8082 message now points at the refusal instead of warning around it. - #7990-census acceptance pins inverted, not deleted (datasource.test.ts, postgres.test.ts); family pins in driver-placeholder-refusal.test.ts. - ADR-0087: D3 semantic entry datasource-config-placeholder-refused (no D2 — no mechanical rewrite exists); registry/spec-changes/upgrade-guide regenerated. Changeset: @objectstack/spec minor (launch-window accept-set narrowing). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- ...eholder-syntax-refused-in-driver-config.md | 57 +++++ docs/protocol-upgrade-guide.md | 3 + packages/spec/spec-changes.json | 14 ++ packages/spec/src/data/datasource.test.ts | 31 ++- packages/spec/src/data/driver/common.zod.ts | 131 +++++++++- .../driver/driver-placeholder-refusal.test.ts | 227 ++++++++++++++++++ packages/spec/src/data/driver/mongo.zod.ts | 35 ++- packages/spec/src/data/driver/mysql.zod.ts | 20 +- .../spec/src/data/driver/postgres.test.ts | 23 +- packages/spec/src/data/driver/postgres.zod.ts | 39 ++- packages/spec/src/data/driver/sqlite.zod.ts | 9 +- packages/spec/src/data/driver/turso.zod.ts | 21 +- ...7.datasource-config-placeholder-refused.ts | 38 +++ packages/spec/src/migrations/registry.ts | 34 +++ 14 files changed, 623 insertions(+), 59 deletions(-) create mode 100644 .changeset/placeholder-syntax-refused-in-driver-config.md create mode 100644 packages/spec/src/data/driver/driver-placeholder-refusal.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts diff --git a/.changeset/placeholder-syntax-refused-in-driver-config.md b/.changeset/placeholder-syntax-refused-in-driver-config.md new file mode 100644 index 0000000000..8434fe9521 --- /dev/null +++ b/.changeset/placeholder-syntax-refused-in-driver-config.md @@ -0,0 +1,57 @@ +--- +"@objectstack/spec": minor +--- + +feat(spec): refuse `${…}` placeholder syntax in connection-material driver config keys at publish (#8336) + +A `${…}` placeholder written in authored datasource config (e.g. +`config.url: 'postgresql://${DB_HOST}/db'`) is resolved by **nothing** — it was +stored verbatim in `sys_metadata` and handed verbatim to the database client at +connect (#7990 census, measured during #8078), so the connection failed (or +connected somewhere unintended) with no error naming the unresolved placeholder. +The maintainer-ruled fix (#8336, direction 2, 2026-08-13) refuses the syntax +loudly at publish instead of implementing resolution, making the non-capability +explicit: **placeholders are not resolved here**. + +**What is refused:** a complete `${…}` span anywhere in a connection-material +string value of the built-in driver configs — postgres/mysql/mongo +`url`/`host`/`database`/`username`, postgres `schema`/`applicationName`, mongo +`authSource` and every nested string in the `options` passthrough, turso +`url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename`. + +**What stays accepted:** every literal value byte-identically, including +placeholder-looking near-misses (`$VAR`, `{name}`, an unclosed `${`); configs of +drivers with no shipped contract (plugin-contributed ids stay unjudged, the +#4410 boundary). + +**Carve-out (by construction):** runtime-environment DSNs — `OS_DATABASE_URL` +and friends — are translated into driver config by the boot hosts and never pass +through this authoring schema; environment-driven deployment is exactly what +they are for. + +## FROM → TO + +```ts +// before — parsed green, stored verbatim, failed at a distance +defineDatasource({ + name: 'prod', driver: 'postgres', + config: { url: 'postgresql://svc@${DB_HOST}:5432/prod' }, +}) + +// after — write the literal value; env-driven deployments use the runtime DSN +defineDatasource({ + name: 'prod', driver: 'postgres', + config: { url: 'postgresql://svc@db.internal:5432/prod' }, +}) +// (or set OS_DATABASE_URL in the runtime environment — never through this +// schema; for secret material, bind it via the connection form's secret field / +// external.credentialsRef instead.) +``` + +There is deliberately **no automatic rewrite**: the placeholder names a value +that exists only in the author's intended deployment environment, which a +source-file transform cannot know — substituting anything would invent a +connection target. `os migrate meta` surfaces the change as a structured TODO +(semantic entry `datasource-config-placeholder-refused`). + + diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 3e8f328c00..8fe4ba2e5a 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -376,6 +376,9 @@ Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, main - **`datasource-config-inline-credential-refused`** — `datasource.config.password (postgres / mysql / mongo) and datasource.config.authToken (turso)` → the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference - Why not automatic: A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest (#7990, maintainer-ruled per-artefact contract closure, 2026-08-12). There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead. - Done when: Every datasource parses with no `config.password` / `config.authToken` key; each affected datasource carries `external.credentialsRef` (or has its secret bound through the connection form) and still connects; no cleartext credential remains in any stored `sys_metadata` row or authored source. +- **`datasource-config-placeholder-refused`** — `connection-material string keys of the built-in driver configs — postgres/mysql/mongo `url`/`host`/`database`/`username`, postgres `schema`/`applicationName`, mongo `authSource` and the `options` passthrough (judged deep), turso `url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename` — values containing `${…}` placeholder syntax` → the literal value. For secret material, the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` reference. For environment-driven connections, the runtime environment itself: `OS_DATABASE_URL` and friends are translated into driver config by the boot hosts and never pass through the publish door + - Why not automatic: A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is stored verbatim in `sys_metadata` and handed verbatim to the database client at connect (#7990 census, measured during #8078), so the connection fails, or connects somewhere unintended, with no error naming the unresolved placeholder — the masked-failure shape. The syntax looked supported: it parsed green, stored fine, and failed at a distance; two shipped refusal messages (#8078 inline credentials, #8082 URL userinfo) had to warn "do NOT substitute a placeholder" around the broken escape. Maintainer-ruled direction 2 on #8336 (2026-08-13): refuse the syntax loudly at publish; implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and zero measured pull for actual substitution. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know — substituting anything would invent a connection target. + - Done when: Every datasource parses with no `${…}` span in any connection-material config value; environment-driven deployments carry their DSN in the runtime environment (`OS_DATABASE_URL` and friends) or bind secrets via `external.credentialsRef`, and still connect; no unresolved placeholder remains in any stored `sys_metadata` row or authored source. - **`datasource-config-url-userinfo-refused`** — `datasource.config.url (postgres / mysql / mongo / turso) and datasource.config.syncUrl (turso) — the URL userinfo password segment (`user:password@host`)` → the same URL with its userinfo password removed (a bare `user@host` stays legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference - Why not automatic: The #7990 closure refused the inline credential KEYS, and #8078 measured that `config.url` still accepted the identical secret one syntax over — `postgresql://user:password@host/db` landed in `sys_metadata` cleartext exactly as `config.password` did, and the key refusal itself steered authors there (#8082, maintainer-ruled Option A, 2026-08-12). Runtime-environment DSNs (`OS_DATABASE_URL` and friends) never pass through the publish door and are unaffected by construction. There is no mechanical rewrite, for the same reason as the sibling entry `datasource-config-inline-credential-refused`: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and stripping the cleartext, which a source-file transform cannot do — auto-stripping the userinfo alone would silently drop a live credential instead. Do not substitute a `${…}` placeholder into the URL: placeholders in authored metadata are resolved by nothing and reach the database client verbatim (#8078, measured). - Done when: Every datasource parses with a credential-free `config.url` / `config.syncUrl` (no userinfo password segment); each affected datasource carries `external.credentialsRef` (or has its secret bound through the connection form) and still connects; no URL-embedded credential remains in any stored `sys_metadata` row or authored source. diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 700209ee20..ac191b1064 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -649,6 +649,13 @@ "toMajor": 17, "rationale": "A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest (#7990, maintainer-ruled per-artefact contract closure, 2026-08-12). There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead." }, + { + "surface": "connection-material string keys of the built-in driver configs — postgres/mysql/mongo `url`/`host`/`database`/`username`, postgres `schema`/`applicationName`, mongo `authSource` and the `options` passthrough (judged deep), turso `url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename` — values containing `${…}` placeholder syntax", + "replacement": "the literal value. For secret material, the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` reference. For environment-driven connections, the runtime environment itself: `OS_DATABASE_URL` and friends are translated into driver config by the boot hosts and never pass through the publish door", + "migrationId": "datasource-config-placeholder-refused", + "toMajor": 17, + "rationale": "A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is stored verbatim in `sys_metadata` and handed verbatim to the database client at connect (#7990 census, measured during #8078), so the connection fails, or connects somewhere unintended, with no error naming the unresolved placeholder — the masked-failure shape. The syntax looked supported: it parsed green, stored fine, and failed at a distance; two shipped refusal messages (#8078 inline credentials, #8082 URL userinfo) had to warn \"do NOT substitute a placeholder\" around the broken escape. Maintainer-ruled direction 2 on #8336 (2026-08-13): refuse the syntax loudly at publish; implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and zero measured pull for actual substitution. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know — substituting anything would invent a connection target." + }, { "surface": "datasource.config.url (postgres / mysql / mongo / turso) and datasource.config.syncUrl (turso) — the URL userinfo password segment (`user:password@host`)", "replacement": "the same URL with its userinfo password removed (a bare `user@host` stays legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference", @@ -1685,6 +1692,13 @@ "toMajor": 17, "rationale": "A datasource artefact is persisted whole into `sys_metadata`, which is served back by the ordinary data API — an inline credential is cleartext at rest (#7990, maintainer-ruled per-artefact contract closure, 2026-08-12). There is no mechanical rewrite: moving the value requires ENCRYPTING it into a `sys_secret` row through a running secret binder and deleting the cleartext, which a source-file transform cannot do — auto-deleting the key alone would silently drop a live credential instead." }, + { + "surface": "connection-material string keys of the built-in driver configs — postgres/mysql/mongo `url`/`host`/`database`/`username`, postgres `schema`/`applicationName`, mongo `authSource` and the `options` passthrough (judged deep), turso `url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename` — values containing `${…}` placeholder syntax", + "replacement": "the literal value. For secret material, the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` reference. For environment-driven connections, the runtime environment itself: `OS_DATABASE_URL` and friends are translated into driver config by the boot hosts and never pass through the publish door", + "migrationId": "datasource-config-placeholder-refused", + "toMajor": 17, + "rationale": "A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is stored verbatim in `sys_metadata` and handed verbatim to the database client at connect (#7990 census, measured during #8078), so the connection fails, or connects somewhere unintended, with no error naming the unresolved placeholder — the masked-failure shape. The syntax looked supported: it parsed green, stored fine, and failed at a distance; two shipped refusal messages (#8078 inline credentials, #8082 URL userinfo) had to warn \"do NOT substitute a placeholder\" around the broken escape. Maintainer-ruled direction 2 on #8336 (2026-08-13): refuse the syntax loudly at publish; implementing real resolution was explicitly rejected — a new capability with an env-exfiltration security surface and zero measured pull for actual substitution. There is no mechanical rewrite: the placeholder names a value that exists only in the author's intended deployment environment, which a source-file transform cannot know — substituting anything would invent a connection target." + }, { "surface": "datasource.config.url (postgres / mysql / mongo / turso) and datasource.config.syncUrl (turso) — the URL userinfo password segment (`user:password@host`)", "replacement": "the same URL with its userinfo password removed (a bare `user@host` stays legal), plus the datasource secret binder: the Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle stored at `external.credentialsRef`), or a direct `external.credentialsRef` secrets-store reference", diff --git a/packages/spec/src/data/datasource.test.ts b/packages/spec/src/data/datasource.test.ts index 592227e2b2..e98f8b945d 100644 --- a/packages/spec/src/data/datasource.test.ts +++ b/packages/spec/src/data/datasource.test.ts @@ -303,6 +303,11 @@ describe('DatasourceSchema', () => { }); it('should accept REST API datasource', () => { + // The `${API_KEY}` placeholder stays ACCEPTED here on purpose: `rest_api` + // ships no config contract, so its `config` is validated against nothing + // (#4410's honest boundary) — the #8336 placeholder refusal judges only + // the built-in driver schemas. Pinned as the boundary in + // driver/driver-placeholder-refusal.test.ts too. const datasource = DatasourceSchema.parse({ name: 'external_api', driver: 'rest_api', @@ -327,14 +332,15 @@ describe('DatasourceSchema', () => { }); - it('should accept datasource with environment variables in config', () => { - // NOTE (#7990 census): nothing in the runtime resolves `${…}` placeholders - // in datasource config — these strings reach the client verbatim. Only - // NON-credential keys keep the placeholder convention; `config.password` - // is refused whatever its value, placeholder included (the placeholder was - // stored in cleartext in `sys_metadata` exactly like a real password, and - // connected with the literal string as the password when unresolved). - const datasource = DatasourceSchema.parse({ + it('refuses datasource with environment variables in config — placeholders are not resolved here (#8336)', () => { + // INVERTED acceptance pin. This test used to pin (#7990 census) that + // `${…}` placeholders in NON-credential connection keys parse — recording + // the measured fact that nothing resolves them and the strings reach the + // client verbatim, the masked-failure shape. The #8336 ruling (direction + // 2, 2026-08-13) refuses the syntax at publish instead: same input, + // opposite verdict. The per-key family pins live in + // driver/driver-placeholder-refusal.test.ts. + const inverted = DatasourceSchema.safeParse({ name: 'secure_db', driver: 'postgres', config: { @@ -344,9 +350,14 @@ describe('DatasourceSchema', () => { username: '${DB_USER}', }, }); + expect(inverted.success).toBe(false); + const paths = inverted.error!.issues + .filter((i) => i.message.includes('placeholders are not resolved here')) + .map((i) => i.path.join('.')); + expect(paths.sort()).toEqual(['config.database', 'config.host', 'config.username']); - expect(datasource.config.username).toBe('${DB_USER}'); - + // Unchanged half: `config.password` is refused whatever its value, + // placeholder included — the KEY is the cleartext sink (#7990). const refused = DatasourceSchema.safeParse({ name: 'secure_db', driver: 'postgres', diff --git a/packages/spec/src/data/driver/common.zod.ts b/packages/spec/src/data/driver/common.zod.ts index dffdee6b01..3f763229bd 100644 --- a/packages/spec/src/data/driver/common.zod.ts +++ b/packages/spec/src/data/driver/common.zod.ts @@ -131,6 +131,9 @@ export const INLINE_CREDENTIAL_REFUSED = (key: string): string => * metadata placeholders are resolved by nothing and reach the client * verbatim, so "put a placeholder in the URL" is a broken escape that * recreates the masked-failure shape (#8082's ruling names this binding). + * Since #8336 the message no longer merely warns: placeholder syntax in + * connection-material keys is itself refused at publish (maintainer-ruled + * direction 2, 2026-08-13), so the sentence now points at that refusal. * - It must state the runtime-DSN carve-out explicitly (maintainer ruling): * a DSN that arrives via the RUNTIME ENVIRONMENT (`OS_DATABASE_URL` and * friends) is translated into a driver config by the boot hosts and handed @@ -152,7 +155,8 @@ export const URL_EMBEDDED_CREDENTIAL_REFUSED = (key: string): string => + '`external.credentialsRef`. The resolved secret is injected at connect time and wins over ' + 'anything embedded in the URL. Do NOT substitute a `${…}` placeholder into the URL: ' + 'placeholders in authored metadata are resolved by nothing and reach the database client ' - + 'verbatim (#8078, measured). Runtime-environment DSNs (`OS_DATABASE_URL` and friends) do ' + + 'verbatim (#8078, measured), and are themselves refused at publish (#8336). ' + + 'Runtime-environment DSNs (`OS_DATABASE_URL` and friends) do ' + 'not pass through this publish door and are unaffected.'; /** @@ -212,6 +216,131 @@ export function credentialFreeUrl(schema: S, key: string) }); } +/** + * Refusal prescription for a `${…}` placeholder written into a + * connection-material driver-config value (#8336, maintainer-ruled direction 2 + * 2026-08-13 — refuse loudly at publish rather than implement resolution). + * + * The defect this closes (#7990 census, measured during #8078): a placeholder + * such as `config.url: 'postgresql://${DB_HOST}/db'` is resolved by NOTHING — + * it is stored verbatim in `sys_metadata` and handed verbatim to the database + * client at connect. The author believes environment substitution happens; the + * connection then fails (or connects somewhere unintended) with no error + * naming the unresolved placeholder — the masked-failure shape. Until #8336 + * the syntax looked supported: it parsed green, stored fine, and failed at a + * distance. Two shipped refusals (#8078's inline-credential message, #8082's + * URL-userinfo message) had to warn AROUND the broken escape instead of + * pointing through it; this refusal makes the non-capability explicit + * (declared = enforced), and implementing real resolution — a capability with + * an env-exfiltration security surface and no measured pull — was explicitly + * rejected for now. + * + * The message names every working escape so it can serve as the migration doc + * for whoever hits it (very often an AI author): + * + * - the literal value, for non-secret connection material; + * - the datasource secret binder / `external.credentialsRef`, for secret + * material (the #7990/#8082 family's mechanisms, injected at connect time); + * - the runtime environment itself (`OS_DATABASE_URL` and friends) for + * environment-driven deployments — those DSNs are translated into driver + * config by the boot hosts and never pass through this authoring schema, + * so they are unaffected by construction (the same carve-out #8082 states). + */ +export const UNRESOLVED_PLACEHOLDER_REFUSED = (key: string): string => + `this \`${key}\` contains a \`\${…}\` placeholder, and placeholders are not resolved here ` + + '(#8336): nothing in the platform substitutes `${…}` in authored datasource config — ' + + 'placeholders in authored metadata are resolved by nothing, are stored verbatim in ' + + '`sys_metadata`, and reach the database client verbatim (#8078, measured), so the ' + + 'connection fails (or connects somewhere unintended) with no error naming the unresolved ' + + 'placeholder. Write the literal value instead. For secret material, bind it: the Setup → ' + + "Datasources connection form's secret field hands it to the datasource secret binder, " + + 'which encrypts it into `sys_secret` and stores only an opaque handle at ' + + '`external.credentialsRef` — the resolved secret is injected at connect time. For ' + + 'environment-driven connections, configure the runtime environment itself: ' + + '`OS_DATABASE_URL` and friends are translated into driver config by the boot hosts and ' + + 'never pass through this publish door, so they are unaffected.'; + +/** + * Does this authored string contain a complete `${…}` placeholder span? + * + * The boundary is placeholder-by-INTENT for the one convention the census + * measured (`${NAME}` — shell/compose-style interpolation), judged as a + * complete span rather than a token grammar: + * + * - `${anything}` matches, whatever is inside the braces — a placeholder + * carrying a non-word character is still a placeholder attempt, and letting + * it through as "not a placeholder" is the `context-tokens.zod.ts` #5586 + * lesson one surface over; + * - a bare `$VAR`, a brace pair without the dollar (`{name}`), and an + * unclosed `${` do NOT match — none of them is the convention authors were + * measured writing, and widening the refusal past the measured defect would + * trade a masked failure for false rejections of legitimate literals. + */ +export function containsUnresolvedPlaceholder(value: string): boolean { + return /\$\{[^}]*\}/.test(value); +} + +/** + * Attach the #8336 placeholder refusal to a connection-material driver-config + * string key. + * + * One shared check for every connection-material string key on the driver + * schemas — the same single-mechanism construction as {@link credentialFreeUrl} + * (#8082), so the policy cannot drift per driver or per key. Composes with + * that check on the URL-bearing keys: `placeholderFree(credentialFreeUrl(…))` + * judges both facts, and an input violating both reports both. + */ +export function placeholderFree(schema: S, key: string) { + return schema.superRefine((value, ctx) => { + if (typeof value !== 'string') return; + if (containsUnresolvedPlaceholder(value)) { + ctx.addIssue({ code: 'custom', message: UNRESOLVED_PLACEHOLDER_REFUSED(key) }); + } + }); +} + +/** + * The #8336 placeholder refusal for a connection-material PASSTHROUGH slot — + * mongo's `options`, a record handed to the MongoDB client verbatim. + * + * The deep walk exists because the passthrough is exactly where the refusal + * would otherwise steer authors: refuse `${RS_NAME}` at `config.url` and the + * green escape becomes `options: { replicaSet: '${RS_NAME}' }` — the same + * one-syntax-over displacement #8078 measured for credentials. Every string + * value in the record (nested objects and arrays included) reaches the client, + * so every one is judged; non-string values carry no placeholder and pass + * untouched. Each finding is reported at its own path so the author is pointed + * at the exact entry, not at the record as a whole. + */ +export function placeholderFreeDeep(schema: S, key: string) { + return schema.superRefine((value, ctx) => { + for (const path of unresolvedPlaceholderPaths(value)) { + ctx.addIssue({ + code: 'custom', + path, + message: UNRESOLVED_PLACEHOLDER_REFUSED([key, ...path].join('.')), + }); + } + }); +} + +/** Paths of every string value under `value` carrying a `${…}` span. */ +function unresolvedPlaceholderPaths( + value: unknown, + path: readonly (string | number)[] = [], +): (string | number)[][] { + if (typeof value === 'string') { + return containsUnresolvedPlaceholder(value) ? [[...path]] : []; + } + if (Array.isArray(value)) { + return value.flatMap((entry, index) => unresolvedPlaceholderPaths(entry, [...path, index])); + } + if (value !== null && typeof value === 'object') { + return Object.entries(value).flatMap(([k, v]) => unresolvedPlaceholderPaths(v, [...path, k])); + } + return []; +} + /** * A driver-config credential key, declared but UNWRITABLE (#7990). * diff --git a/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts b/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts new file mode 100644 index 0000000000..b9a765e881 --- /dev/null +++ b/packages/spec/src/data/driver/driver-placeholder-refusal.test.ts @@ -0,0 +1,227 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #8336 — `${…}` placeholder syntax is refused across the connection-material + * driver-config family (maintainer-ruled direction 2, 2026-08-13: refuse + * loudly at publish; implementing resolution was explicitly rejected). + * + * The defect (#7990 census, measured during #8078): a placeholder in authored + * datasource config is resolved by NOTHING — stored verbatim in + * `sys_metadata`, handed verbatim to the database client, failing at a + * distance with no error naming the placeholder. Until this card the syntax + * looked supported; two shipped refusal messages (#8078, #8082) had to warn + * around the broken escape instead of pointing through it. + * + * Every pin here asserts BOTH directions: + * + * 1. a `${…}` span anywhere in a connection-material string value is + * refused LOUDLY, pathed at the key, with guidance naming the working + * escapes (literal value; secret binder / `external.credentialsRef` for + * secret material; runtime-environment DSNs for env-driven deployments); + * 2. literal values — including strings that merely look placeholder-ish + * (`$VAR`, `{name}`, an unclosed `${`) — keep parsing byte-identically. + * + * Envelope note (same as the #8082 pins): `status` does not exist at this + * layer — every schema refusal is wrapped uniformly by the publish door, + * metadata-protocol's `422 INVALID_METADATA`, whose `issues[]` carry these + * zod issues verbatim (pinned generically by e.g. + * `protocol.save-flow-canonicalization.test.ts` asserting + * `{ code: 'INVALID_METADATA', status: 422 }`). The zod issue's `code` and its + * path are asserted here, as far as the schema layer carries an envelope. + */ + +import { describe, expect, it } from 'vitest'; + +import { DatasourceSchema } from '../datasource.zod'; +import { containsUnresolvedPlaceholder } from './common.zod'; +import { MongoConfigSchema } from './mongo.zod'; +import { MysqlConfigSchema } from './mysql.zod'; +import { PostgresConfigSchema } from './postgres.zod'; +import { SqliteConfigSchema, SqliteWasmConfigSchema } from './sqlite.zod'; +import { TursoConfigSchema } from './turso.zod'; + +/** + * The family under the ruling: every connection-material STRING key across the + * driver-config schemas — the keys the shared factory hands to the database + * client at connect. `valid` is a minimal green config; `literal` is the + * literal spelling of the judged key (the acceptance pin); `placeholder` is + * the same key carrying a `${…}` span (the refusal pin). URL samples carry NO + * userinfo, so the #8082 check cannot be the one firing. + */ +const FAMILY = [ + { name: 'postgres url', schema: PostgresConfigSchema, key: 'url', + valid: {}, literal: 'postgresql://db.internal:5432/prod', placeholder: 'postgresql://${DB_HOST}:5432/prod' }, + { name: 'postgres host', schema: PostgresConfigSchema, key: 'host', + valid: { database: 'prod' }, literal: 'db.internal', placeholder: '${DB_HOST}' }, + { name: 'postgres database', schema: PostgresConfigSchema, key: 'database', + valid: {}, literal: 'prod', placeholder: '${DB_NAME}' }, + { name: 'postgres username', schema: PostgresConfigSchema, key: 'username', + valid: { database: 'prod' }, literal: 'app', placeholder: '${DB_USER}' }, + { name: 'postgres schema', schema: PostgresConfigSchema, key: 'schema', + valid: { database: 'prod' }, literal: 'analytics', placeholder: '${DB_SCHEMA}' }, + { name: 'postgres applicationName', schema: PostgresConfigSchema, key: 'applicationName', + valid: { database: 'prod' }, literal: 'objectstack', placeholder: '${SERVICE_NAME}' }, + { name: 'mysql url', schema: MysqlConfigSchema, key: 'url', + valid: {}, literal: 'mysql://db.internal:3306/prod', placeholder: 'mysql://${DB_HOST}:3306/prod' }, + { name: 'mysql host', schema: MysqlConfigSchema, key: 'host', + valid: { database: 'prod' }, literal: 'db.internal', placeholder: '${DB_HOST}' }, + { name: 'mysql database', schema: MysqlConfigSchema, key: 'database', + valid: {}, literal: 'prod', placeholder: '${DB_NAME}' }, + { name: 'mysql username', schema: MysqlConfigSchema, key: 'username', + valid: { database: 'prod' }, literal: 'app', placeholder: '${DB_USER}' }, + { name: 'mongo url', schema: MongoConfigSchema, key: 'url', + valid: {}, literal: 'mongodb://mongo.internal:27017/events', placeholder: 'mongodb://${MONGO_HOST}:27017/events' }, + { name: 'mongo host', schema: MongoConfigSchema, key: 'host', + valid: { database: 'events' }, literal: 'mongo.internal', placeholder: '${MONGO_HOST}' }, + { name: 'mongo database', schema: MongoConfigSchema, key: 'database', + valid: {}, literal: 'events', placeholder: '${MONGO_DB}' }, + { name: 'mongo username', schema: MongoConfigSchema, key: 'username', + valid: { database: 'events' }, literal: 'svc', placeholder: '${MONGO_USER}' }, + { name: 'mongo authSource', schema: MongoConfigSchema, key: 'authSource', + valid: { database: 'events' }, literal: 'admin', placeholder: '${AUTH_DB}' }, + { name: 'turso url', schema: TursoConfigSchema, key: 'url', + valid: {}, literal: 'libsql://x.turso.io', placeholder: 'libsql://${TURSO_HOST}' }, + { name: 'turso syncUrl', schema: TursoConfigSchema, key: 'syncUrl', + valid: { url: 'file:./data/replica.db' }, literal: 'libsql://x.turso.io', placeholder: 'libsql://${TURSO_SYNC_HOST}' }, + { name: 'turso encryptionKey', schema: TursoConfigSchema, key: 'encryptionKey', + valid: { url: 'file:./data/local.db' }, literal: 'a'.repeat(32), placeholder: '${TURSO_ENCRYPTION_KEY}' }, + { name: 'sqlite filename', schema: SqliteConfigSchema, key: 'filename', + valid: {}, literal: './data/objectstack.db', placeholder: '${DATA_DIR}/objectstack.db' }, + { name: 'sqlite-wasm filename', schema: SqliteWasmConfigSchema, key: 'filename', + valid: {}, literal: './data/objectstack.db', placeholder: '${DATA_DIR}/objectstack.db' }, +] as const; + +describe.each(FAMILY)('$name — unresolved placeholder refusal (#8336)', (f) => { + it('refuses a `${…}` placeholder, naming the key, the defect and the working escapes', () => { + const result = f.schema.safeParse({ ...f.valid, [f.key]: f.placeholder }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === f.key); + expect(issue, `refusal must be pathed at \`${f.key}\``).toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain(`\`${f.key}\``); + // The ruling's guidance, verbatim: the non-capability is explicit. + expect(issue!.message).toContain('placeholders are not resolved here'); + // The measured defect, in the family's shared phrasing (#8078). + expect(issue!.message).toContain('resolved by nothing'); + // The working escapes BY NAME — secret binder for secret material … + expect(issue!.message).toContain('external.credentialsRef'); + expect(issue!.message).toContain('sys_secret'); + // … and the runtime-environment carve-out for env-driven deployments. + expect(issue!.message).toContain('OS_DATABASE_URL'); + expect(issue!.message).toContain('unaffected'); + }); + + it('accepts the literal spelling byte-identically (pin)', () => { + const config = { ...f.valid, [f.key]: f.literal }; + const before = f.schema.safeParse(config); + expect(before.success, JSON.stringify(before.error?.issues)).toBe(true); + expect(f.schema.parse(config)).toEqual(before.data); + }); + + it('placeholder-LOOKING literals stay accepted: `$VAR`, `{name}`, unclosed `${` are not the measured convention', () => { + // The refusal judges the one convention the census measured — a complete + // `${…}` span. Widening past it would trade the masked failure for false + // rejections of legitimate literals (a `$` or brace in a real value). + for (const nearMiss of [ + f.literal + '$SUFFIX', + f.literal + '{curly}', + f.literal + '-${unclosed', + ]) { + const result = f.schema.safeParse({ ...f.valid, [f.key]: nearMiss }); + expect(result.success, `\`${nearMiss}\` must stay accepted: ${JSON.stringify(result.error?.issues)}`).toBe(true); + } + }); +}); + +describe('mongo `options` passthrough — the deep judgement (#8336)', () => { + const base = { database: 'events', host: 'mongo.internal' }; + + it('refuses a `${…}` value at its exact path inside the record', () => { + const result = MongoConfigSchema.safeParse({ + ...base, + options: { replicaSet: '${RS_NAME}' }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'options.replicaSet'); + expect(issue, 'refusal must be pathed at `options.replicaSet`').toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain('`options.replicaSet`'); + expect(issue!.message).toContain('placeholders are not resolved here'); + }); + + it('judges nested objects and arrays — everything in the record reaches the client', () => { + const result = MongoConfigSchema.safeParse({ + ...base, + options: { tls: true, hosts: ['a.internal', '${MONGO_B_HOST}'] }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'options.hosts.1'); + expect(issue, 'refusal must be pathed at `options.hosts.1`').toBeDefined(); + }); + + it('accepts a placeholder-free options record byte-identically (pin)', () => { + const config = { + ...base, + options: { replicaSet: 'rs0', tls: true, serverSelectionTimeoutMS: 5000 }, + }; + const before = MongoConfigSchema.safeParse(config); + expect(before.success, JSON.stringify(before.error?.issues)).toBe(true); + expect(MongoConfigSchema.parse(config)).toEqual(before.data); + }); +}); + +describe('DatasourceSchema — the refusal reaches the authored artefact (#8336)', () => { + it('re-paths the refusal under `config.` for the author', () => { + const result = DatasourceSchema.safeParse({ + name: 'prod', + driver: 'postgres', + config: { database: 'prod', host: '${DB_HOST}' }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'config.host'); + expect(issue, 'issue must be re-pathed under config.host').toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain('placeholders are not resolved here'); + }); + + it('a driver with no shipped contract keeps its config unjudged — the honest #4410 boundary', () => { + // A plugin-contributed driver's config is validated against nothing + // (config-registry.zod.ts: "we validate what we can construct"), so a + // placeholder there is NOT refused — the platform has no schema to judge + // it against, and inventing a verdict would be worse than the silence. + const result = DatasourceSchema.safeParse({ + name: 'external_api', + driver: 'rest_api', + config: { baseUrl: 'https://api.example.com', apiKey: '${API_KEY}' }, + }); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + }); +}); + +describe('composition with the #8082 userinfo check', () => { + it('a URL violating both facts reports both — placeholder AND embedded credential', () => { + const result = PostgresConfigSchema.safeParse({ + url: 'postgresql://svc:${DB_PASSWORD}@db.internal:5432/prod', + }); + expect(result.success).toBe(false); + const messages = result.error!.issues.map((i) => i.message).join('\n'); + expect(messages).toContain('placeholders are not resolved here'); + expect(messages).toContain('embeds a password in its userinfo'); + }); +}); + +describe('containsUnresolvedPlaceholder — the shared value-level judgement (#8336)', () => { + it('matches a complete `${…}` span wherever it sits, whatever it contains', () => { + expect(containsUnresolvedPlaceholder('${DB_HOST}')).toBe(true); + expect(containsUnresolvedPlaceholder('postgresql://${DB_HOST}/db')).toBe(true); + expect(containsUnresolvedPlaceholder('pre-${a b c}-post')).toBe(true); + expect(containsUnresolvedPlaceholder('${}')).toBe(true); + }); + + it('does not match the near-miss shapes — `$VAR`, `{name}`, unclosed `${`', () => { + expect(containsUnresolvedPlaceholder('$DB_HOST')).toBe(false); + expect(containsUnresolvedPlaceholder('{DB_HOST}')).toBe(false); + expect(containsUnresolvedPlaceholder('db-${unclosed')).toBe(false); + expect(containsUnresolvedPlaceholder('plain.internal')).toBe(false); + }); +}); diff --git a/packages/spec/src/data/driver/mongo.zod.ts b/packages/spec/src/data/driver/mongo.zod.ts index f4fb592f86..1fde1c0390 100644 --- a/packages/spec/src/data/driver/mongo.zod.ts +++ b/packages/spec/src/data/driver/mongo.zod.ts @@ -9,6 +9,8 @@ import { credentialFreeUrl, driverConfigJsonSchema, INLINE_CREDENTIAL_REFUSED, + placeholderFree, + placeholderFreeDeep, READ_ONLY_BELONGS_ON_DATASOURCE, refusedInlineCredentialKey, SCHEMA_MODE_BELONGS_ON_DATASOURCE, @@ -76,28 +78,33 @@ export const MongoConfigSchema = lazySchema(() => strictObject( * a `username:password@` userinfo is refused at publish exactly like an * inline `password` (#7990) — bind the secret (`external.credentialsRef` / * the connection form's secret field) and it is injected at connect time. A - * bare username (`user@host1`) stays writable. Runtime-environment DSNs + * bare username (`user@host1`) stays writable. 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. * Format: `mongodb://[username@]host1[:port1][,…][/[db][?options]]` */ - url: credentialFreeUrl(z.string(), 'url').optional() + url: placeholderFree(credentialFreeUrl(z.string(), 'url'), 'url').optional() .describe('Connection URI (supersedes the discrete fields; must not embed a password — bind the secret instead)') .meta({ title: 'Connection URI' }), /** * Database name — the logical database holding the collections. - * Required unless `url` carries it. + * Required unless `url` carries it. Placeholder-free since #8336. */ - database: z.string().min(1).optional().describe('Database name').meta({ title: 'Database' }), + database: placeholderFree(z.string().min(1), 'database').optional() + .describe('Database name').meta({ title: 'Database' }), - /** Hostname. Used only when `url` is absent. */ - host: z.string().default('localhost').describe('Host address').meta({ title: 'Host' }), + /** Hostname. Used only when `url` is absent. Placeholder-free since #8336. */ + host: placeholderFree(z.string(), 'host').default('localhost') + .describe('Host address').meta({ title: 'Host' }), /** Port. Used only when `url` is absent. */ port: z.number().int().default(27017).describe('Port number').meta({ title: 'Port' }), - /** Authentication user. Used only when `url` is absent. */ - username: z.string().optional().describe('Authentication user').meta({ title: 'User' }), + /** Authentication user. Used only when `url` is absent. Placeholder-free since #8336. */ + username: placeholderFree(z.string(), 'username').optional() + .describe('Authentication user').meta({ title: 'User' }), /** * Authentication password — REFUSED inline since #7990 (see postgres.zod.ts: @@ -106,15 +113,19 @@ export const MongoConfigSchema = lazySchema(() => strictObject( */ password: refusedInlineCredentialKey('password', 'Password'), - /** Authentication database, when it differs from `database`. */ - authSource: z.string().optional().describe('Authentication database') + /** Authentication database, when it differs from `database`. Placeholder-free since #8336. */ + authSource: placeholderFree(z.string(), 'authSource').optional() + .describe('Authentication database') .meta({ title: 'Auth source' }), /** * Passthrough options handed to the MongoDB client verbatim - * (`replicaSet`, `tls`, timeouts, …). + * (`replicaSet`, `tls`, timeouts, …). Placeholder-free since #8336, judged + * DEEP: every nested string value reaches the client, and this passthrough + * is exactly where a refusal on `url`/`host` would otherwise displace the + * placeholder to. */ - options: z.record(z.string(), z.unknown()).optional() + options: placeholderFreeDeep(z.record(z.string(), z.unknown()), 'options').optional() .describe('Extra MongoClient options (replicaSet, tls, timeouts, …)'), }) .describe('MongoDB Connection Configuration') diff --git a/packages/spec/src/data/driver/mysql.zod.ts b/packages/spec/src/data/driver/mysql.zod.ts index 8cd698822a..e415719dcf 100644 --- a/packages/spec/src/data/driver/mysql.zod.ts +++ b/packages/spec/src/data/driver/mysql.zod.ts @@ -24,6 +24,7 @@ import { driverConfigJsonSchema, DriverSslToggleSchema, INLINE_CREDENTIAL_REFUSED, + placeholderFree, READ_ONLY_BELONGS_ON_DATASOURCE, refusedInlineCredentialKey, SCHEMA_MODE_BELONGS_ON_DATASOURCE, @@ -79,25 +80,30 @@ export const MysqlConfigSchema = lazySchema(() => strictObject( * exactly like an inline `password` (#7990) — bind the secret * (`external.credentialsRef` / the connection form's secret field) and it is * injected at connect time. A bare username (`user@host`) stays writable. + * 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. * Format: `mysql://[user@][host][:port]/[dbname][?params]` */ - url: credentialFreeUrl(z.string(), 'url').optional() + url: placeholderFree(credentialFreeUrl(z.string(), 'url'), 'url').optional() .describe('Connection URI (supersedes the discrete fields; must not embed a password — bind the secret instead)') .meta({ title: 'Connection URL' }), - /** Hostname or IP address. */ - host: z.string().default('localhost').describe('Host address').meta({ title: 'Host' }), + /** Hostname or IP address. Placeholder-free since #8336. */ + host: placeholderFree(z.string(), 'host').default('localhost') + .describe('Host address').meta({ title: 'Host' }), /** Port number. */ port: z.number().int().default(3306).describe('Port number').meta({ title: 'Port' }), - /** Database (schema) name. Required unless `url` carries it. */ - database: z.string().optional().describe('Database name').meta({ title: 'Database' }), + /** Database (schema) name. Required unless `url` carries it. Placeholder-free since #8336. */ + database: placeholderFree(z.string(), 'database').optional() + .describe('Database name').meta({ title: 'Database' }), - /** Authentication user. Passed to `mysql2` as `user`. */ - username: z.string().optional().describe('Authentication user').meta({ title: 'User' }), + /** Authentication user. Passed to `mysql2` as `user`. Placeholder-free since #8336. */ + username: placeholderFree(z.string(), 'username').optional() + .describe('Authentication user').meta({ title: 'User' }), /** * Authentication password — REFUSED inline since #7990 (see postgres.zod.ts: diff --git a/packages/spec/src/data/driver/postgres.test.ts b/packages/spec/src/data/driver/postgres.test.ts index a13ed74233..26a776725c 100644 --- a/packages/spec/src/data/driver/postgres.test.ts +++ b/packages/spec/src/data/driver/postgres.test.ts @@ -164,20 +164,25 @@ describe('PostgresConfigSchema', () => { expect(result.error!.issues[0]!.message).toContain('`user` → `username`'); }); - it('should accept config with environment variable patterns', () => { - // NOTE (#7990 census): nothing in the runtime resolves `${…}` placeholders - // in datasource config — these strings reach the client verbatim. The test - // pins only that placeholder-shaped strings parse for NON-credential keys; - // `password` is refused whatever its value (including a placeholder), - // because the key itself is the cleartext sink. - const config = PostgresConfigSchema.parse({ + it('refuses config with environment variable patterns — placeholders are not resolved here (#8336)', () => { + // INVERTED acceptance pin. This test used to pin (#7990 census) that + // placeholder-shaped strings PARSE for non-credential keys — recording the + // measured fact that nothing resolves them and they reach the client + // verbatim. The #8336 ruling (direction 2, 2026-08-13) closed that door: + // same input, opposite verdict. The family pins live in + // driver-placeholder-refusal.test.ts; this inversion keeps the historical + // fixture judged rather than deleted. + const result = PostgresConfigSchema.safeParse({ database: '${DB_NAME}', host: '${DB_HOST}', username: '${DB_USER}', }); - expect(config.database).toBe('${DB_NAME}'); - expect(config.host).toBe('${DB_HOST}'); + expect(result.success).toBe(false); + const paths = result.error!.issues + .filter((i) => i.message.includes('placeholders are not resolved here')) + .map((i) => i.path.join('.')); + expect(paths.sort()).toEqual(['database', 'host', 'username']); }); it('accepts the dev-only autoMigrate passthrough', () => { diff --git a/packages/spec/src/data/driver/postgres.zod.ts b/packages/spec/src/data/driver/postgres.zod.ts index 411fdb9e4a..96e6ac1de9 100644 --- a/packages/spec/src/data/driver/postgres.zod.ts +++ b/packages/spec/src/data/driver/postgres.zod.ts @@ -22,6 +22,7 @@ import { driverConfigJsonSchema, DriverSslToggleSchema, INLINE_CREDENTIAL_REFUSED, + placeholderFree, READ_ONLY_BELONGS_ON_DATASOURCE, refusedInlineCredentialKey, SCHEMA_MODE_BELONGS_ON_DATASOURCE, @@ -89,25 +90,30 @@ export const PostgresConfigSchema = lazySchema(() => strictObject( * userinfo is refused at publish exactly like an inline `password` (#7990) — * bind the secret (`external.credentialsRef` / the connection form's secret * field) and it is injected at connect time. A bare username (`user@host`) - * stays writable. Runtime-environment DSNs (`OS_DATABASE_URL`) never pass + * stays writable. 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. * Format: `postgresql://[user@][host][:port][/dbname][?params]` */ - url: credentialFreeUrl(z.string(), 'url').optional() + url: placeholderFree(credentialFreeUrl(z.string(), 'url'), 'url').optional() .describe('Connection URI (supersedes the discrete fields; must not embed a password — bind the secret instead)') .meta({ title: 'Connection URL' }), - /** Hostname or IP address. */ - host: z.string().default('localhost').describe('Host address').meta({ title: 'Host' }), + /** Hostname or IP address. Placeholder-free since #8336. */ + host: placeholderFree(z.string(), 'host').default('localhost') + .describe('Host address').meta({ title: 'Host' }), /** Port number. */ port: z.number().int().default(5432).describe('Port number').meta({ title: 'Port' }), - /** Database name. Required unless `url` carries it. */ - database: z.string().optional().describe('Database name').meta({ title: 'Database' }), + /** Database name. Required unless `url` carries it. Placeholder-free since #8336. */ + database: placeholderFree(z.string(), 'database').optional() + .describe('Database name').meta({ title: 'Database' }), - /** Authentication user. Passed to `pg` as `user`. */ - username: z.string().optional().describe('Authentication user').meta({ title: 'User' }), + /** Authentication user. Passed to `pg` as `user`. Placeholder-free since #8336. */ + username: placeholderFree(z.string(), 'username').optional() + .describe('Authentication user').meta({ title: 'User' }), /** * Authentication password — REFUSED inline since #7990. Declared-unwritable @@ -122,12 +128,21 @@ export const PostgresConfigSchema = lazySchema(() => strictObject( /** TLS settings, passed to `pg` verbatim. */ ssl: DriverSslToggleSchema.optional().meta({ title: 'Use SSL/TLS' }), - /** Default schema for tables that do not name one — knex `searchPath`. */ - schema: z.string().default('public').describe('Default schema (knex searchPath)') + /** Default schema for tables that do not name one — knex `searchPath`. Placeholder-free since #8336. */ + schema: placeholderFree(z.string(), 'schema').default('public') + .describe('Default schema (knex searchPath)') .meta({ title: 'Schema' }), - /** `application_name` on the connection — how this stack shows up in `pg_stat_activity`. */ - applicationName: z.string().optional().describe('Postgres application_name') + /** + * `application_name` on the connection — how this stack shows up in + * `pg_stat_activity`. Placeholder-free since #8336: a `${…}` here does not + * mask a connection failure, but it is authored under the same false belief + * and would report a literal `${SERVICE_NAME}` to every operator reading + * `pg_stat_activity` — and leaving one connection-material string key open + * is exactly the displacement door the class-wide refusal exists to close. + */ + applicationName: placeholderFree(z.string(), 'applicationName').optional() + .describe('Postgres application_name') .meta({ title: 'Application name' }), /** `statement_timeout` in milliseconds — aborts any statement that runs longer. */ diff --git a/packages/spec/src/data/driver/sqlite.zod.ts b/packages/spec/src/data/driver/sqlite.zod.ts index f5863a10c9..59490a0848 100644 --- a/packages/spec/src/data/driver/sqlite.zod.ts +++ b/packages/spec/src/data/driver/sqlite.zod.ts @@ -23,6 +23,7 @@ import { lazySchema } from '../../shared/lazy-schema'; import { strictObject } from '../../shared/strict-object'; import { driverConfigJsonSchema, + placeholderFree, READ_ONLY_BELONGS_ON_DATASOURCE, SCHEMA_MODE_BELONGS_ON_DATASOURCE, SqlAutoMigrateSchema, @@ -65,8 +66,11 @@ export const SqliteConfigSchema = lazySchema(() => strictObject( /** * Database file path, or `:memory:` for an ephemeral in-process database. * A relative path resolves against the server's working directory. + * Placeholder-free since #8336: an unresolved `${DATA_DIR}` would silently + * create and open a literal `./${DATA_DIR}/…` path — a database in the + * wrong place with every signal saying it is configured. */ - filename: z.string().default(':memory:') + filename: placeholderFree(z.string(), 'filename').default(':memory:') .describe('Database file path, or ":memory:" for an ephemeral database') .meta({ title: 'Filename' }), @@ -112,8 +116,9 @@ export const SqliteWasmConfigSchema = lazySchema(() => strictObject( /** * Database file path, or `:memory:` for an ephemeral in-process database. * A file-backed wasm database persists according to {@link SqliteWasmPersistModeSchema}. + * Placeholder-free since #8336, same as the native sqlite `filename`. */ - filename: z.string().default(':memory:') + filename: placeholderFree(z.string(), 'filename').default(':memory:') .describe('Database file path, or ":memory:" for an ephemeral database') .meta({ title: 'Filename' }), diff --git a/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts index 1e575af9fd..262380b103 100644 --- a/packages/spec/src/data/driver/turso.zod.ts +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -9,6 +9,7 @@ import { credentialFreeUrl, driverConfigJsonSchema, INLINE_CREDENTIAL_REFUSED, + placeholderFree, READ_ONLY_BELONGS_ON_DATASOURCE, refusedInlineCredentialKey, SCHEMA_MODE_BELONGS_ON_DATASOURCE, @@ -128,7 +129,9 @@ export const TursoConfigSchema = lazySchema(() => strictObject( * * Credential-free by contract since #8082: a `user:password@` userinfo is * refused at publish exactly like an inline `authToken` (#7990) — bind the - * secret and it reaches the driver at connect time. Runtime-environment + * secret and it reaches the driver at connect time. 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` + `OS_DATABASE_AUTH_TOKEN`) never pass through * this schema and are unaffected. */ @@ -140,7 +143,7 @@ export const TursoConfigSchema = lazySchema(() => strictObject( // docs link checker resolves as an internationalised domain name, and // fails on (caught by CI on this very key). Concrete example URLs belong // in the TSDoc above the key, which the reference tables do not inline. - url: credentialFreeUrl(z.string().min(1), 'url') + url: placeholderFree(credentialFreeUrl(z.string().min(1), 'url'), 'url') .describe('libSQL endpoint or local file: a remote libsql/https Turso URL, a file path, or :memory:') .meta({ title: 'Database URL' }), @@ -155,8 +158,13 @@ export const TursoConfigSchema = lazySchema(() => strictObject( */ authToken: refusedInlineCredentialKey('authToken', 'Auth token'), - /** AES-256 key for the local database file; local/replica modes only. */ - encryptionKey: z.string().optional() + /** + * AES-256 key for the local database file; local/replica modes only. + * Placeholder-free since #8336: an unresolved `${…}` here would encrypt + * the database with the literal placeholder string as its key — data + * unreadable under the key the author believed they set. + */ + encryptionKey: placeholderFree(z.string(), 'encryptionKey').optional() .describe('AES-256 encryption key for the local database file (local/replica modes)') .meta({ title: 'Encryption key', format: 'password' }), @@ -169,9 +177,10 @@ export const TursoConfigSchema = lazySchema(() => strictObject( * Remote sync endpoint that turns a local file into an embedded replica. * Judged by the same #8082 value-level parse as `url`: it is an authored * URL persisted into the identical `sys_metadata` sink, so a - * `user:password@` userinfo is refused the same way. + * `user:password@` userinfo is refused the same way — and by the same + * #8336 parse too (placeholder-free). */ - syncUrl: credentialFreeUrl(z.string(), 'syncUrl').optional() + syncUrl: placeholderFree(credentialFreeUrl(z.string(), 'syncUrl'), 'syncUrl').optional() .describe('Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint') .meta({ title: 'Sync URL' }), diff --git a/packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts b/packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts new file mode 100644 index 0000000000..11f0ab81e5 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.datasource-config-placeholder-refused.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'datasource-config-placeholder-refused', + surface: 'connection-material string keys of the built-in driver configs — ' + + 'postgres/mysql/mongo `url`/`host`/`database`/`username`, postgres `schema`/' + + '`applicationName`, mongo `authSource` and the `options` passthrough (judged deep), ' + + 'turso `url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename` — values ' + + 'containing `${…}` placeholder syntax', + replacement: 'the literal value. For secret material, the datasource secret binder: the ' + + "Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle " + + 'stored at `external.credentialsRef`), or a direct `external.credentialsRef` reference. ' + + 'For environment-driven connections, the runtime environment itself: `OS_DATABASE_URL` ' + + 'and friends are translated into driver config by the boot hosts and never pass through ' + + 'the publish door', + reason: + 'A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is ' + + 'stored verbatim in `sys_metadata` and handed verbatim to the database client at connect ' + + '(#7990 census, measured during #8078), so the connection fails, or connects somewhere ' + + 'unintended, with no error naming the unresolved placeholder — the masked-failure shape. ' + + 'The syntax looked supported: it parsed green, stored fine, and failed at a distance; two ' + + 'shipped refusal messages (#8078 inline credentials, #8082 URL userinfo) had to warn ' + + '"do NOT substitute a placeholder" around the broken escape. Maintainer-ruled direction 2 ' + + 'on #8336 (2026-08-13): refuse the syntax loudly at publish; implementing real resolution ' + + 'was explicitly rejected — a new capability with an env-exfiltration security surface and ' + + 'zero measured pull for actual substitution. There is no mechanical rewrite: the ' + + 'placeholder names a value that exists only in the author\'s intended deployment ' + + 'environment, which a source-file transform cannot know — substituting anything would ' + + 'invent a connection target.', + acceptanceCriteria: + 'Every datasource parses with no `${…}` span in any connection-material config value; ' + + 'environment-driven deployments carry their DSN in the runtime environment ' + + '(`OS_DATABASE_URL` and friends) or bind secrets via `external.credentialsRef`, and ' + + 'still connect; no unresolved placeholder remains in any stored `sys_metadata` row or ' + + 'authored source.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 308e0dbc3a..f59f925b0f 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -2415,6 +2415,40 @@ const step17: MigrationStep = { 'the connection form) and still connects; no cleartext credential remains in any ' + 'stored `sys_metadata` row or authored source.', }, + { + id: 'datasource-config-placeholder-refused', + surface: 'connection-material string keys of the built-in driver configs — ' + + 'postgres/mysql/mongo `url`/`host`/`database`/`username`, postgres `schema`/' + + '`applicationName`, mongo `authSource` and the `options` passthrough (judged deep), ' + + 'turso `url`/`syncUrl`/`encryptionKey`, sqlite/sqlite-wasm `filename` — values ' + + 'containing `${…}` placeholder syntax', + replacement: 'the literal value. For secret material, the datasource secret binder: the ' + + "Setup → Datasources connection form's secret field (encrypted into `sys_secret`, handle " + + 'stored at `external.credentialsRef`), or a direct `external.credentialsRef` reference. ' + + 'For environment-driven connections, the runtime environment itself: `OS_DATABASE_URL` ' + + 'and friends are translated into driver config by the boot hosts and never pass through ' + + 'the publish door', + reason: + 'A `${…}` placeholder in authored datasource config is resolved by NOTHING — it is ' + + 'stored verbatim in `sys_metadata` and handed verbatim to the database client at connect ' + + '(#7990 census, measured during #8078), so the connection fails, or connects somewhere ' + + 'unintended, with no error naming the unresolved placeholder — the masked-failure shape. ' + + 'The syntax looked supported: it parsed green, stored fine, and failed at a distance; two ' + + 'shipped refusal messages (#8078 inline credentials, #8082 URL userinfo) had to warn ' + + '"do NOT substitute a placeholder" around the broken escape. Maintainer-ruled direction 2 ' + + 'on #8336 (2026-08-13): refuse the syntax loudly at publish; implementing real resolution ' + + 'was explicitly rejected — a new capability with an env-exfiltration security surface and ' + + 'zero measured pull for actual substitution. There is no mechanical rewrite: the ' + + 'placeholder names a value that exists only in the author\'s intended deployment ' + + 'environment, which a source-file transform cannot know — substituting anything would ' + + 'invent a connection target.', + acceptanceCriteria: + 'Every datasource parses with no `${…}` span in any connection-material config value; ' + + 'environment-driven deployments carry their DSN in the runtime environment ' + + '(`OS_DATABASE_URL` and friends) or bind secrets via `external.credentialsRef`, and ' + + 'still connect; no unresolved placeholder remains in any stored `sys_metadata` row or ' + + 'authored source.', + }, { id: 'datasource-config-url-userinfo-refused', surface: 'datasource.config.url (postgres / mysql / mongo / turso) and ' + From cbd30204a3890bcda2ac324db2b0eba5bc4518bc Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 15:21:59 +0000 Subject: [PATCH 2/2] chore(spec): regenerate api-surface for the #8336 exports MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit gen:api-surface after rebuilding dist (the migration-registry regen had made dist older than src, which the generator refuses — #7122/#4687). Adds the four new data exports: UNRESOLVED_PLACEHOLDER_REFUSED, containsUnresolvedPlaceholder, placeholderFree, placeholderFreeDeep. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_012MNV7ZSCjNfA38eDCjsXQL --- packages/spec/api-surface/data.json | 4 ++++ packages/spec/export-origins/data.json | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 848f973cce..c42de30a45 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -596,6 +596,7 @@ "TursoDriverSpec (const)", "TursoTransportMode (type)", "TursoTransportModeSchema (const)", + "UNRESOLVED_PLACEHOLDER_REFUSED (const)", "URL_EMBEDDED_CREDENTIAL_REFUSED (const)", "UniqueScope (type)", "UniqueScopeSchema (const)", @@ -614,6 +615,7 @@ "checkLiteralDefaultValue (function)", "checkManagedApiMethodAffordances (function)", "classifyFilterToken (function)", + "containsUnresolvedPlaceholder (function)", "countAuthorableFields (function)", "credentialFreeUrl (function)", "defaultAggregateFor (function)", @@ -688,6 +690,8 @@ "parseDateMacroParam (function)", "parseFilterAST (function)", "percentScaleOf (function)", + "placeholderFree (function)", + "placeholderFreeDeep (function)", "platformProvisionsStorage (function)", "provisionPrimary (function)", "readAutonumberCounter (function)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index a9c4526341..3b70f853a9 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -596,6 +596,7 @@ "TursoDriverSpec": "src/data/driver/turso.zod.ts#TursoDriverSpec (const)", "TursoTransportMode": "src/data/driver/turso.zod.ts#TursoTransportMode (type)", "TursoTransportModeSchema": "src/data/driver/turso.zod.ts#TursoTransportModeSchema (const)", + "UNRESOLVED_PLACEHOLDER_REFUSED": "src/data/driver/common.zod.ts#UNRESOLVED_PLACEHOLDER_REFUSED (const)", "URL_EMBEDDED_CREDENTIAL_REFUSED": "src/data/driver/common.zod.ts#URL_EMBEDDED_CREDENTIAL_REFUSED (const)", "UniqueScope": "src/data/field.zod.ts#UniqueScope (type)", "UniqueScopeSchema": "src/data/field.zod.ts#UniqueScopeSchema (const)", @@ -614,6 +615,7 @@ "checkLiteralDefaultValue": "src/data/default-value-shape.ts#checkLiteralDefaultValue (function)", "checkManagedApiMethodAffordances": "src/data/managed-api-affordance.ts#checkManagedApiMethodAffordances (function)", "classifyFilterToken": "src/data/context-tokens.zod.ts#classifyFilterToken (function)", + "containsUnresolvedPlaceholder": "src/data/driver/common.zod.ts#containsUnresolvedPlaceholder (function)", "countAuthorableFields": "src/data/record-surface.ts#countAuthorableFields (function)", "credentialFreeUrl": "src/data/driver/common.zod.ts#credentialFreeUrl (function)", "defaultAggregateFor": "src/data/aggregation-policy.ts#defaultAggregateFor (function)", @@ -688,6 +690,8 @@ "parseDateMacroParam": "src/data/date-macros.zod.ts#parseDateMacroParam (function)", "parseFilterAST": "src/data/filter.zod.ts#parseFilterAST (function)", "percentScaleOf": "src/data/percent-scale.ts#percentScaleOf (function)", + "placeholderFree": "src/data/driver/common.zod.ts#placeholderFree (function)", + "placeholderFreeDeep": "src/data/driver/common.zod.ts#placeholderFreeDeep (function)", "platformProvisionsStorage": "src/data/injected-system-column-provenance.ts#platformProvisionsStorage (function)", "provisionPrimary": "src/data/display-name.ts#provisionPrimary (function)", "readAutonumberCounter": "src/data/autonumber-format.ts#readAutonumberCounter (function)",