Repository navigation
Commit 7e9f814
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
File tree
- .changeset
- packages
- connectors
- connector-openapi
- src
- connector-rest
- src
- services/service-automation/src
- oauth2-grant
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
29 | 29 | | |
30 | 30 | | |
31 | 31 | | |
32 | | - | |
| 32 | + | |
| 33 | + | |
33 | 34 | | |
34 | 35 | | |
35 | 36 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
20 | 20 | | |
21 | 21 | | |
22 | 22 | | |
23 | | - | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
24 | 35 | | |
25 | 36 | | |
26 | 37 | | |
| |||
81 | 92 | | |
82 | 93 | | |
83 | 94 | | |
| 95 | + | |
| 96 | + | |
84 | 97 | | |
85 | 98 | | |
86 | 99 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
5 | 5 | | |
6 | 6 | | |
7 | 7 | | |
8 | | - | |
| 8 | + | |
9 | 9 | | |
10 | 10 | | |
11 | 11 | | |
12 | 12 | | |
13 | | - | |
14 | | - | |
15 | | - | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
16 | 16 | | |
17 | 17 | | |
18 | 18 | | |
| |||
0 commit comments