Skip to content

Commit 0a52b7d

Browse files
feat(cli): os serve publishes its datasource secret store's resolve as the datasource-secret-resolver service, so a stack's OData plugin resolves a sys_secret credentialsRef with no code (#27952)
epic:#27261#1 · marchtian · session_017ihL2fy9PSGEZJdJpoPvzx <!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSi7GiMpxi6VR7nKYEhHVxChB)_ **Fable review owed.** Developed on Opus while the Fable quota is out. `Clause-②: yes (widening)` (a new published kernel service), so it lands after a contract review PASS on the current head. Fixes #27875 Clause-②: yes (widening) Metadata-protocol: none ## 维护者速读 - **改了什么:** `os serve` 把它为数据源建的密钥解析器的「读」那一半(`resolve`)注册成内核服务 `datasource-secret-resolver`。OData 插件没拿到 `options.secrets` 时,在第一个请求时去读这个服务,于是 `credentialsRef: 'sys_secret:…'` 在 `os serve` 上不用写代码就能解析。显式传的 `options.secrets` 仍然优先;两者都没有时照旧拒绝请求,什么都不发出去,报错里点名这个服务。 - **为什么改:** 声明式 OData 的卖点是只写元数据。凭据这一步要作者自己写 `resolve`,卖点就破了(#27871 写指南时发现)。 - **风险与回滚:** 只发布 `resolve`,`bind`/`unbind` 仍只在数据源管理插件手里;它只解得开 `datasource_credential` 作用域封的行,设置项密钥和密钥字段的行解不开(有测试)。没有加密提供者时 `os serve` 不建解析器,也就不注册服务。进程内任何插件都能调这个 `resolve`;为什么可以接受(信任边界是进程,引擎的 `resolveSecret` 已对进程内调用方解密密钥字段),见 What changes 一节。回滚即 revert。 - **文档:** OData 指南(`odata-datasources.mdx`,#27871 已落地)去掉了「`os serve` 下要自己传 `resolve`」的说明,改为:`os serve` 下不传 `secrets`,凭据走服务端自己的密钥存储。 - **先看:** `packages/types/src/datasource-secret-resolver.ts`(服务名、接口、插件驱动该怎么读);`packages/cli/src/commands/serve.ts` 的 `createDatasourceSecretResolverPlugin` 和挂载处;`packages/drivers/driver-odata/src/plugin.ts` 的 `hostSecretResolver`。 - **两分钟验证:** `pnpm --filter @objectstack/cli exec vitest run src/commands/serve-datasource-secret-resolver.test.ts`,预期 5 个用例全绿:真内核加真 binder,绑定的凭据经服务解出原文,服务上只有 `resolve`,别的生产者封的行解不开。 - **需要您做的:** 无。契约复审 PASS 后落地。 ## What changes **The service and its contract** (`packages/types/src/datasource-secret-resolver.ts`): `DATASOURCE_SECRET_RESOLVER_SERVICE` (`'datasource-secret-resolver'`) and `IDatasourceSecretResolver` (`resolve(credentialsRef)`, answering `undefined` for a handle that is not the binder's, a missing row or a decrypt failure). The module states how every plugin driver that dereferences an `external.credentialsRef` reads it: an explicit secret store handed to the plugin first; the service looked up at the first request, never at `init()` (AGENTS.md, "Startup registry reads"); fail-closed when neither resolves. It lives in `@objectstack/types` because the producer (`os serve`) and the consumers (plugin drivers, in this repository or out of it) never import each other, and the package depends only on `@objectstack/spec`. The card asked whether other plugin drivers should read it the same way: yes, and the module is where that is written down. **The producer** (`packages/cli/src/commands/serve.ts`): `createDatasourceSecretResolverPlugin(secrets)` (plugin `com.objectstack.cli.datasource-secret-resolver`, `providesServices: ['datasource-secret-resolver']`) registers a resolver whose one method calls the binder's `resolve`. `run()` mounts it right after `DatasourceAdminServicePlugin`, with the same binder, and only when a binder was built. No crypto provider means no binder, so no service, and a driver's refusal names the service. **The consumer** (`packages/drivers/driver-odata/src/plugin.ts`, the `secrets` fallback only): `const store = secrets ?? hostSecretResolver(ctxRef)`, evaluated inside the per-request `credential()` provider. `hostSecretResolver` catches the kernel's throw on an unregistered name, so the miss reaches the plugin's own refusal, not the kernel's "not found" text. The refusal message gains the service name and the two remedies (run under `os serve`, or pass `options.secrets`). It reads the service through `@objectstack/types`, which the package already depends on since #27819. **The guide** (`content/docs/data-modeling/odata-datasources.mdx`, landed with #27871 while this was in progress): the mount example drops its `secrets: erpSecretStore` line and import; the option table says an absent `secrets` falls back to the host's `datasource-secret-resolver` service, which `os serve` registers; the Credentials section replaces the "pass your own resolve on os serve" caveat with the two cases (under `os serve`, pass nothing; a host composing its own kernel passes `secrets` or registers the service) and the fail-closed list says "neither `secrets` nor the service". **Who can call `resolve`, and why that is acceptable.** Any plugin in the process can read the service and call `resolve()` on any `sys_secret:` handle. It is not narrowed to the calling datasource's own `credentialsRef`, because the service cannot tell who calls it, and the measured alternatives narrow nothing real: - **The trust boundary is the process, not the plugin.** A plugin is code the operator installed and runs with the kernel's own reach: it can read `sys_secret` rows under the system context through the data engine, and the engine's public `resolveSecret` already decrypts a secret field's `secret:` reference for any in-process caller (`packages/objectql/src/engine.ts`, `resolveSecret`). This service adds no reach of a new kind; it adds the datasource credentials, through the binder the datasource admin already holds. - **What it can open is bounded by scope.** The binder decrypts under the `datasource_credential` crypto scope alone, so a settings secret or a secret field's row fails authentication and answers `undefined` (pinned in the new cli test). `bind` and `unbind` are not on the service. - **"Only a handle some declared datasource names" would not narrow it.** Every datasource definition, with its `credentialsRef`, is readable by any plugin through the engine's `listDatasourceDefs()`, so such a check excludes only orphaned handles while adding a second read on every request. A per-caller restriction needs a caller identity the kernel's service registry does not carry. - **The alternative a stricter host has** is to not publish it: `os serve` mounts it only when it mounts the datasource admin itself, and a host composing its own kernel registers the service or passes `secrets` explicitly. **Ledger:** `scripts/engine-double-contract.pinned.json` gains the new test's `sys_secret` double (its `delete` routes through `assertEngineDeleteDispatch`), written by `check-engine-double-contract.mjs --write` as the gate asks. **Changeset:** `patch` on `@objectstack/types`, `@objectstack/cli` and `@objectstack/driver-odata` (`Metadata-protocol: none` takes patch). ## Why Epic #27261 P2 (ruling 6486145005, Tier B = declarative OData). Found by #27747's carrier (#27871, os-dev-report 6513440511): on `origin/main`, `serve.ts` builds `createDatasourceSecretBinder` and hands it only to `DatasourceAdminServicePlugin`, so a stack's OData plugin cannot resolve a `sys_secret:` handle under `os serve`. ## Verification On `cf00041b` (`origin/main` merged in three times, last after #27819 landed at `ed8b2375`; #27871 and the driver-odata conformance kit came in the merge before), after `pnpm install --frozen-lockfile` and a full build of `./packages/**` (exit 0): - **New tests.** `packages/cli/src/commands/serve-datasource-secret-resolver.test.ts` (5): inside a real `ObjectKernel` over a real binder (`createDatasourceSecretBinder` with `LocalCryptoProvider`), a credential bound through the binder resolves through the service to its cleartext; the service's only key is `resolve`; a settings-scoped row in the same `sys_secret` table, a missing row and a foreign handle all answer `undefined`; `providesServices` names the service; and a source pin (comments masked) that `run()` mounts the plugin inside `if (secrets)` with that binder and defines it once. `packages/drivers/driver-odata/src/plugin.test.ts` (5 new): the handle resolves through the host's service with no `options.secrets` and the service is asked exactly once; an explicit `options.secrets` wins and the service is never asked; a handle the service does not resolve is refused with nothing sent; a service registered after the plugin's `init` is seen at the first request; and on a real kernel with neither, the refusal is the plugin's own message naming the service, never the kernel's text, with nothing sent. The existing "no secret store" refusal row is unchanged. - **Ablations, each red then restored** (`git status --porcelain` empty after each): the fallback removed (`const store = secrets`): 3 failed; precedence flipped (service before `options.secrets`): 1 failed; the `getService` try/catch removed: 1 failed (the real-kernel case); the whole binder registered instead of the read half: 1 failed. - **Suites:** `driver-odata` 732 passed (the connector-credential cases included); `types` 832 passed; `cli` the two `serve-datasource-*` files, 11 passed. `typecheck` exit 0 for `types`, `driver-odata` and `cli` (the cli run includes `check:test-typecheck`). - **Local gates:** `node scripts/pm/dispatch-gates.mjs --commands` derived 101 families from the changed files on that head; every one was run with its exit code recorded before any pipe, and `dispatch-gates --ran` reads "101 derived, 101 run, 0 NOT-MEASURED (a DERIVED zero)" (112 of 112 on the pre-#27819 head `33bc41fb`). PR CI on `cf00041b`: every required check `success`. An earlier pass on the pre-guide head was red on three gates about the new test's double, all fixed in the diff: `check:engine-double-contract` (the double's `delete` now routes through `assertEngineDeleteDispatch`, ledger row written), `check:where-matcher` (the operator refusal moved inside the matcher callback) and `check:cross-package-test-inputs` (a doc comment named another package's test path). The full battery (`pnpm lint`, Test Core shards, UI jobs) is CI's. No dogfood run: no `!` in the title and no `(narrowing)` arm. ## Acceptance notes - **#27819 (#27746) landed first** (`ed8b2375`), so this PR merged `main` and resolved the one conflict in `plugin.ts`: the header keeps both sections (the `credentialsRef` channel with its service fallback, and the borrowed connector credential), one `@objectstack/types` import carries both service names, and `credential()` keeps `secrets ?? hostSecretResolver(ctxRef)` beside `borrowed()`. In `plugin.test.ts` the new cases take `main`'s `boot(options, defs, { services })` signature. The `@objectstack/types` dependency and its lockfile lines are now `main`'s, so neither is in this diff. - **The README** (`packages/drivers/driver-odata/README.md`, added at the epic PM's request): the mount example no longer passes `secrets` and says the handle resolves through the host's service, and the Config table gains the `secrets` row. This edit rides in the merge commit `cf00041b`. - **One file outside the claim's list:** `scripts/engine-double-contract.pinned.json` (the ledger row the gate requires for the new test double; it only grows coverage). - **How this meets #27819 (#27746).** #27819 reshapes `plugin.ts` around a `definition()` helper and a `borrowed()` connector path; this PR edits only the `if (!secrets)` block inside `credential()` plus one helper and one import, so the two should meet as a small textual conflict in `credential()`. Whichever lands second takes the other's lines; the package.json and lockfile lines for `@objectstack/types` are identical in both. The `types/src/index.ts` export sits away from #27819's hunk. - **A stack that registers its own `datasource-secret-resolver`** collides with `os serve`'s at boot (`Service 'datasource-secret-resolver' already registered`), loudly. `os serve` mounts its plugin only when it mounts the datasource admin itself, so a stack that brings its own admin plugin gets no second one. - **Worktree name:** the claim names `objectstack-issue-27875`; the worktree is `objectstack-27875` on the claimed branch. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (Not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: not touched. - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English (the 维护者速读 block is the repo's own Chinese reader section). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_017ihL2fy9PSGEZJdJpoPvzx --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent d5951c8 commit 0a52b7d

10 files changed

Lines changed: 423 additions & 32 deletions

File tree

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@objectstack/types': patch
3+
'@objectstack/cli': patch
4+
'@objectstack/driver-odata': patch
5+
---
6+
7+
feat: `os serve` publishes its datasource secret store's `resolve` as the kernel service `datasource-secret-resolver`, and `driver-odata` reads it when it is passed no secret store
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: none
11+
12+
A stack served by `os serve` that mounts `createODataDriverPlugin` could not resolve its datasource's `external.credentialsRef`: the server's `sys_secret` store was handed only to the datasource admin, so a `sys_secret:` handle stored through Setup → Datasources did not resolve for the plugin, and the stack had to pass a `resolve` of its own. It now resolves with no code:
13+
14+
```ts
15+
plugins: [createODataDriverPlugin({ name: 'erp', serviceRoot: 'https://erp.example.com/odata/', auth: { scheme: 'bearer' } })],
16+
datasources: [{ name: 'erp', driver: 'erp', config: {}, schemaMode: 'external',
17+
external: { credentialsRef: 'sys_secret:…' } }],
18+
// no `secrets` option: under os serve the handle resolves through the server's own store
19+
```
20+
21+
- **`@objectstack/cli`**: `os serve` registers the read half of the datasource secret binder it builds as the kernel service `datasource-secret-resolver` (plugin `com.objectstack.cli.datasource-secret-resolver`). Only `resolve` is published; storing and removing a credential stays the datasource admin's. It decrypts datasource credentials only: a settings secret or a secret field's row in `sys_secret` does not open through it. When `os serve` builds no binder (no crypto provider), the service is not registered.
22+
- **`@objectstack/driver-odata`**: when `options.secrets` is absent, the plugin reads the `datasource-secret-resolver` service at the first request. An explicit `options.secrets` still wins. With neither, the request is refused as before, sent to nobody, and the message now names the service.
23+
- **`@objectstack/types`**: `DATASOURCE_SECRET_RESOLVER_SERVICE` and `IDatasourceSecretResolver`, the contract a host publishes the resolver under and every plugin driver that dereferences a `credentialsRef` reads it by: an explicit store first, the service looked up at the first request (never at `init`), fail-closed when neither resolves.

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

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -43,16 +43,14 @@ The plugin builds the driver from its options and publishes it as the service
4343
```typescript
4444
import { defineStack } from '@objectstack/spec';
4545
import { createODataDriverPlugin } from '@objectstack/driver-odata';
46-
import { erpSecretStore } from './secrets'; // your host's secret store; see Credentials
4746

4847
export default defineStack({
4948
// …the datasource and objects of step 2…
5049
plugins: [
5150
createODataDriverPlugin({
5251
name: 'erp', // = the datasource name
5352
serviceRoot: 'https://erp.example.com/odata/v4/sales/',
54-
auth: { scheme: 'bearer' },
55-
secrets: erpSecretStore,
53+
auth: { scheme: 'bearer' }, // under os serve, no `secrets`: see Credentials
5654
}),
5755
],
5856
});
@@ -64,7 +62,7 @@ export default defineStack({
6462
| `serviceRoot` | (required) | The service root URL, http or https, with no credentials and no query string. |
6563
| `version` | `4.0` | The protocol version. `4.0` is the only value. |
6664
| `auth` | `{ scheme: 'none' }` | `none`, `bearer`, or `basic` with a `username`. The secret is the datasource's `external.credentialsRef`. |
67-
| `secrets` | (none) | Where `credentialsRef` is resolved: an object with `resolve(credentialsRef)`. Required for `bearer` and `basic`. |
65+
| `secrets` | (none) | Where `credentialsRef` is resolved: an object with `resolve(credentialsRef)`. When absent, the host's `datasource-secret-resolver` service is used; `os serve` registers one. |
6866
| `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). |
6967
| `requestTimeoutMs` | `30000` | The timeout of each request. |
7068

@@ -167,7 +165,7 @@ What each piece does on this driver:
167165

168166
⛔ Never put a credential in the stack, in the plugin options, or in the `serviceRoot`
169167
URL. The secret is the datasource's `external.credentialsRef`, and the plugin resolves it
170-
through the `secrets` store you pass:
168+
through the host's secret store:
171169

172170
- **`bearer`** sends `Authorization: Bearer <secret>`.
173171
- **`basic`** takes `username` from the options and the password from the secret.
@@ -180,20 +178,23 @@ old token fails. The driver does not obtain or refresh tokens itself. A service
180178
issues short-lived OAuth2 access tokens needs something that keeps the stored secret
181179
current.
182180

183-
`secrets` is any object with `resolve(credentialsRef)`, answering the secret or
184-
`undefined`:
181+
Which store resolves it:
185182

186-
- **A host that composes its own kernel** passes the same binder it gives
187-
`DatasourceAdminServicePlugin`. A `sys_secret:<id>` handle that the Add Datasource
188-
wizard minted then resolves, as on any federated datasource
183+
- **Under `os serve`**, pass no `secrets`. The server publishes its own datasource secret
184+
store as the kernel service `datasource-secret-resolver`, and the plugin reads it at the
185+
first request. A `sys_secret:<id>` handle that the Add Datasource wizard minted
186+
resolves, as on any federated datasource
189187
([Credentials](/docs/data-modeling/external-datasources#4-credentials)).
190-
- **`os serve`** builds its secret store internally and does not hand it to a stack's
191-
plugins. Pass a `resolve` backed by your host's own secret handling instead, and
192-
declare `credentialsRef` as a handle that it resolves.
188+
- **A host that composes its own kernel** passes `secrets`: the same binder it gives
189+
`DatasourceAdminServicePlugin`, or any object with `resolve(credentialsRef)` answering
190+
the secret or `undefined`. It can instead register that `resolve` as the
191+
`datasource-secret-resolver` service (`DATASOURCE_SECRET_RESOLVER_SERVICE` in
192+
`@objectstack/types`), which every plugin driver reading a `credentialsRef` uses the
193+
same way. A `secrets` option passed to the plugin always wins over the service.
193194

194195
Resolution fails closed. Each of these refuses the request rather than send it without
195-
the credential: an auth scheme with no `credentialsRef`, a `credentialsRef` with no
196-
`secrets`, and a handle that does not resolve. The caller is answered
196+
the credential: an auth scheme with no `credentialsRef`, a `credentialsRef` with neither
197+
`secrets` nor the service, and a handle that does not resolve. The caller is answered
197198
`503 ERR_DATASOURCE_UNAVAILABLE`, and the cause goes to the server log.
198199

199200
## 4. What the service is asked
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os serve` publishes its datasource secret binder's read half as the kernel
5+
* service `datasource-secret-resolver`, so a plugin driver the stack composes
6+
* (`@objectstack/driver-odata`) resolves a datasource's
7+
* `external.credentialsRef` through the host's own `sys_secret` store.
8+
*
9+
* ## The gap
10+
*
11+
* `serve.ts` built the binder inline and handed it only to
12+
* `DatasourceAdminServicePlugin`. No service exposed it, so under `os serve` a
13+
* stack's OData plugin had no way to dereference a `sys_secret:` handle the
14+
* datasource admin had stored, and the stack author had to write a `resolve`
15+
* of their own.
16+
*
17+
* ## What this file pins
18+
*
19+
* The plugin runs inside a REAL `ObjectKernel` over a REAL binder
20+
* (`createDatasourceSecretBinder` with `LocalCryptoProvider`):
21+
*
22+
* 1. a credential bound through the binder resolves through the service, to
23+
* its cleartext;
24+
* 2. the service is the read half only — no `bind`, no `unbind`;
25+
* 3. a `sys_secret` row another producer sealed (a settings secret) does not
26+
* open through it, and neither does a handle that is not the binder's;
27+
* 4. the wiring inside `run()`: the plugin is mounted only when a binder was
28+
* built, with that binder, and the plugin is defined once.
29+
*
30+
* The driver's side (reading the service at its first request, an explicit
31+
* `options.secrets` winning, the fail-closed refusal with no service) is
32+
* pinned in `@objectstack/driver-odata`'s own plugin tests.
33+
*/
34+
35+
import { describe, expect, it } from 'vitest';
36+
import { readFileSync } from 'node:fs';
37+
import { resolve } from 'node:path';
38+
import { fileURLToPath } from 'node:url';
39+
40+
import { ObjectKernel } from '@objectstack/core';
41+
import { assertEngineDeleteDispatch } from '@objectstack/metadata-core';
42+
import { createDatasourceSecretBinder } from '@objectstack/service-datasource';
43+
import { LocalCryptoProvider } from '@objectstack/service-settings';
44+
import { DATASOURCE_SECRET_RESOLVER_SERVICE, type IDatasourceSecretResolver } from '@objectstack/types';
45+
46+
// The repo's ONE code/prose separator: a comment naming the factory must not
47+
// answer for code that does not call it.
48+
import { maskComments } from '../../../../scripts/js-comment-mask.mjs';
49+
50+
import { createDatasourceSecretResolverPlugin } from './serve.js';
51+
52+
/** …/packages/cli/src/commands — seeded from `import.meta.url`. */
53+
const HERE = resolve(fileURLToPath(import.meta.url), '..');
54+
/** `serve.ts`'s CODE, with every comment span blanked. */
55+
const SERVE = maskComments(readFileSync(resolve(HERE, 'serve.ts'), 'utf8'));
56+
57+
const KEY = Buffer.from('404142434445464748494a4b4c4d4e4f505152535455565758595a5b5c5d5e5f', 'hex');
58+
59+
/** The one service a kernel refuses to start without. Nothing here reads data. */
60+
const DATA_STUB = {
61+
name: 'com.example.datasource-secret-resolver.data-stub',
62+
version: '1.0.0',
63+
init: async (ctx: { registerService(name: string, service: unknown): void }) => { ctx.registerService('data', {}); },
64+
start: async () => {},
65+
};
66+
67+
/** An in-memory `sys_secret` table with the engine surface the binder calls. */
68+
function secretStore() {
69+
const rows = new Map<string, Record<string, unknown>>();
70+
return {
71+
rows,
72+
async insert(_object: string, data: Record<string, unknown>) { rows.set(String(data.id), data); return data; },
73+
async delete(_object: string, options: { where: Record<string, unknown> }) {
74+
assertEngineDeleteDispatch(options);
75+
rows.delete(String(options.where.id));
76+
},
77+
async find(_object: string, query: Record<string, unknown>) {
78+
const where = (query.where ?? {}) as Record<string, unknown>;
79+
const hits = [...rows.values()].filter((row) => Object.entries(where).every(([k, v]) => {
80+
if (k.startsWith('$') || (v !== null && typeof v === 'object')) {
81+
throw new Error('only plain equality filters are implemented by this double');
82+
}
83+
return row[k] === v;
84+
}));
85+
return typeof query.limit === 'number' ? hits.slice(0, query.limit) : hits;
86+
},
87+
};
88+
}
89+
90+
/** Boot a real kernel with the resolver plugin over a real binder; hand back the service and the binder. */
91+
async function boot() {
92+
const engine = secretStore();
93+
const cryptoProvider = new LocalCryptoProvider({ key: KEY });
94+
const binder = createDatasourceSecretBinder({ engine, cryptoProvider });
95+
const kernel = new ObjectKernel({ logger: { level: 'silent' }, gracefulShutdown: false });
96+
await kernel.use(DATA_STUB);
97+
await kernel.use(createDatasourceSecretResolverPlugin(binder));
98+
await kernel.bootstrap();
99+
const resolver = kernel.getService<IDatasourceSecretResolver>(DATASOURCE_SECRET_RESOLVER_SERVICE);
100+
return { kernel, engine, cryptoProvider, binder, resolver };
101+
}
102+
103+
describe('createDatasourceSecretResolverPlugin — the binder\'s read half as a kernel service', () => {
104+
it('a credential bound through the binder resolves through the service', async () => {
105+
const { kernel, binder, resolver } = await boot();
106+
try {
107+
const ref = await binder.bind({ value: 'token-from-sys-secret' }, { name: 'trippin' });
108+
expect(ref).toMatch(/^sys_secret:/);
109+
expect(await resolver.resolve(ref)).toBe('token-from-sys-secret');
110+
} finally {
111+
await kernel.shutdown();
112+
}
113+
});
114+
115+
it('publishes resolve and nothing else of the binder', async () => {
116+
const { kernel, resolver } = await boot();
117+
try {
118+
expect(Object.keys(resolver)).toEqual(['resolve']);
119+
} finally {
120+
await kernel.shutdown();
121+
}
122+
});
123+
124+
it('opens no row another producer sealed, and no handle that is not the binder\'s', async () => {
125+
const { kernel, engine, cryptoProvider, resolver } = await boot();
126+
try {
127+
// A settings secret in the same table, sealed under its own scope.
128+
const handle = await cryptoProvider.encrypt('settings-cleartext', { scope: 'settings', namespace: 'mail', key: 'smtp_password' });
129+
await engine.insert('sys_secret', {
130+
id: handle.id, namespace: 'mail', key: 'smtp_password',
131+
kms_key_id: handle.kmsKeyId, alg: handle.alg, version: handle.version, ciphertext: handle.ciphertext,
132+
});
133+
expect(await resolver.resolve(`sys_secret:${handle.id}`)).toBeUndefined();
134+
expect(await resolver.resolve('sys_secret:missing')).toBeUndefined();
135+
expect(await resolver.resolve('vault:somewhere-else')).toBeUndefined();
136+
} finally {
137+
await kernel.shutdown();
138+
}
139+
});
140+
141+
it('declares the service it registers (ADR-0116), so a consumer that requires it can name the provider', () => {
142+
expect(createDatasourceSecretResolverPlugin({ resolve: async () => undefined }).providesServices)
143+
.toEqual([DATASOURCE_SECRET_RESOLVER_SERVICE]);
144+
});
145+
});
146+
147+
describe('the wiring inside `run()`', () => {
148+
it('mounts the plugin only when a binder was built, handing it that binder', () => {
149+
expect(SERVE).toMatch(
150+
/if \(secrets\) \{\s*await kernel\.use\(createDatasourceSecretResolverPlugin\(secrets\)\);/,
151+
);
152+
expect(SERVE.match(/'com\.objectstack\.cli\.datasource-secret-resolver'/g) ?? [], 'a second inline copy of the plugin').toHaveLength(1);
153+
});
154+
});

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

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ import {
2929
// posture is the authoritative knob and `resolveTenancyPosture()` already folds
3030
// the legacy boolean in as its unset-fallback. serve's last direct reader of the
3131
// boolean was the banner, and that was exactly the drift #4801 fixed.
32-
import { readEnvWithDeprecation, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, stampSearchPinyinEnabled, isModuleNotFoundError } from '@objectstack/types';
32+
import { readEnvWithDeprecation, resolveTenancyPosture, resolveAllowDegradedTenancy, isMcpServerEnabled, stampSearchPinyinEnabled, isModuleNotFoundError, DATASOURCE_SECRET_RESOLVER_SERVICE, type IDatasourceSecretResolver } from '@objectstack/types';
3333
import { PLATFORM_CAPABILITY_TOKENS, PLATFORM_ALWAYS_ON_CAPABILITIES, RETIRED_PLATFORM_CAPABILITY_GUIDANCE } from '@objectstack/spec/kernel';
3434
// [#22301] The `requires` token → provider table and its exact identity match,
3535
// shared with `@objectstack/verify`'s `bootStack` — see `Serve.CAPABILITY_PROVIDERS`
@@ -5333,6 +5333,16 @@ export default class Serve extends Command {
53335333
);
53345334
trackPlugin('DatasourceAdminServicePlugin');
53355335

5336+
// The binder's read half, published as a kernel service so a plugin
5337+
// driver the stack composes (`@objectstack/driver-odata`) resolves
5338+
// its datasource's `external.credentialsRef` through this same
5339+
// store. Only when a binder was built: without one the service is
5340+
// absent, and such a driver refuses the request, naming it.
5341+
if (secrets) {
5342+
await kernel.use(createDatasourceSecretResolverPlugin(secrets));
5343+
trackPlugin('DatasourceSecretResolver');
5344+
}
5345+
53365346
// REST routes under /api/v1/datasources, mounted by a tiny plugin
53375347
// during its init (same pattern as the hostname guard above). What
53385348
// it reads, and which warning a boot without a server gets, lives
@@ -6057,6 +6067,37 @@ export function createDatasourceAdminRoutesPlugin(
60576067
};
60586068
}
60596069

6070+
/**
6071+
* The plugin `os serve` publishes its datasource secret binder's read half
6072+
* with: the kernel service {@link DATASOURCE_SECRET_RESOLVER_SERVICE}, whose
6073+
* one method is the binder's `resolve`. A plugin driver the stack composes
6074+
* reads it to dereference a datasource's `external.credentialsRef`, so a
6075+
* `sys_secret:` handle stored through the datasource admin resolves for that
6076+
* driver with no code from the stack author.
6077+
*
6078+
* Only `resolve` is published — `bind` and `unbind` stay with
6079+
* `DatasourceAdminServicePlugin`, which holds the whole binder. The binder
6080+
* decrypts under the `datasource_credential` scope alone, so the service
6081+
* cannot open a settings secret or a secret field's row. Registered in
6082+
* `init`, so it is in place before any request; a driver reads it at its
6083+
* first request, never at its own `init`.
6084+
*/
6085+
export function createDatasourceSecretResolverPlugin(
6086+
secrets: IDatasourceSecretResolver,
6087+
) {
6088+
const resolver: IDatasourceSecretResolver = {
6089+
resolve: (credentialsRef: string) => secrets.resolve(credentialsRef),
6090+
};
6091+
return {
6092+
name: 'com.objectstack.cli.datasource-secret-resolver',
6093+
version: '1.0.0',
6094+
providesServices: [DATASOURCE_SECRET_RESOLVER_SERVICE],
6095+
init: async (ctx: { registerService(name: string, service: unknown): void }): Promise<void> => {
6096+
ctx.registerService(DATASOURCE_SECRET_RESOLVER_SERVICE, resolver);
6097+
},
6098+
};
6099+
}
6100+
60606101
/**
60616102
* What {@link createUnknownHostnameGuardPlugin} is constructed with.
60626103
*/

‎packages/drivers/driver-odata/README.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ export const plugins = [
2424
name: 'erp', // the datasource it serves
2525
serviceRoot: 'https://erp.example.com/odata/v4/sales/',
2626
auth: { scheme: 'bearer' }, // or { scheme: 'basic', username: 'svc_reader' }
27-
secrets: datasourceSecretBinder, // the host's datasource secret store
27+
// no `secrets`: credentialsRef resolves through the host's `datasource-secret-resolver` service
2828
}),
2929
];
3030

@@ -68,6 +68,7 @@ export default defineStack({
6868
| `serviceRoot` | — | The service root URL (http or https, no credentials, no query). |
6969
| `version` | `4.0` | The protocol version. Only the v4 dialect ships. |
7070
| `auth` | `{ scheme: 'none' }` | `none`, `bearer`, or `basic` with a `username`. The secret is the datasource's `external.credentialsRef`. Leave it `none` when the datasource borrows a connector instance's credential (below). |
71+
| `secrets` | (none) | An object with `resolve(credentialsRef)` that the secret is resolved through. When absent, the kernel service `datasource-secret-resolver` is read at the first request; `os serve` registers it over its own `sys_secret` store. A host composing its own kernel passes the binder it gives `DatasourceAdminServicePlugin`, or registers that service. |
7172
| `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). |
7273
| `requestTimeoutMs` | `30000` | Per-request timeout. |
7374

0 commit comments

Comments
 (0)