From 405d447e8ecdda9e52248369b3ea48461b01e961 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 16:44:28 +0000 Subject: [PATCH 1/3] feat(service-datasource): a code-defined datasource reads its credential from OS_DATASOURCE__PASSWORD A datasource declared in code has no route to its password: the driver contracts refuse it inline, the Setup form cannot edit a code datasource and nothing mints a sys_secret handle for one. Per the maintainer ruling, the connection service now reads a variable derived from the datasource name at the boot's declared connect, for names in the host's code-datasource set, when the host switch OS_DATASOURCE_ENV_CREDENTIALS is on. os serve turns it on by default; every other composition leaves it off. A declared credentialsRef or connector wins, an unset variable's failure names the variable, and two names deriving one variable are refused. The credential refusals and the two password guidance strings end with one shared sentence naming the route, and the docs teach the slot, the switch and who can read the variable. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziLJNDCTSUH9pVN32p --- .../27687-code-datasource-env-credential.md | 21 ++ content/docs/data-modeling/drivers.mdx | 30 +- .../data-modeling/external-datasources.mdx | 75 ++++- packages/cli/src/commands/serve.ts | 9 + ...datasource-env-credentials-default.test.ts | 53 ++++ .../datasource-env-credentials-default.ts | 28 ++ .../src/datasource-env-credential.test.ts | 127 ++++++++ .../datasource-credential-migration.test.ts | 4 + .../datasource-env-credential.test.ts | 290 ++++++++++++++++++ .../src/datasource-admin-plugin.ts | 15 + .../src/datasource-connection-service.ts | 64 +++- .../src/datasource-credential-migration.ts | 8 +- .../src/datasource-env-credential.ts | 89 ++++++ .../services/service-datasource/src/index.ts | 10 + packages/spec/src/data/datasource.test.ts | 5 +- packages/spec/src/data/datasource.zod.ts | 8 +- .../code-defined-credential-route.test.ts | 141 +++++++++ packages/spec/src/data/driver/common.zod.ts | 40 ++- 18 files changed, 989 insertions(+), 28 deletions(-) create mode 100644 .changeset/27687-code-datasource-env-credential.md create mode 100644 packages/cli/src/utils/datasource-env-credentials-default.test.ts create mode 100644 packages/cli/src/utils/datasource-env-credentials-default.ts create mode 100644 packages/runtime/src/datasource-env-credential.test.ts create mode 100644 packages/services/service-datasource/src/__tests__/datasource-env-credential.test.ts create mode 100644 packages/services/service-datasource/src/datasource-env-credential.ts create mode 100644 packages/spec/src/data/driver/code-defined-credential-route.test.ts 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/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..677f0fdd618 --- /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', checkOnBoot: false } }, +}; +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/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' }); } From 118b0e033a0719e237ae7073ddee73c2a94b9d3d Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 16:44:28 +0000 Subject: [PATCH 2/3] chore(spec): regenerate api-surface, export-origins and driver reference pages Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziLJNDCTSUH9pVN32p --- content/docs/references/data/driver-mongo.mdx | 2 +- content/docs/references/data/driver-mysql.mdx | 2 +- content/docs/references/data/driver-postgres.mdx | 2 +- content/docs/references/data/driver-turso.mdx | 2 +- packages/spec/api-surface/data.json | 1 + packages/spec/export-origins/data.json | 1 + 6 files changed, 6 insertions(+), 4 deletions(-) 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/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)", From 5eda07f1ece5039367337c329c052efb8091b41f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 16:44:28 +0000 Subject: [PATCH 3/3] test(service-datasource): drop an undeclared validation key from the env-credential fixture Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_01WGufziLJNDCTSUH9pVN32p --- .../src/__tests__/datasource-env-credential.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 677f0fdd618..ba60b89b663 100644 --- a/packages/services/service-datasource/src/__tests__/datasource-env-credential.test.ts +++ b/packages/services/service-datasource/src/__tests__/datasource-env-credential.test.ts @@ -110,7 +110,7 @@ const warehouse: ConnectableDatasource = { driver: 'postgres', schemaMode: 'external', config: { host: 'db.internal', database: 'warehouse', username: 'svc' }, - external: { allowWrites: false, validation: { onMismatch: 'warn', checkOnBoot: false } }, + external: { allowWrites: false, validation: { onMismatch: 'warn' } }, }; const BOOT = { context: { origin: 'code' as const, trigger: 'declared-auto' as const } };