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
60 changes: 60 additions & 0 deletions .changeset/url-userinfo-credentials-refused-at-publish.md
Original file line numberDiff line numberDiff line change
@@ -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:<handle>' },
})
// (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`).

<!-- adr-0087: registered datasource-config-url-userinfo-refused -->
2 changes: 1 addition & 1 deletion content/docs/references/data/driver-mongo.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/driver-mysql.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 |
Expand Down
2 changes: 1 addition & 1 deletion content/docs/references/data/driver-postgres.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -40,7 +40,7 @@ PostgreSQL connection configuration

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **url** | `string` | optional | Connection URI (supersedes the discrete fields) |
| **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 |
Expand Down
3 changes: 3 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -373,6 +373,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-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/<manifest.namespace>/<subpath>`, 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/<namespace>/…`), 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/<your manifest.namespace>/<subpath>` 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.
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -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: [] });
});
});
Original file line numberDiff line numberDiff line change
Expand Up@@ -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
*
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/api-surface/data.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -590,6 +590,7 @@
"TursoDriverSpec (const)",
"TursoTransportMode (type)",
"TursoTransportModeSchema (const)",
"URL_EMBEDDED_CREDENTIAL_REFUSED (const)",
"UniqueScope (type)",
"UniqueScopeSchema (const)",
"UnknownAuthoringKeyFinding (interface)",
Expand All@@ -608,6 +609,7 @@
"checkManagedApiMethodAffordances (function)",
"classifyFilterToken (function)",
"countAuthorableFields (function)",
"credentialFreeUrl (function)",
"defaultAggregateFor (function)",
"defaultValueTokenIssue (function)",
"defineCube (function)",
Expand DownExpand Up@@ -701,6 +703,7 @@
"stripLegacyApiMethods (function)",
"suggestDefaultValueToken (function)",
"suggestFieldTypeForSqlType (function)",
"urlUserinfoPassword (function)",
"utcInstantMs (function)",
"validateDriverConfig (function)",
"valueSchemaFor (function)"
Expand Down
3 changes: 3 additions & 0 deletions packages/spec/export-origins/data.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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)",
Expand All@@ -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)",
Expand DownExpand Up@@ -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)"
Expand Down
Loading
Loading