From 6d1328c7a87e3fa0b6b6f0ae33814a740b1ffbf9 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 05:26:59 +0000 Subject: [PATCH 1/3] feat(spec)!: refuse URL-embedded credentials in driver config.url at publish (#8082) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The #7990 closure refused the inline credential keys; #8078 measured and pinned that config.url still accepted the identical secret one syntax over. Maintainer-ruled Option A (2026-08-12): one value-level parse (urlUserinfoPassword / credentialFreeUrl, data/driver/common.zod.ts) shared by the four URL-bearing driver schemas — postgres/mysql/mongo url, turso url + syncUrl — refuses a URL whose userinfo carries a non-empty password. - Bare-user userinfo (user@host) stays accepted, matching #7990's posture (username is a writable key; only the secret is refused) — and it is the exact shape the #8126 read path serves for legacy rows, so an untouched Save keeps working. - The refusal message names the working mechanisms (secret binder / external.credentialsRef), states the runtime-DSN carve-out explicitly (OS_DATABASE_URL never passes the publish door), and warns that ${...} placeholders resolve to nothing (#8078, measured) instead of steering authors into that broken escape. - The #8078 acceptance pin (driver-credential-refusal.test.ts) is INVERTED to a rejection pin, not deleted; the #8126 write-door acceptance pin in service-datasource flips the same way (its own comment said it waited on exactly this ruling). - ADR-0087: D3 semantic entry datasource-config-url-userinfo-refused (no D2 conversion — a credential cannot be mechanically encrypted into sys_secret); registry, spec-changes, upgrade guide, api-surface, export-origins, reference docs regenerated. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 --- ...userinfo-credentials-refused-at-publish.md | 60 ++++++ content/docs/references/data/driver-mongo.mdx | 2 +- content/docs/references/data/driver-mysql.mdx | 2 +- .../docs/references/data/driver-postgres.mdx | 2 +- docs/protocol-upgrade-guide.md | 3 + .../datasource-config-redaction.test.ts | 20 +- .../src/datasource-config-redaction.ts | 22 ++- packages/spec/api-surface/data.json | 3 + packages/spec/export-origins/data.json | 3 + packages/spec/spec-changes.json | 14 ++ packages/spec/src/data/driver/common.zod.ts | 101 ++++++++++ .../driver/driver-credential-refusal.test.ts | 173 +++++++++++++++++- packages/spec/src/data/driver/mongo.zod.ts | 13 +- packages/spec/src/data/driver/mysql.zod.ts | 14 +- packages/spec/src/data/driver/postgres.zod.ts | 14 +- packages/spec/src/data/driver/turso.zod.ts | 18 +- ....datasource-config-url-userinfo-refused.ts | 34 ++++ packages/spec/src/migrations/registry.ts | 30 +++ 18 files changed, 493 insertions(+), 35 deletions(-) create mode 100644 .changeset/url-userinfo-credentials-refused-at-publish.md create mode 100644 packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts diff --git a/.changeset/url-userinfo-credentials-refused-at-publish.md b/.changeset/url-userinfo-credentials-refused-at-publish.md new file mode 100644 index 0000000000..0f359d42cb --- /dev/null +++ b/.changeset/url-userinfo-credentials-refused-at-publish.md @@ -0,0 +1,60 @@ +--- +"@objectstack/spec": major +--- + +feat(spec)!: refuse URL-embedded credentials (`user:password@host`) in driver `config.url` at publish (#8082) + +#7990 refused the inline credential keys (`config.password` / `config.authToken`), +and #8078 measured — and pinned as a fact — 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 (very often AI authors) into the URL form. The +maintainer-ruled fix (#8082, Option A) closes that door with one value-level +parse shared by the four URL-bearing driver schemas (postgres / mysql / mongo +`config.url`, turso `config.url` + `config.syncUrl`). + +**What is refused:** a URL whose userinfo carries a NON-EMPTY password segment +(`user:password@host`, `user:${DB_PASSWORD}@host`, percent-encoded included). + +**What stays accepted:** a bare username (`user@host` — `username` is a writable +key; only the secret is refused, matching #7990's posture), and every +credential-free URL byte-identically. The shape the #8126 read path serves for a +legacy stored row (`user@host`) parses green, so an untouched "Save" on a legacy +row keeps working. + +**Carve-out (by construction):** runtime-environment DSNs — `OS_DATABASE_URL` +and friends — are translated into driver configs by the boot hosts and handed to +the driver factory directly; they never pass through this authoring schema and +are unaffected. + +## FROM → TO + +```ts +// before — accepted, stored in cleartext in sys_metadata +defineDatasource({ + name: 'legacy', driver: 'postgres', + config: { url: 'postgresql://svc:hunter2@db.internal:5432/prod' }, +}) + +// after — the URL is credential-free; the secret lives in the secret store +defineDatasource({ + name: 'legacy', driver: 'postgres', schemaMode: 'external', + config: { url: 'postgresql://svc@db.internal:5432/prod' }, + external: { allowWrites: false, credentialsRef: 'sys_secret:' }, +}) +// (Setup → Datasources binds the secret for you: its secret field encrypts into +// sys_secret and writes 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). + +There is deliberately **no automatic rewrite**, for the same reason as #7990's +entry: moving the credential requires encrypting it through a running secret +binder, which a source-file transform cannot do — auto-stripping the userinfo +would silently drop a live credential. `os migrate meta` surfaces the change as +a structured TODO (semantic entry `datasource-config-url-userinfo-refused`). + + diff --git a/content/docs/references/data/driver-mongo.mdx b/content/docs/references/data/driver-mongo.mdx index 2471c9aafe..508b391af8 100644 --- a/content/docs/references/data/driver-mongo.mdx +++ b/content/docs/references/data/driver-mongo.mdx @@ -41,7 +41,7 @@ MongoDB Connection Configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **url** | `string` | optional | Connection URI (supersedes the discrete fields) | +| **url** | `string` | optional | Connection URI (supersedes the discrete fields; must not embed a password — bind the secret instead) | | **database** | `string` | optional | Database name | | **host** | `string` | ✅ | Host address | | **port** | `integer` | ✅ | Port number | diff --git a/content/docs/references/data/driver-mysql.mdx b/content/docs/references/data/driver-mysql.mdx index 36e3090fd9..d97bda0dcd 100644 --- a/content/docs/references/data/driver-mysql.mdx +++ b/content/docs/references/data/driver-mysql.mdx @@ -42,7 +42,7 @@ MySQL / MariaDB connection configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **url** | `string` | optional | Connection URI (supersedes the discrete fields) | +| **url** | `string` | optional | Connection URI (supersedes the discrete fields; must not embed a password — bind the secret instead) | | **host** | `string` | ✅ | Host address | | **port** | `integer` | ✅ | Port number | | **database** | `string` | optional | Database name | diff --git a/content/docs/references/data/driver-postgres.mdx b/content/docs/references/data/driver-postgres.mdx index c4f051908e..324d459961 100644 --- a/content/docs/references/data/driver-postgres.mdx +++ b/content/docs/references/data/driver-postgres.mdx @@ -40,7 +40,7 @@ PostgreSQL connection configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **url** | `string` | optional | Connection URI (supersedes the discrete fields) | +| **url** | `string` | optional | Connection URI (supersedes the discrete fields; must not embed a password — bind the secret instead) | | **host** | `string` | ✅ | Host address | | **port** | `integer` | ✅ | Port number | | **database** | `string` | optional | Database name | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index a1d8e2f64b..25f0798047 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -370,6 +370,9 @@ It also removes the three pass-through-only list-view display keys `striped` / ` - **`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-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. - **`declarative-apis-endpoints-live`** — `stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)` → the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }` - Why not automatic: This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is "did the author of this endpoint mean for the internet to reach it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. ⚠️ If you author endpoints in TypeScript, annotate them with `ApiEndpoint` — the AUTHOR state — so that omitting `authRequired` compiles: `const e: ApiEndpoint = { name, path, method, type, target }` is legal and is the safe shape this paragraph prescribes. `ApiEndpointParsed` is the POST-parse type (defaults materialized, ADR-0122), where `authRequired` is required — annotating a declaration with it forces you to write the key out, and being made to think about a key whose only unrecoverable value is `false` is the one thing this entry is trying to avoid (#5227). Hold a parse RESULT with `ApiEndpointParsed`; write declarations as `ApiEndpoint`. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps//…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call. - Done when: You have READ every entry of every `apis:` block, not just the ones that fail to publish. Concretely: (1) each declared `path` is `/api/v1/apps//` and the stack declares that `manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is one you INTEND to be reachable without a session, and each carries `rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not intended to be anonymous have the key removed so the safe default (`true`) applies; (3) `objectstack validate` passes, which also proves no endpoint declares a shape 17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an `object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, `inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and (4) after publishing, each endpoint answers as you expect — an anonymous request to a session-only endpoint returns 401 rather than data. diff --git a/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts b/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts index 8ca1e7da6d..a739e6e45f 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-config-redaction.test.ts @@ -310,11 +310,21 @@ describe('GREEN ON MAIN — #8078 is not weakened by anything above', () => { ).rejects.toThrow(/is a credential and is not accepted inline/); }); - it('GREEN ON MAIN — a URL-embedded credential is still ACCEPTED at the write door', () => { - // #7990 left refusing these UNRULED and #8078 pinned the acceptance as a - // FACT. Redacting on the way out must not become refusing on the way in; - // this pin fails the moment that boundary moves without a ruling. - expect(validateDriverConfig('postgres', { url: 'postgresql://u:pass@h:5432/d' })) + it('a URL-embedded credential is now REFUSED at the write door (#8082 — the ruling this pin waited for)', () => { + // This pin used to assert the ACCEPTANCE, with the comment "this pin fails + // the moment that boundary moves without a ruling". The ruling arrived + // (#8082, maintainer 2026-08-12, Option A): the write door refuses a URL + // userinfo password, so the pin inverts. The REDACTED shape the read path + // serves (`u@h`) must stay accepted, or every untouched "Save" on a legacy + // row would 400 — the #8126 regression shape, re-checked here from the + // write side. + const refused = validateDriverConfig('postgres', { url: 'postgresql://u:pass@h:5432/d' }); + expect(refused).toMatchObject({ known: true }); + const issues = (refused as { issues: Array<{ path: unknown[]; message: string }> }).issues; + expect(issues.length).toBeGreaterThan(0); + expect(issues[0].path).toEqual(['url']); + expect(issues[0].message).toContain('external.credentialsRef'); + expect(validateDriverConfig('postgres', { url: 'postgresql://u@h:5432/d' })) .toEqual({ known: true, issues: [] }); }); }); diff --git a/packages/services/service-datasource/src/datasource-config-redaction.ts b/packages/services/service-datasource/src/datasource-config-redaction.ts index b4417f2585..be3e3c7f52 100644 --- a/packages/services/service-datasource/src/datasource-config-redaction.ts +++ b/packages/services/service-datasource/src/datasource-config-redaction.ts @@ -49,15 +49,19 @@ * ## URL-embedded credentials * * A `postgresql://user:pass@host/db` in `config.url` carries the same secret as - * `config.password`, and #7990/#8078 left refusing it explicitly UNRULED — the - * spec half pinned the behaviour as a fact rather than rejecting it. This - * module does not disturb that: nothing here refuses a URL, at any door. But a - * scrub that dropped `config.password` and then served the identical credential - * one key over would be a scrub in name only — the same "claims a protection it - * does not perform" shape #8081 exists to end. So the read path redacts the - * PASSWORD COMPONENT of a URL's userinfo and leaves everything else, including - * the username, byte-for-byte. Redacting a value on the way out is not the same - * act as refusing it on the way in, and only the second one is unruled. + * `config.password`. When this module landed, refusing it was explicitly + * UNRULED (#7990/#8078 pinned the acceptance as a fact); #8082 has since ruled + * it (maintainer 2026-08-12, Option A), and the WRITE door now refuses a URL + * userinfo password via the spec's shared value-level parse + * (`urlUserinfoPassword`, `@objectstack/spec` `data/driver/common.zod.ts`). + * This module is still the READ half: a scrub that dropped `config.password` + * and then served the identical credential one key over would be a scrub in + * name only — the same "claims a protection it does not perform" shape #8081 + * exists to end. So the read path redacts the PASSWORD COMPONENT of a URL's + * userinfo and leaves everything else, including the username, byte-for-byte — + * and the redacted shape it serves (`user@host`) is exactly what the write + * door still accepts, which is what keeps an untouched "Save" on a legacy row + * working. * * ## Why redaction must be reversible * diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 967f80d553..e90c801d2d 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -590,6 +590,7 @@ "TursoDriverSpec (const)", "TursoTransportMode (type)", "TursoTransportModeSchema (const)", + "URL_EMBEDDED_CREDENTIAL_REFUSED (const)", "UniqueScope (type)", "UniqueScopeSchema (const)", "UnknownAuthoringKeyFinding (interface)", @@ -608,6 +609,7 @@ "checkManagedApiMethodAffordances (function)", "classifyFilterToken (function)", "countAuthorableFields (function)", + "credentialFreeUrl (function)", "defaultAggregateFor (function)", "defaultValueTokenIssue (function)", "defineCube (function)", @@ -701,6 +703,7 @@ "stripLegacyApiMethods (function)", "suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType (function)", + "urlUserinfoPassword (function)", "utcInstantMs (function)", "validateDriverConfig (function)", "valueSchemaFor (function)" diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index e2611b87fa..bdc33b0812 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -590,6 +590,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)", + "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)", "UnknownAuthoringKeyFinding": "src/data/authoring-key-lint.ts#UnknownAuthoringKeyFinding (interface)", @@ -608,6 +609,7 @@ "checkManagedApiMethodAffordances": "src/data/managed-api-affordance.ts#checkManagedApiMethodAffordances (function)", "classifyFilterToken": "src/data/context-tokens.zod.ts#classifyFilterToken (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)", "defaultValueTokenIssue": "src/data/default-value-shape.ts#defaultValueTokenIssue (function)", "defineCube": "src/data/analytics.zod.ts#defineCube (function)", @@ -701,6 +703,7 @@ "stripLegacyApiMethods": "src/data/object.zod.ts#stripLegacyApiMethods (function)", "suggestDefaultValueToken": "src/data/default-value-shape.ts#suggestDefaultValueToken (function)", "suggestFieldTypeForSqlType": "src/data/type-compat.ts#suggestFieldTypeForSqlType (function)", + "urlUserinfoPassword": "src/data/driver/common.zod.ts#urlUserinfoPassword (function)", "utcInstantMs": "src/data/calendar-day.ts#utcInstantMs (function)", "validateDriverConfig": "src/data/driver/config-registry.zod.ts#validateDriverConfig (function)", "valueSchemaFor": "src/data/field-value.zod.ts#valueSchemaFor (function)" diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index a8f7c4e8ff..6a4506a68c 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -636,6 +636,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": "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", + "migrationId": "datasource-config-url-userinfo-refused", + "toMajor": 17, + "rationale": "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)." + }, { "surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)", "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`", @@ -1638,6 +1645,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": "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", + "migrationId": "datasource-config-url-userinfo-refused", + "toMajor": 17, + "rationale": "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)." + }, { "surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)", "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`", diff --git a/packages/spec/src/data/driver/common.zod.ts b/packages/spec/src/data/driver/common.zod.ts index d620eb54c0..dffdee6b01 100644 --- a/packages/spec/src/data/driver/common.zod.ts +++ b/packages/spec/src/data/driver/common.zod.ts @@ -111,6 +111,107 @@ export const INLINE_CREDENTIAL_REFUSED = (key: string): string => + '`external.credentialsRef`. The resolved secret is injected at connect time and always ' + 'wins over anything embedded in `config`.'; +/** + * Refusal prescription for a credential embedded in an authored URL's userinfo + * (#8082, maintainer-ruled Option A 2026-08-12 — the same per-artefact contract + * closure as #7990, applied to the one-syntax-over workaround it left open). + * + * #7990 refused the inline credential KEYS (`config.password` / + * `config.authToken`) and #8078 measured, and pinned as a fact, that + * `config.url` still admitted `postgresql://user:password@host/db` on every + * driver that declares a `url` — the identical secret, in the identical + * `sys_metadata` cleartext sink, one syntax over. Worse, the refusal itself + * steered authors there: an author (very often an AI) refused on + * `config.password` "fixes" the error by moving the secret into the URL, and + * the parse goes green. This message closes that door loudly. + * + * Wording constraints, all measured rather than stylistic: + * + * - It must NOT recommend `${…}` placeholders: #8078 measured that authored- + * 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). + * - 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 + * to the driver factory directly — it never passes through this authoring + * schema, so it is unaffected by construction. + * - A bare username in userinfo (`postgres://svc@host/db`) stays accepted, + * matching #7990's posture: `username` is a writable inline key; only the + * secret is refused. + */ +export const URL_EMBEDDED_CREDENTIAL_REFUSED = (key: string): string => + `this \`${key}\` embeds a password in its userinfo (\`user:password@host\`) and is not ` + + 'accepted at publish (#8082): the datasource is persisted whole into `sys_metadata`, which ' + + 'is served back by the ordinary data API, so a URL-embedded credential lands in cleartext ' + + 'at rest exactly like an inline `password` (#7990). Keep the authored URL credential-free ' + + '(a bare username, `user@host`, is fine) and bind the secret instead: 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` — or reference the secrets store directly with ' + + '`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 ' + + 'not pass through this publish door and are unaffected.'; + +/** + * The password component of a URL-ish string's userinfo, or `undefined` when + * the string carries none — the shared value-level parse behind + * {@link credentialFreeUrl} (#8082). + * + * Deliberately NOT `new URL()`: real DSNs take forms WHATWG parsing rejects or + * mangles (postgres/mongo multi-host `user:pass@h1:5432,h2:5432/db`, bare + * `:memory:`, `file:` paths), and a detector that throws on the exact inputs it + * must judge would fail open. The boundaries below are RFC 3986's, and match + * the read-path redactor (`service-datasource`'s `redactUrlPassword`) so the + * write door refuses precisely the material the read door redacts: + * + * - the authority is what follows `//` (scheme-relative included), up to the + * first `/`, `?` or `#` — a `:` or `@` in a path or query is never userinfo; + * - userinfo ends at the LAST `@` in the authority (a malformed literal `@` + * inside a password must not decide how much of it goes unjudged); + * - the password starts after the FIRST `:` in userinfo, and only a NON-EMPTY + * password is credential material (`user@host` and `user:@host` carry no + * secret; both stay accepted, and the second is what the read-path redaction + * of a legacy row round-trips as). + * + * A string with no `//` (sqlite/turso `file:` paths, `:memory:`) has no + * authority and answers `undefined`. + */ +export function urlUserinfoPassword(value: string): string | undefined { + const scheme = /^(?:[a-z][a-z0-9+.-]*:)?\/\//i.exec(value); + if (!scheme) return undefined; + const rest = value.slice(scheme[0].length); + const end = rest.search(/[/?#]/); + const authority = end === -1 ? rest : rest.slice(0, end); + const at = authority.lastIndexOf('@'); + if (at === -1) return undefined; + const userinfo = authority.slice(0, at); + const colon = userinfo.indexOf(':'); + if (colon === -1) return undefined; + const password = userinfo.slice(colon + 1); + return password.length > 0 ? password : undefined; +} + +/** + * Attach the #8082 URL-userinfo credential refusal to a driver-config URL key. + * + * One shared check for every URL-bearing key on the four driver schemas + * (postgres/mysql/mongo `url`, turso `url` + `syncUrl`), so the policy cannot + * drift per driver — the maintainer's ruling names a single value-level parse + * as the mechanism, precisely so no second copy exists to disagree with this + * one (the rejected Option C shape). + */ +export function credentialFreeUrl(schema: S, key: string) { + return schema.superRefine((value, ctx) => { + if (typeof value !== 'string') return; + if (urlUserinfoPassword(value) !== undefined) { + ctx.addIssue({ code: 'custom', message: URL_EMBEDDED_CREDENTIAL_REFUSED(key) }); + } + }); +} + /** * A driver-config credential key, declared but UNWRITABLE (#7990). * diff --git a/packages/spec/src/data/driver/driver-credential-refusal.test.ts b/packages/spec/src/data/driver/driver-credential-refusal.test.ts index 827d4e2ff2..3cd30ad794 100644 --- a/packages/spec/src/data/driver/driver-credential-refusal.test.ts +++ b/packages/spec/src/data/driver/driver-credential-refusal.test.ts @@ -3,6 +3,9 @@ /** * #7990 — inline credentials are refused across the driver-config family * (maintainer-ruled Option A, 2026-08-12: per-artefact contract closure). + * #8082 — the same closure for URL-embedded credentials (`user:password@host` + * in `config.url`), the one-syntax-over workaround #8078 measured and pinned + * as a fact; that acceptance pin is INVERTED below, per the 2026-08-12 ruling. * * `sys_metadata.metadata` is reachable through the ordinary data API, and a * datasource is persisted whole — so a credential the schema ACCEPTS inline is @@ -24,6 +27,7 @@ import { describe, expect, it } from 'vitest'; import { DatasourceSchema } from '../datasource.zod'; +import { urlUserinfoPassword } from './common.zod'; import { getMongoConfigJsonSchema, MongoConfigSchema, @@ -161,15 +165,174 @@ describe('DatasourceSchema — the refusal reaches the authored artefact (#7990) expect(DatasourceSchema.parse(refBased)).toEqual(result.data); }); - it('embedded-in-URL credentials remain accepted (measured, NOT ruled — #7990 open question)', () => { - // The ruling covers inline credential FIELDS. A `user:password@host` inside - // `config.url` is a live, unruled door — pinned here as a FACT so a future - // ruling starts from measurement, not as an endorsement. + it('embedded-in-URL credentials are REFUSED at `config.url` (#8082 — the inverted #8078 pin)', () => { + // This test used to pin the ACCEPTANCE of exactly this input as a measured + // fact (#7990 open question). The 2026-08-12 #8082 ruling (Option A) + // closed the door, so the pin inverts rather than disappears: same input, + // opposite verdict, and the verdict's envelope is asserted as far as the + // schema layer carries one — the zod issue's `code` and its re-pathed + // location. (`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 codes verbatim.) const result = DatasourceSchema.safeParse({ name: 'legacy', driver: 'postgres', config: { url: 'postgresql://user:pass@db.example.com:5432/production' }, }); - expect(result.success).toBe(true); + 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'); + // The migration doc for whoever hits it: the working mechanisms BY NAME … + expect(issue!.message).toContain('external.credentialsRef'); + expect(issue!.message).toContain('sys_secret'); + expect(issue!.message).toContain('secret binder'); + // … the explicit runtime-DSN carve-out (maintainer ruling: say it, don't + // imply it) … + expect(issue!.message).toContain('OS_DATABASE_URL'); + expect(issue!.message).toContain('unaffected'); + // … and NO steering toward the broken `${…}` escape: the message may only + // mention placeholders to say they do not work (#8078, measured). + expect(issue!.message).toContain('resolved by nothing'); + }); +}); + +/** The URL-bearing keys under the #8082 ruling: every driver with a `url`, plus turso's `syncUrl`. */ +const URL_FAMILY = [ + { + name: 'postgres url', + schema: PostgresConfigSchema, + key: 'url', + make: (url: string) => ({ url }), + sample: (userinfo: string) => `postgresql://${userinfo}db.example.com:5432/prod`, + }, + { + name: 'mysql url', + schema: MysqlConfigSchema, + key: 'url', + make: (url: string) => ({ url }), + sample: (userinfo: string) => `mysql://${userinfo}db.example.com:3306/prod`, + }, + { + name: 'mongo url', + schema: MongoConfigSchema, + key: 'url', + make: (url: string) => ({ url }), + sample: (userinfo: string) => `mongodb://${userinfo}mongo.example.com:27017/events`, + }, + { + name: 'turso url', + schema: TursoConfigSchema, + key: 'url', + make: (url: string) => ({ url }), + sample: (userinfo: string) => `libsql://${userinfo}x.turso.io`, + }, + { + name: 'turso syncUrl', + schema: TursoConfigSchema, + key: 'syncUrl', + make: (syncUrl: string) => ({ url: 'file:./data/replica.db', syncUrl }), + sample: (userinfo: string) => `libsql://${userinfo}x.turso.io`, + }, +] as const; + +describe.each(URL_FAMILY)('$name — URL-embedded credential refusal (#8082)', (f) => { + const refusalAt = (config: Record) => { + const result = f.schema.safeParse(config); + if (result.success) return undefined; + return result.error.issues.find((i) => i.path.join('.') === f.key); + }; + + it('refuses `user:password@host`, naming the replacement mechanisms and the carve-out', () => { + const issue = refusalAt(f.make(f.sample('svc:hunter2@'))); + expect(issue, `refusal must be pathed at \`${f.key}\``).toBeDefined(); + expect(issue!.code).toBe('custom'); + expect(issue!.message).toContain(`\`${f.key}\``); + expect(issue!.message).toContain('external.credentialsRef'); + expect(issue!.message).toContain('sys_secret'); + expect(issue!.message).toContain('secret binder'); + // The refusal must not recommend the `${…}` placeholder escape — #8078 + // measured it resolves to nothing — and must state the runtime-DSN + // carve-out rather than leaving it implied (both binding, #8082 ruling). + expect(issue!.message).toContain('resolved by nothing'); + expect(issue!.message).toContain('OS_DATABASE_URL'); + }); + + it('refuses a `${…}` placeholder password exactly like a real one — placeholders resolve to nothing', () => { + expect(refusalAt(f.make(f.sample('svc:${DB_PASSWORD}@')))).toBeDefined(); + }); + + it('refuses a percent-encoded password — encoding is not absence', () => { + expect(refusalAt(f.make(f.sample('svc:p%40ssw%3Ard@')))).toBeDefined(); + }); + + it('refuses a malformed double-`@` password WHOLE (the read-path redactor boundary, RFC 3986)', () => { + expect(refusalAt(f.make(f.sample('svc:p@ss@')))).toBeDefined(); + }); + + it('refuses userinfo in front of an IPv6 host, and accepts the same IPv6 host without one', () => { + const scheme = f.sample('').split('://')[0]; + expect(refusalAt(f.make(`${scheme}://svc:hunter2@[2001:db8::1]:6543/prod`))).toBeDefined(); + // The bracket colons are host syntax, not a password boundary. + expect(refusalAt(f.make(`${scheme}://[2001:db8::1]:6543/prod`))).toBeUndefined(); + }); + + it('accepts a bare username (`user@host`) — `username` is a writable key, only the secret is refused (#7990 posture)', () => { + const config = f.make(f.sample('svc@')); + expect(refusalAt(config)).toBeUndefined(); + const parsed = f.schema.safeParse(config); + expect(parsed.success, JSON.stringify(parsed.error?.issues)).toBe(true); + }); + + it('accepts the credential-free URL byte-identically (pin) — including the redacted round-trip shape', () => { + // `user@host` is exactly what the #8126 read path serves for a legacy + // stored `user:pass@host` row, and what the Studio edit form PUTs back: + // this acceptance is what keeps "Save" on an untouched legacy row working. + for (const userinfo of ['', 'svc@']) { + const config = f.make(f.sample(userinfo)); + const before = f.schema.safeParse(config); + expect(before.success, JSON.stringify(before.error?.issues)).toBe(true); + expect(f.schema.parse(config)).toEqual(before.data); + } + }); +}); + +describe('urlUserinfoPassword — the shared value-level parse (#8082)', () => { + it('judges the DSN forms real drivers take, which `new URL()` rejects or mangles', () => { + // postgres/mongo multi-host DSNs are not WHATWG URLs; the detector must + // judge them rather than fail open on a parse error. + expect(urlUserinfoPassword('postgresql://u:p@h1:5432,h2:5432/db')).toBe('p'); + expect(urlUserinfoPassword('mongodb://u:p@a.example.com:27017,b.example.com:27017/db?replicaSet=rs0')).toBe('p'); + expect(urlUserinfoPassword('postgresql://h1:5432,h2:5432/db')).toBeUndefined(); + }); + + it('scheme-relative URLs are judged too — the authority is the authority, named scheme or not', () => { + expect(urlUserinfoPassword('//u:p@h/db')).toBe('p'); + expect(urlUserinfoPassword('//u@h/db')).toBeUndefined(); + }); + + it('userinfo ends at the LAST `@`, so a malformed literal-`@` password is caught whole', () => { + // Mirrors the read-path redactor's boundary (service-datasource + // `redactUrlPassword`): a URL malformed in exactly the way that hides part + // of a password must not be the case that goes unjudged. + expect(urlUserinfoPassword('postgres://u:p@ss@host/db')).toBe('p@ss'); + }); + + it('a colon or `@` in a path, query or fragment is never userinfo', () => { + expect(urlUserinfoPassword('https://host/a:b@c')).toBeUndefined(); + expect(urlUserinfoPassword('https://host/p?to=a:b@c')).toBeUndefined(); + expect(urlUserinfoPassword('https://host/p#a:b@c')).toBeUndefined(); + }); + + it('non-authority strings carry no userinfo: file paths, :memory:, bare words', () => { + for (const value of ['file:./data/objectstack.db', ':memory:', 'public', 'a:b@c']) { + expect(urlUserinfoPassword(value), value).toBeUndefined(); + } + }); + + it('only a NON-EMPTY password is credential material — `user@` and `user:@` carry no secret', () => { + expect(urlUserinfoPassword('postgres://u@h/db')).toBeUndefined(); + expect(urlUserinfoPassword('postgres://u:@h/db')).toBeUndefined(); + expect(urlUserinfoPassword('postgres://:p@h/db')).toBe('p'); }); }); diff --git a/packages/spec/src/data/driver/mongo.zod.ts b/packages/spec/src/data/driver/mongo.zod.ts index 162ab4b762..f4fb592f86 100644 --- a/packages/spec/src/data/driver/mongo.zod.ts +++ b/packages/spec/src/data/driver/mongo.zod.ts @@ -6,6 +6,7 @@ import { lazySchema } from '../../shared/lazy-schema'; import { strictObject } from '../../shared/strict-object'; import type { DriverDefinition } from '../datasource.zod'; import { + credentialFreeUrl, driverConfigJsonSchema, INLINE_CREDENTIAL_REFUSED, READ_ONLY_BELONGS_ON_DATASOURCE, @@ -71,10 +72,16 @@ export const MongoConfigSchema = lazySchema(() => strictObject( /** * Connection URI (standard connection string). When present it supersedes * `host`/`port`/`database`/`username`/`authSource` — those are only used to - * COMPOSE a URI when none is given. - * Format: `mongodb://[username:password@]host1[:port1][,…][/[db][?options]]` + * COMPOSE a URI when none is given. Credential-free by contract since #8082: + * 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 + * (`OS_DATABASE_URL`) never pass through this schema and are unaffected. + * Format: `mongodb://[username@]host1[:port1][,…][/[db][?options]]` */ - url: z.string().optional().describe('Connection URI (supersedes the discrete fields)') + url: credentialFreeUrl(z.string(), 'url').optional() + .describe('Connection URI (supersedes the discrete fields; must not embed a password — bind the secret instead)') .meta({ title: 'Connection URI' }), /** diff --git a/packages/spec/src/data/driver/mysql.zod.ts b/packages/spec/src/data/driver/mysql.zod.ts index bcf0dc388a..8cd698822a 100644 --- a/packages/spec/src/data/driver/mysql.zod.ts +++ b/packages/spec/src/data/driver/mysql.zod.ts @@ -20,6 +20,7 @@ import { z } from 'zod'; import { lazySchema } from '../../shared/lazy-schema'; import { strictObject } from '../../shared/strict-object'; import { + credentialFreeUrl, driverConfigJsonSchema, DriverSslToggleSchema, INLINE_CREDENTIAL_REFUSED, @@ -73,10 +74,17 @@ export const MysqlConfigSchema = lazySchema(() => strictObject( }, { /** - * Connection URI, passed to `mysql2` as-is when present. - * Format: `mysql://[user[:password]@][host][:port]/[dbname][?params]` + * Connection URI, passed to `mysql2` as-is when present. Credential-free by + * contract since #8082: a `user: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@host`) stays writable. + * Runtime-environment DSNs (`OS_DATABASE_URL`) never pass through this + * schema and are unaffected. + * Format: `mysql://[user@][host][:port]/[dbname][?params]` */ - url: z.string().optional().describe('Connection URI (supersedes the discrete fields)') + url: credentialFreeUrl(z.string(), '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. */ diff --git a/packages/spec/src/data/driver/postgres.zod.ts b/packages/spec/src/data/driver/postgres.zod.ts index b9365edd0f..411fdb9e4a 100644 --- a/packages/spec/src/data/driver/postgres.zod.ts +++ b/packages/spec/src/data/driver/postgres.zod.ts @@ -18,6 +18,7 @@ import { z } from 'zod'; import { lazySchema } from '../../shared/lazy-schema'; import { strictObject } from '../../shared/strict-object'; import { + credentialFreeUrl, driverConfigJsonSchema, DriverSslToggleSchema, INLINE_CREDENTIAL_REFUSED, @@ -84,11 +85,16 @@ export const PostgresConfigSchema = lazySchema(() => strictObject( { /** * Connection URI. When present it supersedes `host`/`port`/`database`/ - * `username`, and a datasource secret (`external.credentialsRef`) still - * overrides any password embedded in it. - * Format: `postgresql://[user[:password]@][host][:port][/dbname][?params]` + * `username`. Credential-free by contract since #8082: a `user: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@host`) + * stays writable. Runtime-environment DSNs (`OS_DATABASE_URL`) never pass + * through this schema and are unaffected. + * Format: `postgresql://[user@][host][:port][/dbname][?params]` */ - url: z.string().optional().describe('Connection URI (supersedes the discrete fields)') + url: credentialFreeUrl(z.string(), '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. */ diff --git a/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts index 76dfd70fa5..1e575af9fd 100644 --- a/packages/spec/src/data/driver/turso.zod.ts +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -6,6 +6,7 @@ import { lazySchema } from '../../shared/lazy-schema'; import { strictObject } from '../../shared/strict-object'; import type { DriverDefinition } from '../datasource.zod'; import { + credentialFreeUrl, driverConfigJsonSchema, INLINE_CREDENTIAL_REFUSED, READ_ONLY_BELONGS_ON_DATASOURCE, @@ -124,6 +125,12 @@ export const TursoConfigSchema = lazySchema(() => strictObject( * is the single fact that makes `hasLocalDefault: false` true for turso, * and the reason both boot hosts refuse a driver selection with no URL * rather than guessing one (#6345 fork 2). + * + * 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 + * DSNs (`OS_DATABASE_URL` + `OS_DATABASE_AUTH_TOKEN`) never pass through + * this schema and are unaffected. */ // The description names the SHAPES in words rather than pasting URL // prefixes, matching how the postgres/mysql/mongo `url` keys describe @@ -133,7 +140,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: z.string().min(1) + url: credentialFreeUrl(z.string().min(1), 'url') .describe('libSQL endpoint or local file: a remote libsql/https Turso URL, a file path, or :memory:') .meta({ title: 'Database URL' }), @@ -158,8 +165,13 @@ export const TursoConfigSchema = lazySchema(() => strictObject( .describe('Maximum concurrent requests to the remote database') .meta({ title: 'Concurrency' }), - /** Remote sync endpoint that turns a local file into an embedded replica. */ - syncUrl: z.string().optional() + /** + * 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. + */ + syncUrl: credentialFreeUrl(z.string(), '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-url-userinfo-refused.ts b/packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts new file mode 100644 index 0000000000..c9b9385952 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.datasource-config-url-userinfo-refused.ts @@ -0,0 +1,34 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'datasource-config-url-userinfo-refused', + 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', + reason: + '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).', + acceptanceCriteria: + '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/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 6fa8341efb..f59bfe5f5c 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -2323,6 +2323,36 @@ 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-url-userinfo-refused', + 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', + reason: + '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).', + acceptanceCriteria: + '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.', + }, { id: 'declarative-apis-endpoints-live', surface: 'stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)', From 0343c4da7fd03bac74b8a2a88f24485228b6c3ac Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 06:12:34 +0000 Subject: [PATCH 2/3] test(spec): re-spell two driver URI fixtures credential-free (#8082 fixture triage) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit mongo.test.ts / postgres.test.ts 'accept config with connection URI' merely used the userinfo spelling to demonstrate URI acceptance — re-spelled to the bare-user form the rule still accepts (disposition: re-spell; the family rejection pins live in driver-credential-refusal.test.ts). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 --- packages/spec/src/data/driver/mongo.test.ts | 6 ++++-- packages/spec/src/data/driver/postgres.test.ts | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/data/driver/mongo.test.ts b/packages/spec/src/data/driver/mongo.test.ts index b5562d61f8..45f7e4d333 100644 --- a/packages/spec/src/data/driver/mongo.test.ts +++ b/packages/spec/src/data/driver/mongo.test.ts @@ -11,12 +11,14 @@ describe('MongoConfigSchema', () => { }); it('should accept config with connection URI', () => { + // Credential-free URL since #8082: a `user:password@` userinfo is refused + // at publish (see driver-credential-refusal.test.ts for the family pins). const config = MongoConfigSchema.parse({ - url: 'mongodb://user:pass@host1:27017/mydb?authSource=admin', + url: 'mongodb://user@host1:27017/mydb?authSource=admin', database: 'mydb', }); - expect(config.url).toBe('mongodb://user:pass@host1:27017/mydb?authSource=admin'); + expect(config.url).toBe('mongodb://user@host1:27017/mydb?authSource=admin'); }); it('should accept config with all fields', () => { diff --git a/packages/spec/src/data/driver/postgres.test.ts b/packages/spec/src/data/driver/postgres.test.ts index b8ef9b9a40..a13ed74233 100644 --- a/packages/spec/src/data/driver/postgres.test.ts +++ b/packages/spec/src/data/driver/postgres.test.ts @@ -14,12 +14,14 @@ describe('PostgresConfigSchema', () => { }); it('should accept config with connection URI', () => { + // Credential-free URL since #8082: a `user:password@` userinfo is refused + // at publish (see driver-credential-refusal.test.ts for the family pins). const config = PostgresConfigSchema.parse({ - url: 'postgresql://user:pass@db.example.com:5432/production', + url: 'postgresql://user@db.example.com:5432/production', database: 'production', }); - expect(config.url).toBe('postgresql://user:pass@db.example.com:5432/production'); + expect(config.url).toBe('postgresql://user@db.example.com:5432/production'); }); it('should accept config with all fields', () => { From d0b8bd6f7a0900bb10e40b0614a8b39103144d88 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 13 Aug 2026 06:26:16 +0000 Subject: [PATCH 3/3] chore(spec): regenerate artifact projections on the merged tree (os-regen step 4) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01Euoy6wyfzgiWtgCg4s6JK2 --- docs/protocol-upgrade-guide.md | 3 +++ packages/spec/spec-changes.json | 12 ++++++++++++ 2 files changed, 15 insertions(+) diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 25f0798047..69e363eb7f 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -240,6 +240,8 @@ The action LOCATION vocabulary loses `global_nav` in this step (#6888, ADR-0049, It also removes the three pass-through-only list-view display keys `striped` / `bordered` / `virtualScroll` (#7176, ADR-0049 enforce-or-remove, maintainer ruling 2026-08-10). All three were graded live on reads that turned out to be forwarding copies: the react spec-bridge, plugin-list and plugin-view/app-shell each copy the key onto the next node, and the chain ends at ObjectGrid, which never spells any of the three — so an author who wrote `striped: true` got a parse-clean no-op, the exact silent-no-op shape enforce-or-remove exists to end. Copy-without-apply is dead in effect; per the ruling, if objectui wants one of these as real behavior, that is an implementation card filed first, and the key stays retired pending it. +Finally it removes the 'pdf' member of `view.exportOptions` formats (#8010, maintainer ruling 2026-08-12). PDF export was declined platform-side (#1301 NOT_PLANNED), so the member was declared-but-unrenderable: ObjectGrid dropped the format from the export menu with only a runtime console.warn, so `exportOptions: ['xlsx', 'pdf']` type-checked, validated, and silently rendered a menu without PDF. The same ruling adopted the OBJECT form for `exportOptions` — `{ formats?, maxRecords?, includeHeaders?, fileNamePrefix?, streaming? }`, exactly the key set the renderer reads, ending the state where no declaration was both type-legal and functional — with the legacy bare array still accepted and lifted to `{ formats: [...] }` at parse, which is why the conversion strips only 'pdf' and does not rewrite the array spelling. This is an enum VALUE, not a key, so — as with `crypto.hash` above — there is no `retiredKey()` tombstone: the format enum's error map carries the prescription, keyed on the received value so only the spelling that used to be legal is told it "was removed", plus a union-level dispatch so the refusal is the top-level message in either authored form. The strip keeps an emptied `formats` array rather than deleting the declaration. + ### Mechanical (applied for you) | Conversion | Surface | Change | Load window | @@ -264,6 +266,7 @@ It also removes the three pass-through-only list-view display keys `striped` / ` | `flow-inert-keys-removed` | `flow.active / flow.template / flow.nodes[].outputSchema / flow.errorHandling.fallbackNodeId` | flow keys 'active'/'template', node 'outputSchema' and errorHandling 'fallbackNodeId' removed (#3896 close-out — active:false never stopped a flow; status is the enforced lifecycle) | retired — `migrate meta` only | | `view-inert-keys-removed` | `view.list.responsive / view.list.performance / view.form.defaultSort / view.form.aria` | view keys removed (#3896 close-out): list 'responsive'/'performance', form 'defaultSort'/'aria' — no renderer read them (list aria/data and form data stay live) | retired — `migrate meta` only | | `view-list-passthrough-keys-removed` | `view.list.striped / view.list.bordered / view.list.virtualScroll` | view list keys removed (#7176): 'striped'/'bordered'/'virtualScroll' — every measured reader copied the key forward and none applied it (pass-through-only; ADR-0049 enforce-or-remove) | retired — `migrate meta` only | +| `view-export-options-pdf-removed` | `view.list.exportOptions / view.listViews.*.exportOptions` | list-view export format 'pdf' removed (#8010 — PDF export was declined as #1301 NOT_PLANNED; ObjectGrid dropped the declared format from the menu with only a runtime console.warn) | retired — `migrate meta` only | | `dashboard-inert-keys-removed` | `dashboard.aria / dashboard.performance / dashboard.widgets[].performance` | dashboard keys 'aria'/'performance' and widget 'performance' removed (#3896 close-out — no renderer applied any of them) | retired — `migrate meta` only | | `dashboard-widget-responsive-removed` | `dashboard.widgets[].responsive` | dashboard widget key 'responsive' removed (#4876 — no renderer ever applied per-widget breakpoint overrides; page.components[].responsive is unaffected) | retired — `migrate meta` only | | `dashboard-widget-action-aria-removed` | `dashboard.widgets[].actionUrl / dashboard.widgets[].actionType / dashboard.widgets[].actionIcon / dashboard.widgets[].aria` | dashboard widget keys 'actionUrl'/'actionType'/'actionIcon' and 'aria' removed (#5010 — no renderer ever drew a per-widget action button, and widget ARIA attributes never reached the DOM; use header.actions[] and the widget title/description) | retired — `migrate meta` only | diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 6a4506a68c..c9b7844dd9 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -188,6 +188,12 @@ "conversionId": "view-list-passthrough-keys-removed", "toMajor": 17 }, + { + "surface": "view.list.exportOptions / view.listViews.*.exportOptions", + "to": "list-view export format 'pdf' removed (#8010 — PDF export was declined as #1301 NOT_PLANNED; ObjectGrid dropped the declared format from the menu with only a runtime console.warn)", + "conversionId": "view-export-options-pdf-removed", + "toMajor": 17 + }, { "surface": "dashboard.aria / dashboard.performance / dashboard.widgets[].performance", "to": "dashboard keys 'aria'/'performance' and widget 'performance' removed (#3896 close-out — no renderer applied any of them)", @@ -1267,6 +1273,12 @@ "conversionId": "view-list-passthrough-keys-removed", "toMajor": 17 }, + { + "surface": "view.list.exportOptions / view.listViews.*.exportOptions", + "to": "list-view export format 'pdf' removed (#8010 — PDF export was declined as #1301 NOT_PLANNED; ObjectGrid dropped the declared format from the menu with only a runtime console.warn)", + "conversionId": "view-export-options-pdf-removed", + "toMajor": 17 + }, { "surface": "dashboard.aria / dashboard.performance / dashboard.widgets[].performance", "to": "dashboard keys 'aria'/'performance' and widget 'performance' removed (#3896 close-out — no renderer applied any of them)",