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
57 changes: 57 additions & 0 deletions .changeset/placeholder-syntax-refused-in-driver-config.md
Original file line numberDiff line numberDiff line change
@@ -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`).

<!-- adr-0087: registered datasource-config-placeholder-refused -->
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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.
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/api-surface/data.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -596,6 +596,7 @@
"TursoDriverSpec (const)",
"TursoTransportMode (type)",
"TursoTransportModeSchema (const)",
"UNRESOLVED_PLACEHOLDER_REFUSED (const)",
"URL_EMBEDDED_CREDENTIAL_REFUSED (const)",
"UniqueScope (type)",
"UniqueScopeSchema (const)",
Expand All@@ -614,6 +615,7 @@
"checkLiteralDefaultValue (function)",
"checkManagedApiMethodAffordances (function)",
"classifyFilterToken (function)",
"containsUnresolvedPlaceholder (function)",
"countAuthorableFields (function)",
"credentialFreeUrl (function)",
"defaultAggregateFor (function)",
Expand DownExpand Up@@ -688,6 +690,8 @@
"parseDateMacroParam (function)",
"parseFilterAST (function)",
"percentScaleOf (function)",
"placeholderFree (function)",
"placeholderFreeDeep (function)",
"platformProvisionsStorage (function)",
"provisionPrimary (function)",
"readAutonumberCounter (function)",
Expand Down
4 changes: 4 additions & 0 deletions packages/spec/export-origins/data.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand All@@ -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)",
Expand DownExpand Up@@ -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)",
Expand Down
14 changes: 14 additions & 0 deletions packages/spec/spec-changes.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand DownExpand Up@@ -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",
Expand Down
31 changes: 21 additions & 10 deletions packages/spec/src/data/datasource.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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',
Expand All@@ -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: {
Expand All@@ -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',
Expand Down
Loading
Loading