diff --git a/.changeset/27687-code-datasource-env-credential.md b/.changeset/27687-code-datasource-env-credential.md new file mode 100644 index 00000000000..ebbefe5e060 --- /dev/null +++ b/.changeset/27687-code-datasource-env-credential.md @@ -0,0 +1,21 @@ +--- +'@objectstack/service-datasource': patch +'@objectstack/cli': patch +'@objectstack/spec': patch +--- + +feat(service-datasource): a datasource defined in code reads its credential at connect from `OS_DATASOURCE__PASSWORD` + +Clause-②: yes (widening) — `@objectstack/service-datasource` exports `datasourceEnvCredentialVar`, `resolveDatasourceEnvCredentialsSwitch`, `DATASOURCE_ENV_CREDENTIALS_SWITCH` and the `DatasourceEnvCredentialSource` type, and `DatasourceConnectionServiceConfig` gains the optional `envCredentials`; `@objectstack/spec/data` exports `CODE_DEFINED_CREDENTIAL_ROUTE`. Nothing an author writes is added or refused. +Metadata-protocol: none + +A datasource declared in code (`defineDatasource`, or a package's `*.datasource.ts`) had no way to get its password: the driver contracts refuse `config.password` and a `user:password@` URL, the Setup → Datasources form cannot edit a code datasource, and nothing mints a `sys_secret:` handle for one. Now the credential stays out of the declaration and is read at connect from a variable derived from the datasource name: `warehouse` reads `OS_DATASOURCE_WAREHOUSE_PASSWORD` (upper-cased; every character outside `A-Z0-9` becomes `_`). The value is the connection's secret: the password for postgres, mysql and mongodb, the `authToken` for turso. + +- **A host switch.** `OS_DATASOURCE_ENV_CREDENTIALS` takes `on` or `off`; any other value refuses the boot with a sentence. `os serve` (and so `os start` and `os dev`) turns it on unless the operator set it. Every other composition leaves it off, so a host that loads several tenants' artifacts into one process never reads the variable. +- **Code datasources only, by provenance.** The variable is read on the boot's declared connect, for a name in the host's code-datasource set: the booted artifact's datasources and those of a package installed locally. A datasource created through Setup or the API under the same name never reads it, and neither does the `default` datasource, which keeps `OS_DATABASE_URL`. A record's own `origin` is never consulted. +- **A declared credential wins.** With `external.credentialsRef` or `external.connector` declared the variable is not read, and a reference that fails to resolve stays fail-closed. +- **Unset.** The connect goes ahead without a secret as before; when the database then refuses, the reason names the variable to set. +- **Two names deriving one variable** are refused at their connect. +- **Who can read it.** Any code datasource the host registers under a name reads that name's variable, including one in an installed package. Install only packages you trust with the credentials in the process environment. + +The credential refusals (`config.password`, a URL userinfo password, a credential query parameter, the mongo `options` passthrough) and the two `password` guidance entries on `DatasourceSchema` / `ExternalDatasourceSettingsSchema` end with one shared sentence naming this route, after the Setup and `external.credentialsRef` routes they already name. `planCredentialMigration`'s remedy for a code-defined row names the row's variable. diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index 34034d277c2..baced2ec2ce 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -944,11 +944,13 @@ export default defineStack({ ``` Each `url` must be credential-free when the stack is published: a -`user:password@` URL is refused, like an inline `config.password`. Bind the -password through `external.credentialsRef` instead, keeping the user in the URL -(`mongodb://svc@host/db`): the bound secret is injected beside it at connect. The -[read-only example](#read-only-grant-it-at-the-database-not-in-metadata) below -shows the binding. +`user:password@` URL is refused, like an inline `config.password`. Keep the user +in the URL (`mongodb://svc@host/db`) and supply the password at runtime: a +datasource declared in code reads it at connect from `OS_DATASOURCE__PASSWORD` +(`OS_DATASOURCE_ANALYTICS_PASSWORD` here), which `os serve` / `os start` honour by +default, and the value is injected beside the user. A datasource created through +Setup binds it through `external.credentialsRef` instead. See +[Credentials](/docs/data-modeling/external-datasources#4-credentials). Then in your object definition: @@ -990,19 +992,21 @@ export const Analytics = defineDatasource({ driver: 'postgres', // The connection itself cannot write. Nothing in the app can talk past it. config: { host: 'db.internal', database: 'analytics', username: 'analytics_ro' }, - // The account's password, bound through the secret store (see below). - external: { credentialsRef: 'sys_secret:9f2c…' }, + // The account's password is read at connect from OS_DATASOURCE_ANALYTICS_PASSWORD. active: true, }); ``` The read-only account's password never goes in `config`: the driver contract -refuses `config.password`, and a `user:password@` URL, at publish. Bind it -instead. The Setup → Datasources connection form's secret field encrypts it into -`sys_secret` and stores the opaque handle at `external.credentialsRef`, or you -write a handle the secret store minted there yourself. `credentialsRef` is the -one `external` key a `managed` datasource may carry; every federation key is -still refused there. See +refuses `config.password`, and a `user:password@` URL, at publish. Because this +datasource is defined in code, the runtime reads the password at connect from +`OS_DATASOURCE_ANALYTICS_PASSWORD`, derived from its name. The CLI host +(`os serve` / `os start`) reads it by default; elsewhere the host turns it on +with `OS_DATASOURCE_ENV_CREDENTIALS=on`. The same datasource created through +Setup → Datasources binds the password instead: the connection form's secret +field encrypts it into `sys_secret` and stores the opaque handle at +`external.credentialsRef`. `credentialsRef` is the one `external` key a `managed` +datasource may carry; every federation key is still refused there. See [Credentials](/docs/data-modeling/external-datasources#4-credentials). Note that a read-only account also refuses **DDL**, so such a datasource cannot diff --git a/content/docs/data-modeling/external-datasources.mdx b/content/docs/data-modeling/external-datasources.mdx index 3e10becb5b3..712b5cfedba 100644 --- a/content/docs/data-modeling/external-datasources.mdx +++ b/content/docs/data-modeling/external-datasources.mdx @@ -258,7 +258,9 @@ const policy: DatasourceConnectPolicy = { Never inline a password. Put a reference in `external.credentialsRef` and store the secret in the secret store (the same `SecretBinder` / `ICryptoProvider` the runtime-admin "Add Datasource" wizard uses). The credential is resolved to -cleartext **at connect, before the driver is built**. +cleartext **at connect, before the driver is built**. A datasource defined in code +reads an environment variable instead; see +[A datasource defined in code](#a-datasource-defined-in-code-the-environment-variable). The shipped binder encrypts the cleartext into a `sys_secret` row and mints an **opaque** handle — `sys_secret:` — as the `credentialsRef`; that is the only @@ -274,6 +276,77 @@ for the datasources listed under [When auto-connect fails](#when-auto-connect-fa and degrades with a warning otherwise. (Credential-less drivers such as SQLite simply have no `credentialsRef`.) +### A datasource defined in code: the environment variable + +The handle above is minted by the Setup → Datasources form, which cannot edit a +datasource defined in code (`defineDatasource`, or a `*.datasource.ts` file in a +package). A code author leaves the credential out of the declaration entirely, +and the runtime reads it **at connect** from an environment variable whose name +the platform derives from the datasource name: + +| Datasource name | Variable read at connect | +| :--- | :--- | +| `warehouse` | `OS_DATASOURCE_WAREHOUSE_PASSWORD` | +| `crm_replica` | `OS_DATASOURCE_CRM_REPLICA_PASSWORD` | + +The name is upper-cased and every character outside `A-Z0-9` becomes `_`. The +value is handed to the driver as the connection's secret: the password for +`postgres`, `mysql` and `mongodb`, the `authToken` for `turso`. It is never +logged, stored or answered. + +```typescript +import { defineDatasource } from '@objectstack/spec/data'; + +export const Warehouse = defineDatasource({ + name: 'warehouse', + driver: 'postgres', + // No password here: it is read from OS_DATASOURCE_WAREHOUSE_PASSWORD at connect. + config: { host: 'db.internal', database: 'warehouse', username: 'svc_app' }, +}); +``` + +```bash +OS_DATASOURCE_WAREHOUSE_PASSWORD='…' os start +``` + +What the slot does and does not do: + +- **It is a host switch, `OS_DATASOURCE_ENV_CREDENTIALS`, which takes `on` or + `off`.** `os serve`, `os start` and `os dev` turn it on unless you set it + yourself. Every other composition (a host that builds its own kernel, a cloud + runtime) leaves it off, so the variable is never read there. Any other value + is refused at boot. +- **It only serves datasources the host registers from code.** That means the + ones in the booted artifact and in packages installed locally. A datasource + created through Setup or the API under the same name never reads the + variable. Nor does the `default` datasource, which keeps `OS_DATABASE_URL`. +- **A declared credential wins.** With `external.credentialsRef` or + `external.connector` declared, the variable is not consulted. If that + reference fails to resolve, the datasource stays unconnected (fail-closed, as + above) and does not fall back to the environment. +- **An unset variable leaves the connect as it was.** The driver connects + without a secret; if the database refuses, the datasource is unconnected and + its reason names the variable to set. +- **Two datasources that derive one variable are refused.** Valid datasource + names are lower-case, so this cannot happen between two declarations that + pass the schema; the guard covers a host that hands `connect()` other names. +- **A plugin driver that resolves its credential per request** (the OData + driver resolves `credentialsRef` that way) does not read this slot: it feeds + only the secret a driver receives when its connection is built. + + +**Who can read the variable.** The derived name is the only key, so any code +datasource the host registers under that name reads it. Under `os serve` that +includes every package installed into the environment: **a package can declare +a datasource of any free name and so read the matching +`OS_DATASOURCE__PASSWORD`**. Install only packages you trust with the +credentials in that process's environment. On a host where several tenants' +artifacts share one process, a process-wide variable would be readable by every +tenant artifact that declares the name. That is why the switch is off outside the +CLI host; turn it on there only when one operator owns every artifact the process +loads. + + ### Borrowing a connector instance's credential An **API-backed** datasource, one served by a plugin driver such as the diff --git a/content/docs/references/data/driver-mongo.mdx b/content/docs/references/data/driver-mongo.mdx index 930ad567127..40a03066e91 100644 --- a/content/docs/references/data/driver-mongo.mdx +++ b/content/docs/references/data/driver-mongo.mdx @@ -47,7 +47,7 @@ MongoDB Connection Configuration | **host** | `string` | optional (default: `"localhost"`) | Host address | | **port** | `integer` | optional (default: `27017`) | Port number | | **username** | `string` | optional | Authentication user | -| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` | +| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config`. A datasource defined in code (`defineDatasource`) leaves the credential out of the declaration: it is read at connect time from the runtime environment variable derived from the datasource name (`OS_DATASOURCE_WAREHOUSE_PASSWORD` for a datasource named `warehouse`), which `os serve` / `os start` honour by default (`OS_DATASOURCE_ENV_CREDENTIALS=on`). | | **authSource** | `string` | optional | Authentication database | | **options** | `Record` | optional | Extra MongoClient options (replicaSet, tls, timeouts, …). Credential-spelled keys (`password`, `authToken`, and their former aliases) are refused inline at any depth — bind the secret via the connection form / external.credentialsRef. `proxyPassword`, `tlsCertificateKeyFilePassword`, `key`, and `passphrase` are accepted and stored at rest in cleartext; they're redacted only when the datasource is read back, not refused at write. | diff --git a/content/docs/references/data/driver-mysql.mdx b/content/docs/references/data/driver-mysql.mdx index e05d84058ba..53a2ed7cf68 100644 --- a/content/docs/references/data/driver-mysql.mdx +++ b/content/docs/references/data/driver-mysql.mdx @@ -48,7 +48,7 @@ MySQL / MariaDB connection configuration | **port** | `integer` | optional (default: `3306`) | Port number | | **database** | `string` | optional | Database name | | **username** | `string` | optional | Authentication user | -| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` | +| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config`. A datasource defined in code (`defineDatasource`) leaves the credential out of the declaration: it is read at connect time from the runtime environment variable derived from the datasource name (`OS_DATASOURCE_WAREHOUSE_PASSWORD` for a datasource named `warehouse`), which `os serve` / `os start` honour by default (`OS_DATASOURCE_ENV_CREDENTIALS=on`). | | **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 | diff --git a/content/docs/references/data/driver-postgres.mdx b/content/docs/references/data/driver-postgres.mdx index d2c7a8bc37b..bcbaaac41fa 100644 --- a/content/docs/references/data/driver-postgres.mdx +++ b/content/docs/references/data/driver-postgres.mdx @@ -46,7 +46,7 @@ PostgreSQL connection configuration | **port** | `integer` | optional (default: `5432`) | Port number | | **database** | `string` | optional | Database name | | **username** | `string` | optional | Authentication user | -| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` | +| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config`. A datasource defined in code (`defineDatasource`) leaves the credential out of the declaration: it is read at connect time from the runtime environment variable derived from the datasource name (`OS_DATASOURCE_WAREHOUSE_PASSWORD` for a datasource named `warehouse`), which `os serve` / `os start` honour by default (`OS_DATASOURCE_ENV_CREDENTIALS=on`). | | **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. | | **schema** | `string` | optional (default: `"public"`) | 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 b3da663c79c..545f2d32cfc 100644 --- a/content/docs/references/data/driver-turso.mdx +++ b/content/docs/references/data/driver-turso.mdx @@ -67,7 +67,7 @@ Turso / libSQL Connection Configuration | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **url** | `string` | ✅ | libSQL endpoint or local file: a remote libsql/https Turso URL, a local file written as a file: URL (never a bare path), or :memory: | -| **authToken** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` | +| **authToken** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config`. A datasource defined in code (`defineDatasource`) leaves the credential out of the declaration: it is read at connect time from the runtime environment variable derived from the datasource name (`OS_DATASOURCE_WAREHOUSE_PASSWORD` for a datasource named `warehouse`), which `os serve` / `os start` honour by default (`OS_DATASOURCE_ENV_CREDENTIALS=on`). | | **encryptionKey** | `never` | optional | [REMOVED] `turso config.encryptionKey` was removed in @objectstack/spec 18 and @objectstack/driver-turso 18 (ADR-0049) — it never encrypted anything, in any mode: a local or embedded-replica file is read and written through a SQLite engine that has no encryption codec, and the driver never handed the key to a remote database, whose storage encryption the server configures. Delete the key. A local file that must be encrypted at rest belongs on an encrypted disk or volume. Run `os migrate meta --from 17` to list the mechanical edits for the unset and remote-url cases; `--write` applies the ones it can prove, and you delete the key from a local or replica datasource by hand. | | **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/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index 54fad5e582a..6df3659aba8 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -6,6 +6,7 @@ import fs from 'fs'; import net from 'net'; import chalk, { chalkStderr } from 'chalk'; import { bundleRequire } from 'bundle-require'; +import { stampDatasourceEnvCredentialsDefault } from '../utils/datasource-env-credentials-default.js'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { mergeBootConfig } from '../utils/merge-boot-config.js'; import { provisionTelemetryDatasource, standaloneTelemetryPrimary } from '../utils/telemetry-datasource.js'; @@ -5279,6 +5280,14 @@ export default class Serve extends Command { registerDatasourceAdminRoutes, } = adminMod; + // [#27687] This host boots one operator's artifact, so a code-defined + // datasource may read its credential from OS_DATASOURCE_{NAME}_PASSWORD + // at connect. Stamped before the admin plugin's `init()` reads the + // switch; an explicit operator value (`off` included) wins. + if (typeof adminMod.DATASOURCE_ENV_CREDENTIALS_SWITCH === 'string') { + stampDatasourceEnvCredentialsDefault(process.env, adminMod.DATASOURCE_ENV_CREDENTIALS_SWITCH); + } + if ( DatasourceAdminServicePlugin && !hasPluginMatching(['com.objectstack.service-datasource-admin', 'DatasourceAdminServicePlugin']) diff --git a/packages/cli/src/utils/datasource-env-credentials-default.test.ts b/packages/cli/src/utils/datasource-env-credentials-default.test.ts new file mode 100644 index 00000000000..a02f47d4a56 --- /dev/null +++ b/packages/cli/src/utils/datasource-env-credentials-default.test.ts @@ -0,0 +1,53 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #27687 — the CLI host turns the code-datasource env-credential switch on by + * default; every other composition leaves it off. Two halves: the stamp keeps + * an operator's explicit value, and `os serve` (which `os start` and `os dev` + * run) stamps it before the datasource admin plugin is built, whose `init()` + * reads the switch. + */ + +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; +import { stampDatasourceEnvCredentialsDefault } from './datasource-env-credentials-default.js'; + +const SWITCH = 'OS_DATASOURCE_ENV_CREDENTIALS'; + +describe('stampDatasourceEnvCredentialsDefault', () => { + it('stamps `on` when the switch is unset or empty', () => { + for (const env of [{}, { [SWITCH]: '' }, { [SWITCH]: ' ' }] as Record[]) { + expect(stampDatasourceEnvCredentialsDefault(env, SWITCH)).toBe(true); + expect(env[SWITCH]).toBe('on'); + } + }); + + it("keeps an operator's explicit value — `off`, `on`, and an invalid one the boot then refuses", () => { + for (const value of ['off', 'OFF', 'on', 'true']) { + const env: Record = { [SWITCH]: value }; + expect(stampDatasourceEnvCredentialsDefault(env, SWITCH)).toBe(false); + expect(env[SWITCH]).toBe(value); + } + }); +}); + +describe('`os serve` stamps the default before the datasource admin plugin is built', () => { + const source = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'commands', 'serve.ts'), 'utf8'); + + it('stamps once, into the process environment, under the name the admin module exports', () => { + const calls = source.match(/stampDatasourceEnvCredentialsDefault\(/g) ?? []; + expect(calls).toHaveLength(1); + expect(source).toContain( + 'stampDatasourceEnvCredentialsDefault(process.env, adminMod.DATASOURCE_ENV_CREDENTIALS_SWITCH);', + ); + }); + + it('the stamp precedes the plugin construction', () => { + const stamp = source.indexOf('stampDatasourceEnvCredentialsDefault(process.env'); + const construct = source.indexOf('new DatasourceAdminServicePlugin('); + expect(stamp).toBeGreaterThan(0); + expect(construct).toBeGreaterThan(stamp); + }); +}); diff --git a/packages/cli/src/utils/datasource-env-credentials-default.ts b/packages/cli/src/utils/datasource-env-credentials-default.ts new file mode 100644 index 00000000000..b26bba1493e --- /dev/null +++ b/packages/cli/src/utils/datasource-env-credentials-default.ts @@ -0,0 +1,28 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#27687] The CLI host's default for the code-datasource env-credential + * switch (`OS_DATASOURCE_ENV_CREDENTIALS`, owned by + * `@objectstack/service-datasource`). + * + * The switch is off in every composition that does not opt in, because on a + * host where several tenants' artifacts share one process a process-wide + * `OS_DATASOURCE_{NAME}_PASSWORD` would be readable by every tenant artifact + * declaring that name. `os serve` (and `os start` / `os dev`, which run it) + * boots ONE operator's artifact plus the packages that operator installs, so + * it opts in — by stamping the default into the environment the datasource + * admin plugin reads at `init()`, the same seam `os start` uses for + * `OS_CRYPTO_AUTOKEY`. An operator's explicit value, `off` included, always + * wins: only an unset or empty switch is stamped. + * + * Returns whether it stamped. + */ +export function stampDatasourceEnvCredentialsDefault( + env: Record, + switchName: string, +): boolean { + const current = env[switchName]; + if (current !== undefined && current.trim() !== '') return false; + env[switchName] = 'on'; + return true; +} diff --git a/packages/runtime/src/datasource-env-credential.test.ts b/packages/runtime/src/datasource-env-credential.test.ts new file mode 100644 index 00000000000..92ca4d9c20e --- /dev/null +++ b/packages/runtime/src/datasource-env-credential.test.ts @@ -0,0 +1,127 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// [#27687] The env-credential slot, wired the way a host boots it: the +// datasource admin plugin reads `OS_DATASOURCE_ENV_CREDENTIALS` at `init()` +// and answers "is this name code-defined" from the code-datasource set the +// runtime fills. Pinned at that level because the two halves live in two +// packages — the unit pins of each guard are in `@objectstack/service-datasource` +// (`datasource-env-credential.test.ts`). +// +// - a declared datasource of the boot artifact reads its variable; +// - a package installed into the running kernel (install-local, +// `connectPackageDatasources` with `installed: true`) reads its own too — +// the trust consequence the docs state: a package can declare a datasource +// of any free name and so read the matching variable; +// - with the switch unset (every composition but the CLI host) nothing is read; +// - a value other than `on` / `off` refuses the boot with the sentence. +// +// The recording factory wraps the real one (`createDefaultDatasourceDriverFactory`, +// sqlite `:memory:`), so the connect is the production path; it only notes the +// `secret` each build received. + +import { describe, it, expect, afterEach } from 'vitest'; +import { Runtime } from './runtime.js'; +import { DriverPlugin } from './driver-plugin.js'; +import { AppPlugin } from './app-plugin.js'; +import { connectPackageDatasources } from './declared-datasources.js'; + +// [#10126] Pay the first transform of these dist-resolved workspace deps at +// module load (see `datasource-autoconnect.test.ts`). +import '@objectstack/driver-sql'; +import '@objectstack/objectql'; +import '@objectstack/service-datasource'; + +const BOOT_TIMEOUT = 60_000; +const SWITCH = 'OS_DATASOURCE_ENV_CREDENTIALS'; +const TOUCHED = [SWITCH, 'OS_DATASOURCE_WAREHOUSE_PASSWORD', 'OS_DATASOURCE_PKG_STORE_PASSWORD']; + +const external = (name: string) => ({ + name, + driver: 'sqlite', + schemaMode: 'external', + config: { filename: ':memory:' }, + external: { allowWrites: false, validation: { onMismatch: 'warn', checkOnBoot: false } }, + active: true, +}); + +function artifact() { + return { + manifest: { id: 'com.test.ds-env-credential', name: 'DS Env Credential', version: '1.0.0' }, + objects: [{ name: 'note', label: 'Note', fields: { title: { type: 'text' } } }], + datasources: [external('warehouse')], + }; +} + +async function boot() { + const { SqlDriver } = await import('@objectstack/driver-sql'); + const { ObjectQLPlugin } = await import('@objectstack/objectql'); + const { DatasourceAdminServicePlugin, createDefaultDatasourceDriverFactory } = await import( + '@objectstack/service-datasource' + ); + const real = createDefaultDatasourceDriverFactory(); + const seen: Array<{ name?: string; secret?: string }> = []; + const factory = { + supports: (id: string) => real.supports(id), + create: async (spec: any) => { + seen.push({ name: spec.name, secret: spec.secret }); + return real.create(spec); + }, + }; + let ctx: any; + const runtime = new Runtime({ cluster: false }); + const kernel = runtime.getKernel(); + await kernel.use(new DriverPlugin(new SqlDriver({ client: 'better-sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true }))); + await kernel.use(new ObjectQLPlugin()); + await kernel.use(new AppPlugin(artifact())); + await kernel.use(new DatasourceAdminServicePlugin({ driverFactory: factory as never })); + await kernel.use({ name: 'test.capture-ctx', init: async (c: any) => { ctx = c; } } as never); + await kernel.bootstrap(); + return { kernel, seen, ctx: () => ctx }; +} + +describe('[#27687] env-credential slot — kernel wiring', () => { + const saved = Object.fromEntries(TOUCHED.map((k) => [k, process.env[k]])); + let kernel: any; + + afterEach(async () => { + try { await kernel?.stop?.(); } catch { /* noop */ } + kernel = undefined; + for (const k of TOUCHED) { + if (saved[k] === undefined) delete process.env[k]; + else process.env[k] = saved[k]; + } + }); + + it('switch on: the boot artifact\'s datasource and an install-local package\'s both read their variable', async () => { + process.env[SWITCH] = 'on'; + process.env.OS_DATASOURCE_WAREHOUSE_PASSWORD = 'boot-artifact-secret'; + process.env.OS_DATASOURCE_PKG_STORE_PASSWORD = 'installed-package-secret'; + const booted = await boot(); + kernel = booted.kernel; + expect(booted.seen.find((s) => s.name === 'warehouse')?.secret).toBe('boot-artifact-secret'); + + const verdicts = await connectPackageDatasources(booted.ctx(), { + appId: 'com.example.pkg', + source: 'MarketplaceInstallLocal', + datasources: [external('pkg_store')], + installed: true, + }); + expect(verdicts).toEqual([{ name: 'pkg_store', status: 'connected' }]); + expect(booted.seen.find((s) => s.name === 'pkg_store')?.secret).toBe('installed-package-secret'); + }, BOOT_TIMEOUT); + + it('switch unset (every composition but the CLI host): nothing is read', async () => { + delete process.env[SWITCH]; + process.env.OS_DATASOURCE_WAREHOUSE_PASSWORD = 'boot-artifact-secret'; + const booted = await boot(); + kernel = booted.kernel; + const warehouse = booted.seen.find((s) => s.name === 'warehouse'); + expect(warehouse).toBeDefined(); + expect(warehouse?.secret).toBeUndefined(); + }, BOOT_TIMEOUT); + + it('a value other than `on` / `off` refuses the boot with the sentence', async () => { + process.env[SWITCH] = 'true'; + await expect(boot()).rejects.toThrow(/OS_DATASOURCE_ENV_CREDENTIALS must be 'on' or 'off', got 'true'/); + }, BOOT_TIMEOUT); +}); diff --git a/packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts b/packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts index 9f2f799f011..fa16867566c 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-credential-migration.test.ts @@ -118,6 +118,10 @@ describe('planCredentialMigration — stated outcomes for what it will not touch it('refuses a code-defined datasource — its definition is owned by the artifact', () => { const plan = refusal(row({ origin: 'code', config: { host: 'h', password: 'p' } })); expect(plan.reason).toContain('code-defined'); + // #27687 — the remedy names the variable this datasource reads at connect, + // derived from its name, and the switch that gates it. + expect(plan.remedy).toContain('OS_DATASOURCE_WAREHOUSE_PASSWORD'); + expect(plan.remedy).toContain('OS_DATASOURCE_ENV_CREDENTIALS'); }); it('refuses an artefact row with no `origin` at all (treated as code)', () => { diff --git a/packages/services/service-datasource/src/__tests__/datasource-env-credential.test.ts b/packages/services/service-datasource/src/__tests__/datasource-env-credential.test.ts new file mode 100644 index 00000000000..ba60b89b663 --- /dev/null +++ b/packages/services/service-datasource/src/__tests__/datasource-env-credential.test.ts @@ -0,0 +1,290 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #27687 (maintainer ruling: option 5) — a datasource defined in code reads + * its credential at connect from `OS_DATASOURCE__PASSWORD`. + * + * Each guard the ruling names is pinned at the connection service, the one + * door every connect goes through: + * - the slot fills `secret` for a code-defined name on a `declared-auto` + * connect — the boot artifact's and an install-local package's alike; + * - a `runtime-admin` connect of the same name, a name outside the + * provenance set, the `default` datasource, and a host that did not opt in + * never read it — and a caller-set `origin: 'code'` is not provenance; + * - a declared `credentialsRef` / `external.connector` wins, and a failing + * one stays fail-closed rather than falling back; + * - an unset variable keeps the verdict, and its reason names the variable; + * - two names deriving one variable are refused; + * - the value reaches no log line and no answer. + * The kernel-level wiring (the admin plugin's switch, the provenance set the + * runtime fills) is pinned in `@objectstack/runtime`'s + * `datasource-env-credential.test.ts`. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { CODE_DEFINED_CREDENTIAL_ROUTE } from '@objectstack/spec/data'; +import { + DatasourceConnectionService, + type ConnectableDatasource, + type ConnectionEngineLike, +} from '../datasource-connection-service.js'; +import { + DATASOURCE_ENV_CREDENTIALS_SWITCH, + datasourceEnvCredentialVar, + resolveDatasourceEnvCredentialsSwitch, +} from '../datasource-env-credential.js'; +import type { IDatasourceDriverFactory } from '../contracts/datasource-driver-factory.js'; + +const VALUE = 'Env-Cred-Value-7f3a91'; + +/** A minimal engine double: registration and the availability records only. */ +function engine(): ConnectionEngineLike { + const drivers = new Map(); + return { + registerDriver: (driver: any) => { if (!drivers.has(driver.name)) drivers.set(driver.name, driver); }, + registerDatasourceDef: () => {}, + getDriverByName: (name) => drivers.get(name) as never, + syncObjectSchema: async () => {}, + markDatasourceUnavailable: () => {}, + clearDatasourceUnavailable: () => {}, + unregisterDriver: (name) => drivers.delete(name), + }; +} + +/** + * A factory that records the `secret` each build received. `connectFails` + * makes the connect throw the way a database refusing an unauthenticated + * login does. + */ +function recordingFactory(opts: { connectFails?: boolean } = {}) { + const seen: Array<{ name?: string; secret?: string }> = []; + const factory: IDatasourceDriverFactory = { + supports: () => true, + create: vi.fn(async (spec) => { + seen.push({ name: spec.name, secret: spec.secret }); + const driver: any = { name: 'com.fake.driver' }; + return { + driver, + connect: async () => { + if (opts.connectFails) throw new Error('password authentication failed for user "svc"'); + }, + }; + }), + }; + return { factory, seen }; +} + +/** Every line any logger level received, flattened with its meta. */ +function capturingLogger() { + const lines: string[] = []; + const at = (level: string) => (msg: string, meta?: unknown) => { + lines.push(`${level} ${msg} ${meta === undefined ? '' : JSON.stringify(meta, (_k, v) => (v instanceof Error ? `${v.name}: ${v.message}` : v))}`); + }; + return { logger: { warn: at('warn'), info: at('info'), error: at('error'), debug: at('debug') }, lines }; +} + +function service(opts: { + codeNames?: string[]; + env?: Record; + slot?: boolean; + connectFails?: boolean; + secrets?: { resolve?: (ref: string) => Promise }; +} = {}) { + const codeNames = new Set(opts.codeNames ?? ['warehouse']); + const env = opts.env ?? { [datasourceEnvCredentialVar('warehouse')]: VALUE }; + const read = vi.fn((variable: string) => env[variable]); + const { factory, seen } = recordingFactory({ connectFails: opts.connectFails }); + const { logger, lines } = capturingLogger(); + const svc = new DatasourceConnectionService({ + factory: () => factory, + engine: () => engine(), + secrets: opts.secrets, + ...(opts.slot === false ? {} : { envCredentials: { isCodeDatasource: (n: string) => codeNames.has(n), read } }), + logger, + }); + return { svc, read, seen, lines, codeNames, factory }; +} + +const warehouse: ConnectableDatasource = { + name: 'warehouse', + driver: 'postgres', + schemaMode: 'external', + config: { host: 'db.internal', database: 'warehouse', username: 'svc' }, + external: { allowWrites: false, validation: { onMismatch: 'warn' } }, +}; +const BOOT = { context: { origin: 'code' as const, trigger: 'declared-auto' as const } }; + +describe('datasourceEnvCredentialVar — the derived name', () => { + it('upper-cases the name and turns every character outside [A-Z0-9] into `_`', () => { + expect(datasourceEnvCredentialVar('warehouse')).toBe('OS_DATASOURCE_WAREHOUSE_PASSWORD'); + expect(datasourceEnvCredentialVar('crm_replica2')).toBe('OS_DATASOURCE_CRM_REPLICA2_PASSWORD'); + expect(datasourceEnvCredentialVar('my-ds.eu')).toBe('OS_DATASOURCE_MY_DS_EU_PASSWORD'); + }); + + it("is the variable the spec refusals' worked example names, and the switch they name is this one", () => { + const m = /`(OS_DATASOURCE_[A-Z0-9_]+_PASSWORD)` for a datasource named `([a-z0-9_]+)`/.exec(CODE_DEFINED_CREDENTIAL_ROUTE); + expect(m).not.toBeNull(); + expect(datasourceEnvCredentialVar(m![2])).toBe(m![1]); + expect(CODE_DEFINED_CREDENTIAL_ROUTE).toContain(`\`${DATASOURCE_ENV_CREDENTIALS_SWITCH}=on\``); + }); +}); + +describe('resolveDatasourceEnvCredentialsSwitch — `on` / `off`, nothing else', () => { + it('unset or empty is off; `on` / `off` read in any case', () => { + expect(resolveDatasourceEnvCredentialsSwitch({})).toBe(false); + expect(resolveDatasourceEnvCredentialsSwitch({ [DATASOURCE_ENV_CREDENTIALS_SWITCH]: ' ' })).toBe(false); + expect(resolveDatasourceEnvCredentialsSwitch({ [DATASOURCE_ENV_CREDENTIALS_SWITCH]: 'on' })).toBe(true); + expect(resolveDatasourceEnvCredentialsSwitch({ [DATASOURCE_ENV_CREDENTIALS_SWITCH]: 'ON' })).toBe(true); + expect(resolveDatasourceEnvCredentialsSwitch({ [DATASOURCE_ENV_CREDENTIALS_SWITCH]: 'off' })).toBe(false); + }); + + it.each(['true', '1', 'yes', 'false', 'enabled'])('refuses %j with a sentence naming the switch and both values', (raw) => { + expect(() => resolveDatasourceEnvCredentialsSwitch({ [DATASOURCE_ENV_CREDENTIALS_SWITCH]: raw })).toThrow( + new RegExp(`^${DATASOURCE_ENV_CREDENTIALS_SWITCH} must be 'on' or 'off', got '${raw}'\\.`), + ); + }); +}); + +describe('the slot — read only for a code-defined name on the declared connect', () => { + it('a code datasource connects with the variable set: the value is the build secret', async () => { + const { svc, read, seen } = service(); + const result = await svc.connect(warehouse, BOOT); + expect(result.status).toBe('connected'); + expect(read).toHaveBeenCalledWith('OS_DATASOURCE_WAREHOUSE_PASSWORD'); + expect(seen).toEqual([{ name: 'warehouse', secret: VALUE }]); + }); + + it('through `connectDeclared`, the door the boot and an install-local package both use', async () => { + const { svc, seen } = service(); + await svc.connectDeclared({ datasources: [warehouse] }); + expect(seen).toEqual([{ name: 'warehouse', secret: VALUE }]); + }); + + it('a name the host registers after construction (an install-local package) is read at its connect', async () => { + const { svc, seen, codeNames } = service({ codeNames: [], env: { OS_DATASOURCE_PKG_STORE_PASSWORD: VALUE } }); + codeNames.add('pkg_store'); + await svc.connectDeclared({ datasources: [{ ...warehouse, name: 'pkg_store' }] }); + expect(seen).toEqual([{ name: 'pkg_store', secret: VALUE }]); + }); + + it('the same name created through the admin door (`registerPool`, runtime-admin) never reads it', async () => { + const { svc, read, seen } = service(); + await svc.connect({ ...warehouse, origin: 'runtime' }, { context: { origin: 'runtime', trigger: 'runtime-admin' } }); + expect(read).not.toHaveBeenCalled(); + expect(seen).toEqual([{ name: 'warehouse', secret: undefined }]); + }); + + it("a caller-set `origin: 'code'` is not provenance: a name outside the code set never reads it", async () => { + const { svc, read, seen } = service({ codeNames: [] }); + await svc.connect({ ...warehouse, origin: 'code' }, BOOT); + expect(read).not.toHaveBeenCalled(); + expect(seen[0]?.secret).toBeUndefined(); + }); + + it('a host that did not opt in (no slot) never reads the environment', async () => { + const { svc, read, seen } = service({ slot: false }); + await svc.connect(warehouse, BOOT); + expect(read).not.toHaveBeenCalled(); + expect(seen[0]?.secret).toBeUndefined(); + }); + + it('the `default` datasource keeps its own channel: an `asDefault` connect never reads the slot', async () => { + const { svc, read } = service({ codeNames: ['default'] }); + await svc.connect({ name: 'default', driver: 'sqlite', config: { filename: ':memory:' } }, { ...BOOT, asDefault: true }); + expect(read).not.toHaveBeenCalled(); + }); +}); + +describe('a declared credential wins, and stays fail-closed', () => { + const withRef = { ...warehouse, external: { ...warehouse.external, credentialsRef: 'sys_secret:abc' } }; + + it('a resolving `credentialsRef` is the secret; the variable is not consulted', async () => { + const { svc, read, seen } = service({ secrets: { resolve: async () => 'bound-secret' } }); + await svc.connect(withRef, BOOT); + expect(read).not.toHaveBeenCalled(); + expect(seen).toEqual([{ name: 'warehouse', secret: 'bound-secret' }]); + }); + + it('an unresolvable `credentialsRef` refuses the connect and does NOT fall back to the variable', async () => { + const { svc, read, factory } = service({ secrets: { resolve: async () => undefined } }); + const result = await svc.connect(withRef, BOOT).catch((err: Error) => ({ status: 'threw', reason: err.message })); + expect(['failed-credentials', 'threw']).toContain(result.status); + expect(read).not.toHaveBeenCalled(); + expect(factory.create).not.toHaveBeenCalled(); + }); + + it('a declared `external.connector` borrows (or refuses) on its own channel; the variable is not consulted', async () => { + const { svc, read } = service(); + await svc + .connect({ ...warehouse, driver: 'odata', external: { ...warehouse.external, connector: 'erp_api' } }, BOOT) + .catch(() => undefined); + expect(read).not.toHaveBeenCalled(); + }); +}); + +describe('an unset variable keeps the verdict, and the reason names the variable', () => { + it('the database refusing the login answers the variable to set', async () => { + const { svc, seen } = service({ env: {}, connectFails: true }); + const result = await svc.connect(warehouse, BOOT).catch((err: Error) => ({ status: 'threw', reason: err.message })); + expect(seen).toEqual([{ name: 'warehouse', secret: undefined }]); + expect(result.status).not.toBe('connected'); + expect(result.reason).toContain('password authentication failed'); + expect(result.reason).toContain('OS_DATASOURCE_WAREHOUSE_PASSWORD'); + }); + + it('an EMPTY variable is unset, not a password of length zero', async () => { + const { svc, seen } = service({ env: { OS_DATASOURCE_WAREHOUSE_PASSWORD: '' }, connectFails: true }); + const result = await svc.connect(warehouse, BOOT).catch((err: Error) => ({ status: 'threw', reason: err.message })); + expect(seen[0]?.secret).toBeUndefined(); + expect(result.reason).toContain('OS_DATASOURCE_WAREHOUSE_PASSWORD'); + }); + + it('a failure on a connect the slot did not apply to keeps its reason unchanged', async () => { + const { svc } = service({ env: {}, connectFails: true }); + const result = await svc.connect(warehouse, { context: { origin: 'runtime', trigger: 'runtime-admin' } }); + expect(result.reason).not.toContain('OS_DATASOURCE_'); + }); +}); + +describe('two datasources that derive one variable are refused', () => { + it('the second name is refused with both names and the variable, and its variable is never read', async () => { + const { svc, read, seen } = service({ + codeNames: ['eu-sales', 'eu_sales'], + env: { OS_DATASOURCE_EU_SALES_PASSWORD: VALUE }, + }); + const first = await svc.connect({ ...warehouse, name: 'eu-sales' }, BOOT); + expect(first.status).toBe('connected'); + const second = await svc + .connect({ ...warehouse, name: 'eu_sales' }, BOOT) + .catch((err: Error) => ({ status: 'threw', reason: err.message })); + expect(second.status).not.toBe('connected'); + expect(second.reason).toContain("datasource 'eu_sales' and datasource 'eu-sales' both derive"); + expect(second.reason).toContain('OS_DATASOURCE_EU_SALES_PASSWORD'); + expect(read).toHaveBeenCalledTimes(1); + expect(seen.map((s) => s.name)).toEqual(['eu-sales']); + }); + + it('a reconnect of the same name is not a collision, and a disconnect releases the variable', async () => { + const { svc, seen } = service({ codeNames: ['eu-sales', 'eu_sales'], env: { OS_DATASOURCE_EU_SALES_PASSWORD: VALUE } }); + await svc.connect({ ...warehouse, name: 'eu-sales' }, BOOT); + await svc.disconnect('eu-sales'); + await svc.connect({ ...warehouse, name: 'eu-sales' }, BOOT); + await svc.disconnect('eu-sales'); + const other = await svc.connect({ ...warehouse, name: 'eu_sales' }, BOOT); + expect(other.status).toBe('connected'); + expect(seen.map((s) => s.secret)).toEqual([VALUE, VALUE, VALUE]); + }); +}); + +describe('the value goes nowhere but the driver', () => { + it('no log line, connect answer or connection state carries it — connected or failed', async () => { + for (const connectFails of [false, true]) { + const { svc, lines, seen } = service({ connectFails }); + const result = await svc.connect(warehouse, BOOT).catch((err: Error) => ({ status: 'threw', reason: err.message })); + expect(seen[0]?.secret).toBe(VALUE); // it did reach the driver + const answers = JSON.stringify({ result, states: svc.listConnectionStates(), state: svc.getConnectionState('warehouse') }); + expect(answers).not.toContain(VALUE); + expect(lines.join('\n')).not.toContain(VALUE); + } + }); +}); diff --git a/packages/services/service-datasource/src/datasource-admin-plugin.ts b/packages/services/service-datasource/src/datasource-admin-plugin.ts index 314bb9efdcb..9f6f5229a2a 100644 --- a/packages/services/service-datasource/src/datasource-admin-plugin.ts +++ b/packages/services/service-datasource/src/datasource-admin-plugin.ts @@ -27,6 +27,7 @@ import { resolveTursoUrl } from './turso-driver-config.js'; import type { IDataEngine, IObjectQLEngine } from '@objectstack/spec/contracts'; import { CONNECTOR_CREDENTIAL_SOURCE_SERVICE, type IConnectorCredentialSource } from '@objectstack/types'; import { borrowConnectorCredential, borrowedConnectorOf } from './datasource-connector-credential.js'; +import { resolveDatasourceEnvCredentialsSwitch } from './datasource-env-credential.js'; /** * Minimal metadata-service surface used for datasource persistence + the @@ -411,6 +412,20 @@ export class DatasourceAdminServicePlugin implements Plugin { factory, engine: () => engineOf() as ConnectionEngineLike | undefined, secrets: { resolve: (ref) => this.options.secrets?.resolve?.(ref) ?? Promise.resolve(undefined) }, + // [#27687] The code author's credential route, read only when the host + // opted in (`OS_DATASOURCE_ENV_CREDENTIALS=on`; the CLI host stamps that + // default, every other composition leaves it off). A value other than + // on/off throws here, so the boot refuses it with the sentence. Whether + // a name is code-defined is the host's provenance set — the same answer + // `servedOrigin` serves — read at connect, never at `init()`. + ...(resolveDatasourceEnvCredentialsSwitch() + ? { + envCredentials: { + isCodeDatasource: (name: string) => codeDatasourceNamesOf(ctx)?.has(name) === true, + read: (variable: string) => process.env[variable], + }, + } + : {}), connectorCredentials, policy: this.options.connectPolicy, logger: this.options.logger, diff --git a/packages/services/service-datasource/src/datasource-connection-service.ts b/packages/services/service-datasource/src/datasource-connection-service.ts index 152816f3eb1..5f00dac592d 100644 --- a/packages/services/service-datasource/src/datasource-connection-service.ts +++ b/packages/services/service-datasource/src/datasource-connection-service.ts @@ -48,6 +48,7 @@ import type { Logger } from './logger.js'; import type { IDataDriver, IObjectQLEngine } from '@objectstack/spec/contracts'; import type { ConnectorCredentialHandle, IConnectorCredentialSource } from '@objectstack/types'; import { borrowConnectorCredential, borrowedConnectorOf } from './datasource-connector-credential.js'; +import { datasourceEnvCredentialVar, type DatasourceEnvCredentialSource } from './datasource-env-credential.js'; /** A datasource definition this service can connect (code- or runtime-origin). */ export interface ConnectableDatasource { @@ -162,6 +163,12 @@ export interface DatasourceConnectionServiceConfig { * at connect: nothing else can supply its credential. */ connectorCredentials?: () => IConnectorCredentialSource | undefined; + /** + * [#27687] The runtime-environment credential slot a code-defined datasource + * reads at connect (`datasource-env-credential.ts`). Absent ⇒ off: the slot + * is read only when the host opted in. + */ + envCredentials?: DatasourceEnvCredentialSource; /** Injectable connect policy. Defaults to {@link allowAllConnectPolicy}. */ policy?: DatasourceConnectPolicy; logger?: Logger; @@ -347,6 +354,12 @@ export class DatasourceConnectionService { /** The lenders whose `onLent` this service subscribed to, one subscription each, with their unsubscribes. */ private readonly lendSubscriptions = new Map void>(); + /** + * [#27687] Which datasource each derived env-credential variable was read + * for. Two code datasources deriving one variable would read one password + * for two connections; the second is refused instead. + */ + private readonly envCredentialOwners = new Map(); constructor(cfg: DatasourceConnectionServiceConfig) { this.cfg = cfg; @@ -743,6 +756,34 @@ export class DatasourceConnectionService { credential = borrow.handle; } + // [#27687] The code author's route, after both declared channels and only + // when neither is declared: a declared reference keeps precedence and its + // failure above never falls through to here. `envCredentialVar` stays set + // when the slot applied but the variable was empty, so a failed connect + // can name the variable to set. + let envCredentialVar: string | undefined; + if (!credentialsRef && connector === undefined && !opts.asDefault) { + const variable = this.envCredentialVarFor(name, opts.context); + if (variable !== undefined) { + const owner = this.envCredentialOwners.get(variable); + if (owner !== undefined && owner !== name) { + return this.handleFailure( + record, + 'failed-credentials', + `datasource '${name}' and datasource '${owner}' both derive the credential variable ${variable}, ` + + 'so one password would be read for two connections. Rename one of them.', + opts.context, + opts.objects, + opts.mappedObjects, + ); + } + this.envCredentialOwners.set(variable, name); + const value = this.cfg.envCredentials?.read(variable); + if (value) secret = value; + else envCredentialVar = variable; + } + } + // #13578 — set to the registry key ONLY when this attempt is the thing that // put it there, so the rollback below can never evict a driver someone else // owns. `registerDriver` is a no-op on a name it already holds (it keeps the @@ -861,10 +902,28 @@ export class DatasourceConnectionService { // build output being absent is reported as `err.code === // 'ERR_MODULE_NOT_FOUND'`, and that structured signal is gone the moment // the error is stringified (#5794). - return this.handleFailure(record, 'failed-degraded', errMsg(err), opts.context, opts.objects, opts.mappedObjects, err); + const reason = envCredentialVar === undefined + ? errMsg(err) + : `${errMsg(err)} — datasource '${name}' is defined in code and reads its credential from ` + + `${envCredentialVar} at connect, which is not set on this host`; + return this.handleFailure(record, 'failed-degraded', reason, opts.context, opts.objects, opts.mappedObjects, err); } } + /** + * [#27687] The env-credential variable this connect may read, or `undefined` + * when the slot does not apply: the host did not opt in, the connect is not + * the boot's declared-auto one, or the name is not one the host registers + * from code. Provenance only — `context.origin` and `record.origin` are the + * caller's to set and are never consulted. + */ + private envCredentialVarFor(name: string, context: DatasourceConnectContext | undefined): string | undefined { + const slot = this.cfg.envCredentials; + if (!slot || context?.trigger !== 'declared-auto') return undefined; + if (!slot.isCodeDatasource(name)) return undefined; + return datasourceEnvCredentialVar(name); + } + /** * Gracefully disconnect a previously-registered datasource pool. * @@ -898,6 +957,9 @@ export class DatasourceConnectionService { // datasource that no longer exists in that state. this.states.delete(name); this.awaitingLend.delete(name); + for (const [variable, owner] of this.envCredentialOwners) { + if (owner === name) this.envCredentialOwners.delete(variable); + } engine?.clearDatasourceUnavailable?.(name); // #13578 — the eviction this method never did. Closing the pool above only diff --git a/packages/services/service-datasource/src/datasource-credential-migration.ts b/packages/services/service-datasource/src/datasource-credential-migration.ts index 3716f50303d..dbdf6cd5e5b 100644 --- a/packages/services/service-datasource/src/datasource-credential-migration.ts +++ b/packages/services/service-datasource/src/datasource-credential-migration.ts @@ -78,6 +78,7 @@ import { validateDriverConfig, } from '@objectstack/spec/data'; import type { StoredDatasource } from './datasource-admin-service.js'; +import { DATASOURCE_ENV_CREDENTIALS_SWITCH, datasourceEnvCredentialVar } from './datasource-env-credential.js'; /** Remedy sentence shared by every refusal that leaves the credential at rest. */ const REENTER_REMEDY = @@ -165,8 +166,11 @@ export function planCredentialMigration(record: StoredDatasource): CredentialMig `Datasource '${record.name}' is code-defined (origin: 'code'), so its definition is owned by ` + 'the artifact that declares it and cannot be rewritten at runtime.', remedy: - 'Move the credential in the source that defines this datasource: bind it through ' - + '`external.credentialsRef`, or supply it from the runtime environment.', + 'Move the credential out of the source that defines this datasource: leave it out of the ' + + `declaration and set ${datasourceEnvCredentialVar(record.name)} in the runtime environment, ` + + `which the datasource reads at connect when the host's ${DATASOURCE_ENV_CREDENTIALS_SWITCH} ` + + 'switch is on (the default under `os serve` / `os start`), or bind it through ' + + '`external.credentialsRef`.', }; } diff --git a/packages/services/service-datasource/src/datasource-env-credential.ts b/packages/services/service-datasource/src/datasource-env-credential.ts new file mode 100644 index 00000000000..95a402f9550 --- /dev/null +++ b/packages/services/service-datasource/src/datasource-env-credential.ts @@ -0,0 +1,89 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The runtime-environment credential slot for a CODE-defined datasource + * (#27687, maintainer ruling: option 5). + * + * A datasource declared in code (`defineDatasource`, an installed package's + * `*.datasource.ts`) cannot carry its password: the driver contracts refuse an + * inline `config.password` and a `user:password@` URL, because the declaration + * is compiled into the artifact and stored whole in `sys_metadata`. It also + * cannot reach the two ruled routes a UI-created datasource has — the admin + * door refuses a code-origin datasource, and nothing mints a `sys_secret:` + * handle for one. This slot is the code author's route: the credential stays + * out of the declaration and is read at CONNECT time from an environment + * variable whose name the platform derives from the datasource name — + * `warehouse` reads `OS_DATASOURCE_WAREHOUSE_PASSWORD` — the same pattern as + * `OS_DATABASE_URL` for `default`. + * + * ## The guards, each a ruled condition + * + * - **The metadata names no variable.** The name is derived, so authored + * metadata cannot point the runtime at an arbitrary host variable. + * - **Code origin only, by provenance.** The slot is read for a name in the + * host's code-datasource set (`code-datasource-names`, filled by the + * runtime from code) on a `declared-auto` connect — ⛔ never on the + * caller-supplied `record.origin`, which `connectDeclared` defaults to + * `'code'`. A datasource created through the UI or API under the same name + * connects through `registerPool` (`runtime-admin`) and never reads it. + * - **A host switch, off unless the host opts in.** On a host where several + * tenants' artifacts share one process, a process-wide variable is readable + * by every tenant artifact that declares the same name. So the slot is read + * only when {@link DATASOURCE_ENV_CREDENTIALS_SWITCH} is `on`. The + * single-artifact CLI host (`os serve` / `os start` / `os dev`) stamps + * that default; every other composition leaves it unset, i.e. off. + * - **A declared credential wins.** `external.credentialsRef` and + * `external.connector` keep precedence and stay fail-closed: a declared + * reference that does not resolve never falls back to the environment. + * - **The value goes nowhere but the driver.** It is handed to the driver + * factory as the connection's `secret` and is never logged, stored or + * answered; a message names the VARIABLE, never its value. + */ + +/** The host switch: `on` reads the slot, `off` (or unset) never does. */ +export const DATASOURCE_ENV_CREDENTIALS_SWITCH = 'OS_DATASOURCE_ENV_CREDENTIALS'; + +/** + * The environment variable a code-defined datasource reads its credential + * from: `OS_DATASOURCE_` + the name upper-cased, each character outside + * `[A-Z0-9]` replaced by `_`, + `_PASSWORD`. + * + * The suffix is the ruled spelling for every driver; the value is the + * connection's `secret`, which each driver arm applies where its client takes + * it (the password for postgres, mysql and mongo; the `authToken` for turso). + */ +export function datasourceEnvCredentialVar(name: string): string { + return `OS_DATASOURCE_${name.toUpperCase().replace(/[^A-Z0-9]/g, '_')}_PASSWORD`; +} + +/** + * Read the host switch. Unset or empty is `off`; `on` / `off` are read in any + * letter case; anything else is refused with a sentence, because a typo that + * silently read as off would leave every code datasource unconnected with a + * reason that blames the variable. + */ +export function resolveDatasourceEnvCredentialsSwitch( + env: Record = process.env, +): boolean { + const raw = env[DATASOURCE_ENV_CREDENTIALS_SWITCH]; + if (raw === undefined || raw.trim() === '') return false; + const value = raw.trim().toLowerCase(); + if (value === 'on') return true; + if (value === 'off') return false; + throw new Error( + `${DATASOURCE_ENV_CREDENTIALS_SWITCH} must be 'on' or 'off', got '${raw}'. ` + + "'on' lets a code-defined datasource read its credential from OS_DATASOURCE_" + + "{NAME}_PASSWORD at connect; 'off' (the default outside the CLI host) never reads it.", + ); +} + +/** + * The slot as {@link DatasourceConnectionService} consumes it. Absent ⇒ the + * slot is off, which is what every composition that does not opt in gets. + */ +export interface DatasourceEnvCredentialSource { + /** Whether the host registers `name` from code — the provenance set, never a record's `origin`. */ + isCodeDatasource(name: string): boolean; + /** Read one variable. Called only with a name {@link datasourceEnvCredentialVar} derived. */ + read(variable: string): string | undefined; +} diff --git a/packages/services/service-datasource/src/index.ts b/packages/services/service-datasource/src/index.ts index c4bba8675c9..fce54a9775f 100644 --- a/packages/services/service-datasource/src/index.ts +++ b/packages/services/service-datasource/src/index.ts @@ -61,6 +61,16 @@ export type { ConnectStatus, } from './datasource-connection-service.js'; +// The runtime-environment credential slot of a code-defined datasource +// (#27687) — exported so a host that opts in stamps the switch by its name, +// and an operator tool names the variable the way the connect path derives it. +export { + DATASOURCE_ENV_CREDENTIALS_SWITCH, + datasourceEnvCredentialVar, + resolveDatasourceEnvCredentialsSwitch, +} from './datasource-env-credential.js'; +export type { DatasourceEnvCredentialSource } from './datasource-env-credential.js'; + // The #8155 credential re-homing planner — exported so a host that drives the // migration through its own surface asks the same question the Setup action // asks, rather than re-deriving which stored keys are safe to re-home. diff --git a/packages/spec/api-surface/data.json b/packages/spec/api-surface/data.json index 866682ee1b9..d1858db817b 100644 --- a/packages/spec/api-surface/data.json +++ b/packages/spec/api-surface/data.json @@ -86,6 +86,7 @@ "CALENDAR_DATE_TYPES (const)", "CANONICAL_CREDENTIAL_KEYS (const)", "CLOCK_TIME_TYPES (const)", + "CODE_DEFINED_CREDENTIAL_ROUTE (const)", "COMPOSED_READABLE_IDS_BRAND (const)", "COMPUTED_VALUE_TYPES (const)", "CONTEXT_TOKENS (const)", diff --git a/packages/spec/export-origins/data.json b/packages/spec/export-origins/data.json index ec843a82973..7437a2b6fad 100644 --- a/packages/spec/export-origins/data.json +++ b/packages/spec/export-origins/data.json @@ -83,6 +83,7 @@ "CALENDAR_DATE_TYPES": "src/data/field-value.zod.ts#CALENDAR_DATE_TYPES (const)", "CANONICAL_CREDENTIAL_KEYS": "src/data/driver/common.zod.ts#CANONICAL_CREDENTIAL_KEYS (const)", "CLOCK_TIME_TYPES": "src/data/field-value.zod.ts#CLOCK_TIME_TYPES (const)", + "CODE_DEFINED_CREDENTIAL_ROUTE": "src/data/driver/common.zod.ts#CODE_DEFINED_CREDENTIAL_ROUTE (const)", "COMPOSED_READABLE_IDS_BRAND": "src/data/readable-ids.ts#COMPOSED_READABLE_IDS_BRAND (const)", "COMPUTED_VALUE_TYPES": "src/data/field-value.zod.ts#COMPUTED_VALUE_TYPES (const)", "CONTEXT_TOKENS": "src/data/context-tokens.zod.ts#CONTEXT_TOKENS (const)", diff --git a/packages/spec/src/data/datasource.test.ts b/packages/spec/src/data/datasource.test.ts index 92a7621118a..764f92e5da9 100644 --- a/packages/spec/src/data/datasource.test.ts +++ b/packages/spec/src/data/datasource.test.ts @@ -836,13 +836,14 @@ describe('datasource `password` guidance — following it parses', () => { if (followed.success) expect(followed.data.external?.credentialsRef).toBe(example); }); - it('the drivers page read-only sample (managed, credential-free config, bound secret) parses', () => { + it('the drivers page read-only sample (managed, credential-free config, password read from the environment) parses', () => { + // A `defineDatasource` sample: the password is not declared at all — it is + // read at connect from OS_DATASOURCE_ANALYTICS_PASSWORD (#27687). const result = DatasourceSchema.safeParse({ name: 'analytics', label: 'Analytics (read-only account)', driver: 'postgres', config: { host: 'db.internal', database: 'analytics', username: 'analytics_ro' }, - external: { credentialsRef: 'sys_secret:9f2c…' }, active: true, }); expect(result.success).toBe(true); diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index aa9c6ddd917..476d24d433d 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -4,7 +4,7 @@ import { z } from 'zod'; import { lazySchema } from '../shared/lazy-schema'; import { strictObject } from '../shared/strict-object'; import { MetadataProtectionFields } from '../kernel/metadata-protection.zod'; -import { urlUserinfoUsername } from './driver/common.zod'; +import { CODE_DEFINED_CREDENTIAL_ROUTE, urlUserinfoUsername } from './driver/common.zod'; import { RETIRED_DRIVER_ID_GUIDANCE, resolveDriverId, validateDriverConfig } from './driver/config-registry.zod'; /* @@ -315,7 +315,8 @@ export const ExternalDatasourceSettingsSchema = strictObject( + "connection form's secret field encrypts it into `sys_secret` and stores the opaque " + 'handle here, or set `credentialsRef` to a handle the secret store minted (e.g. ' + '`credentialsRef: "sys_secret:9f2c"`). The shipped binder resolves only ' - + '`sys_secret:` handles; it never dereferences a hand-written path.', + + '`sys_secret:` handles; it never dereferences a hand-written path. ' + + CODE_DEFINED_CREDENTIAL_ROUTE, // #4487 corrected the second half of this line. It used to offer // `capabilities.readOnly` as the place to "describe the driver" — a key the // liveness audit found has NO reader (liveness/datasource.json), so an @@ -631,7 +632,8 @@ export const DatasourceSchema = lazySchema(() => strictObject( + '`sys_secret` and stores the opaque handle at `external.credentialsRef`, or set that ' + 'key to a handle the secret store minted (e.g. ' + '`external: { credentialsRef: "sys_secret:9f2c" }`). `external.credentialsRef` is ' - + 'valid in every `schemaMode`, a managed datasource included.', + + 'valid in every `schemaMode`, a managed datasource included. ' + + CODE_DEFINED_CREDENTIAL_ROUTE, readReplicas: RETIRED_READ_REPLICAS, replicas: RETIRED_READ_REPLICAS, capabilities: RETIRED_CAPABILITIES.capabilities, diff --git a/packages/spec/src/data/driver/code-defined-credential-route.test.ts b/packages/spec/src/data/driver/code-defined-credential-route.test.ts new file mode 100644 index 00000000000..b9da4c0c886 --- /dev/null +++ b/packages/spec/src/data/driver/code-defined-credential-route.test.ts @@ -0,0 +1,141 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * #27687 — the code author's credential route, in every credential refusal. + * + * The refusals prescribe two routes (the Setup → Datasources secret field, a + * minted `external.credentialsRef` handle) and both are a UI author's: the + * Setup form cannot edit a datasource defined in code, and nothing mints a + * handle for one. Maintainer ruling (option 5): a code-defined datasource + * reads its credential at connect time from `OS_DATASOURCE__PASSWORD`. + * Each refusal now carries that route in ONE shared sentence + * ({@link CODE_DEFINED_CREDENTIAL_ROUTE}), keeps the UI routes, and is walked + * here exactly as written: the example name and variable are read OUT of the + * message, the credential is left out of the very declaration that was + * refused, and the result must parse. That the example variable is the one + * the runtime derives is pinned where the derivation lives + * (`service-datasource`'s `datasource-env-credential.test.ts`). + */ + +import { describe, expect, it } from 'vitest'; + +import { DatasourceSchema } from '../datasource.zod'; +import { CODE_DEFINED_CREDENTIAL_ROUTE } from './common.zod'; + +/** Every issue message the authored artefact draws, with its dotted path. */ +const issuesOf = (input: unknown): { path: string; message: string }[] => { + const result = DatasourceSchema.safeParse(input); + return result.success ? [] : result.error.issues.map((i) => ({ path: i.path.join('.'), message: i.message })); +}; + +/** Read the route's worked example back out of a message. */ +const exampleOf = (message: string) => { + const m = /`(OS_DATASOURCE_[A-Z0-9_]+_PASSWORD)` for a datasource named `([a-z0-9_]+)`/.exec(message); + return m ? { variable: m[1], name: m[2] } : undefined; +}; + +const PG = { host: 'db.internal', database: 'warehouse', username: 'svc' } as const; + +/** + * One row per refusal family: the refused authored artefact, where the refusal + * is pathed, and the same artefact with the credential left out — what the + * route tells a code author to write. + */ +const REFUSALS: { family: string; refused: (name: string) => unknown; at: string; following: (name: string) => unknown }[] = [ + { + family: 'inline `config.password`', + refused: (name) => ({ name, driver: 'postgres', config: { ...PG, password: 'hunter2' } }), + at: 'config.password', + following: (name) => ({ name, driver: 'postgres', config: PG }), + }, + { + family: 'URL userinfo password', + refused: (name) => ({ name, driver: 'postgres', config: { url: 'postgres://svc:hunter2@db.internal/warehouse' } }), + at: 'config.url', + following: (name) => ({ name, driver: 'postgres', config: { url: 'postgres://svc@db.internal/warehouse' } }), + }, + { + family: 'URL credential query parameter', + refused: (name) => ({ name, driver: 'postgres', config: { url: 'postgres://svc@db.internal/warehouse?password=hunter2' } }), + at: 'config.url', + following: (name) => ({ name, driver: 'postgres', config: { url: 'postgres://svc@db.internal/warehouse' } }), + }, + { + family: 'mongo passthrough `auth.password`', + refused: (name) => ({ + name, + driver: 'mongodb', + config: { database: 'events', host: 'mongo.internal', options: { auth: { username: 'svc', password: 'hunter2' } } }, + }), + at: 'config.options.auth.password', + following: (name) => ({ + name, + driver: 'mongodb', + config: { database: 'events', host: 'mongo.internal', options: { auth: { username: 'svc' } } }, + }), + }, + { + family: 'mongo passthrough nested credential spelling', + refused: (name) => ({ + name, + driver: 'mongodb', + config: { database: 'events', host: 'mongo.internal', username: 'svc', options: { pool: { password: 'hunter2' } } }, + }), + at: 'config.options.pool.password', + following: (name) => ({ + name, + driver: 'mongodb', + config: { database: 'events', host: 'mongo.internal', username: 'svc', options: { pool: {} } }, + }), + }, + { + family: 'top-level `password` guidance', + refused: (name) => ({ name, driver: 'postgres', password: 'hunter2', config: PG }), + at: '', + following: (name) => ({ name, driver: 'postgres', config: PG }), + }, + { + family: '`external.password` guidance', + refused: (name) => ({ name, driver: 'postgres', schemaMode: 'external', config: PG, external: { password: 'hunter2' } }), + at: 'external', + following: (name) => ({ name, driver: 'postgres', schemaMode: 'external', config: PG, external: {} }), + }, +]; + +describe('CODE_DEFINED_CREDENTIAL_ROUTE — the sentence itself', () => { + it('names the derived variable by a worked example, the CLI host default and the switch', () => { + const example = exampleOf(CODE_DEFINED_CREDENTIAL_ROUTE); + expect(example).toEqual({ variable: 'OS_DATASOURCE_WAREHOUSE_PASSWORD', name: 'warehouse' }); + expect(CODE_DEFINED_CREDENTIAL_ROUTE).toContain('`defineDatasource`'); + expect(CODE_DEFINED_CREDENTIAL_ROUTE).toContain('`os serve` / `os start`'); + expect(CODE_DEFINED_CREDENTIAL_ROUTE).toContain('`OS_DATASOURCE_ENV_CREDENTIALS=on`'); + }); + + it('carries no tracker id and no pasteable placeholder', () => { + expect(CODE_DEFINED_CREDENTIAL_ROUTE).not.toMatch(/#\d{3,6}\b/); + expect(CODE_DEFINED_CREDENTIAL_ROUTE).not.toMatch(/<[A-Z_]+>|\$\{/); + }); +}); + +describe.each(REFUSALS)('$family — the refusal carries the code route and following it parses', ({ refused, at, following }) => { + it('is refused with the shared sentence, keeping the UI routes', () => { + const issue = issuesOf(refused('warehouse')).find( + (i) => i.path === at && i.message.includes(CODE_DEFINED_CREDENTIAL_ROUTE), + ); + expect(issue, `a refusal at \`${at}\` carrying the shared sentence`).toBeDefined(); + // The ruled UI routes stay in the same message (#7990 / #8082's prescription). + expect(issue!.message).toContain('Setup → Datasources'); + expect(issue!.message).toContain('sys_secret'); + expect(issue!.message).not.toMatch(/#\d{3,6}\b/); + }); + + it("following the route — the example's name, the credential left out — parses, with no secret anywhere", () => { + const issue = issuesOf(refused('warehouse')).find((i) => i.path === at && i.message.includes(CODE_DEFINED_CREDENTIAL_ROUTE)); + const example = exampleOf(issue?.message ?? ''); + expect(example).toBeDefined(); + const followed = DatasourceSchema.safeParse(following(example!.name)); + expect(followed.success, JSON.stringify(followed.error?.issues)).toBe(true); + expect(JSON.stringify(followed.data)).not.toContain('hunter2'); + expect(followed.data?.name).toBe(example!.name); + }); +}); diff --git a/packages/spec/src/data/driver/common.zod.ts b/packages/spec/src/data/driver/common.zod.ts index 8073d5fe152..99ecf71f9b3 100644 --- a/packages/spec/src/data/driver/common.zod.ts +++ b/packages/spec/src/data/driver/common.zod.ts @@ -84,6 +84,29 @@ 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.'; +/** + * The code author's route, one sentence shared by every credential refusal and + * the two `password` guidance entries on `DatasourceSchema` / + * `ExternalDatasourceSettingsSchema` (maintainer ruling: a code-defined + * datasource reads its credential at connect time from a runtime environment + * variable whose name the platform derives from the datasource name). + * + * The two ruled routes the refusals already name are the UI author's: the + * Setup → Datasources form cannot edit a datasource defined in code, and + * nothing mints a `sys_secret:` handle for one. This sentence is the route a + * `defineDatasource` author can follow. It names the derived variable by + * example, never by a placeholder an author could paste, and the host switch + * that gates it (off outside the CLI host — the multi-tenant guard). + * `@objectstack/service-datasource` (`datasource-env-credential.ts`) owns the + * derivation and the switch; this is their prescription. + */ +export const CODE_DEFINED_CREDENTIAL_ROUTE = + 'A datasource defined in code (`defineDatasource`) leaves the credential out of the ' + + 'declaration: it is read at connect time from the runtime environment variable derived ' + + 'from the datasource name (`OS_DATASOURCE_WAREHOUSE_PASSWORD` for a datasource named ' + + '`warehouse`), which `os serve` / `os start` honour by default ' + + '(`OS_DATASOURCE_ENV_CREDENTIALS=on`).'; + /** * Refusal prescription for an inline credential written into driver config * (#7990, maintainer-ruled Option A 2026-08-12: per-artefact contract closure). @@ -110,7 +133,8 @@ export const INLINE_CREDENTIAL_REFUSED = (key: string): string => + '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`.'; + + 'wins over anything embedded in `config`. ' + + CODE_DEFINED_CREDENTIAL_ROUTE; /** * Refusal prescription for a credential embedded in an authored URL's userinfo @@ -158,7 +182,8 @@ export const URL_EMBEDDED_CREDENTIAL_REFUSED = (key: string): string => + 'placeholders in authored metadata are resolved by nothing and reach the database client ' + 'verbatim (measured), and are themselves refused at publish. ' + 'Runtime-environment DSNs (`OS_DATABASE_URL` and friends) do ' - + 'not pass through this publish door and are unaffected.'; + + 'not pass through this publish door and are unaffected. ' + + CODE_DEFINED_CREDENTIAL_ROUTE; /** * The userinfo component of a URL-ish string, or `undefined` when the string @@ -337,7 +362,8 @@ export const URL_CREDENTIAL_QUERY_PARAM_REFUSED = (key: string, param: string): + 'substitute a `${…}` placeholder into the URL: placeholders in authored metadata are ' + 'resolved by nothing and reach the database client verbatim (measured), and are ' + 'themselves refused at publish. Runtime-environment DSNs (`OS_DATABASE_URL` and ' - + 'friends) do not pass through this publish door and are unaffected.'; + + 'friends) do not pass through this publish door and are unaffected. ' + + CODE_DEFINED_CREDENTIAL_ROUTE; /** * Refusal prescription for a credential written into the MongoClient @@ -366,7 +392,8 @@ export const PASSTHROUGH_INLINE_CREDENTIAL_REFUSED = (path: string): string => + '`external.credentialsRef` — or reference the secrets store directly with ' + '`external.credentialsRef`. The resolved secret is injected at connect time and wins over ' + 'an `auth` block embedded in the passthrough (measured). Placeholders are no ' - + 'escape either: a `${…}` span anywhere in `options` is itself refused at publish.'; + + 'escape either: a `${…}` span anywhere in `options` is itself refused at publish. ' + + CODE_DEFINED_CREDENTIAL_ROUTE; /** * The paths inside mongo's `options` passthrough that resolve into a login @@ -503,7 +530,7 @@ export const PASSTHROUGH_NESTED_CREDENTIAL_REFUSED = (path: string): string => + "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`. ' + 'Placeholders are no escape either: a `${…}` span anywhere in `options` is itself refused ' - + 'at publish.'; + + 'at publish. ' + CODE_DEFINED_CREDENTIAL_ROUTE; /** * Attach commit 24206416a's passthrough-credential refusal to mongo's `options` slot. @@ -821,7 +848,8 @@ 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`', + + 'encrypted into `sys_secret`, never stored in `config`. ' + + CODE_DEFINED_CREDENTIAL_ROUTE, ) .meta({ title: formTitle, format: 'password' }); }