Skip to content

Commit e063a87

Browse files
feat(service-datasource): a code-defined datasource reads its credential from OS_DATASOURCE_<NAME>_PASSWORD (#28208)
domain:spec#3 · os-support-ai · session_01WGufzgbJhFoXYsLSe5WSPP <!-- claude-projects-attribution: {"github_login":"os-support-ai"} --> _Requested by **Yibo Wang** · [project thread](https://claude.ai/code/project/chan_01BVUyyPie8gHwoPM18iABwB?thread=cmsg_01BVUyyPie8gHwoPM18iABwBR9NEQPF4XiEBhFuE3M9hKB)_ Fixes #27687 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, and no existing key changes meaning. Metadata-protocol: none ## 维护者速读 1. 代码里声明的数据源(`defineDatasource` / 包内 `*.datasource.ts`)现在在连接时读 `OS_DATASOURCE_<名字>_PASSWORD`,例如 `warehouse` → `OS_DATASOURCE_WAREHOUSE_PASSWORD`。 2. 只在主机开关 `OS_DATASOURCE_ENV_CREDENTIALS=on` 时读;`os serve` / `os start` / `os dev` 默认开,其他组合(云端、多租户、自建 kernel)默认关;其他取值启动即拒绝。 3. 只读代码数据源(按来源集合判定,从不看记录的 `origin`);UI/API 创建的同名数据源、`default` 都不读。 4. 声明了 `credentialsRef` 或 `connector` 时它们优先,失败仍然 fail-closed,不回落到环境变量;变量没设时,失败原因会写出该设哪个变量;值不进日志、不进任何应答。 5. 信任后果:`os serve` 下本地安装的包也读这个槽,所以任何已安装的包都能声明任意空闲名字的数据源,从而读到对应变量。文档和 changeset 已写明。 6. 规范层只改了拒绝文本(5 条凭据拒绝 + 2 条 `password` 提示共用一句新句子),accept 集合不变;changeset 全部 `patch`(`Metadata-protocol: none` 时门禁要求 patch,见下文)。 ## The ruling Maintainer ruling on #27687 (option 5, recorded by the seat in comment 6525963988, claim amended in 6525977981): a datasource defined in code reads its credential at connect time from a runtime environment variable whose name the platform derives from the datasource name. The seat's follow-ups: the switch is opt-in in `os serve` / `os start` with the library default off and no tenancy coupling, the off value is spelled `off`, and the install-local route (#28132) keeps reading the slot, with its trust consequence stated and pinned. ## Measurements on `origin/main` `d70e94ae` **(a) The seam where the CLI host turns the switch on.** No new public option was needed. `os serve` loads `@objectstack/service-datasource` dynamically and builds `DatasourceAdminServicePlugin` itself (`packages/cli/src/commands/serve.ts`, the `adminMod` block). The plugin's `init()` reads `process.env`, so `serve` stamps `OS_DATASOURCE_ENV_CREDENTIALS=on` into its own environment just before it builds the plugin, when the operator left it unset or empty. That is the same seam `os start` uses for `OS_CRYPTO_AUTOKEY` (`start.ts:482`). `os start` spawns `serve` (`start.ts:528`) and `os dev` spawns `serve --dev` (`dev.ts:848`), so both inherit the stamp. Every other composition never runs that line and stays off. **(b) The shape of `secret`.** `DatasourceConnectionSpec.secret?: string` (`contracts/datasource-driver-factory.ts`) is the cleartext the connection service hands to `factory.create`. Its readers in `default-datasource-driver-factory.ts`: postgres puts it in `password` (`:603`, `:611`), mysql the same (`:830`, `:839`), mongodb puts it in the `MongoClient` `auth` block (`:945`, `:1047`), and turso reads it ahead of `config.authToken`. sqlite, sqlite-wasm and memory have no credential slot. The slot fills exactly this value, so it reaches every built-in driver that takes a secret. A plugin driver that resolves its credential per request (OData, through `DATASOURCE_SECRET_RESOLVER_SERVICE`) does not read it; the docs say so. **(c) Where `CODE_DATASOURCE_NAMES_SERVICE` is filled, and how `connect()` gets it.** Three producers, all in `@objectstack/runtime`, all through `contributeCodeDatasourceNames` (`code-datasource-names.ts:156`): - `app-plugin.ts:626`: the boot artifact's declared datasources; - `default-datasource-plugin.ts:150`: `default`; - `declared-datasources.ts:160`: a package installed into the running kernel (install-local, since #28132). `connect()` gets it as a callback. The admin plugin passes `isCodeDatasource(name)`, which reads the set through `codeDatasourceNamesOf(ctx)`, the same reader `servedOrigin` uses. The set is read at each connect, never cached at `init()`, so a name an install adds later is honoured. ## What changes - **`DatasourceConnectionService.connect()`**, after the `credentialsRef` and connector steps: when neither is declared, the connect is not `asDefault`, the slot is configured (switch on), the trigger is `declared-auto`, and the name is in the code set, it reads `OS_DATASOURCE_<NAME>_PASSWORD`. `<NAME>` is the name upper-cased with each non-`[A-Z0-9]` character replaced by `_`. A non-empty value becomes the build's `secret`. - **`resolveDatasourceEnvCredentialsSwitch()`**: unset or empty is off; `on` / `off` in any letter case; anything else throws `OS_DATASOURCE_ENV_CREDENTIALS must be 'on' or 'off', got '<value>'. …`. The admin plugin calls it in `init()`, so the boot is refused. - **`os serve`** stamps the `on` default (`packages/cli/src/utils/datasource-env-credentials-default.ts`). - **`planCredentialMigration`**'s code-origin remedy names the row's variable and the switch. - **Spec prescriptions**: one shared sentence, `CODE_DEFINED_CREDENTIAL_ROUTE`, appended to the credential refusals and the two `password` guidance entries (before/after below). - **Docs**: - `external-datasources.mdx` §4 gains "A datasource defined in code: the environment variable". It covers the derivation table, an example, the switch and its default, the code-only rule, precedence, the unset case, collisions, the plugin-driver limit, and a warning callout on who can read the variable (installed packages; why the switch is off on multi-tenant hosts). - `drivers.mdx`: the Multi-Datasource paragraph and the read-only sample now teach the variable instead of a `sys_secret:` handle a code author cannot mint. ### Deviations from the order, stated - **Unset variable: the verdict stays `failed-degraded`, not `failed-credentials`.** The order says "keep the existing `failed-credentials` verdict", but the existing verdict for a code datasource connecting with no password is the driver's own connect failure, `failed-degraded`, so keeping it means `failed-degraded`. Only the reason changes: it gains "— datasource '<name>' is defined in code and reads its credential from `OS_DATASOURCE_<NAME>_PASSWORD` at connect, which is not set on this host". If the database accepts the connection without a password, nothing changes. A bound datasource still refuses the boot, and the thrown message names the variable. - **Collision refused at connect, not "at boot" as a separate pass.** Datasource names must match `/^[a-z_][a-z0-9_]*$/`, so two names that pass the schema can never derive one variable. The guard is defensive: the second connect is refused as `failed-credentials` with both names and the variable, and its variable is never read. On the boot path that refusal is a boot failure whenever the datasource is fail-fast. - **Five refusals carry the sentence, not four**: `INLINE_CREDENTIAL_REFUSED`, `URL_EMBEDDED_CREDENTIAL_REFUSED`, `URL_CREDENTIAL_QUERY_PARAM_REFUSED`, `PASSTHROUGH_INLINE_CREDENTIAL_REFUSED`, and `PASSTHROUGH_NESTED_CREDENTIAL_REFUSED`. The fifth prescribes the same "bind it instead" route and would otherwise send a code author to the same unfollowable door. The `.describe()` on `refusedInlineCredentialKey` carries it too, which is why four generated driver reference pages moved. - **Changeset levels are `patch` on all three packages, not minor.** The seat asked for service-datasource minor and cli minor, but `check-changeset-no-major` refuses a `minor` beside `Metadata-protocol: none` ("`none` -> every introduced entry `patch`"). `none` is the measured kind: no key, value or metadata type an author writes changes. ## Before / after for each string The shared sentence (`CODE_DEFINED_CREDENTIAL_ROUTE`, in `packages/spec/src/data/driver/common.zod.ts`): > 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`). Each string below is unchanged up to its last sentence. The after-text is the before-text plus ` ` + the sentence above. | String | Before ends with | After | | :--- | :--- | :--- | | `INLINE_CREDENTIAL_REFUSED` | "…always wins over anything embedded in `config`." | + the sentence | | `URL_EMBEDDED_CREDENTIAL_REFUSED` | "…do not pass through this publish door and are unaffected." | + the sentence | | `URL_CREDENTIAL_QUERY_PARAM_REFUSED` | "…do not pass through this publish door and are unaffected." | + the sentence | | `PASSTHROUGH_INLINE_CREDENTIAL_REFUSED` | "…a `${…}` span anywhere in `options` is itself refused at publish." | + the sentence | | `PASSTHROUGH_NESTED_CREDENTIAL_REFUSED` | "…a `${…}` span anywhere in `options` is itself refused at publish." | + the sentence | | `refusedInlineCredentialKey` `.describe()` | "…never stored in `config`." | + the sentence | | `ExternalDatasourceSettingsSchema` guidance `password` | "…it never dereferences a hand-written path." | + the sentence | | `DatasourceSchema` guidance `password` | "…a managed datasource included." | + the sentence | Every one keeps the Setup → Datasources and `external.credentialsRef` routes, and none carries a `#NNNN`; the test below checks both. `planCredentialMigration` code-origin remedy: - **Before:** "Move the credential in the source that defines this datasource: bind it through `external.credentialsRef`, or supply it from the runtime environment." - **After:** "Move the credential out of the source that defines this datasource: leave it out of the declaration and set `OS_DATASOURCE_<NAME>_PASSWORD` in the runtime environment, which the datasource reads at connect when the host's `OS_DATASOURCE_ENV_CREDENTIALS` switch is on (the default under `os serve` / `os start`), or bind it through `external.credentialsRef`." The real row's variable is substituted. ## Guards and their tests (each ablated) Unit pins are in `packages/services/service-datasource/src/__tests__/datasource-env-credential.test.ts` (24 tests). Kernel wiring is in `packages/runtime/src/datasource-env-credential.test.ts` (3, a real admin plugin and the real driver factory). The CLI pins are in `packages/cli/src/utils/datasource-env-credentials-default.test.ts` (4) and the spec pins in `packages/spec/src/data/driver/code-defined-credential-route.test.ts` (16). Every ablation used `scripts/ablation-replace.mjs` against the committed tree and restored to `blob == HEAD`. | Guard | Test | Ablation → red | | :--- | :--- | :--- | | A code datasource connects with the variable set | "a code datasource connects with the variable set", "through `connectDeclared`", runtime "switch on: the boot artifact's datasource…" | A8 (switch resolver returns off) → 13 red; A10 (admin wiring `isCodeDatasource` → false, rebuilt dist) → runtime 1 red | | Install-local reads the slot (trust consequence) | "a name the host registers after construction (an install-local package)", runtime `connectPackageDatasources(…, installed: true)` | A10 → runtime red | | `registerPool` / runtime-admin never reads it | "the same name created through the admin door…" | A2 (drop the `declared-auto` check) → 2 red | | Provenance, never `origin` | "a caller-set `origin: 'code'` is not provenance" | A3 (drop `isCodeDatasource`) → 1 red | | Switch off: no read | "a host that did not opt in", runtime "switch unset" | A10 (runtime); unit: no-slot case reads nothing | | Switch refuses other values | "refuses %j…" ×5, runtime "a value other than `on` / `off` refuses the boot" | A8 → 5 red | | A declared `credentialsRef` wins and stays fail-closed | "a resolving `credentialsRef` is the secret", "an unresolvable `credentialsRef`… does NOT fall back" | A4 (drop `!credentialsRef`) → 1 red | | `default` keeps `OS_DATABASE_URL` | "an `asDefault` connect never reads the slot" | A11 (drop `!opts.asDefault`) → 1 red | | Unset variable: the reason names it | "the database refusing the login answers the variable to set", "an EMPTY variable is unset" | A6 (drop the reason suffix) → 2 red | | Collision refused | "the second name is refused with both names and the variable" | A5 (disable the owner check) → 1 red | | The value in no log line or answer | "no log line, connect answer or connection state carries it" | A7 (log the value) → 1 red | | Every rewritten refusal parses its own example back | spec `describe.each` over 7 refusal families: the example name is read out of the message, the credential is left out of the very artefact that was refused, and the result parses with no secret in it | A1 (drop the sentence from the nested refusal) → 2 red; A12 (example variable drifts to `OS_DATASOURCE_WH_PASSWORD`) → 1 red | | The example is the variable the runtime derives | service "is the variable the spec refusals' worked example names" (`datasourceEnvCredentialVar(name) === variable`, and the switch name matches) | reads spec through `dist`; pinned against the rebuilt dist | | `os serve` stamps before the plugin is built | cli "stamps once…", "the stamp precedes the plugin construction" | A9 (remove the stamp) → 2 red | ## Stale text under governed paths (named, not edited) - `skills/objectstack-data/rules/datasources.md:63`: "## Credentials — one channel, fail-closed". A code datasource now has the environment channel too. - `docs/adr/0015-external-datasource-federation.md` §10 (`:764`), "Credentials never appear in metadata artefacts. `credentialsRef` points at `service-secrets`…", and the D7 amendment note (`:10`), "§10 says credentials reach a datasource only through `credentialsRef`. That stays true for a database." - `docs/adr/0062-external-datasource-runtime.md` D3 (`:92`): the connection service resolves `credentialsRef` through the `SecretBinder` before building. The env slot is a third route, after it and the D7 borrow. ## Verification All on head `5eda07f1ec` (the same tree as the measured `4b85a06c79`; only the commit trailers were reworded to the model-free pair) (base `d70e94ae`; `origin/main` moved to `c48cd2c1` meanwhile, and none of its 51 changed files is in this diff, so no merge was made). - `node scripts/pm/dispatch-gates.mjs --commands`: 110 families. All 110 were run and recorded `CMD :: exit N`, and `--ran` reconciles to "110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN". Four of them first exited 3 (prerequisite not met, nothing measured): `check-plugin-teardown-shape --self-test` on the shallow clone, and `check:skill-examples`, `check:dual-build-cjs-loads` and `check:i18n-coverage` before a full build. After `git fetch --unshallow` and `pnpm build` (115/115 tasks), all four exit 0. `check-nul-bytes`: OK. - `@objectstack/spec`: `typecheck` 0, `test` 0 (757 files, 22537 tests), `test:repo` 0 (87 files, 1448 tests). `check:generated` → `--fix` regenerated exactly the three it proved stale: `api-surface/`, `export-origins/`, and `content/docs/references/data/driver-{mongo,mysql,postgres,turso}.mdx`. - `@objectstack/service-datasource`: `typecheck` 0, `test` 0 (66 files, 1093 tests). - `@objectstack/runtime`: `typecheck` 0, `test` 0 (486 files, 7888 passed, 19 skipped). - `@objectstack/cli`: `typecheck` 0; `vitest run --project unit` 0 (354 files, 5100 tests). The new CLI pin is in the unit tier. - Workspace typecheck over the dependents: `pnpm --filter "...^@objectstack/service-datasource" typecheck` 0, covering 27 packages including dogfood, rest, hono, the examples and `downstream-contract`. The spec change is additive: one new export and longer strings. Every spec consumer under that filter, and spec's own `test`/`test:repo`, are green. --- _Generated by [Claude Code](https://claude.ai/code/session_01WGufziLJNDCTSUH9pVN32p)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 69ce01d commit e063a87

24 files changed

Lines changed: 995 additions & 32 deletions
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
'@objectstack/service-datasource': patch
3+
'@objectstack/cli': patch
4+
'@objectstack/spec': patch
5+
---
6+
7+
feat(service-datasource): a datasource defined in code reads its credential at connect from `OS_DATASOURCE_<NAME>_PASSWORD`
8+
9+
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.
10+
Metadata-protocol: none
11+
12+
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.
13+
14+
- **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.
15+
- **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.
16+
- **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.
17+
- **Unset.** The connect goes ahead without a secret as before; when the database then refuses, the reason names the variable to set.
18+
- **Two names deriving one variable** are refused at their connect.
19+
- **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.
20+
21+
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.

‎content/docs/data-modeling/drivers.mdx‎

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -944,11 +944,13 @@ export default defineStack({
944944
```
945945

946946
Each `url` must be credential-free when the stack is published: a
947-
`user:password@` URL is refused, like an inline `config.password`. Bind the
948-
password through `external.credentialsRef` instead, keeping the user in the URL
949-
(`mongodb://svc@host/db`): the bound secret is injected beside it at connect. The
950-
[read-only example](#read-only-grant-it-at-the-database-not-in-metadata) below
951-
shows the binding.
947+
`user:password@` URL is refused, like an inline `config.password`. Keep the user
948+
in the URL (`mongodb://svc@host/db`) and supply the password at runtime: a
949+
datasource declared in code reads it at connect from `OS_DATASOURCE_<NAME>_PASSWORD`
950+
(`OS_DATASOURCE_ANALYTICS_PASSWORD` here), which `os serve` / `os start` honour by
951+
default, and the value is injected beside the user. A datasource created through
952+
Setup binds it through `external.credentialsRef` instead. See
953+
[Credentials](/docs/data-modeling/external-datasources#4-credentials).
952954

953955
Then in your object definition:
954956

@@ -990,19 +992,21 @@ export const Analytics = defineDatasource({
990992
driver: 'postgres',
991993
// The connection itself cannot write. Nothing in the app can talk past it.
992994
config: { host: 'db.internal', database: 'analytics', username: 'analytics_ro' },
993-
// The account's password, bound through the secret store (see below).
994-
external: { credentialsRef: 'sys_secret:9f2c…' },
995+
// The account's password is read at connect from OS_DATASOURCE_ANALYTICS_PASSWORD.
995996
active: true,
996997
});
997998
```
998999

9991000
The read-only account's password never goes in `config`: the driver contract
1000-
refuses `config.password`, and a `user:password@` URL, at publish. Bind it
1001-
instead. The Setup → Datasources connection form's secret field encrypts it into
1002-
`sys_secret` and stores the opaque handle at `external.credentialsRef`, or you
1003-
write a handle the secret store minted there yourself. `credentialsRef` is the
1004-
one `external` key a `managed` datasource may carry; every federation key is
1005-
still refused there. See
1001+
refuses `config.password`, and a `user:password@` URL, at publish. Because this
1002+
datasource is defined in code, the runtime reads the password at connect from
1003+
`OS_DATASOURCE_ANALYTICS_PASSWORD`, derived from its name. The CLI host
1004+
(`os serve` / `os start`) reads it by default; elsewhere the host turns it on
1005+
with `OS_DATASOURCE_ENV_CREDENTIALS=on`. The same datasource created through
1006+
Setup → Datasources binds the password instead: the connection form's secret
1007+
field encrypts it into `sys_secret` and stores the opaque handle at
1008+
`external.credentialsRef`. `credentialsRef` is the one `external` key a `managed`
1009+
datasource may carry; every federation key is still refused there. See
10061010
[Credentials](/docs/data-modeling/external-datasources#4-credentials).
10071011

10081012
Note that a read-only account also refuses **DDL**, so such a datasource cannot

‎content/docs/data-modeling/external-datasources.mdx‎

Lines changed: 74 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -258,7 +258,9 @@ const policy: DatasourceConnectPolicy = {
258258
Never inline a password. Put a reference in `external.credentialsRef` and store the
259259
secret in the secret store (the same `SecretBinder` / `ICryptoProvider` the
260260
runtime-admin "Add Datasource" wizard uses). The credential is resolved to
261-
cleartext **at connect, before the driver is built**.
261+
cleartext **at connect, before the driver is built**. A datasource defined in code
262+
reads an environment variable instead; see
263+
[A datasource defined in code](#a-datasource-defined-in-code-the-environment-variable).
262264

263265
The shipped binder encrypts the cleartext into a `sys_secret` row and mints an
264266
**opaque** handle — `sys_secret:<id>` — as the `credentialsRef`; that is the only
@@ -274,6 +276,77 @@ for the datasources listed under [When auto-connect fails](#when-auto-connect-fa
274276
and degrades with a warning otherwise. (Credential-less drivers such as SQLite
275277
simply have no `credentialsRef`.)
276278

279+
### A datasource defined in code: the environment variable
280+
281+
The handle above is minted by the Setup → Datasources form, which cannot edit a
282+
datasource defined in code (`defineDatasource`, or a `*.datasource.ts` file in a
283+
package). A code author leaves the credential out of the declaration entirely,
284+
and the runtime reads it **at connect** from an environment variable whose name
285+
the platform derives from the datasource name:
286+
287+
| Datasource name | Variable read at connect |
288+
| :--- | :--- |
289+
| `warehouse` | `OS_DATASOURCE_WAREHOUSE_PASSWORD` |
290+
| `crm_replica` | `OS_DATASOURCE_CRM_REPLICA_PASSWORD` |
291+
292+
The name is upper-cased and every character outside `A-Z0-9` becomes `_`. The
293+
value is handed to the driver as the connection's secret: the password for
294+
`postgres`, `mysql` and `mongodb`, the `authToken` for `turso`. It is never
295+
logged, stored or answered.
296+
297+
```typescript
298+
import { defineDatasource } from '@objectstack/spec/data';
299+
300+
export const Warehouse = defineDatasource({
301+
name: 'warehouse',
302+
driver: 'postgres',
303+
// No password here: it is read from OS_DATASOURCE_WAREHOUSE_PASSWORD at connect.
304+
config: { host: 'db.internal', database: 'warehouse', username: 'svc_app' },
305+
});
306+
```
307+
308+
```bash
309+
OS_DATASOURCE_WAREHOUSE_PASSWORD='…' os start
310+
```
311+
312+
What the slot does and does not do:
313+
314+
- **It is a host switch, `OS_DATASOURCE_ENV_CREDENTIALS`, which takes `on` or
315+
`off`.** `os serve`, `os start` and `os dev` turn it on unless you set it
316+
yourself. Every other composition (a host that builds its own kernel, a cloud
317+
runtime) leaves it off, so the variable is never read there. Any other value
318+
is refused at boot.
319+
- **It only serves datasources the host registers from code.** That means the
320+
ones in the booted artifact and in packages installed locally. A datasource
321+
created through Setup or the API under the same name never reads the
322+
variable. Nor does the `default` datasource, which keeps `OS_DATABASE_URL`.
323+
- **A declared credential wins.** With `external.credentialsRef` or
324+
`external.connector` declared, the variable is not consulted. If that
325+
reference fails to resolve, the datasource stays unconnected (fail-closed, as
326+
above) and does not fall back to the environment.
327+
- **An unset variable leaves the connect as it was.** The driver connects
328+
without a secret; if the database refuses, the datasource is unconnected and
329+
its reason names the variable to set.
330+
- **Two datasources that derive one variable are refused.** Valid datasource
331+
names are lower-case, so this cannot happen between two declarations that
332+
pass the schema; the guard covers a host that hands `connect()` other names.
333+
- **A plugin driver that resolves its credential per request** (the OData
334+
driver resolves `credentialsRef` that way) does not read this slot: it feeds
335+
only the secret a driver receives when its connection is built.
336+
337+
<Callout type="warn">
338+
**Who can read the variable.** The derived name is the only key, so any code
339+
datasource the host registers under that name reads it. Under `os serve` that
340+
includes every package installed into the environment: **a package can declare
341+
a datasource of any free name and so read the matching
342+
`OS_DATASOURCE_<NAME>_PASSWORD`**. Install only packages you trust with the
343+
credentials in that process's environment. On a host where several tenants'
344+
artifacts share one process, a process-wide variable would be readable by every
345+
tenant artifact that declares the name. That is why the switch is off outside the
346+
CLI host; turn it on there only when one operator owns every artifact the process
347+
loads.
348+
</Callout>
349+
277350
### Borrowing a connector instance's credential
278351

279352
An **API-backed** datasource, one served by a plugin driver such as the

‎content/docs/references/data/driver-mongo.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ MongoDB Connection Configuration
4747
| **host** | `string` | optional (default: `"localhost"`) | Host address |
4848
| **port** | `integer` | optional (default: `27017`) | Port number |
4949
| **username** | `string` | optional | Authentication user |
50-
| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` |
50+
| **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`). |
5151
| **authSource** | `string` | optional | Authentication database |
5252
| **options** | `Record<string, any>` | 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. |
5353

‎content/docs/references/data/driver-mysql.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ MySQL / MariaDB connection configuration
4848
| **port** | `integer` | optional (default: `3306`) | Port number |
4949
| **database** | `string` | optional | Database name |
5050
| **username** | `string` | optional | Authentication user |
51-
| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` |
51+
| **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`). |
5252
| **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. |
5353
| **autoMigrate** | `Enum<'off' \| 'safe'>` | optional | Dev-only non-destructive schema self-heal |
5454

‎content/docs/references/data/driver-postgres.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ PostgreSQL connection configuration
4646
| **port** | `integer` | optional (default: `5432`) | Port number |
4747
| **database** | `string` | optional | Database name |
4848
| **username** | `string` | optional | Authentication user |
49-
| **password** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` |
49+
| **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`). |
5050
| **ssl** | `boolean` | optional | Enable TLS. Certificates go in the datasource-level `ssl` block. |
5151
| **schema** | `string` | optional (default: `"public"`) | Default schema (knex searchPath) |
5252
| **applicationName** | `string` | optional | Postgres application_name |

‎content/docs/references/data/driver-turso.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ Turso / libSQL Connection Configuration
6767
| Property | Type | Required | Description |
6868
| :--- | :--- | :--- | :--- |
6969
| **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: |
70-
| **authToken** | `never` | optional | Set through the connection form's secret field or `external.credentialsRef` — encrypted into `sys_secret`, never stored in `config` |
70+
| **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`). |
7171
| **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. |
7272
| **concurrency** | `integer` | optional | Maximum concurrent requests to the remote database |
7373
| **syncUrl** | `string` | optional | Remote sync URL for embedded-replica mode: a libsql or https Turso endpoint |

‎packages/cli/src/commands/serve.ts‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import fs from 'fs';
66
import net from 'net';
77
import chalk, { chalkStderr } from 'chalk';
88
import { bundleRequire } from 'bundle-require';
9+
import { stampDatasourceEnvCredentialsDefault } from '../utils/datasource-env-credentials-default.js';
910
import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js';
1011
import { mergeBootConfig } from '../utils/merge-boot-config.js';
1112
import { provisionTelemetryDatasource, standaloneTelemetryPrimary } from '../utils/telemetry-datasource.js';
@@ -5279,6 +5280,14 @@ export default class Serve extends Command {
52795280
registerDatasourceAdminRoutes,
52805281
} = adminMod;
52815282

5283+
// [#27687] This host boots one operator's artifact, so a code-defined
5284+
// datasource may read its credential from OS_DATASOURCE_{NAME}_PASSWORD
5285+
// at connect. Stamped before the admin plugin's `init()` reads the
5286+
// switch; an explicit operator value (`off` included) wins.
5287+
if (typeof adminMod.DATASOURCE_ENV_CREDENTIALS_SWITCH === 'string') {
5288+
stampDatasourceEnvCredentialsDefault(process.env, adminMod.DATASOURCE_ENV_CREDENTIALS_SWITCH);
5289+
}
5290+
52825291
if (
52835292
DatasourceAdminServicePlugin &&
52845293
!hasPluginMatching(['com.objectstack.service-datasource-admin', 'DatasourceAdminServicePlugin'])
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* #27687 — the CLI host turns the code-datasource env-credential switch on by
5+
* default; every other composition leaves it off. Two halves: the stamp keeps
6+
* an operator's explicit value, and `os serve` (which `os start` and `os dev`
7+
* run) stamps it before the datasource admin plugin is built, whose `init()`
8+
* reads the switch.
9+
*/
10+
11+
import { readFileSync } from 'node:fs';
12+
import { dirname, join } from 'node:path';
13+
import { fileURLToPath } from 'node:url';
14+
import { describe, expect, it } from 'vitest';
15+
import { stampDatasourceEnvCredentialsDefault } from './datasource-env-credentials-default.js';
16+
17+
const SWITCH = 'OS_DATASOURCE_ENV_CREDENTIALS';
18+
19+
describe('stampDatasourceEnvCredentialsDefault', () => {
20+
it('stamps `on` when the switch is unset or empty', () => {
21+
for (const env of [{}, { [SWITCH]: '' }, { [SWITCH]: ' ' }] as Record<string, string | undefined>[]) {
22+
expect(stampDatasourceEnvCredentialsDefault(env, SWITCH)).toBe(true);
23+
expect(env[SWITCH]).toBe('on');
24+
}
25+
});
26+
27+
it("keeps an operator's explicit value — `off`, `on`, and an invalid one the boot then refuses", () => {
28+
for (const value of ['off', 'OFF', 'on', 'true']) {
29+
const env: Record<string, string | undefined> = { [SWITCH]: value };
30+
expect(stampDatasourceEnvCredentialsDefault(env, SWITCH)).toBe(false);
31+
expect(env[SWITCH]).toBe(value);
32+
}
33+
});
34+
});
35+
36+
describe('`os serve` stamps the default before the datasource admin plugin is built', () => {
37+
const source = readFileSync(join(dirname(fileURLToPath(import.meta.url)), '..', 'commands', 'serve.ts'), 'utf8');
38+
39+
it('stamps once, into the process environment, under the name the admin module exports', () => {
40+
const calls = source.match(/stampDatasourceEnvCredentialsDefault\(/g) ?? [];
41+
expect(calls).toHaveLength(1);
42+
expect(source).toContain(
43+
'stampDatasourceEnvCredentialsDefault(process.env, adminMod.DATASOURCE_ENV_CREDENTIALS_SWITCH);',
44+
);
45+
});
46+
47+
it('the stamp precedes the plugin construction', () => {
48+
const stamp = source.indexOf('stampDatasourceEnvCredentialsDefault(process.env');
49+
const construct = source.indexOf('new DatasourceAdminServicePlugin(');
50+
expect(stamp).toBeGreaterThan(0);
51+
expect(construct).toBeGreaterThan(stamp);
52+
});
53+
});

0 commit comments

Comments
 (0)