diff --git a/.changeset/inline-credentials-refused-at-publish.md b/.changeset/inline-credentials-refused-at-publish.md new file mode 100644 index 0000000000..04c9ccd8eb --- /dev/null +++ b/.changeset/inline-credentials-refused-at-publish.md @@ -0,0 +1,74 @@ +--- +"@objectstack/spec": major +"@objectstack/example-showcase": patch +--- + +feat(spec)!: refuse inline credentials at publish — driver `config.password` / `config.authToken` and connector `authentication` on authored entries (#7990) + +`sys_metadata.metadata` is served back by the ordinary data API, and a datasource or +connector artefact is persisted whole — so any schema that *accepted* an inline +credential stored that credential in cleartext at rest. The maintainer-ruled fix +(#7990, Option A: per-artefact contract closure) makes the two measured surfaces +refuse the inline form at publish and divert to the mechanisms that already exist. + +**Driver config (postgres / mysql / mongo / turso).** `config.password` (SQL/mongo) +and `config.authToken` (turso) are now declared-unwritable: writing one fails `tsc` +(the input type is `never`) and fails the parse with a prescription naming the +replacement. The former alias spellings (`passwd`, `pwd`, `token`, `jwt`, +`auth_token`, `authtoken`) carry the same refusal. The connection form's masked +secret input is unaffected — it never wrote `config`; it feeds the datasource secret +binder, which encrypts into `sys_secret` and stores only an opaque handle. + +**Connector authoring door.** `DeclarativeConnectorEntrySchema` (behind +`defineStack({ connectors })` and `PUT /meta/connector/:name`) now refuses a +non-`none` `authentication` on **every** authored entry — catalog descriptors +included. Until now only provider-bound instances were covered (ADR-0097 §3), so a +descriptor could publish an inline `token`/`key`/`password`/`clientSecret`. The +runtime shape is unchanged: a plugin handing resolved secrets to +`registerConnector` keeps working. + +## FROM → TO + +```ts +// before — accepted, stored in cleartext in sys_metadata +defineDatasource({ + name: 'warehouse', driver: 'postgres', + config: { database: 'analytics', username: 'ro', password: 'hunter2' }, +}) + +// after — the secret lives in the secret store; config carries no credential +defineDatasource({ + name: 'warehouse', driver: 'postgres', schemaMode: 'external', + config: { database: 'analytics', username: 'ro' }, + external: { allowWrites: false, credentialsRef: 'sys_secret:' }, +}) +// (Setup → Datasources binds the secret for you: its password field encrypts into +// sys_secret and writes external.credentialsRef — it never wrote config.) +``` + +```ts +// before — descriptor published an inline credential +defineConnector({ + name: 'erp', label: 'ERP', type: 'saas', + authentication: { type: 'api-key', key: '…', headerName: 'X-API-Key' }, +}) + +// after — descriptor: no live credentials (document the scheme in prose); +defineConnector({ name: 'erp', label: 'ERP', type: 'saas', + description: 'Authenticates with an API key in the X-API-Key header.' }) +// instance: reference the credential (ADR-0097 §3) +defineConnector({ name: 'erp', label: 'ERP', type: 'saas', provider: 'openapi', + providerConfig: { spec: './erp-openapi.json' }, + auth: { type: 'api-key', credentialRef: 'ERP_API_KEY' } }) +``` + +There is deliberately **no automatic rewrite**: moving a cleartext credential into +`sys_secret` requires encrypting it through a running secret binder, which a +source-file transform cannot do — auto-deleting the key would silently drop a live +credential instead. `os migrate meta` surfaces both changes as structured TODOs +(semantic entries `datasource-config-inline-credential-refused`, +`connector-inline-authentication-publish-refused`). The migration story for +**already-stored** cleartext rows is programme scope, tracked as a follow-up card +under #7990 — this release closes the doors that keep writing new ones. + + diff --git a/content/docs/references/data/driver-mongo.mdx b/content/docs/references/data/driver-mongo.mdx index 9da1944082..2471c9aafe 100644 --- a/content/docs/references/data/driver-mongo.mdx +++ b/content/docs/references/data/driver-mongo.mdx @@ -46,7 +46,7 @@ MongoDB Connection Configuration | **host** | `string` | ✅ | Host address | | **port** | `integer` | ✅ | Port number | | **username** | `string` | optional | Authentication user | -| **password** | `string` | optional | Authentication password (prefer external.credentialsRef) | +| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` (#7990) | | **authSource** | `string` | optional | Authentication database | | **options** | `Record` | optional | Extra MongoClient options (replicaSet, tls, timeouts, …) | diff --git a/content/docs/references/data/driver-mysql.mdx b/content/docs/references/data/driver-mysql.mdx index d8d819492a..36e3090fd9 100644 --- a/content/docs/references/data/driver-mysql.mdx +++ b/content/docs/references/data/driver-mysql.mdx @@ -47,7 +47,7 @@ MySQL / MariaDB connection configuration | **port** | `integer` | ✅ | Port number | | **database** | `string` | optional | Database name | | **username** | `string` | optional | Authentication user | -| **password** | `string` | optional | Authentication password (prefer external.credentialsRef) | +| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` (#7990) | | **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. | | **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal (#2186) | diff --git a/content/docs/references/data/driver-postgres.mdx b/content/docs/references/data/driver-postgres.mdx index 7fe5756f48..c4f051908e 100644 --- a/content/docs/references/data/driver-postgres.mdx +++ b/content/docs/references/data/driver-postgres.mdx @@ -45,7 +45,7 @@ PostgreSQL connection configuration | **port** | `integer` | ✅ | Port number | | **database** | `string` | optional | Database name | | **username** | `string` | optional | Authentication user | -| **password** | `string` | optional | Authentication password (prefer external.credentialsRef) | +| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` (#7990) | | **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. | | **schema** | `string` | ✅ | Default schema (knex searchPath) | | **applicationName** | `string` | optional | Postgres application_name | diff --git a/content/docs/references/data/driver-turso.mdx b/content/docs/references/data/driver-turso.mdx index 9e793a480a..07d37a4ee9 100644 --- a/content/docs/references/data/driver-turso.mdx +++ b/content/docs/references/data/driver-turso.mdx @@ -66,7 +66,7 @@ Turso / libSQL Connection Configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **url** | `string` | ✅ | libSQL endpoint or local file: a remote libsql/https Turso URL, a file path, or :memory: | -| **authToken** | `string` | optional | JWT auth token for a remote libSQL database (prefer external.credentialsRef) | +| **authToken** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` (#7990) | | **encryptionKey** | `string` | optional | AES-256 encryption key for the local database file (local/replica modes) | | **concurrency** | `integer` | optional | Maximum concurrent requests to the remote database | | **syncUrl** | `string` | optional | Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint | diff --git a/content/docs/references/integration/connector.mdx b/content/docs/references/integration/connector.mdx index 1b0c77a586..b9ba752fd3 100644 --- a/content/docs/references/integration/connector.mdx +++ b/content/docs/references/integration/connector.mdx @@ -166,7 +166,7 @@ Circuit breaker configuration | **type** | `Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>` | ✅ | Connector type | | **description** | `string` | optional | Connector description | | **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | +| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets (#7990): use `auth.credentialRef` on a provider-bound instance. | | **provider** | `string` | optional | Generic-executor key that materializes this declarative entry at boot (e.g. openapi/mcp/rest). Omit for a catalog-only descriptor. Unknown provider ⇒ hard boot error (ADR-0097). | | **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | | **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | @@ -488,7 +488,7 @@ Connector type | **type** | `Enum<'saas' \| 'database' \| 'file_storage' \| 'message_queue' \| 'api' \| 'custom'>` | ✅ | Connector type | | **description** | `string` | optional | Connector description | | **icon** | `string` | optional | Icon identifier | -| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead. | +| **authentication** | `{ type: 'oauth2'; authorizationUrl: string; tokenUrl: string; clientId: string; … } \| { type: 'api-key'; key: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; password: string } \| { type: 'bearer'; token: string } \| { type: 'none' }` | optional | Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets (#7990): use `auth.credentialRef` on a provider-bound instance. | | **provider** | `string` | optional | Generic-executor key that materializes this declarative entry at boot (e.g. openapi/mcp/rest). Omit for a catalog-only descriptor. Unknown provider ⇒ hard boot error (ADR-0097). | | **providerConfig** | `Record` | optional | Provider-specific config validated by the provider factory at boot (e.g. `{ spec, baseUrl }` for openapi, where spec is an inline document, a package-relative file path like './billing-openapi.json', or an http(s) URL). Requires `provider`. | | **auth** | `{ type: 'none' } \| { type: 'bearer'; credentialRef: string } \| { type: 'api-key'; credentialRef: string; headerName?: string; paramName?: string } \| { type: 'basic'; username: string; credentialRef: string }` | optional | Declarative instance auth — references credentials via `credentialRef` (resolved at boot), never inline secrets. Requires `provider` (ADR-0097). | diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index b1bb6bba5d..a5e4f7c06f 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -343,6 +343,9 @@ The action LOCATION vocabulary loses `global_nav` in this step (#6888, ADR-0049, - **`client-delete-result-success`** — `client.DeleteDataResult.deleted (the return of `client.data.delete()`)` → `success` — `r.deleted` → `r.success`. Same call, same wire body, declared name - Why not automatic: `DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same ruling #5581 applied on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the #6350 stock reconciliation. ADR-0087, #5638 (backfilled #6350). - Done when: No code reads `.deleted` off a `client.data.delete()` / `client.project(id).data.delete()` result; `tsc` names every site for a typed caller, and an untyped JS caller must be swept by hand because nothing will report it. Nothing about the request, the route, the status codes or the error shapes changes, and no server needs upgrading — the value you may now read is the one that was already arriving. ⚠️ The real work is behavioural: every `if (r.deleted)` has been false since it was written, so re-read what each of those branches was supposed to do. Post-delete cleanup, cache invalidation, audit writes and UI refreshes guarded that way have never run, and switching to `r.success` turns them ON for the first time — verify that is what you want rather than assuming it restores prior behaviour. Any test that passed while asserting on `deleted` was asserting on `undefined` and needs rewriting, not renaming. +- **`connector-inline-authentication-publish-refused`** — `connector.authentication on AUTHORED entries (defineStack `connectors:`, `PUT /meta/connector/:name`) — previously refused only on provider-bound instances (ADR-0097 §3), now refused on catalog descriptors too` → a catalog descriptor drops `authentication` (or sets `{ type: "none" }`) and documents the auth scheme in `description`; a dispatchable instance declares `provider` and references its credential with `auth: { type, credentialRef }` (ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape still carries resolved secrets inline. + - Why not automatic: A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` / `password` / `clientSecret` is cleartext at rest, readable through the data API (#7990). No mechanical rewrite exists: whether the entry should become a `none` descriptor or a provider-bound instance with a `credentialRef` — and which secret store receives the credential — is a judgment about the connector, not a rename. + - Done when: Every authored connector entry parses through `DeclarativeConnectorEntrySchema`; no authored entry carries a non-`none` `authentication`; formerly inline credentials are reachable through `credentialRef` resolution and the connector still materializes. - **`dashboard-widget-compareto-offset`** — `dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')` → compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter` - Why not automatic: The widget declared three comparison arms; the analytics executor implements one shape, `{ kind, dimension? }`, with no `offset` concept in it at all. On the ADR-0021 dataset path — the spec's single author-facing analytics shape — `{ offset }` was forwarded verbatim into that contract and threw `compareTo requires a timeDimension "undefined"`, taking the widget down; the arm ever only ran on the legacy inline chart path (#5011). The conversion rewrites `{ offset: '1y' }`, which IS `previousYear` by definition. Every other duration has NO faithful target: `previousPeriod` shifts by the length of whatever window the widget's filter resolves to, which equals `7d` only when that window happens to be seven days long. Rewriting mechanically would silently change which rows the comparison column counts — a wrong number rather than a missing one, which is strictly worse and exactly the class this convergence exists to end. Re-stating the intended window is a judgment about the presentation, not a transform. - Done when: No dashboard widget declares `compareTo.offset`. Each former offset comparison states its window on the widget's `filter` and compares with `compareTo: { kind: 'previousPeriod' }` (or `'previousYear'`), and `dimension` is named wherever the selection dates more than one time dimension. `objectstack validate` passes, and each affected widget renders a `__compare` column over the window its author intended. @@ -358,6 +361,9 @@ The action LOCATION vocabulary loses `global_nav` in this step (#6888, ADR-0049, - **`data-field-changed-event-retired`** — `api.DataEventType 'data.field.changed'` → the `data.record.updated` event, whose payload already carries the per-field detail: `changes` (the changed fields), plus `before` / `after` - Why not automatic: `data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673. - Done when: No consumer subscribes to or switches on `data.field.changed`; per-field change detail is read from a `data.record.updated` event's `changes` map (with `before` / `after` for the surrounding state). Deleting the dead branch changes no observable behaviour — it never executed — so the migration is removing code that could not run, not rebuilding a capability. +- **`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. - **`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/examples/app-showcase/src/system/connectors/index.ts b/examples/app-showcase/src/system/connectors/index.ts index 1190544ca3..a8ba826179 100644 --- a/examples/app-showcase/src/system/connectors/index.ts +++ b/examples/app-showcase/src/system/connectors/index.ts @@ -147,10 +147,18 @@ export const ErpCatalogConnector = defineConnector({ label: 'ERP Integration (Catalog Descriptor)', type: 'saas', description: - 'Catalog-only descriptor documenting a planned ERP integration: what it is, how it authenticates, ' + - 'and which actions it will expose. Not dispatchable — see the connector plugins in ' + - 'objectstack.config.ts for the live registry entries this collection does NOT feed (#2612).', - authentication: { type: 'api-key', key: 'SET_AT_INSTALL_TIME', headerName: 'X-API-Key' }, + 'Catalog-only descriptor documenting a planned ERP integration: what it is, how it authenticates ' + + '(API key in the X-API-Key header, bound at install time), and which actions it will expose. ' + + 'Not dispatchable — see the connector plugins in objectstack.config.ts for the live registry ' + + 'entries this collection does NOT feed (#2612).', + // No `authentication` block — a descriptor holds no live credentials (#7990: + // the publish door refuses any non-`none` `authentication`, because the row + // lands whole in `sys_metadata`). The auth SCHEME is prose in `description`; + // when this becomes a dispatchable instance it declares `provider` and + // references its key with `auth: { type: 'api-key', credentialRef: … }` + // (ADR-0097 §3). Until #7990 this entry carried + // `authentication: { type: 'api-key', key: 'SET_AT_INSTALL_TIME', … }` — a + // placeholder, but the exact inline-cleartext shape the door now refuses. // Descriptor-level action catalog: key + label + I/O JSON Schemas. Note the // deliberate absence of any execution binding (HTTP method/path) — that is // what keeps descriptors inert today and what ADR-0097's provider binding diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 1125c41335..38cc957e37 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -309,6 +309,7 @@ "IMPORT_BOOLEAN_FALSE_TOKENS (const)", "IMPORT_BOOLEAN_TRUE_TOKENS (const)", "IMPORT_REFERENCE_TYPES (const)", + "INLINE_CREDENTIAL_REFUSED (const)", "INSTANT_TYPES (const)", "ImportFieldMapping (type)", "ImportFieldMappingParsed (type)", @@ -678,6 +679,7 @@ "reduceFilterVerdict (function)", "referenceTargetOf (function)", "referencedFields (function)", + "refusedInlineCredentialKey (function)", "renderAutonumber (function)", "resolveAutonumberFormat (function)", "resolveBulkPerRowHookBudget (function)", diff --git a/packages/spec/authorable-surface/data.json b/packages/spec/authorable-surface/data.json index a8d7da72d3..b5c17f27d5 100644 --- a/packages/spec/authorable-surface/data.json +++ b/packages/spec/authorable-surface/data.json @@ -539,14 +539,14 @@ "data/MongoConfig:database", "data/MongoConfig:host", "data/MongoConfig:options", - "data/MongoConfig:password", + "data/MongoConfig:password [RETIRED]", "data/MongoConfig:port", "data/MongoConfig:url", "data/MongoConfig:username", "data/MysqlConfig:autoMigrate", "data/MysqlConfig:database", "data/MysqlConfig:host", - "data/MysqlConfig:password", + "data/MysqlConfig:password [RETIRED]", "data/MysqlConfig:port", "data/MysqlConfig:ssl", "data/MysqlConfig:url", @@ -690,7 +690,7 @@ "data/PostgresConfig:autoMigrate", "data/PostgresConfig:database", "data/PostgresConfig:host", - "data/PostgresConfig:password", + "data/PostgresConfig:password [RETIRED]", "data/PostgresConfig:port", "data/PostgresConfig:schema", "data/PostgresConfig:ssl", @@ -862,7 +862,7 @@ "data/StringOperator:$startsWith", "data/TenancyConfig:enabled", "data/TenancyConfig:tenantField", - "data/TursoConfig:authToken", + "data/TursoConfig:authToken [RETIRED]", "data/TursoConfig:concurrency", "data/TursoConfig:encryptionKey", "data/TursoConfig:mode", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index 712ef167aa..074d214cd3 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -309,6 +309,7 @@ "IMPORT_BOOLEAN_FALSE_TOKENS": "src/data/import-coercion.ts#IMPORT_BOOLEAN_FALSE_TOKENS (const)", "IMPORT_BOOLEAN_TRUE_TOKENS": "src/data/import-coercion.ts#IMPORT_BOOLEAN_TRUE_TOKENS (const)", "IMPORT_REFERENCE_TYPES": "src/data/import-coercion.ts#IMPORT_REFERENCE_TYPES (const)", + "INLINE_CREDENTIAL_REFUSED": "src/data/driver/common.zod.ts#INLINE_CREDENTIAL_REFUSED (const)", "INSTANT_TYPES": "src/data/field-value.zod.ts#INSTANT_TYPES (const)", "ImportFieldMapping": "src/data/mapping.zod.ts#ImportFieldMapping (type)", "ImportFieldMappingParsed": "src/data/mapping.zod.ts#ImportFieldMappingParsed (type)", @@ -678,6 +679,7 @@ "reduceFilterVerdict": "src/data/filter-verdict.ts#reduceFilterVerdict (function)", "referenceTargetOf": "src/data/field-value.zod.ts#referenceTargetOf (function)", "referencedFields": "src/data/autonumber-format.ts#referencedFields (function)", + "refusedInlineCredentialKey": "src/data/driver/common.zod.ts#refusedInlineCredentialKey (function)", "renderAutonumber": "src/data/autonumber-format.ts#renderAutonumber (function)", "resolveAutonumberFormat": "src/data/autonumber-format.ts#resolveAutonumberFormat (function)", "resolveBulkPerRowHookBudget": "src/data/bulk-write-hook-conformance.ts#resolveBulkPerRowHookBudget (function)", diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index 389bcbe54e..ad0ad39fb8 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -574,6 +574,13 @@ "toMajor": 17, "rationale": "`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same ruling #5581 applied on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the #6350 stock reconciliation. ADR-0087, #5638 (backfilled #6350)." }, + { + "surface": "connector.authentication on AUTHORED entries (defineStack `connectors:`, `PUT /meta/connector/:name`) — previously refused only on provider-bound instances (ADR-0097 §3), now refused on catalog descriptors too", + "replacement": "a catalog descriptor drops `authentication` (or sets `{ type: \"none\" }`) and documents the auth scheme in `description`; a dispatchable instance declares `provider` and references its credential with `auth: { type, credentialRef }` (ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape still carries resolved secrets inline.", + "migrationId": "connector-inline-authentication-publish-refused", + "toMajor": 17, + "rationale": "A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` / `password` / `clientSecret` is cleartext at rest, readable through the data API (#7990). No mechanical rewrite exists: whether the entry should become a `none` descriptor or a provider-bound instance with a `credentialRef` — and which secret store receives the credential — is a judgment about the connector, not a rename." + }, { "surface": "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')", "replacement": "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`", @@ -609,6 +616,13 @@ "toMajor": 17, "rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673." }, + { + "surface": "datasource.config.password (postgres / mysql / mongo) and datasource.config.authToken (turso)", + "replacement": "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-inline-credential-refused", + "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": "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, … }`", @@ -1542,6 +1556,13 @@ "toMajor": 17, "rationale": "`DeleteDataResult` carried the comment `Spec: DeleteDataResponseSchema` above a declaration that contradicted it: the interface declared `deleted: boolean` while `DeleteDataResponseSchema` declares `{ object, id, success }`. `deleted` has never been declared by any schema and no server path has ever returned it on `/data/:object/:id`. Both delete surfaces — `client.data.delete()` and the project-scoped `client.project(id).data.delete()` — are pure `unwrapResponse` / `_unwrap` passthroughs, so the interface is a CLAIM about the wire, never a rewrite of it, and the claim was false in the one direction that matters: the compiler endorsed the wrong spelling. `if (r.deleted)` compiled, read `undefined` at runtime, and the branch was never taken; `if (r.success)` was rejected by the compiler and correct on the wire. So this rename REVEALS a defect rather than breaking working code — every reader of the old key was already reading `undefined`, on every deployment and not just some, because the protocol path has always answered `success`. It is registered as a semantic entry rather than a mechanical conversion for the reason the rewrite itself does not capture: the key is one token, but a call site that branched on `r.deleted` has been taking the FALSE branch unconditionally since it was written, and whatever that branch did — or skipped — is what actually has to be re-read. There is no authored source for the chain to rewrite either; this is a published TypeScript surface whose enforced channel is tsc at the call site, and for an untyped JS caller there is no constrained channel at all, which is why the ledger entry is the only notification that reaches them. ⛔ Do not write `r.success ?? r.deleted`: there is one producer shape, and a consumer accepting two spellings is what contract-first exists to prevent (the same ruling #5581 applied on the producer side). No deprecated `deleted?: boolean` transition key ships, for the same reason — a transition period is for keys that WORKED, and this one never did. Registered by the #6350 stock reconciliation. ADR-0087, #5638 (backfilled #6350)." }, + { + "surface": "connector.authentication on AUTHORED entries (defineStack `connectors:`, `PUT /meta/connector/:name`) — previously refused only on provider-bound instances (ADR-0097 §3), now refused on catalog descriptors too", + "replacement": "a catalog descriptor drops `authentication` (or sets `{ type: \"none\" }`) and documents the auth scheme in `description`; a dispatchable instance declares `provider` and references its credential with `auth: { type, credentialRef }` (ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape still carries resolved secrets inline.", + "migrationId": "connector-inline-authentication-publish-refused", + "toMajor": 17, + "rationale": "A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` / `password` / `clientSecret` is cleartext at rest, readable through the data API (#7990). No mechanical rewrite exists: whether the entry should become a `none` descriptor or a provider-bound instance with a `credentialRef` — and which secret store receives the credential — is a judgment about the connector, not a rename." + }, { "surface": "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')", "replacement": "compareTo: { kind: 'previousPeriod' } plus an explicit window on the widget's own `filter`", @@ -1577,6 +1598,13 @@ "toMajor": 17, "rationale": "`data.field.changed` was declared in `DataEventType` and emitted by nothing — the engine's `publishDataEvent` sends `data.record.{created,updated,deleted}` and (since #4639) `data.records.{updated,deleted}`, and no other producer exists in either repository. A subscriber that switched on it was waiting on an event no producer sends: the branch never ran, and because the surrounding `switch` still compiled, nothing anywhere reported the gap (ADR-0078's silently-inert declaration, on the event vocabulary). `DataEventSchema` could not have carried the semantics even if something had emitted it — the payload is record-shaped (`recordId`, `changes`, `before`, `after`) with no `field` / `oldValue` / `newValue` slot — so the member promised a granularity the contract has no room for. Per-field detail is therefore not lost: it has always ridden on `data.record.updated` as `changes`, which is one event per write rather than N events on a wide table. This is a runtime EVENT surface — no stack, example or template authors an event name (webhooks subscribe through the separate authorable `WebhookTriggerType`, whose vocabulary was already trimmed to producers that exist, #3196) — so there is no source for the chain to rewrite, and deliberately no schema tombstone: a removed ENUM MEMBER cannot carry a retiredKey() fix-it error the way an authorable object key can (the same limit the sharing-rule `full` retirement `owd-full-alias-removed` hit). The enforced channels are tsc, which fails any consumer still naming the value in a `DataEventType` position, and the enum parse, which now rejects the name instead of accepting an event that never arrives. A genuine per-field stream, if one is ever wanted, gets its own honest contract the way #4639 gave bulk writes theirs. ADR-0049 / ADR-0078, #4673." }, + { + "surface": "datasource.config.password (postgres / mysql / mongo) and datasource.config.authToken (turso)", + "replacement": "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-inline-credential-refused", + "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": "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/datasource.test.ts b/packages/spec/src/data/datasource.test.ts index 4093a1f481..592227e2b2 100644 --- a/packages/spec/src/data/datasource.test.ts +++ b/packages/spec/src/data/datasource.test.ts @@ -183,6 +183,8 @@ describe('DatasourceSchema', () => { }); it('should accept datasource with all fields', () => { + // `config.password` is deliberately absent: inline credentials are refused + // since #7990 (pinned in driver/driver-credential-refusal.test.ts). const datasource = DatasourceSchema.parse({ name: 'production_db', label: 'Production Database', @@ -192,7 +194,6 @@ describe('DatasourceSchema', () => { port: 5432, database: 'production', username: 'app_user', - password: '${DB_PASSWORD}', ssl: true, }, description: 'Main production PostgreSQL database', @@ -204,6 +205,8 @@ describe('DatasourceSchema', () => { }); it('should accept PostgreSQL datasource', () => { + // No `config.password` — inline credentials are refused since #7990 + // (pinned in driver/driver-credential-refusal.test.ts). const datasource = DatasourceSchema.parse({ name: 'postgres_db', driver: 'postgres', @@ -212,7 +215,6 @@ describe('DatasourceSchema', () => { port: 5432, database: 'mydb', username: 'user', - password: 'pass', }, }); @@ -326,6 +328,12 @@ 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({ name: 'secure_db', driver: 'postgres', @@ -334,11 +342,17 @@ describe('DatasourceSchema', () => { port: 5432, database: '${DB_NAME}', username: '${DB_USER}', - password: '${DB_PASSWORD}', }, }); - expect(datasource.config.password).toBe('${DB_PASSWORD}'); + expect(datasource.config.username).toBe('${DB_USER}'); + + const refused = DatasourceSchema.safeParse({ + name: 'secure_db', + driver: 'postgres', + config: { database: 'prod', password: '${DB_PASSWORD}' }, + }); + expect(refused.success).toBe(false); }); it('should accept datasource with complex config', () => { diff --git a/packages/spec/src/data/driver/common.zod.ts b/packages/spec/src/data/driver/common.zod.ts index 673a5a4b8e..d620eb54c0 100644 --- a/packages/spec/src/data/driver/common.zod.ts +++ b/packages/spec/src/data/driver/common.zod.ts @@ -83,6 +83,57 @@ export const SSL_DETAIL_BELONGS_ON_DATASOURCE = + '`ssl: { enabled: true, rejectUnauthorized: false, ca: … }` next to `driver`. Inside `config`, ' + '`ssl` is the on/off shorthand only.'; +/** + * Refusal prescription for an inline credential written into driver config + * (#7990, maintainer-ruled Option A 2026-08-12: per-artefact contract closure). + * + * Why the KEY is refused, not just discouraged: a datasource artefact is + * persisted whole into `sys_metadata`, and `sys_metadata` declares + * `apiMethods: ['get','list']` — so an inline credential is cleartext at rest, + * readable through the ordinary data API. The two mechanisms this prescription + * names are the ones that already exist and already win over an inline value + * at connect time (`DatasourceConnectionService` resolves + * `external.credentialsRef` and injects the secret into the driver factory). + * + * Used both as the `z.never` error of a {@link refusedInlineCredentialKey} + * (the declared key) and as the `guidance` entry for the key's former alias + * spellings (`passwd`/`pwd`/`token`/`jwt`) — an alias row pointing at an + * unwritable key would be the `triggerPhrase → triggerPhrases` two-step + * rejection `shared/strict-object.ts` documents. + */ +export const INLINE_CREDENTIAL_REFUSED = (key: string): string => + `\`${key}\` is a credential and is not accepted inline in driver config (#7990): the ` + + 'datasource is persisted whole into `sys_metadata`, which is served back by the ordinary ' + + 'data API, so an inline credential lands in cleartext at rest. 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 always ' + + 'wins over anything embedded in `config`.'; + +/** + * A driver-config credential key, declared but UNWRITABLE (#7990). + * + * Same construction as `shared/retired-key.ts`'s `retiredKey()` — `z.never()` + * emits as `{ "not": {} }`, so the authorable-surface ratchet reads the key as + * `[RETIRED]` and `tsc` types it `never` — but hand-rolled here for the one + * thing `retiredKey()` cannot carry: the `.meta({ format: 'password' })` + * projection. The Studio connection form (objectui + * `DatasourceResourcePage.tsx`) renders its SECRET input from exactly that + * marker and routes the value to the top-level `secret` — the datasource + * secret binder's door, not `config` — so the marker must survive the + * refusal or the wizard loses the very input the refusal diverts authors to. + * The parse and the form cannot disagree: they read one schema. + */ +export function refusedInlineCredentialKey(key: string, formTitle: string) { + return z.never({ error: () => INLINE_CREDENTIAL_REFUSED(key) }).optional() + .describe( + "Set through the connection form's secret field or `external.credentialsRef` — " + + 'encrypted into `sys_secret`, never stored in `config` (#7990)', + ) + .meta({ title: formTitle, format: 'password' }); +} + /** Options every driver-config JSON-Schema projection is built with. */ const TO_JSON_SCHEMA = { target: 'draft-2020-12', diff --git a/packages/spec/src/data/driver/driver-credential-refusal.test.ts b/packages/spec/src/data/driver/driver-credential-refusal.test.ts new file mode 100644 index 0000000000..827d4e2ff2 --- /dev/null +++ b/packages/spec/src/data/driver/driver-credential-refusal.test.ts @@ -0,0 +1,175 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #7990 — inline credentials are refused across the driver-config family + * (maintainer-ruled Option A, 2026-08-12: per-artefact contract closure). + * + * `sys_metadata.metadata` is reachable through the ordinary data API, and a + * datasource is persisted whole — so a credential the schema ACCEPTS inline is + * a credential stored in cleartext. Every pin here asserts BOTH directions the + * ruling names: + * + * 1. the inline form is refused LOUDLY, with guidance naming the mechanisms + * that already exist (`sys_secret` via the datasource secret binder; + * `external.credentialsRef`) — never a bare `unrecognized_keys`; + * 2. the ref-based and credential-free forms keep parsing BYTE-IDENTICALLY. + * + * Plus the projection contract that makes the refusal safe to ship: the Studio + * connection form renders its SECRET input from the `format: 'password'` + * marker in the driver's JSON-Schema projection and routes the value to the + * top-level `secret` (the binder's door). The refused key must keep that + * marker, or the wizard loses the very input the refusal diverts authors to. + */ + +import { describe, expect, it } from 'vitest'; + +import { DatasourceSchema } from '../datasource.zod'; +import { + getMongoConfigJsonSchema, + MongoConfigSchema, +} from './mongo.zod'; +import { getMysqlConfigJsonSchema, MysqlConfigSchema } from './mysql.zod'; +import { getPostgresConfigJsonSchema, PostgresConfigSchema } from './postgres.zod'; +import { getTursoConfigJsonSchema, TursoConfigSchema } from './turso.zod'; + +/** The family under the ruling: schema, its credential key, a minimal valid config. */ +const FAMILY = [ + { + driver: 'postgres', + key: 'password', + schema: PostgresConfigSchema, + jsonSchema: getPostgresConfigJsonSchema, + valid: { database: 'prod', host: 'db.internal', username: 'app' }, + formerAliases: ['passwd', 'pwd'], + }, + { + driver: 'mysql', + key: 'password', + schema: MysqlConfigSchema, + jsonSchema: getMysqlConfigJsonSchema, + valid: { database: 'prod', host: 'db.internal', username: 'app' }, + formerAliases: ['passwd', 'pwd'], + }, + { + driver: 'mongo', + key: 'password', + schema: MongoConfigSchema, + jsonSchema: getMongoConfigJsonSchema, + valid: { database: 'events', host: 'mongo.internal', username: 'svc' }, + formerAliases: ['passwd', 'pwd'], + }, + { + driver: 'turso', + key: 'authToken', + schema: TursoConfigSchema, + jsonSchema: getTursoConfigJsonSchema, + valid: { url: 'libsql://x.turso.io' }, + formerAliases: ['token', 'jwt', 'auth_token', 'authtoken'], + }, +] as const; + +describe.each(FAMILY)('$driver — inline credential refusal (#7990)', (f) => { + it(`refuses an inline \`${f.key}\`, naming the key's replacement mechanisms`, () => { + const result = f.schema.safeParse({ ...f.valid, [f.key]: 'hunter2' }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === f.key); + expect(issue, `refusal must be pathed at \`${f.key}\``).toBeDefined(); + // The ADR-named mechanisms, BY NAME — the message is the migration doc for + // whoever hits it (very often an AI author). + expect(issue!.message).toContain(`\`${f.key}\``); + expect(issue!.message).toContain('external.credentialsRef'); + expect(issue!.message).toContain('sys_secret'); + expect(issue!.message).toContain('secret binder'); + }); + + it('refuses a placeholder value exactly like a real one — the KEY is the sink', () => { + // `${…}` placeholders are resolved by nothing (measured, #7990 census): + // they were stored verbatim in `sys_metadata` and connected verbatim. + const result = f.schema.safeParse({ ...f.valid, [f.key]: '${DB_PASSWORD}' }); + expect(result.success).toBe(false); + }); + + it.each([...f.formerAliases])( + 'former alias spelling `%s` carries the refusal directly (no two-step rename)', + (alias) => { + const result = f.schema.safeParse({ ...f.valid, [alias]: 'hunter2' }); + expect(result.success).toBe(false); + const text = JSON.stringify(result.error!.issues); + expect(text).toContain('external.credentialsRef'); + expect(text).toContain('sys_secret'); + // Never a rename hint onto the unwritable key — that is the + // `triggerPhrase → triggerPhrases` two-step rejection. + expect(text).not.toContain('Did you mean'); + }, + ); + + it('accepts the credential-free config byte-identically (pin)', () => { + const before = f.schema.safeParse(f.valid); + expect(before.success, JSON.stringify(before.error?.issues)).toBe(true); + // Parse twice — the output is a pure function of the input, and the parsed + // value must carry no trace of the refused key. + const again = f.schema.parse(f.valid); + expect(again).toEqual(before.data); + expect(Object.keys(again)).not.toContain(f.key); + }); + + it("keeps the connection form's secret input renderable: `format: 'password'` survives", () => { + const json = f.jsonSchema() as { + properties?: Record; + }; + const prop = json.properties?.[f.key]; + expect(prop, `projection must still declare \`${f.key}\``).toBeDefined(); + // Both halves of the dual role, pinned together: the wizard's marker … + expect(prop!.format).toBe('password'); + // … and the refusal (z.never emits `{ not: {} }` — also what flags the key + // `[RETIRED]` in the authorable-surface ratchet). + expect(prop!.not).toEqual({}); + }); +}); + +describe('DatasourceSchema — the refusal reaches the authored artefact (#7990)', () => { + it('re-paths the refusal under `config.` for the author', () => { + const result = DatasourceSchema.safeParse({ + name: 'prod', + driver: 'postgres', + config: { database: 'prod', password: 'hunter2' }, + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'config.password'); + expect(issue, 'issue must be re-pathed under config.password').toBeDefined(); + expect(issue!.message).toContain('external.credentialsRef'); + }); + + it('still accepts the ref-based form byte-identically (pin)', () => { + // The shape the refusal diverts to: secret in the store, opaque handle at + // `external.credentialsRef` — exactly what the datasource secret binder + // writes (`sys_secret:`), on a federated datasource. + const refBased = { + name: 'warehouse', + driver: 'postgres', + schemaMode: 'external', + config: { database: 'analytics', host: 'wh.internal', username: 'readonly' }, + external: { + allowWrites: false, + credentialsRef: 'sys_secret:01J9ZK4T2N', + }, + } as const; + const result = DatasourceSchema.safeParse(refBased); + expect(result.success, JSON.stringify(result.error?.issues)).toBe(true); + expect(result.data!.external?.credentialsRef).toBe('sys_secret:01J9ZK4T2N'); + // Byte-identical across parses — the refusal changed nothing on this path. + 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. + const result = DatasourceSchema.safeParse({ + name: 'legacy', + driver: 'postgres', + config: { url: 'postgresql://user:pass@db.example.com:5432/production' }, + }); + expect(result.success).toBe(true); + }); +}); diff --git a/packages/spec/src/data/driver/mongo.test.ts b/packages/spec/src/data/driver/mongo.test.ts index 9acee1d3e5..b5562d61f8 100644 --- a/packages/spec/src/data/driver/mongo.test.ts +++ b/packages/spec/src/data/driver/mongo.test.ts @@ -20,13 +20,14 @@ describe('MongoConfigSchema', () => { }); it('should accept config with all fields', () => { + // `password` is deliberately absent: inline credentials are refused since + // #7990 — the refusal itself is pinned in driver-credential-refusal.test.ts. const config = MongoConfigSchema.parse({ url: 'mongodb://localhost:27017', database: 'production', host: 'db.example.com', port: 27018, username: 'admin', - password: 'secret', authSource: 'admin', options: { ssl: true, diff --git a/packages/spec/src/data/driver/mongo.zod.ts b/packages/spec/src/data/driver/mongo.zod.ts index 52230b8d2d..162ab4b762 100644 --- a/packages/spec/src/data/driver/mongo.zod.ts +++ b/packages/spec/src/data/driver/mongo.zod.ts @@ -7,7 +7,9 @@ import { strictObject } from '../../shared/strict-object'; import type { DriverDefinition } from '../datasource.zod'; import { driverConfigJsonSchema, + INLINE_CREDENTIAL_REFUSED, READ_ONLY_BELONGS_ON_DATASOURCE, + refusedInlineCredentialKey, SCHEMA_MODE_BELONGS_ON_DATASOURCE, } from './common.zod'; @@ -41,13 +43,15 @@ export const MongoConfigSchema = lazySchema(() => strictObject( dbname: 'database', db: 'database', user: 'username', - passwd: 'password', - pwd: 'password', authdb: 'authSource', authdatabase: 'authSource', replicaset: 'options', }, guidance: { + // #7990 — former aliases of the now-unwritable `password` key; the + // refusal is carried directly (see postgres.zod.ts for the reasoning). + passwd: INLINE_CREDENTIAL_REFUSED('passwd'), + pwd: INLINE_CREDENTIAL_REFUSED('pwd'), pool: '`pool` is not driver config — connection pooling is configured once for every driver in ' + "the datasource's own `pool` block, which the factory maps onto the Mongo client's " @@ -89,12 +93,11 @@ export const MongoConfigSchema = lazySchema(() => strictObject( username: z.string().optional().describe('Authentication user').meta({ title: 'User' }), /** - * Authentication password. Prefer `external.credentialsRef` — a datasource - * secret always wins over this value. + * Authentication password — REFUSED inline since #7990 (see postgres.zod.ts: + * declared-unwritable so `tsc`, the parse and the connection form's secret + * input all stay wired to the secret binder / `external.credentialsRef`). */ - password: z.string().optional() - .describe('Authentication password (prefer external.credentialsRef)') - .meta({ title: 'Password', format: 'password' }), + password: refusedInlineCredentialKey('password', 'Password'), /** Authentication database, when it differs from `database`. */ authSource: z.string().optional().describe('Authentication database') diff --git a/packages/spec/src/data/driver/mysql.zod.ts b/packages/spec/src/data/driver/mysql.zod.ts index aacc07e3ec..bcf0dc388a 100644 --- a/packages/spec/src/data/driver/mysql.zod.ts +++ b/packages/spec/src/data/driver/mysql.zod.ts @@ -22,7 +22,9 @@ import { strictObject } from '../../shared/strict-object'; import { driverConfigJsonSchema, DriverSslToggleSchema, + INLINE_CREDENTIAL_REFUSED, READ_ONLY_BELONGS_ON_DATASOURCE, + refusedInlineCredentialKey, SCHEMA_MODE_BELONGS_ON_DATASOURCE, SqlAutoMigrateSchema, SSL_DETAIL_BELONGS_ON_DATASOURCE, @@ -38,8 +40,6 @@ export const MysqlConfigSchema = lazySchema(() => strictObject( db: 'database', schema: 'database', user: 'username', - passwd: 'password', - pwd: 'password', connectionstring: 'url', dsn: 'url', uri: 'url', @@ -48,6 +48,10 @@ export const MysqlConfigSchema = lazySchema(() => strictObject( usessl: 'ssl', }, guidance: { + // #7990 — former aliases of the now-unwritable `password` key; the + // refusal is carried directly (see postgres.zod.ts for the reasoning). + passwd: INLINE_CREDENTIAL_REFUSED('passwd'), + pwd: INLINE_CREDENTIAL_REFUSED('pwd'), pool: '`pool` is not driver config — connection pooling is configured once for every driver in ' + "the datasource's own `pool` block. Move it next to `driver`.", @@ -88,12 +92,11 @@ export const MysqlConfigSchema = lazySchema(() => strictObject( username: z.string().optional().describe('Authentication user').meta({ title: 'User' }), /** - * Authentication password. Prefer `external.credentialsRef`; a datasource - * secret always wins over this value. + * Authentication password — REFUSED inline since #7990 (see postgres.zod.ts: + * declared-unwritable so `tsc`, the parse and the connection form's secret + * input all stay wired to the secret binder / `external.credentialsRef`). */ - password: z.string().optional() - .describe('Authentication password (prefer external.credentialsRef)') - .meta({ title: 'Password', format: 'password' }), + password: refusedInlineCredentialKey('password', 'Password'), /** TLS settings, passed to `mysql2` verbatim. */ ssl: DriverSslToggleSchema.optional().meta({ title: 'Use SSL/TLS' }), diff --git a/packages/spec/src/data/driver/postgres.test.ts b/packages/spec/src/data/driver/postgres.test.ts index 45315638ae..b8ef9b9a40 100644 --- a/packages/spec/src/data/driver/postgres.test.ts +++ b/packages/spec/src/data/driver/postgres.test.ts @@ -23,13 +23,14 @@ describe('PostgresConfigSchema', () => { }); it('should accept config with all fields', () => { + // `password` is deliberately absent: inline credentials are refused since + // #7990 — the refusal itself is pinned in driver-credential-refusal.test.ts. const config = PostgresConfigSchema.parse({ url: 'postgresql://localhost/mydb', database: 'production', host: 'db.example.com', port: 5433, username: 'app_user', - password: 'secret', schema: 'app_schema', ssl: true, applicationName: 'objectstack', @@ -162,11 +163,15 @@ describe('PostgresConfigSchema', () => { }); it('should accept config with environment variable patterns', () => { + // NOTE (#7990 census): nothing in the runtime resolves `${…}` placeholders + // in datasource config — these strings reach the client verbatim. The test + // pins only that placeholder-shaped strings parse for NON-credential keys; + // `password` is refused whatever its value (including a placeholder), + // because the key itself is the cleartext sink. const config = PostgresConfigSchema.parse({ database: '${DB_NAME}', host: '${DB_HOST}', username: '${DB_USER}', - password: '${DB_PASSWORD}', }); expect(config.database).toBe('${DB_NAME}'); diff --git a/packages/spec/src/data/driver/postgres.zod.ts b/packages/spec/src/data/driver/postgres.zod.ts index 437ca9debf..b9365edd0f 100644 --- a/packages/spec/src/data/driver/postgres.zod.ts +++ b/packages/spec/src/data/driver/postgres.zod.ts @@ -20,7 +20,9 @@ import { strictObject } from '../../shared/strict-object'; import { driverConfigJsonSchema, DriverSslToggleSchema, + INLINE_CREDENTIAL_REFUSED, READ_ONLY_BELONGS_ON_DATASOURCE, + refusedInlineCredentialKey, SCHEMA_MODE_BELONGS_ON_DATASOURCE, SqlAutoMigrateSchema, SSL_DETAIL_BELONGS_ON_DATASOURCE, @@ -41,8 +43,6 @@ export const PostgresConfigSchema = lazySchema(() => strictObject( dbname: 'database', db: 'database', user: 'username', - passwd: 'password', - pwd: 'password', connectionstring: 'url', dsn: 'url', uri: 'url', @@ -54,6 +54,13 @@ export const PostgresConfigSchema = lazySchema(() => strictObject( usessl: 'ssl', }, guidance: { + // #7990 — former ALIASES of `password` (`passwd:`/`pwd:` used to rename + // onto it). Now that the key itself is unwritable they carry the refusal + // directly: an alias row pointing at a tombstoned key would send the + // author into a second rejection (`strict-object.ts`'s `triggerPhrase` + // lesson). + passwd: INLINE_CREDENTIAL_REFUSED('passwd'), + pwd: INLINE_CREDENTIAL_REFUSED('pwd'), pool: poolBelongsOnDatasource('pool', 'max'), min: poolBelongsOnDatasource('min', 'min'), max: poolBelongsOnDatasource('max', 'max'), @@ -97,13 +104,14 @@ export const PostgresConfigSchema = lazySchema(() => strictObject( username: z.string().optional().describe('Authentication user').meta({ title: 'User' }), /** - * Authentication password. Prefer `external.credentialsRef` — a secret-store - * reference — or an environment placeholder; a datasource secret always wins - * over this value. + * Authentication password — REFUSED inline since #7990. Declared-unwritable + * (`z.never()`) rather than deleted so the removal is audible in `tsc` and + * in the parse, and so the `format: 'password'` projection keeps rendering + * the connection form's secret input (which routes to the secret binder — + * the mechanism the refusal diverts to). The resolved + * `external.credentialsRef` secret is injected at connect time. */ - password: z.string().optional() - .describe('Authentication password (prefer external.credentialsRef)') - .meta({ title: 'Password', format: 'password' }), + password: refusedInlineCredentialKey('password', 'Password'), /** TLS settings, passed to `pg` verbatim. */ ssl: DriverSslToggleSchema.optional().meta({ title: 'Use SSL/TLS' }), diff --git a/packages/spec/src/data/driver/turso.test.ts b/packages/spec/src/data/driver/turso.test.ts index dc46e42ffc..7facb9df07 100644 --- a/packages/spec/src/data/driver/turso.test.ts +++ b/packages/spec/src/data/driver/turso.test.ts @@ -17,8 +17,13 @@ import { TursoConfigSchema, TursoDriverSpec } from './turso.zod'; describe('TursoConfigSchema', () => { it('accepts the shapes the driver actually connects with', () => { + // `{ url, authToken }` left this list in #7990: an inline `authToken` is + // refused at authoring (driver-credential-refusal.test.ts pins it). The + // remote-with-credential shape is authored as `{ url }` + + // `external.credentialsRef`; the boot hosts' env-resolved token never + // passes through this schema. for (const config of [ - { url: 'libsql://my-db.turso.io', authToken: 'jwt' }, + { url: 'libsql://my-db.turso.io' }, { url: 'file:./data/objectstack.db' }, { url: ':memory:' }, { url: 'file:./local.db', syncUrl: 'libsql://my-db.turso.io', sync: { intervalSeconds: 60 } }, @@ -38,11 +43,17 @@ describe('TursoConfigSchema', () => { // The exact failure this contract was written for: `token` is the plausible // spelling, `authToken` is the real one, and before #6345 the misspelling was - // accepted in silence and the connection attempted unauthenticated. - it('rejects `token` with a rename hint pointing at `authToken`', () => { + // accepted in silence and the connection attempted unauthenticated. Until + // #7990 the fix was a rename hint onto `authToken`; now that `authToken` is + // itself unwritable the same spelling gets the credential refusal directly — + // a rename hint would send the author into a second rejection. + it('rejects `token` with the inline-credential refusal, not a rename hint', () => { const result = TursoConfigSchema.safeParse({ url: 'libsql://x.turso.io', token: 'jwt' }); expect(result.success).toBe(false); - expect(JSON.stringify(result.error?.issues)).toContain('authToken'); + const issues = JSON.stringify(result.error?.issues); + expect(issues).toContain('credentialsRef'); + expect(issues).toContain('sys_secret'); + expect(issues).not.toContain('Did you mean'); }); it('rejects `sync` without `syncUrl` — on its own it configures nothing', () => { @@ -79,8 +90,13 @@ describe('turso is a known driver to the config registry now (#6345)', () => { // parse onto its own issue list, so the flip reaches authored metadata. it('DatasourceSchema now judges a turso datasource config', () => { expect(DatasourceSchema.safeParse({ - name: 'edge', driver: 'turso', config: { url: 'libsql://x.turso.io', authToken: 'jwt' }, + name: 'edge', driver: 'turso', config: { url: 'libsql://x.turso.io' }, }).success).toBe(true); + // An inline `authToken` is refused (#7990), re-pathed under the config slot + // it was written in — driver-credential-refusal.test.ts pins the message. + expect(DatasourceSchema.safeParse({ + name: 'edge', driver: 'turso', config: { url: 'libsql://x.turso.io', authToken: 'jwt' }, + }).success).toBe(false); expect(DatasourceSchema.safeParse({ name: 'edge', driver: 'turso', config: { token: 'jwt' }, }).success).toBe(false); diff --git a/packages/spec/src/data/driver/turso.zod.ts b/packages/spec/src/data/driver/turso.zod.ts index f444c152ba..76dfd70fa5 100644 --- a/packages/spec/src/data/driver/turso.zod.ts +++ b/packages/spec/src/data/driver/turso.zod.ts @@ -7,7 +7,9 @@ import { strictObject } from '../../shared/strict-object'; import type { DriverDefinition } from '../datasource.zod'; import { driverConfigJsonSchema, + INLINE_CREDENTIAL_REFUSED, READ_ONLY_BELONGS_ON_DATASOURCE, + refusedInlineCredentialKey, SCHEMA_MODE_BELONGS_ON_DATASOURCE, } from './common.zod'; @@ -75,23 +77,32 @@ export const TursoConfigSchema = lazySchema(() => strictObject( { surface: "this turso datasource's config", // Semantic near-misses only — the spellings edit distance cannot reach. - // Case and underscore variants of a DECLARED key (`auth_token`, - // `encryption_key`, `sync_url`) are deliberately absent: the unknown-key - // probe already normalizes those onto the declared name, so entries for - // them would be alias rows that never fire, and two of them collided with - // each other on one probe (`auth_token`/`authtoken`, - // `sync_interval`/`syncinterval`) — caught by `alias-integrity.test.ts`. + // Case and underscore variants of a DECLARED key (`encryption_key`, + // `sync_url`) are deliberately absent: the unknown-key probe already + // normalizes those onto the declared name, so entries for them would be + // alias rows that never fire, and two of them collided with each other on + // one probe (`auth_token`/`authtoken`, `sync_interval`/`syncinterval`) — + // caught by `alias-integrity.test.ts`. The `auth_token`/`authtoken` + // variants moved to `guidance` in #7990: `authToken` is tombstoned, so it + // left the probe's candidate list (`acceptsNothing`) and the normalization + // that made alias rows redundant no longer reaches it. aliases: { uri: 'url', connectionstring: 'url', dsn: 'url', database: 'url', databaseurl: 'url', - token: 'authToken', - jwt: 'authToken', syncinterval: 'sync', }, guidance: { + // #7990 — former aliases of the now-unwritable `authToken` key (`token:` + // was the misspelling this file's history block records). They carry the + // refusal directly rather than renaming onto a key that would reject + // them a second time (see postgres.zod.ts for the reasoning). + token: INLINE_CREDENTIAL_REFUSED('token'), + jwt: INLINE_CREDENTIAL_REFUSED('jwt'), + auth_token: INLINE_CREDENTIAL_REFUSED('auth_token'), + authtoken: INLINE_CREDENTIAL_REFUSED('authtoken'), pool: '`pool` is not driver config — libSQL sizes remote concurrency with `concurrency`, and ' + "every driver's pooling block lives next to `driver` on the datasource itself.", @@ -127,13 +138,15 @@ export const TursoConfigSchema = lazySchema(() => strictObject( .meta({ title: 'Database URL' }), /** - * JWT for a remote database. Prefer `external.credentialsRef` — a - * datasource secret always wins over an inline value, exactly as on the - * SQL drivers' `password`. + * JWT for a remote database — REFUSED inline since #7990, exactly as the + * SQL drivers' `password` (see postgres.zod.ts: declared-unwritable so + * `tsc`, the parse and the connection form's secret input all stay wired + * to the secret binder / `external.credentialsRef`). The standalone boot + * path is unaffected: `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN` are + * resolved by the host and handed to the driver factory directly, never + * through this authoring schema. */ - authToken: z.string().optional() - .describe('JWT auth token for a remote libSQL database (prefer external.credentialsRef)') - .meta({ title: 'Auth token', format: 'password' }), + authToken: refusedInlineCredentialKey('authToken', 'Auth token'), /** AES-256 key for the local database file; local/replica modes only. */ encryptionKey: z.string().optional() diff --git a/packages/spec/src/integration/connector-provider.test.ts b/packages/spec/src/integration/connector-provider.test.ts index 5d116e3951..81fa6c277a 100644 --- a/packages/spec/src/integration/connector-provider.test.ts +++ b/packages/spec/src/integration/connector-provider.test.ts @@ -71,18 +71,42 @@ describe('ADR-0097 connector schema evolution', () => { expect(() => DeclarativeConnectorEntrySchema.parse(validInstance)).not.toThrow(); }); - it('accepts a plain descriptor (no provider) with inline authentication + actions', () => { + it('accepts a plain descriptor (no provider) with actions and no live credentials', () => { + // Until #7990 this pin read "…with inline authentication + actions" + // and the bearer token below was ACCEPTED — the ①-d hole of the + // #7902 survey: a descriptor published through `sys_metadata` + // carried its credential in cleartext. Actions stay authorable on + // a descriptor; the credential does not. expect(() => DeclarativeConnectorEntrySchema.parse({ name: 'legacy', label: 'Legacy', type: 'api', - authentication: { type: 'bearer', token: 'kept-for-descriptor' }, + authentication: { type: 'none' }, actions: [{ key: 'do', label: 'Do' }], }), ).not.toThrow(); }); + it('rejects inline `authentication` secrets on a catalog descriptor (#7990)', () => { + const result = DeclarativeConnectorEntrySchema.safeParse({ + name: 'legacy', + label: 'Legacy', + type: 'api', + authentication: { type: 'bearer', token: 'kept-for-descriptor' }, + actions: [{ key: 'do', label: 'Do' }], + }); + expect(result.success).toBe(false); + const issue = result.error!.issues.find((i) => i.path.join('.') === 'authentication'); + expect(issue, 'refusal must be re-pathed under `authentication`').toBeDefined(); + // The message must carry the fix for BOTH shapes an author may want: + // descriptor (drop the credential) and instance (credentialRef). + expect(issue!.message).toContain('`authentication`'); + expect(issue!.message).toContain('sys_metadata'); + expect(issue!.message).toContain('credentialRef'); + expect(issue!.message).toContain('ADR-0097'); + }); + it('rejects inline `authentication` secrets on a provider-bound instance (§3)', () => { expect(() => DeclarativeConnectorEntrySchema.parse({ diff --git a/packages/spec/src/integration/connector.zod.ts b/packages/spec/src/integration/connector.zod.ts index 7d6f4e40ee..8543de3930 100644 --- a/packages/spec/src/integration/connector.zod.ts +++ b/packages/spec/src/integration/connector.zod.ts @@ -716,12 +716,17 @@ export const ConnectorSchema = lazySchema(() => z.object({ /** * Authentication configuration (runtime shape — carries resolved secrets * inline, supplied by a plugin at `registerConnector`). Optional and defaults - * to `{ type: 'none' }` so a declarative provider-bound instance can reference - * credentials through {@link auth}/`credentialRef` instead of inlining them - * here (ADR-0097). Hand-written / plugin connectors keep setting it as before. + * to `{ type: 'none' }` so a declarative entry can reference credentials + * through {@link auth}/`credentialRef` instead of inlining them here + * (ADR-0097). Hand-written / plugin connectors keep setting it at + * `registerConnector` as before — but the AUTHORING door refuses any + * non-`none` value: since #7990 `DeclarativeConnectorEntrySchema` (the shape + * behind `defineStack({ connectors })` and `PUT /meta/connector/:name`) + * rejects an inline credential on every entry, descriptor or instance, + * because a published row lands whole in `sys_metadata`. */ authentication: ConnectorAuthConfigSchema.optional().default({ type: 'none' }).describe( - 'Authentication configuration (runtime shape with inline secrets). Provider-bound declarative instances use `auth.credentialRef` instead.', + 'Authentication configuration (runtime shape with inline secrets — plugin-supplied at registerConnector). Authored entries must not inline secrets (#7990): use `auth.credentialRef` on a provider-bound instance.', ), /** @@ -901,11 +906,17 @@ export function defineConnector(config: z.input): Connec * this; the base {@link ConnectorSchema} stays a plain object so connector * *subtypes* (github / database / …) can still `.extend()` it. * - * All rules key off `provider` — instance declaration vs. catalog descriptor: + * One rule applies to EVERY authored entry (#7990, maintainer-ruled 2026-08-12): + * - NO entry may inline secrets via `authentication`. A published connector + * row lands whole in `sys_metadata` (`apiMethods: ['get','list']`), so an + * inline `token`/`key`/`password`/`clientSecret` is cleartext at rest, + * readable through the ordinary data API. Until #7990 this rule bound only + * provider-bound instances (ADR-0097 §3) and a catalog DESCRIPTOR could + * still publish an inline credential — the ①-d hole of the #7902 survey. + * + * The remaining rules key off `provider` — instance vs. catalog descriptor: * - `providerConfig` / `auth` require a `provider`; on a pure descriptor they * are meaningless materialization inputs, so they are rejected. - * - A provider-bound instance must NOT inline secrets via `authentication` — - * credentials are references (`auth.credentialRef`), never authored literals (§3). * - A provider-bound instance must NOT author `actions` / `triggers` — the * provider derives them from the upstream (OpenAPI document / MCP `tools/list`); * authoring both the instance and its actions reintroduces drift (§5 non-goals). @@ -913,6 +924,20 @@ export function defineConnector(config: z.input): Connec export const DeclarativeConnectorEntrySchema = lazySchema(() => ConnectorSchema.superRefine((entry, ctx) => { const isInstance = typeof entry.provider === 'string' && entry.provider.length > 0; + // #7990 — the one rule that binds EVERY authored entry, descriptor and + // instance alike: `authentication` is the RUNTIME shape (its secret fields + // are required and inline), so any non-`none` value published through this + // door puts a cleartext credential into `sys_metadata`. The two messages + // differ because the fixes differ; both name the mechanism to use instead. + if (entry.authentication && entry.authentication.type !== 'none') { + ctx.addIssue({ + code: 'custom', + path: ['authentication'], + message: isInstance + ? `Provider-bound connector instance '${entry.name}' must not inline secrets via \`authentication\`; reference credentials with \`auth: { type, credentialRef }\` instead (ADR-0097 §3).` + : `Connector '${entry.name}' must not inline secrets via \`authentication\` — a published connector row is stored whole in \`sys_metadata\`, so the credential would land in cleartext (#7990). A catalog descriptor holds no live credentials: drop \`authentication\` (or set \`{ type: 'none' }\`) and describe the auth scheme in \`description\`. A dispatchable instance declares \`provider\` and references its credential with \`auth: { type, credentialRef }\` (ADR-0097 §3).`, + }); + } if (!isInstance) { if (entry.providerConfig !== undefined) { ctx.addIssue({ @@ -930,14 +955,6 @@ export const DeclarativeConnectorEntrySchema = lazySchema(() => } return; } - // Provider-bound instance declaration. - if (entry.authentication && entry.authentication.type !== 'none') { - ctx.addIssue({ - code: 'custom', - path: ['authentication'], - message: `Provider-bound connector instance '${entry.name}' must not inline secrets via \`authentication\`; reference credentials with \`auth: { type, credentialRef }\` instead (ADR-0097 §3).`, - }); - } if (entry.actions && entry.actions.length > 0) { ctx.addIssue({ code: 'custom', diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__MongoConfig__password.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__MongoConfig__password.ts new file mode 100644 index 0000000000..e6cf3b2e67 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.data__MongoConfig__password.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7990 — sibling of `data/PostgresConfig:password`, same ruling, same +// disposition: tombstoned inline credential; the secret binder / +// `external.credentialsRef` is the mechanism. See that entry for the reasoning +// and the D3 semantic entry `datasource-config-inline-credential-refused` for +// the hand-migration prescription. +export const entry = 'data/MongoConfig:password'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__MysqlConfig__password.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__MysqlConfig__password.ts new file mode 100644 index 0000000000..bc872b8bf5 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.data__MysqlConfig__password.ts @@ -0,0 +1,8 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7990 — sibling of `data/PostgresConfig:password`, same ruling, same +// disposition: tombstoned inline credential; the secret binder / +// `external.credentialsRef` is the mechanism. See that entry for the reasoning +// and the D3 semantic entry `datasource-config-inline-credential-refused` for +// the hand-migration prescription. +export const entry = 'data/MysqlConfig:password'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__PostgresConfig__password.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__PostgresConfig__password.ts new file mode 100644 index 0000000000..1d6888a4f7 --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.data__PostgresConfig__password.ts @@ -0,0 +1,12 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7990 (maintainer-ruled Option A, 2026-08-12) — inline datasource credentials +// refused at publish. The key is tombstoned (`z.never()` with the refusal +// prescription), not deleted: `sys_metadata` serves rows through the ordinary +// data API, so an inline `config.password` was cleartext at rest. The secret +// belongs to the datasource secret binder (`sys_secret` + +// `external.credentialsRef`), which already wins over an inline value at +// connect time. No D2 conversion: a stored cleartext credential cannot be +// mechanically rewritten into an encrypted `sys_secret` row at load — see the +// D3 semantic entry `datasource-config-inline-credential-refused`. +export const entry = 'data/PostgresConfig:password'; diff --git a/packages/spec/src/migrations/entries/retired-keys/17.data__TursoConfig__authToken.ts b/packages/spec/src/migrations/entries/retired-keys/17.data__TursoConfig__authToken.ts new file mode 100644 index 0000000000..bde6b4d12d --- /dev/null +++ b/packages/spec/src/migrations/entries/retired-keys/17.data__TursoConfig__authToken.ts @@ -0,0 +1,9 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +// #7990 — the turso face of `data/PostgresConfig:password` (the credential key +// is `authToken`, a JWT, rather than a password — same inline-cleartext class, +// same ruling, same disposition). The standalone boot path is untouched: +// `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN` are resolved by the host and +// handed to the driver factory directly, never through the authoring schema. +// See the D3 semantic entry `datasource-config-inline-credential-refused`. +export const entry = 'data/TursoConfig:authToken'; diff --git a/packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts b/packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts new file mode 100644 index 0000000000..3f6cff2a4c --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.connector-inline-authentication-publish-refused.ts @@ -0,0 +1,25 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'connector-inline-authentication-publish-refused', + surface: 'connector.authentication on AUTHORED entries (defineStack `connectors:`, ' + + '`PUT /meta/connector/:name`) — previously refused only on provider-bound instances ' + + '(ADR-0097 §3), now refused on catalog descriptors too', + replacement: 'a catalog descriptor drops `authentication` (or sets `{ type: "none" }`) ' + + 'and documents the auth scheme in `description`; a dispatchable instance declares ' + + '`provider` and references its credential with `auth: { type, credentialRef }` ' + + '(ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape ' + + 'still carries resolved secrets inline.', + reason: + 'A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` ' + + '/ `password` / `clientSecret` is cleartext at rest, readable through the data API ' + + '(#7990). No mechanical rewrite exists: whether the entry should become a `none` ' + + 'descriptor or a provider-bound instance with a `credentialRef` — and which secret ' + + 'store receives the credential — is a judgment about the connector, not a rename.', + acceptanceCriteria: + 'Every authored connector entry parses through `DeclarativeConnectorEntrySchema`; no ' + + 'authored entry carries a non-`none` `authentication`; formerly inline credentials are ' + + 'reachable through `credentialRef` resolution and the connector still materializes.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts b/packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts new file mode 100644 index 0000000000..54b2c656ce --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/17.datasource-config-inline-credential-refused.ts @@ -0,0 +1,24 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'datasource-config-inline-credential-refused', + surface: 'datasource.config.password (postgres / mysql / mongo) and ' + + 'datasource.config.authToken (turso)', + replacement: "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: + '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.', + acceptanceCriteria: + '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.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 47ee8610cf..2426819c63 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -2015,6 +2015,27 @@ const step17: MigrationStep = { + 'want rather than assuming it restores prior behaviour. Any test that passed while ' + 'asserting on `deleted` was asserting on `undefined` and needs rewriting, not renaming.', }, + { + id: 'connector-inline-authentication-publish-refused', + surface: 'connector.authentication on AUTHORED entries (defineStack `connectors:`, ' + + '`PUT /meta/connector/:name`) — previously refused only on provider-bound instances ' + + '(ADR-0097 §3), now refused on catalog descriptors too', + replacement: 'a catalog descriptor drops `authentication` (or sets `{ type: "none" }`) ' + + 'and documents the auth scheme in `description`; a dispatchable instance declares ' + + '`provider` and references its credential with `auth: { type, credentialRef }` ' + + '(ADR-0097 §3). Runtime `registerConnector` calls are unaffected — the runtime shape ' + + 'still carries resolved secrets inline.', + reason: + 'A published connector row lands whole in `sys_metadata`, so an inline `token` / `key` ' + + '/ `password` / `clientSecret` is cleartext at rest, readable through the data API ' + + '(#7990). No mechanical rewrite exists: whether the entry should become a `none` ' + + 'descriptor or a provider-bound instance with a `credentialRef` — and which secret ' + + 'store receives the credential — is a judgment about the connector, not a rename.', + acceptanceCriteria: + 'Every authored connector entry parses through `DeclarativeConnectorEntrySchema`; no ' + + 'authored entry carries a non-`none` `authentication`; formerly inline credentials are ' + + 'reachable through `credentialRef` resolution and the connector still materializes.', + }, { id: 'dashboard-widget-compareto-offset', surface: "dashboard.widgets[].compareTo: { offset: '7d' | '1M' | … } (every duration except '1y')", @@ -2200,6 +2221,26 @@ const step17: MigrationStep = { + 'behaviour — it never executed — so the migration is removing code that could not ' + 'run, not rebuilding a capability.', }, + { + id: 'datasource-config-inline-credential-refused', + surface: 'datasource.config.password (postgres / mysql / mongo) and ' + + 'datasource.config.authToken (turso)', + replacement: "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: + '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.', + acceptanceCriteria: + '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.', + }, { id: 'declarative-apis-endpoints-live', surface: 'stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)', @@ -4428,6 +4469,35 @@ export const RETIRED_KEYS_BY_MAJOR: Readonly> // `query-distinct-retired` / `query-window-functions-retired`, #4286). 'data/AggregationNode:distinct', 'data/ExternalFieldMapping:transform', + // #7990 — sibling of `data/PostgresConfig:password`, same ruling, same + // disposition: tombstoned inline credential; the secret binder / + // `external.credentialsRef` is the mechanism. See that entry for the reasoning + // and the D3 semantic entry `datasource-config-inline-credential-refused` for + // the hand-migration prescription. + 'data/MongoConfig:password', + // #7990 — sibling of `data/PostgresConfig:password`, same ruling, same + // disposition: tombstoned inline credential; the secret binder / + // `external.credentialsRef` is the mechanism. See that entry for the reasoning + // and the D3 semantic entry `datasource-config-inline-credential-refused` for + // the hand-migration prescription. + 'data/MysqlConfig:password', + // #7990 (maintainer-ruled Option A, 2026-08-12) — inline datasource credentials + // refused at publish. The key is tombstoned (`z.never()` with the refusal + // prescription), not deleted: `sys_metadata` serves rows through the ordinary + // data API, so an inline `config.password` was cleartext at rest. The secret + // belongs to the datasource secret binder (`sys_secret` + + // `external.credentialsRef`), which already wins over an inline value at + // connect time. No D2 conversion: a stored cleartext credential cannot be + // mechanically rewritten into an encrypted `sys_secret` row at load — see the + // D3 semantic entry `datasource-config-inline-credential-refused`. + 'data/PostgresConfig:password', + // #7990 — the turso face of `data/PostgresConfig:password` (the credential key + // is `authToken`, a JWT, rather than a password — same inline-cleartext class, + // same ruling, same disposition). The standalone boot path is untouched: + // `OS_DATABASE_AUTH_TOKEN` / `TURSO_AUTH_TOKEN` are resolved by the host and + // handed to the driver factory directly, never through the authoring schema. + // See the D3 semantic entry `datasource-config-inline-credential-refused`. + 'data/TursoConfig:authToken', 'integration/ConnectorFieldMapping:transform', // #4914 — ADR-0049 enforce-or-remove on the plugin manifest's whole // `loading` block (maintainer ruling 2026-08-04). ONE tombstoned key here, diff --git a/packages/spec/src/shared/connector-auth.zod.ts b/packages/spec/src/shared/connector-auth.zod.ts index 13a3b2f54d..da6a2fea06 100644 --- a/packages/spec/src/shared/connector-auth.zod.ts +++ b/packages/spec/src/shared/connector-auth.zod.ts @@ -6,6 +6,20 @@ import { z } from 'zod'; * SHARED CONNECTOR AUTHENTICATION SCHEMAS * These schemas are used by connectors and integrations for external auth. * They define "How we authenticate TO other systems", not "How users authenticate TO us". + * + * ⚠️ Two shapes live here, and which one a door accepts is load-bearing (#7990): + * + * - {@link ConnectorAuthConfigSchema} is the RUNTIME shape — every non-`none` + * variant REQUIRES its secret inline (`token` / `key` / `password` / + * `clientSecret`), because it describes what a provider factory receives + * AFTER resolution, or what a plugin hands to `registerConnector`. It must + * never be accepted at an authoring/publish door: a published connector row + * is stored whole in `sys_metadata`, so an inline secret is cleartext at + * rest. `DeclarativeConnectorEntrySchema` enforces that refusal on every + * authored entry (descriptor or instance) since #7990. + * - {@link ConnectorInstanceAuthSchema} is the AUTHORED shape — secret-bearing + * variants carry a `credentialRef` reference resolved through the + * secrets/env layer at materialization (ADR-0097 §3), never the secret. */ /**