Skip to content

Commit 7e9f814

Browse files
feat(connectors): rest and openapi serve oauth2 and app-credential instances through the credential handle; Feishu, DingTalk and WeCom exchanges (ADR-0143 S2) (#28399)
<!-- claude-projects-attribution: {"github_login":"os-tesla"} --> _Requested by **Tesla** · [project thread](https://claude.ai/code/project/chan_01AXsQkLFKRdQCqQ3RTr6Jao?thread=cmsg_01AXsQkLFKRdQCqQ3RTr6JaoHgewSpchhjBvPu7yhxUUJx)_ Part of #26702 Clause-②: yes (widening) Metadata-protocol: semantic Skills-docs: follow-up owed — content/docs/automation/connectors.mdx, skills/objectstack-automation/references/integration-recipes.md ## 维护者速读 - 改了什么:内置的 `rest` 和 `openapi` 连接器现在能真正调用需要 OAuth 的外部系统了;飞书、钉钉、企业微信的「应用凭证」实例也不再被挂起,平台会自己换取并续期访问令牌。 - 为什么改:上一切片(#28317)已经能管理令牌,但内置连接器都还不接入,声明了 `oauth2` 或 `app-credential` 的实例只能降级、无法调用。本 PR 让业务流程可以直接调用这些系统。 - 风险与回滚:只影响声明了 `oauth2` / `app-credential` 的实例(原来都是降级状态),静态凭证(bearer / api-key / basic)的实例行为不变;回滚即还原本 PR,这些实例回到降级。 - 先看:`packages/services/service-automation/src/oauth2-grant/app-credential.ts`(三家厂商的换取方式与企业微信令牌提前失效的处理)、`packages/connectors/connector-rest/src/rest-connector.ts`(每次请求取令牌、401 刷新一次)、`packages/connectors/connector-openapi/src/openapi-connector.ts`(读取文档的 `security`)。 - 两分钟验证:`pnpm --filter @objectstack/connector-rest exec vitest run src/rest-provider-credential-handle.test.ts`,预期 10 个用例全部通过,其中包括企业微信令牌提前失效后自动重新换取并重试一次的端到端用例。 - 需要你做的:无,席位落地。 ## What changes ADR-0143 S2, first half: the providers slice for `rest` and `openapi`, and the D2.2 `app-credential` exchanges. S1 (#28317) served `oauth2` only on a provider registered with `{ credentialHandle: true }`, and none of the built-ins was; every `app-credential` instance was held degraded. The `mcp` provider is the second half of S2 and comes in its own pull request (see Acceptance notes). **`@objectstack/connector-rest`, `@objectstack/connector-openapi`** - Both plugins register their provider with `{ credentialHandle: true }`, and both factories pass `ctx.credentialHandle` to the connector. - With a handle, every request goes through `callWithBorrowedCredential` in the name of the run's organization (`ctx.automation.tenantId`): the handle is asked on each request, an upstream `401` refreshes once and retries once, and a second `401` is the seam's `503` naming the instance. The token goes where the handle says: `Authorization: Bearer`, a vendor header, or a query parameter. - A transport error thrown during a handle-authenticated request has the credential removed from its message (`withoutBorrowedCredential`) before it propagates. - A handle beside a static credential or a request signer is refused when the instance is built: a request is authenticated one way. - `openapi` reads `security` (document level and per operation): - An operation whose effective `security` is `[]` is anonymous: the handle is not asked and no token is sent. - On a grant-bearing instance, a parameter that a required `apiKey` scheme names is filled by the platform and is dropped from the action's input schema. - An `oauth2` scheme's `flows` stay documentation: the instance's own `auth` names the token endpoint. The OpenAPI types gain `security`, `flows`, `bearerFormat`, `openIdConnectUrl` and `mutualTLS`. - Both packages now depend on `@objectstack/types` (the seam's helpers). Their vitest configs alias it to source, as `check:test-source-alias` requires. **`@objectstack/service-automation`** - `oauth2-grant/app-credential.ts` is the shared transport for the D2.2 exchanges, one entry per vendor, each as its docs describe it: - Feishu: `client_credentials` at `accounts.feishu.cn`, form-encoded; sent as a bearer token. - DingTalk: the v1.0 `oauth2/accessToken` API with a JSON body; sent in `x-acs-dingtalk-access-token`. - WeCom: `cgi-bin/gettoken`; sent as the `access_token` query parameter. - `createAppCredentialHandle` implements `ConnectorCredentialHandle` for an instance: the token is held in memory only, refreshed inside the 30 s skew margin, and concurrent asks and refreshes share one exchange. The token request never follows a redirect and has a timeout. - Failure split (ADR-0097 / D4): no answer, `429` or `5xx` is `CONNECTOR_UPSTREAM_UNAVAILABLE`, with no `cause` (a transport error may carry the request URL). A refusal, or a `2xx` without a token, is a `503` naming the vendor's error code only, never the vendor's text or the secret. - `retryOnVendorTokenRefusal`: WeCom answers an early-invalidated token inside an ordinary answer (`errcode` `40014` / `42001`), not with a `401`. A WeCom instance's handlers treat it as the `401` rule does: refresh once for the run's organization, repeat the call once, and a second refusal is the `503`. Feishu and DingTalk answer `401`, so the provider's rule covers them and their handlers are not wrapped. - `plugin.ts`, the materializer guard: an `app-credential` instance is served on a provider that asks the credential handle (with no static `auth` in the provider context, and the app secret never in it) and held degraded, naming the provider, on one that does not. One reason builder now serves both grant-bearing kinds. ## Why ADR-0143 opens outbound connector auth in the open tier. After S1 the token machinery existed but no built-in provider used it, so an `oauth2` or `app-credential` instance could be declared and never dispatched. The ADR's acceptance needs "a Feishu (or DingTalk) instance declared with `app-credential` dispatches through `openapi` or `rest`, and its token renews without a restart"; this PR covers that, and the WeCom early-invalidation unit case the ADR names. ## Tests - `connector-rest/src/rest-provider-credential-handle.test.ts` (10): handle asked per request per organization, no-organization ask, 401 refresh-once, second 401 → 503, a handle refusal sends nothing, header vs query placement, transport-error scrub, exclusivity, the plugin opt-in, and a WeCom `app-credential` instance end to end through `AutomationServicePlugin` + `ConnectorRestPlugin` (early invalidation → one re-exchange, one retry). - `connector-openapi/src/openapi-provider-credential-handle.test.ts` (7): per-request ask and bearer, 401 rules, anonymous operation, scheme-filled parameter dropped from the input, the no-handle control, exclusivity with a signer, the plugin opt-in. - `service-automation/src/oauth2-grant/app-credential.test.ts` (19): each vendor's request and answer, refusals per vendor, a tokenless `2xx`, no answer / `429` / `5xx`, no redirect, cache + skew + single flight, refresh single flight, WeCom retry for both codes, second refusal, the control, unwrapped vendors. - `service-automation/src/connector-materialization.test.ts`: the S0 "holds app-credential degraded" case is replaced by three: served on an opted-in provider with no static auth and the secret not in the context, held on a provider that does not opt in, held naming the reference when the secret does not resolve. <details><summary>Ablations — 16, each red and each restored (<code>scripts/ablation-replace.mjs</code>, tree clean after each)</summary> | # | Mutation | Red | |---|---|---| | 1 | rest: `callWithBorrowedCredential` → a get-only call | 401 refresh-once; second 401 → 503 | | 2 | rest: exclusivity `ways.length > 1` → `> 2` | refuses a handle beside a static credential | | 3 | rest: delete the transport-error scrub | the scrub pin | | 4 | openapi: `anonymous = false` | the anonymous-operation pin (handle was asked) | | 5 | openapi: delete the scheme-filled `continue` | the input-schema pin (`access_token` back as an input) | | 6 | openapi: exclusivity `> 1` → `> 2` | refuses a handle beside a signer | | 7 | openapi: get-only call | 401 rules | | 8 | rest plugin: `credentialHandle: false` | the opt-in pin and the WeCom end-to-end | | 9 | openapi plugin: `credentialHandle: false` | the opt-in pin | | 10 | app-credential: put the transport error into the no-answer refusal | the no-leak pin | | 11 | app-credential: delete `redirect: 'error'` | the no-redirect pin | | 12 | WeCom wrapper: return the first answer as is | both errcode cases and the second-refusal case | | 13 | WeCom wrapper: return the second answer as is | the second-refusal pin | | 14 | handle: `inflight ??=` → `inflight =` | both single-flight pins | | 15 | plugin guard: serve `app-credential` on any provider | held degraded on a non-opted provider | | 16 | plugin: drop the WeCom wrap (service-automation **dist** rebuilt; `ablation-dist-preflight` absent → exit 0, then present after restore) | the WeCom end-to-end in connector-rest; green again after the restore rebuild | </details> ## Verification - `pnpm --filter @objectstack/connector-rest exec vitest run`: 5 files, 43 passed. `@objectstack/connector-openapi`: 7 files, 89 passed. `@objectstack/service-automation` full suite: 243 files, 3084 passed (on `1838861a`; later commits touch only the changeset, two vitest configs and two type casts in a test, after which `app-credential.test.ts` is 19/19 again). - `pnpm --filter <pkg> run typecheck` (with `check:test-typecheck`): exit 0 for connector-rest, connector-openapi and service-automation. - `pnpm lint` scoped to the touched packages: exit 0. - `dispatch-gates --commands`: 73 families; all exit 0 after two fixes in this branch (`check:test-source-alias` named the new `@objectstack/types` import → aliased; `check:plugin-teardown-shape --self-test` and `check:dual-build-cjs-loads` exited 3 until the pinned commit was fetched and the whole tree built). `--ran` reconcile: 73 derived, 73 run, 0 NOT-MEASURED, 0 unrun. ## Acceptance notes - **The `mcp` provider is not in this PR.** S2 is split by provider (the order's size rule): this PR is `rest` + `openapi` + the `app-credential` transport, about 1,350 lines; the `mcp` authorization flow (protected-resource and authorization-server metadata, dynamic client registration, PKCE, the pre-registered `clientId` form) follows in a second PR. Until then an `oauth2` or `app-credential` instance on `mcp` stays degraded, as today. - **DingTalk** is served on its v1.0 APIs (`api.dingtalk.com`, token in `x-acs-dingtalk-access-token`). The legacy `oapi.dingtalk.com` APIs, which take the token in the query, are not served by this exchange. - **Early invalidation**: Feishu and DingTalk rely on their `401`; WeCom's in-body codes `40014` / `42001` are handled by the wrapper. Other WeCom codes are returned to the flow as the upstream's answer. - **Unchanged here**: datasource lending (`external.connector`) still refuses grant-bearing kinds with `422`; `def.authentication` stays `none` for handle-authenticated instances; the connection door handlers and `capabilities.ts` are untouched (#28327). - **Docs**: `content/docs/automation/connectors.mdx` and the integration-recipes skill reference still say `app-credential` and the built-in providers are not served; they are outside this card's file surface and are declared owed above. ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` is not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset (`.changeset/26702-connector-providers-oauth.md`), 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. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L9qibwa6RkD7A2gK6rYRNT --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 9eaa209 commit 7e9f814

21 files changed

Lines changed: 1352 additions & 108 deletions
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
---
2+
'@objectstack/connector-rest': minor
3+
'@objectstack/connector-openapi': minor
4+
'@objectstack/service-automation': minor
5+
---
6+
7+
feat(connectors): the built-in `rest` and `openapi` providers serve `oauth2` and `app-credential` instances through the per-dispatch credential handle, and the Feishu, DingTalk and WeCom app-credential exchanges run (ADR-0143 S2, part of #26702)
8+
9+
Clause-②: yes (widening)
10+
Metadata-protocol: semantic
11+
12+
ADR-0143 S1 (#28317) served an `oauth2` instance only on a provider that asks the credential handle, and none of the built-in providers did; every `app-credential` instance was held degraded. Now:
13+
14+
- **`rest` and `openapi` ask the handle.** Both plugins register their provider with `{ credentialHandle: true }`. A grant-bearing instance on either one is served: every request asks the handle for the run's organization, sends the token where the handle places it (an `Authorization: Bearer` header, or a vendor's header or query parameter), refreshes once and retries once on an upstream `401`, and a second `401` is the dispatch's `503` naming the instance. A thrown transport error has the token removed from its message first. A handle beside a static credential or a request signer is refused when the instance is built: a request is authenticated one way.
15+
- **`openapi` reads the document's `security`.** An operation whose effective `security` is `[]` is anonymous: the handle is not asked and no token is sent. On a grant-bearing instance, a parameter that an `apiKey` scheme the operation requires names is filled by the platform and is no longer an input of the action. An `oauth2` scheme's flows stay documentation: the instance's own `auth` names the token endpoint.
16+
- **`app-credential` exchanges run** (`@objectstack/service-automation`), each as its vendor documents it: Feishu's `client_credentials` token at `accounts.feishu.cn` (sent as a bearer token), DingTalk's v1.0 `accessToken` API (sent in `x-acs-dingtalk-access-token`; the legacy `oapi` APIs that take `access_token` in the query are not served), and WeCom's `gettoken` (sent as the `access_token` query parameter). The token is held in memory only, refreshed inside the 30 second skew margin, and concurrent asks share one exchange. The token request never follows a redirect. No answer, `429` or `5xx` is `CONNECTOR_UPSTREAM_UNAVAILABLE`; a refusal is a `503` naming the vendor's error code only, never the secret.
17+
- **WeCom early invalidation.** WeCom answers an invalidated token inside an ordinary answer (`errcode` `40014` or `42001`). A WeCom instance's actions treat that as the `401` rule does: the token is obtained afresh once and the call is repeated once; a second refusal is the `503`.
18+
- **The guard.** An `app-credential` instance is served on a provider that asks the credential handle and held degraded, naming the provider, on one that does not. `mcp` does not ask the handle yet, so an `oauth2` or `app-credential` instance on `mcp` stays degraded.

‎packages/connectors/connector-openapi/package.json‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@
2929
},
3030
"dependencies": {
3131
"@objectstack/core": "workspace:*",
32-
"@objectstack/spec": "workspace:*"
32+
"@objectstack/spec": "workspace:*",
33+
"@objectstack/types": "workspace:*"
3334
},
3435
"devDependencies": {
3536
"@objectstack/service-automation": "workspace:*",

‎packages/connectors/connector-openapi/src/connector-openapi-plugin.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,18 @@ export interface ConnectorRegistrySurface {
2020
>,
2121
): void;
2222
unregisterConnector(name: string): void;
23-
registerConnectorProvider(providerKey: string, factory: ConnectorProviderFactory): void;
23+
/**
24+
* `options.credentialHandle: true` says the factory asks
25+
* `ConnectorProviderContext.credentialHandle` on every request, so the host
26+
* serves grant-bearing instances (`oauth2`, `app-credential`) through it
27+
* (ADR-0143 D4). An older host ignores the third argument and keeps
28+
* holding those instances degraded.
29+
*/
30+
registerConnectorProvider(
31+
providerKey: string,
32+
factory: ConnectorProviderFactory,
33+
options?: { readonly credentialHandle?: boolean },
34+
): void;
2435
}
2536

2637
/**
@@ -81,6 +92,8 @@ export class ConnectorOpenApiPlugin implements Plugin {
8192
automation.registerConnectorProvider(
8293
OPENAPI_PROVIDER_KEY,
8394
createOpenApiProviderFactory({ fetchImpl: this.options.fetchImpl }),
95+
// ADR-0143 D4 — the transport asks the credential handle per request.
96+
{ credentialHandle: true },
8497
);
8598
ctx.logger.info("ConnectorOpenApiPlugin: registered 'openapi' connector provider");
8699
}

‎packages/connectors/connector-openapi/src/index.ts‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,14 @@
55
*
66
* Generate an ObjectStack {@link Connector} from a declarative OpenAPI 3.x
77
* document (ADR-0023). One operation becomes one connector action; a single
8-
* generic handler drives a self-contained static-auth HTTP transport (mirroring
8+
* generic handler drives a self-contained HTTP transport (mirroring
99
* `@objectstack/connector-rest`). The generated connector is an ordinary
1010
* `type: 'api'` connector — registered via `engine.registerConnector` with no
1111
* new engine surface.
1212
*
13-
* Open-source scope: static auth only (`none` / `api-key` / `basic` / `bearer`),
14-
* credentials supplied by the caller. Managed OAuth2, credential vaulting, and
15-
* per-tenant lifecycle are the enterprise tier (ADR-0015 / 0022).
13+
* Static auth (`none` / `api-key` / `basic` / `bearer`), a request signer
14+
* (ADR-0143 D2.3), or a per-dispatch credential handle for the grant-bearing
15+
* kinds `oauth2` and `app-credential` (ADR-0143 D4, open tier per D1).
1616
*/
1717

1818
export {

0 commit comments

Comments
 (0)