Skip to content

Commit ed8b237

Browse files
feat(service-datasource): a datasource naming a connector instance in external.connector borrows its credential, and its driver presents it per request (ADR-0015 D7) (#27819)
epic:#27261#1 · marchtian · session_017ihL2bt3wjGsdgJrX877gc <!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiTBAskixFS9M3heVqfidcZS)_ **Fable review owed.** Developed on Opus while the Fable quota is out. `Clause-②: yes`, so this is a contract face: it lands only after a full Fable review and a `CONTRACT_REVIEW_TIER` contract review record PASS on the current head. Fixes #27746 Clause-②: yes Metadata-protocol: none ## 维护者速读 - **改了什么**:数据源的 `external.connector`(#27783 的 spec 键)在运行时生效。自动化服务注册一个 `connector-credential-source` 服务,把已声明连接器实例的凭据(bearer、API key、basic)按连接器自己的解析链(先环境变量,再 Setup 里 Connector credentials 存的行,最后宿主解析器)借给数据源;数据源服务在连接、测试连接、管理端保存时借用,并把凭据句柄交给驱动工厂;驱动每次请求通过 `@objectstack/types` 的 `callWithBorrowedCredential` 出示:过期先刷新,401 刷新一次重试,再 401 则 503 并点名实例。OData 驱动(`@objectstack/driver-odata`)是第一个使用方:写了 `external.connector` 的数据源在首个请求时借到句柄,按实例 `auth` 的形式(bearer、basic、API key 请求头或查询参数)出示。实例未声明、只是目录描述、没有 auth、被禁用,或栈里没有自动化服务时,连接 fail-closed 拒绝并给出改法,不会连上再在第一次读时吃 401。 - **为什么改**:D7 裁决(6486136301)的运行时一半;没有它,spec 的键只是声明。 - **风险与回滚**:只对写了 `external.connector` 的数据源生效;`credentialsRef` 数据源不受影响。不动 `objectql`,不改 `packages/spec/src/**`(#27783 已落地,本分支已合回 `main`,diff 只剩本卡的文件)。回滚即 revert 本 PR。 - **先看**:`packages/types/src/connector-credential-source.ts`(契约与出示规则);`packages/services/service-automation/src/connector-credential-source.ts`(出借方);`packages/services/service-datasource/src/datasource-connection-service.ts` 的借用块;`packages/drivers/driver-odata/src/transport.ts` 的 `request`。 - **两分钟验证**:`pnpm --filter @objectstack/service-datasource exec vitest run src/__tests__/datasource-connector-credential.test.ts`,预期 14 个用例全绿,能看到静态凭据到达上游、oauth2 形状的凭据过期后刷新、401 刷新一次后重试、未声明与被禁用的实例在连接时被拒。 - **需要你做的**:Fable 复审与契约复审通过后落地本 PR(#27783 已于 01:37Z 落地)。 ## Contract review 6513636834 (FAIL on `fb1e8502`), answered in `3351ae35` - **Must-fix 1, a plugin driver's connect never judged the connector.** `attemptConnect`'s already-registered guard, the only connect a driver adopted from its `driver.<name>` service reaches, now borrows to judge a datasource naming `external.connector`. A refusal is `failed-credentials` through `handleFailure`: the datasource shows failed with the reason, it is marked unavailable, and the boot fails when objects bind to it. The adopted driver is not evicted, because it belongs to the plugin that registered it (#13578) and it refuses each request on its own. Pinned by 5 cases on a real `ObjectQL` engine and the real `DatasourceAdminServicePlugin` through `init()` and `start()`, the driver adopted from a `driver.sap_orders` service the way `ObjectQLPlugin.start()` adopts it. Ablation: the verdict made false turns 4 of them red. - **Must-fix 2, a query-parameter API key could reach an error body.** `@objectstack/types` gains `borrowedCredentialForms` and `withoutBorrowedCredential` (the value, both URL encodings, base64, and base64 of `username:password` for basic; the spellings `connector-credentials.ts` withholds on the connector path). The OData transport scrubs the upstream's refusal message and code with them before it builds `ODataRequestError`. Pinned by an upstream double that echoes the request URI on a 404, and 3 unit cases. Ablation: the scrub removed turns the driver case red. - **Should-fix.** The lender's docblock now says a handle sees a declaration change after the next reconcile, or on a `refresh()`, and does not watch the declaration itself. - The liveness row's first anchor names the already-registered guard, and the changeset names both fixes. ## What changes **The dependency direction the ruling left open.** The contract lives in `@objectstack/types` (`packages/types/src/connector-credential-source.ts`): `IConnectorCredentialSource`, `ConnectorCredentialHandle`, `BorrowedConnectorCredential`, the service name `CONNECTOR_CREDENTIAL_SOURCE_SERVICE` (`'connector-credential-source'`) and `callWithBorrowedCredential`. `service-automation` (the lender) and `service-datasource` (the borrower) both already depend on that package, and so can an out-of-repository plugin driver, so no edge is added among `objectql`, `service-datasource` and `service-automation`. `objectql` is not touched: the engine sees a registered driver, never a credential. **The handle is per dispatch from the first kind.** `get({ organizationId })` answers the credential to send; `refresh()` answers a new one, single-flight per handle. `callWithBorrowedCredential(handle, call, opts)` is the one rule a driver presents through (ADR-0143 D4): refresh when `expiresAt` minus a 30 s margin has passed; on a `401` (a result with `status: 401`, a caller-supplied predicate, or a throw declaring `status`/`statusCode` 401) refresh once and retry; a second `401` throws `503 SERVICE_UNAVAILABLE` naming the instance and the remedy, never the upstream's text (which may echo the credential). Under the plugin-driver floor that 503 is `ERR_DATASOURCE_UNAVAILABLE` with the message in the server log only. **The lender** (`packages/services/service-automation/src/connector-credential-source.ts`, registered in `init()` and added to `providesServices`): - `borrow(connector)` judges the declaration the reconcile reads (`readDeclaredConnectorItems`, registry plus served view plus reload) and resolves no value. It refuses `422 VALIDATION_ERROR` for an undeclared instance, a catalog descriptor (no `provider`), an instance with no `auth` or `auth: { type: 'none' }`, and a disabled one (`enabled: false`, the case #27783's contract review asked to see refused visibly rather than as a silent 401); `503` when the declaration cannot be read. - A handle resolves the instance's `auth.credentialRef` through the connector's own chain (`resolveConnectorCredential`: env, then the `sys_connector_credential` row, then the host's resolver; no stored arm under a walled posture), reading only that reference's row. A credential not set yet makes `get` refuse `503` with `missingCredentialReason`'s remedy and answer once it is set, with no new borrow. - Today's instances carry static kinds, which ADR-0143 D4 leaves resolved once, so a handle holds its value until `refresh()` (the driver's 401 path picks up a value rotated where the plugin cannot see it, such as an env var), or until the plugin's `credentialGeneration` moves. It moves on every reconcile, and every credential door write runs one, so a value set through the door reaches the datasource's next request. - A host resolver's thrown message is never interpolated. **The borrower** (`packages/services/service-datasource`): - `datasource-connector-credential.ts` holds the one borrow every door makes (`borrowConnectorCredential`, never throws; `borrowedConnectorOf`). - `DatasourceConnectionService.attemptConnect` judges the connector at its already-registered guard (a plugin driver adopted from its `driver.<name>` service; see the review section above), and for a factory-built driver borrows right after the `credentialsRef` block, with the same fail-closed posture: a refused borrow, or no source, is `failed-credentials` through `handleFailure` (so a datasource that objects bind to fails the boot, and the engine is marked unavailable), nothing is built and nothing registered. The handle goes to the factory as `DatasourceConnectionSpec.credential`. - `DatasourceAdminServicePlugin` resolves the source per use (`safeGetService`), passes it to the connection service, probes a draft with its borrowed handle, and answers `connectorRefusal` for the admin service. - `DatasourceAdminService` refuses `createDatasource` (before anything is written) and an `updateDatasource` that touches `external`, and answers `testConnection` with the reason without probing, when the named instance cannot lend. This is the "runtime-created datasource never passes `defineStack`" half #27783's acceptance notes left to this card. **The first presenter** (`packages/drivers/driver-odata`, wired now that #27796 has landed): - `plugin.ts` gains a lazy `borrowed()` beside the `credentialsRef` provider. When the datasource definition names `external.connector`, it borrows a handle from the `connector-credential-source` service at the first request (kept per connector name; a refused borrow is not kept, so the next request asks again). A lend refusal is rethrown without its `422`, so the engine's floor answers it as the connection's condition (`503`), not as a malformed request. - `transport.ts` sends every request through `callWithBorrowedCredential` when a handle exists: refreshed at expiry, once on a `401` (its body cancelled, never read), then the helper's `503`. The instance's `auth` decides the presentation: `Authorization: Bearer`, `Authorization: Basic`, or an API key as its declared header or, with `paramName`, a query parameter. With no connector the `credentialsRef` path runs exactly as before, including its resolve-again-on-401. - Fail-closed: no source registered, both `connector` and `credentialsRef` on the definition, or an `auth.scheme` other than `none` on the plugin beside the connector (it would send a second credential) refuse the request with nothing sent. - The upstream's refusal text is run through `withoutBorrowedCredential` before it is thrown, so the floor never hands a caller an echoed credential. - `README.md` gains a "Borrowing a connector instance's credential" section; `@objectstack/types` joins the package's dependencies (`pnpm-lock.yaml` +3 lines). **Ledger.** `packages/spec/liveness/datasource.json`'s `external.connector` row flips `planned` to `live` with the two anchors (claim amendment 2), and `state-counts/datasource.md` is regenerated. **Changeset**: `patch` on `@objectstack/types`, `@objectstack/service-automation`, `@objectstack/service-datasource` and `@objectstack/driver-odata` (`Metadata-protocol: none` takes patch). ## Why ADR-0015 amendment D7 (ruling 6486136301 on #27298): one Connect per vendor serves the connector's flow actions and the datasource's virtual objects. The spec half (#27742, #27783) declared and validated the key; until a runtime reads it the key does nothing. Both should ship in the same release. ## Verification On `3351ae35` (the review fixes over `fb1e8502`, where `origin/main` was merged back in after #27783 landed; the diff against `main` is this card's files only), after `pnpm build` (exit 0): - `pnpm --filter @objectstack/types test`: 813 tests passed. `service-datasource`: 1047 passed. `service-automation`: 2810 passed. `driver-odata`: 451 passed. - `typecheck` exit 0 for all four packages; `eslint` over the touched source and test files exit 0; `pnpm --filter @objectstack/spec check:generated` exit 0 and `check:liveness` green after the merge's liveness conflict was resolved (this card's `live` row kept, its note now naming the OData driver as the first consumer). - New tests: `packages/types/src/connector-credential-source.test.ts` (12: static pass-through, refresh on expiry and inside the skew margin, no refresh while valid, refresh-once on a 401 result and on a 401 throw, the 503 without upstream text, non-401 pass-through, a custom predicate, the organization forwarded, single-flight under concurrency, a handle refusal propagated); `packages/services/service-automation/src/connector-credential-source.test.ts` (15, real ObjectQL over SQLite for the stored arm: registration and `providesServices`, bearer from env, api-key and basic shapes, unset then set then rewritten with no refresh, an env rotation picked up through the 401 path, five 422 refusals, and the handle's 503s); `packages/services/service-datasource/src/__tests__/datasource-connector-credential.test.ts` (14, with a lender double and an API driver double presenting through the helper: static credential reaches the upstream, oauth2-shaped refresh after expiry, refresh once on 401 then 503, undeclared and disabled instances and a missing lender refused at connect with nothing built, the boot failing for a bound datasource, the admin door refusing create, update and probe); `packages/drivers/driver-odata/src/plugin.test.ts` (11 new: the static credential sent and the secret store never asked; basic, API key header and API key query parameter; refresh once on a 401 then retry; refresh at expiry; a second 401 as a 503 naming the instance and not echoing the upstream; five fail-closed refusals with nothing sent; a lend refusal without its 422, borrowed again next request). <details><summary>Ablations (each pin can go red)</summary> Through `scripts/ablation-replace.mjs` (anchor hit, blob moved, restored, `git diff HEAD` empty afterwards): - `datasource-connection-service.ts`: the borrow guard made false (blob `3530b62bb166` to `401f123de28c`): the connect block goes red, 7 failed. - `datasource-admin-service.ts`: `connectorLendRefusal` answers nothing (`420e0221f6d0` to `bcdabd366b06`): the admin block goes red, 4 failed. - `service-automation/src/connector-credential-source.ts`: the disabled refusal never matches (`16a766b32331` to `ddaf1e5fb677`): 1 failed, the disabled case. - `service-automation/src/plugin.ts`: the reconcile no longer moves `credentialGeneration` (`8744db630ecd` to `6e3897f93f09`): 1 failed, the write-picked-up-without-refresh case. - `types/src/connector-credential-source.ts`: no refresh before the retry (`46ece16a317c` to `922e1bb8b425`): 6 failed. - `driver-odata` (by hand, file restored from a copy, `git diff` clean afterwards): the borrowed path skipped in `transport.ts` (6 failed); the lend refusal keeping its 422 (1); a refused borrow cached (1); a plugin `auth.scheme` allowed beside the connector (1). </details> Local gates on `3351ae35`: `node scripts/pm/dispatch-gates.mjs --commands` derived 83 families from the 23 changed files; every one was run with its exit code recorded before any pipe, all 83 exit 0, and `dispatch-gates --ran` reads "83 derived, 83 run, 0 NOT-MEASURED" (a derived zero). 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 - **No grant-bearing kind exists yet.** `ConnectorInstanceAuthSchema` is still `none | bearer | api-key | basic`, and ADR-0143 D4's per-dispatch resolver and connection record are #26702 items 2 to 4. The handle has D4's shape so those kinds plug in without a driver change; the card's "oauth2 refreshed after expiry" test runs against a source double minting expiring tokens (claim amendment 6511046968). Organization selection (`organizationId`) is forwarded and ignored by the static kinds, which are environment-level. - **`driver-odata` is wired here.** #27796 (#27741) landed first, so this PR, landing second, wires it to `callWithBorrowedCredential` (claim 6511028605, amended for `packages/drivers/driver-odata/src/**`). - **A 401 on a borrowed credential does not mark the stored row `rejected`.** The connector path does that on a call refusal; the datasource path refreshes and then answers 503. Sharing that bookkeeping is a follow-up if wanted. - **Hosts that build a `DatasourceConnectionService` without a lender** (`packages/runtime/src/default-datasource-plugin.ts`, the managed default datasource that cannot carry the key; `packages/cli/src/utils/schema-migrate.ts`) refuse an `external.connector` datasource loudly with the plugin named. Not on this claim's surface. - **#27783's changeset** still says the runtime "ships separately; until it does, the key is validated and not yet read". With both in one release that sentence is stale; it is #27783's file. - **Skills and guide** (the `Skills-docs` follow-up #27783 names) can now teach `external.connector`. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` is CI's (eslint over the touched files exits 0 locally). - [x] 4. One issue, one branch, one pull request (merge-based on #27783's branch, as dispatched; `main` merged back in after #27783 landed). - [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 (the liveness ledger only): its generated shard is regenerated and committed. - [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) --- _Generated by [Claude Code](https://claude.ai/code/session_017ihL2bt3wjGsdgJrX877gc)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f8633c7 commit ed8b237

23 files changed

Lines changed: 2052 additions & 36 deletions
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
---
2+
'@objectstack/types': patch
3+
'@objectstack/service-automation': patch
4+
'@objectstack/service-datasource': patch
5+
'@objectstack/driver-odata': patch
6+
---
7+
8+
feat: a datasource that names a connector instance in `external.connector` borrows that instance's credential at runtime, and its driver presents it per request
9+
10+
Clause-②: yes
11+
Metadata-protocol: none
12+
13+
The runtime half of ADR-0015 amendment D7. A datasource over a vendor API that names a declared connector instance now reads with that instance's credential — the one set under Setup → Connector credentials (or the instance's environment variable, or the host's resolver) — so one credential serves the connector's flow actions and the datasource's virtual objects:
14+
15+
```ts
16+
connectors: [{ name: 'sap_s4', type: 'api', provider: 'openapi', providerConfig: { spec: './sap-openapi.json' },
17+
auth: { type: 'bearer', credentialRef: 'sap_token' } }],
18+
datasources: [{ name: 'sap_orders', driver: 'sap_orders', config: {},
19+
schemaMode: 'external', external: { connector: 'sap_s4' } }],
20+
// served by createODataDriverPlugin({ name: 'sap_orders', serviceRoot: '…' }), its auth left at 'none'
21+
```
22+
23+
- **`@objectstack/service-automation`** registers a `connector-credential-source` service that lends a declared instance's credential (bearer, API key, basic), resolved through the same chain the connector itself uses. A credential set or changed through the credential door is picked up by the datasource's next request, with no restart.
24+
- **`@objectstack/service-datasource`** borrows it when such a datasource connects (a plugin driver the engine already adopted from its `driver.<name>` service is judged at that connect too), is probed (Test connection) or is saved from the admin door, and hands the driver factory a credential handle on `DatasourceConnectionSpec.credential`. A datasource whose instance is not declared, is a catalog descriptor, has no `auth` credential or is disabled — or a stack with no automation service to lend it — is refused, fail-closed, with a reason naming the fix: it never connects to fail its first read with an upstream 401.
25+
- **`@objectstack/driver-odata`** presents it: a datasource it serves that names `external.connector` borrows the handle at its first request and sends every request through `callWithBorrowedCredential`, in the form the instance's `auth` declares (bearer, basic, or an API key as a header or query parameter). An upstream's error text is stripped of every spelling of the presented credential before it is thrown (`withoutBorrowedCredential`), so an API key sent as a query parameter never reaches an error body through a service that echoes the request URI. `external.credentialsRef` keeps its path; both channels at once, or an `auth.scheme` set on the plugin beside the connector, refuse the request.
26+
- **`@objectstack/types`** carries the contract (`IConnectorCredentialSource`, `ConnectorCredentialHandle`, `CONNECTOR_CREDENTIAL_SOURCE_SERVICE`) and `callWithBorrowedCredential`, the one rule a plugin driver presents a borrowed credential by: refreshed when it has expired, refreshed once and retried on a `401`, and a `503 SERVICE_UNAVAILABLE` naming the instance when the fresh one is refused too.
27+
28+
Nothing changes for a datasource that binds its own secret with `external.credentialsRef`. The handle answers the grant-bearing kinds (OAuth2 and the app-credential exchange) through the same interface when connector instances can declare them; a driver written against it today needs no change then.

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

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,10 +67,38 @@ export default defineStack({
6767
| `name` | `odata` | The datasource name, the driver name and its `driver.<name>` service suffix. |
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. |
70-
| `auth` | `{ scheme: 'none' }` | `none`, `bearer`, or `basic` with a `username`. The secret is the datasource's `external.credentialsRef`. |
70+
| `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). |
7171
| `pageSize` | `500` | Rows asked for per server page (`Prefer: odata.maxpagesize`). |
7272
| `requestTimeoutMs` | `30000` | Per-request timeout. |
7373

74+
## Borrowing a connector instance's credential
75+
76+
When a connector instance already authorizes the vendor, the datasource can name it in
77+
`external.connector` instead of binding a secret of its own (ADR-0015 amendment D7): one
78+
credential, set under Setup → Connector credentials, serves the connector's flow actions and
79+
the datasource's records.
80+
81+
```typescript
82+
connectors: [{
83+
name: 'erp_api', type: 'api', provider: 'openapi', providerConfig: { spec: './erp-openapi.json' },
84+
auth: { type: 'bearer', credentialRef: 'erp_token' },
85+
}],
86+
datasources: [{
87+
name: 'erp', driver: 'erp', schemaMode: 'external', config: {},
88+
external: { connector: 'erp_api' }, // no credentialsRef: exactly one channel
89+
}],
90+
```
91+
92+
The plugin is created with `auth` left at `none`: the instance's `auth` decides how the credential
93+
is sent (a bearer token, basic auth, or an API key as a header or a query parameter). It borrows
94+
from the `connector-credential-source` service, which `@objectstack/service-automation`
95+
registers, at the first request, and presents it on every request through
96+
`callWithBorrowedCredential`: refreshed when it has expired, refreshed once and retried on a
97+
`401`, and refused with a `503` naming the instance when the fresh one is refused too. A stack with
98+
no automation service, an instance that cannot lend (not declared, disabled, no `auth`), an `auth`
99+
scheme set on the plugin beside the connector, and a `credentialsRef` beside it all refuse the
100+
request instead of sending it.
101+
74102
## What is pushed down
75103

76104
`$filter`: `$eq`, `$ne`, `$gt`, `$gte`, `$lt`, `$lte`, `$between`, `$in`, `$nin`, `$null`,

‎packages/drivers/driver-odata/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@
3737
"dependencies": {
3838
"@objectstack/core": "workspace:*",
3939
"@objectstack/spec": "workspace:*",
40+
"@objectstack/types": "workspace:*",
4041
"fast-xml-parser": "^5.10.1",
4142
"zod": "^4.6.6"
4243
},

‎packages/drivers/driver-odata/src/odata-driver.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import type { DriverOptions } from '@objectstack/spec/data';
5454
import type { EdmEntitySet, EdmEntityType, EdmProperty, ODataDialect, ODataServiceModel } from './dialect.js';
5555
import { odataV4Dialect } from './dialect-v4.js';
5656
import { translateWhere, type FilterTarget } from './filter.js';
57+
import type { ConnectorCredentialHandle } from '@objectstack/types';
5758
import { ODataRequestError, ODataTransport, type FetchLike, type ODataResponse } from './transport.js';
5859
import type { ODataDriverConfig } from './config.js';
5960

@@ -71,6 +72,8 @@ export interface ODataDriverRuntime {
7172
fetch?: FetchLike;
7273
/** The secret behind the datasource's `external.credentialsRef`; see `plugin.ts`. */
7374
credential?: () => Promise<string | undefined>;
75+
/** The credential borrowed from the connector instance the datasource names in `external.connector`; see `plugin.ts`. */
76+
borrowed?: () => Promise<ConnectorCredentialHandle | undefined>;
7477
}
7578

7679
/** An introspected table, with the navigation properties the import offers as lookups. */
@@ -153,6 +156,7 @@ export class ODataDriver implements IDataDriver {
153156
pageSize: config.pageSize,
154157
...(runtime.fetch ? { fetch: runtime.fetch } : {}),
155158
...(runtime.credential ? { credential: runtime.credential } : {}),
159+
...(runtime.borrowed ? { borrowed: runtime.borrowed } : {}),
156160
});
157161
}
158162

‎packages/drivers/driver-odata/src/plugin.test.ts‎

Lines changed: 155 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,21 +3,35 @@
33
/**
44
* The plugin: what it publishes, and where the credential comes from — the
55
* datasource's `external.credentialsRef`, dereferenced through the host's
6-
* secret store at the first request, failing closed every way.
6+
* secret store at the first request, or the connector instance its
7+
* `external.connector` borrows from (ADR-0015 D7) — failing closed every way.
78
*/
89

910
import { describe, expect, it } from 'vitest';
1011
import type { PluginContext } from '@objectstack/core';
1112
import { needsExternalDriverFloor } from '@objectstack/objectql';
13+
import {
14+
CONNECTOR_CREDENTIAL_SOURCE_SERVICE,
15+
connectorCredentialRefusal,
16+
type BorrowedConnectorCredential,
17+
type IConnectorCredentialSource,
18+
} from '@objectstack/types';
1219
import { createODataDriverPlugin, type DatasourceDefinitionSource, type ODataDriverPluginOptions } from './plugin.js';
13-
import { METADATA_EXCHANGE, SERVICE_ROOT, page, recordedService } from './__fixtures__/recorded-service.js';
20+
import { METADATA_EXCHANGE, SERVICE_ROOT, page, recordedService, type Exchange } from './__fixtures__/recorded-service.js';
1421

1522
type Def = ReturnType<DatasourceDefinitionSource['listDatasourceDefs']>[number];
1623

17-
async function boot(options: Omit<ODataDriverPluginOptions, 'serviceRoot' | 'fetch'>, defs: Def[]) {
18-
const service = recordedService([METADATA_EXCHANGE, { url: 'People', body: page([]) }]);
24+
async function boot(
25+
options: Omit<ODataDriverPluginOptions, 'serviceRoot' | 'fetch'>,
26+
defs: Def[],
27+
extra: { exchanges?: Exchange[]; services?: Record<string, unknown> } = {},
28+
) {
29+
const service = recordedService(extra.exchanges ?? [METADATA_EXCHANGE, { url: 'People', body: page([]) }]);
1930
const plugin = createODataDriverPlugin({ serviceRoot: SERVICE_ROOT, fetch: service.fetch, ...options });
20-
const services = new Map<string, unknown>([['objectql', { listDatasourceDefs: () => defs } satisfies DatasourceDefinitionSource]]);
31+
const services = new Map<string, unknown>([
32+
['objectql', { listDatasourceDefs: () => defs } satisfies DatasourceDefinitionSource],
33+
...Object.entries(extra.services ?? {}),
34+
]);
2135
const ctx = {
2236
registerService: (name: string, value: unknown) => { services.set(name, value); },
2337
getService: (name: string) => services.get(name),
@@ -80,3 +94,139 @@ describe('createODataDriverPlugin', () => {
8094
expect(requests).toHaveLength(0);
8195
});
8296
});
97+
98+
/**
99+
* A lender double standing in for `@objectstack/service-automation`'s source:
100+
* `get` answers the current credential, `refresh` moves to the next one, the
101+
* shape a grant-bearing (OAuth2) instance answers through.
102+
*/
103+
function lender(answers: BorrowedConnectorCredential[], refuse?: Error) {
104+
const calls = { borrow: [] as string[], get: 0, refresh: 0 };
105+
let current = 0;
106+
const source: IConnectorCredentialSource = {
107+
async borrow(connector) {
108+
calls.borrow.push(connector);
109+
if (refuse) throw refuse;
110+
return {
111+
connector,
112+
async get() { calls.get += 1; return answers[current]!; },
113+
async refresh() { calls.refresh += 1; current = Math.min(current + 1, answers.length - 1); return answers[current]!; },
114+
};
115+
},
116+
};
117+
return { source, calls, services: { [CONNECTOR_CREDENTIAL_SOURCE_SERVICE]: source } };
118+
}
119+
120+
const BORROWING: Def[] = [{ name: 'trippin', external: { connector: 'trippin_api' } }];
121+
const bearer = (token: string, expiresAt?: number): BorrowedConnectorCredential =>
122+
({ auth: { type: 'bearer', token }, ...(expiresAt !== undefined ? { expiresAt } : {}) });
123+
124+
describe('createODataDriverPlugin — a credential borrowed from external.connector (ADR-0015 D7)', () => {
125+
it('sends the connector instance\'s credential, borrowed once, and never asks the secret store', async () => {
126+
const { calls, services } = lender([bearer('tok-1')]);
127+
const secrets = { resolve: async () => { throw new Error('the secret store was asked'); } };
128+
const { plugin, requests } = await boot({ name: 'trippin', secrets }, BORROWING, { services });
129+
await plugin.driver.find('trip_person', {});
130+
expect(requests.map((r) => r.headers.Authorization)).toEqual(['Bearer tok-1', 'Bearer tok-1']);
131+
expect(calls.borrow).toEqual(['trippin_api']);
132+
expect(calls.refresh).toBe(0);
133+
});
134+
135+
it('presents the instance\'s auth as it declares it: basic, an API key header, an API key query parameter', async () => {
136+
const basic = lender([{ auth: { type: 'basic', username: 'svc', password: 'pw' } }]);
137+
const one = await boot({ name: 'trippin' }, BORROWING, { services: basic.services });
138+
await one.plugin.driver.find('trip_person', {});
139+
expect(one.requests[0]!.headers.Authorization).toBe(`Basic ${Buffer.from('svc:pw').toString('base64')}`);
140+
141+
const header = lender([{ auth: { type: 'api-key', key: 'k-1', headerName: 'APIKey' } }]);
142+
const two = await boot({ name: 'trippin' }, BORROWING, { services: header.services });
143+
await two.plugin.driver.find('trip_person', {});
144+
expect(two.requests[0]!.headers.APIKey).toBe('k-1');
145+
expect(two.requests[0]!.headers.Authorization).toBeUndefined();
146+
147+
const query = lender([{ auth: { type: 'api-key', key: 'k-2', headerName: 'X-API-Key', paramName: 'apikey' } }]);
148+
const three = await boot({ name: 'trippin' }, BORROWING, {
149+
services: query.services,
150+
exchanges: [{ ...METADATA_EXCHANGE, url: '$metadata?apikey=k-2' }, { url: 'People?apikey=k-2', body: page([]) }],
151+
});
152+
await three.plugin.driver.find('trip_person', {});
153+
expect(three.requests.map((r) => r.url)).toEqual(['$metadata?apikey=k-2', 'People?apikey=k-2']);
154+
expect(three.requests[0]!.headers['X-API-Key']).toBeUndefined();
155+
});
156+
157+
it('refreshes once on a 401 and retries with the credential obtained afresh', async () => {
158+
const { calls, services } = lender([bearer('tok-1'), bearer('tok-2')]);
159+
const { plugin, requests } = await boot({ name: 'trippin' }, BORROWING, {
160+
services,
161+
exchanges: [{ url: '$metadata', status: 401, body: { error: { message: 'expired' } }, once: true }, METADATA_EXCHANGE, { url: 'People', body: page([]) }],
162+
});
163+
await plugin.driver.find('trip_person', {});
164+
expect(requests.map((r) => r.headers.Authorization)).toEqual(['Bearer tok-1', 'Bearer tok-2', 'Bearer tok-2']);
165+
expect(calls.refresh).toBe(1);
166+
});
167+
168+
it('refreshes a credential at its expiry before sending it', async () => {
169+
const { calls, services } = lender([bearer('tok-stale', Date.now() - 1), bearer('tok-fresh')]);
170+
const { plugin, requests } = await boot({ name: 'trippin' }, BORROWING, { services });
171+
await plugin.driver.find('trip_person', {});
172+
expect(requests[0]!.headers.Authorization).toBe('Bearer tok-fresh');
173+
expect(calls.refresh).toBe(1);
174+
});
175+
176+
it('ends a second 401 as a 503 naming the instance, never the upstream\'s text', async () => {
177+
const { services } = lender([bearer('tok-1'), bearer('tok-2')]);
178+
const { plugin, requests } = await boot({ name: 'trippin' }, BORROWING, {
179+
services,
180+
exchanges: [{ url: '$metadata', status: 401, body: { error: { message: 'token tok-2 rejected' } } }],
181+
});
182+
const failure = (await plugin.driver.find('trip_person', {}).then(() => undefined, (err: unknown) => err)) as Error & { status?: number; code?: string };
183+
expect(failure).toMatchObject({ status: 503, code: 'SERVICE_UNAVAILABLE' });
184+
expect(failure.message).toContain("connector instance 'trippin_api'");
185+
expect(failure.message).not.toContain('tok-2');
186+
expect(requests).toHaveLength(2);
187+
});
188+
189+
it('withholds a query-parameter API key from an upstream refusal that echoes the request URI', async () => {
190+
const key = 'k-2/x+y==';
191+
const { services } = lender([{ auth: { type: 'api-key', key, headerName: 'X-API-Key', paramName: 'apikey' } }]);
192+
const echoed = new URL(`People?apikey=${encodeURIComponent(key)}`, SERVICE_ROOT).toString();
193+
const { plugin } = await boot({ name: 'trippin' }, BORROWING, {
194+
services,
195+
exchanges: [
196+
{ ...METADATA_EXCHANGE, url: `$metadata?apikey=${key}` },
197+
{ url: `People?apikey=${key}`, status: 404, body: { error: { code: 'NotFound', message: `No resource at '${echoed}' (apikey=${key}, form ${new URLSearchParams({ apikey: key }).toString()})` } } },
198+
],
199+
});
200+
const failure = (await plugin.driver.find('trip_person', {}).then(() => undefined, (err: unknown) => err)) as Error & { status?: number };
201+
expect(failure).toBeInstanceOf(Error);
202+
expect(failure.status).toBe(404);
203+
expect(failure.message).toContain('[credential withheld]');
204+
for (const spelling of [key, encodeURIComponent(key), new URLSearchParams({ v: key }).toString().slice(2)]) {
205+
expect(failure.message).not.toContain(spelling);
206+
}
207+
});
208+
209+
const refusals: Array<[string, Omit<ODataDriverPluginOptions, 'serviceRoot' | 'fetch'>, Def[], Record<string, unknown>, RegExp]> = [
210+
['no connector credential source registered', { name: 'trippin' }, BORROWING, {}, /no connector credential source is registered/],
211+
['both credential channels', { name: 'trippin', secrets: SECRETS }, [{ name: 'trippin', external: { connector: 'trippin_api', credentialsRef: 'sys_secret:sec_trippin' } }], lender([bearer('tok-1')]).services, /names both external\.connector and external\.credentialsRef/],
212+
['an auth scheme beside the connector', { name: 'trippin', auth: { scheme: 'bearer' } }, BORROWING, lender([bearer('tok-1')]).services, /set auth\.scheme to 'none'/],
213+
['an instance the source refuses to lend', { name: 'trippin' }, BORROWING, lender([], connectorCredentialRefusal("connector instance 'trippin_api' is disabled", 'VALIDATION_ERROR', 422)).services, /'trippin_api' is disabled/],
214+
['a credential the handle cannot answer', { name: 'trippin' }, BORROWING, { [CONNECTOR_CREDENTIAL_SOURCE_SERVICE]: { borrow: async (connector: string) => ({ connector, get: async () => { throw connectorCredentialRefusal("connector instance 'trippin_api': credential not set", 'SERVICE_UNAVAILABLE', 503); }, refresh: async () => { throw new Error('unreached'); } }) } }, /credential not set/],
215+
];
216+
217+
it.each(refusals)('fails closed, sending nothing, on %s', async (_label, options, defs, services, message) => {
218+
const { plugin, requests } = await boot(options, defs, { services });
219+
await expect(plugin.driver.find('trip_person', {})).rejects.toThrow(message);
220+
expect(requests).toHaveLength(0);
221+
});
222+
223+
it('answers a lend refusal without its 422, so the engine reads it as the connection\'s condition, and asks again next request', async () => {
224+
const refused = lender([], connectorCredentialRefusal("connector instance 'trippin_api' is disabled", 'VALIDATION_ERROR', 422));
225+
const { plugin } = await boot({ name: 'trippin' }, BORROWING, { services: refused.services });
226+
const failure = (await plugin.driver.find('trip_person', {}).then(() => undefined, (err: unknown) => err)) as Error & { status?: number };
227+
expect(failure).toBeInstanceOf(Error);
228+
expect(failure.status).toBeUndefined();
229+
await plugin.driver.find('trip_person', {}).catch(() => {});
230+
expect(refused.calls.borrow).toEqual(['trippin_api', 'trippin_api']);
231+
});
232+
});

0 commit comments

Comments
 (0)