Skip to content

feat(connectors): rest and openapi serve oauth2 and app-credential instances through the credential handle; Feishu, DingTalk and WeCom exchanges (ADR-0143 S2) - #28399

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-8z6igj
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-8z6igj

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Tesla · project thread

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 的外部系统了;飞书、钉钉、企业微信的「应用凭证」实例也不再被挂起,平台会自己换取并续期访问令牌。
  • 为什么改:上一切片(feat(service-automation): serve oauth2 connector instances per dispatch, and connect an authorization_code instance per organization (ADR-0143 S1) #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.
Ablations — 16, each red and each restored (scripts/ablation-replace.mjs, tree clean after each)
# 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

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 (security: a dedicated org-scoped manage_connector_connections capability for connecting OAuth2 connector instances (before ADR-0143 S4's Console flow) #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

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (packages/spec is not touched.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; pnpm lint passes.
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 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.
  • 6. packages/spec touched: not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English.
  • The Before Submitting list in CONTRIBUTING.md is done.

🤖 Generated with Claude Code

https://claude.ai/code/session_01L9qibwa6RkD7A2gK6rYRNT

@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 31, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/connector-openapi, @objectstack/connector-rest, @objectstack/service-automation, touching 67 documentable anchor(s). ⚠️ 7 changed file(s) yielded no anchor (packages/connectors/connector-openapi/package.json, packages/connectors/connector-openapi/src/index.ts, packages/connectors/connector-openapi/vitest.config.ts, …), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

30 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 125ad062d2eaf49687a89489766a284ba60aa618.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 7 changed file(s) yielded no anchor (packages/connectors/connector-openapi/package.json, packages/connectors/connector-openapi/src/index.ts, packages/connectors/connector-openapi/vitest.config.ts, …) — pages documenting those are invisible to this run
  • 14 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 60 of 212 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 12 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 125ad062d2eaf49687a89489766a284ba60aa618 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 24fe2545d3396597f706cde6d07e5a45b92f497e — the merge of head be639274b21ed3515272bbc67c3dc9cddaa58cbf into base 125ad062d2eaf49687a89489766a284ba60aa618, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 24fe2545d3396597f706cde6d07e5a45b92f497e && git checkout 24fe2545d3396597f706cde6d07e5a45b92f497e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 125ad062d2eaf49687a89489766a284ba60aa618 be639274b21ed3515272bbc67c3dc9cddaa58cbf && git checkout -B drift-repro 125ad062d2eaf49687a89489766a284ba60aa618 && git merge --no-ff be639274b21ed3515272bbc67c3dc9cddaa58cbf

node scripts/docs-audit/affected-docs.mjs --json 125ad062d2eaf49687a89489766a284ba60aa618

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 125ad062d2eaf49687a89489766a284ba60aa618 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract-review-claim: session_01L9qibw58ehQ2gRHP2h2Puz · head be639274b21ed3515272bbc67c3dc9cddaa58cbf · 2026-10-31T11:44Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: be639274b21ed3515272bbc67c3dc9cddaa58cbf
Local-runs: none
Scope: packages/connectors/connector-rest/src/rest-connector.ts and rest-provider.ts and connector-rest-plugin.ts (credentialHandle option, callWithBorrowedCredential per request, the one-way exclusivity, the { credentialHandle: true } registration), packages/connectors/connector-openapi/src/openapi-connector.ts and openapi-provider.ts and connector-openapi-plugin.ts (the same, plus the document's security: effectiveSecurity, schemeFilledParameters, the OpenApiSecurityScheme and OpenApiOAuthFlow shapes), both packages' index.ts headers, package.json @objectstack/types dependency and vitest alias, packages/services/service-automation/src/oauth2-grant/app-credential.ts (new: APP_CREDENTIAL_EXCHANGES, requestAppCredentialToken, createAppCredentialHandle, retryOnVendorTokenRefusal) and index.ts, src/plugin.ts (the guard credentialHandleNotAppliedReason, the app-credential handle creation, the WeCom wrap), the three new test files and the materialization test, .changeset/26702-connector-providers-oauth.md, pnpm-lock.yaml

Inputs: card #26702 (the slice plan 6524271601 §3 S2, the release 6541163472, the seat's S2 claim 6541963747 with its ⛔ on the connection door and capabilities.ts, the dev report 6542669233 with its 16 ablations, the declarations and the two out-of-scope findings, the claim amendment 6542689344 splitting S2 by provider: this PR carries rest + openapi + the D2.2 transport, the mcp half follows on the same branch), PR #28399 (body; 21 files, +1352/−108, the hand-written diff read whole, the test files' cases read by name), ADR-0143 on main (D2.2 app-credential as one exchange per closed vendor value, nothing authored; D4 per-dispatch resolution, refresh on expiry with skew and once on 401, single flight, the configuration/operation failure split, Q1 = A), the S0 and S1 seams on main the PR reads (callWithBorrowedCredential and withoutBorrowedCredential and connectorCredentialRefusal and BORROWED_CREDENTIAL_SKEW_MS in @objectstack/types, ConnectorCredentialHandle and BorrowedConnectorAuth in @objectstack/spec/integration, engine.registerConnectorProvider(key, factory, { credentialHandle }) and providerAppliesCredentialHandle in service-automation/src/engine.ts, OAuth2GrantHandle with seed and forget, ConnectorAppCredentialVendorSchema = feishu | dingtalk | wecom), resilientFetch on main (a 401 is returned, not retried and not thrown, so the seam's default status === 401 reads it), the 69 check-runs on this head (read 2026-10-31T12:00Z: all 69 runs completed, none failed, none cancelled, none in progress; success: Build & E2E UI, Build Core, Build Docs, Build UI Deps, Check Changeset, Dogfood Regression Gate, Flag docs affected by code changes, Governed Surface Queue Guard, Lint & Repo Gates, Lint UI, Lint · CI, release & engine gates, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Lint · scoped · tooling self-tests, Live E2E UI, PR-body guards (claim · duplicate · Part-of · single-writer path), Plan Test Core shards, Test Core, Test Core (all 8), Test UI, Test UI (all 16), Type Check UI, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, Validate Package Dependencies, filter; skipped: 18.x upgrade harness (opt-in), Auto Label, Check PR Size, Dogfood Regression Gate (matrix shard/3), Dogfood Verify CLI, Packed-tarball smoke (opt-in), Schedule gate · skip when a green push run covers the commit, Temporal Conformance (live PG + MySQL), UI Published Artifacts), and the docs on main the dev declares stale (connectors.mdx "what is not served yet" paragraph and failure-modes row; integration-recipes.md "Not yet" entry). The head is one commit line off main fa74b489; no file of this PR moved on main since, and the head is mergeable and clean today.

① Derived judgments

  • Both built-in HTTP providers now consume the handle, through the one copy of the call rule - right. Each plugin registers its factory with { credentialHandle: true }, so the S1 host serves a grant-bearing instance through it instead of holding it degraded; each factory passes ctx.credentialHandle into the connector; each transport, when a handle is present, sends through callWithBorrowedCredential(handle, send, { organizationId }), which refreshes an expired credential before sending, refreshes once and retries once on a 401 Response, and ends a second 401 in the seam's 503 naming the instance. The organization is read from the host's handler context (automation.tenantId), so a run selects its own organization's connection (Q1 = A) and a run with none asks with none (D4's only-connection rule, S1's handle). A thrown transport error has the token scrubbed from its message, which matters for a query-parameter token that rides the URL. Pinned on both providers (get-only call, 401 refresh-retry, second 401, handle refusal, scrub, DingTalk header and WeCom query placement).
  • A request is authenticated one way - right. Both constructors refuse at build when more than one of a static credential, a request signer and a credential handle is given, naming the ways; the S1 materializer never passes a static auth beside a handle, and the static kinds, signed and the handle paths stay disjoint. Pinned on both.
  • openapi reads the document's security only to withhold and to fill, never to authenticate - right. The operation's own security replaces the document's; an empty list is anonymous, so no token is sent and the handle is not asked (a grant's token never reaches an endpoint that did not ask); a non-array reads as "says nothing", never as anonymous. On a handle-bearing connector, the header or query parameter an apiKey scheme the operation requires names is left out of the action's input schema, as Authorization already was; without a handle the document changes nothing (CONTROL case). An oauth2 scheme's flows are typed and left as documentation: the instance's own auth names the token endpoint, as the changeset says.
  • The D2.2 exchanges are the vendors' and nothing about them is authored - right. One AppCredentialExchange per closed vendor value, keyed by the spec enum so a new vendor cannot enter without its row: Feishu client credentials at accounts.feishu.cn (form, bearer presentation), DingTalk v1.0 accessToken (JSON, x-acs-dingtalk-access-token header), WeCom gettoken (GET with corpid and corpsecret, access_token query parameter via api-key with paramName, which both applyAuths put on the query only). The token request never follows a redirect (redirect: 'error'), times out on the S1 default, treats no answer, 429 and 5xx as CONNECTOR_UPSTREAM_UNAVAILABLE (D4's operational side), and a refused or token-less answer as the seam's 503 naming the instance, the vendor and at most a vendor error code matched against a short-token pattern; the transport's error is not carried as a cause because WeCom's URL holds the secret. Expiry is measured from when the request was sent.
  • The app-credential handle is D4's handle - right. get answers the held token while it is fresh past the 30 s skew margin, else exchanges; refresh drops the held token and exchanges, sharing an in-flight exchange; the token lives in the handle's memory only (D3's last bullet: the configured secret re-derives it); seed is a no-op and forget clears, so the plugin keeps one handle type. The run's organization selects nothing, since the token belongs to the app. Pinned: cache until skew, single flight on get and on refresh.
  • WeCom's in-body invalidation gets the refresh-once rule and no more - right. retryOnVendorTokenRefusal wraps every handler of a WeCom instance: a result whose body.errcode is 40014 or 42001 refreshes the handle once and repeats the call once; a second refusal is the seam's 503; any other result passes through; a vendor without the predicate gets its handlers back unchanged, so the provider's own 401 rule still covers Feishu and DingTalk. Pinned both ways, and end to end through AutomationServicePlugin + ConnectorRestPlugin.
  • The materializer serves and guards correctly - right. An app-credential instance on a provider that asks the handle gets createAppCredentialHandle from the resolved app secret and no static auth; on a provider that does not, it is held degraded by the same credentialHandleNotAppliedReason an oauth2 instance gets, naming the provider, so mcp instances stay degraded until the second PR; an unresolved credentialRef degrades first as connectors: sys_connector_credential + open-tier resolver chain (env → credential object → vault), reconcile on credential change, install-local prompt for missing credentialRefs (ruling A on #27230) #27237 rules, so the ?? '' fallback is never reached. The S1 "not served yet" reason is retired with its cause. Three materialization cases pin it.
  • Clause-② yes (widening) and Metadata-protocol: semantic - accepted. No authored key changes; what an author already writes (app-credential, oauth2 on rest or openapi, a document's security: []) now dispatches, or is anonymous, where it was degraded or token-bearing before. That is a semantic change to served metadata and is why this review is served at CONTRACT_REVIEW_TIER. The ConnectorRegistrySurface third argument is a widening of a plugin-side interface the host already accepts.
  • Check-runs as gate verdicts - read as above; every latest run per name on this head completed green or skipped, Check Changeset and Validate Package Dependencies among them.

② Semver level

  • @objectstack/connector-rest, @objectstack/connector-openapi, @objectstack/service-automation minor - accepted. Two providers gain an auth kind and an option, one service gains a public module (oauth2-grant/app-credential.ts re-exported from the index); the only prose retirements are two description strings on the rest connector's definition, which are declaration text, not a contract.

③ Boundary flags

  • Docs go stale on merge, declared as a follow-up - connectors.mdx (the "what is not served yet" paragraph and the failure-modes row) and skills/objectstack-automation/references/integration-recipes.md (the "Not yet" entry) say app-credential and the built-in providers are not served, which this PR makes false. The dev declares Skills-docs: follow-up owed; the recipe is under skills/**, so that follow-up is a Tier H change needing a human approver, which is a reason to carry it as its own small PR rather than inside the mcp half. Not a defect of this head; worth landing in the same release line.
  • The mcp half and its discovery question - the dev's open question (which connection an oauth2 mcp instance's tool discovery uses when several organizations are connected; options A/B/C, B recommended) is for the seat's second PR and the maintainer, not this one; the claim amendment widens that PR's surface to the registered_client column and the DCR step. Nothing here pre-empts it.
  • The rest end-to-end case reads service-automation through the package's dist - the rest package's vitest config aliases only @objectstack/types, so the WeCom end-to-end case judges the built service; the dev proved the wrap's removal reds it with the dist rebuilt. A later edit to plugin.ts needs that rebuild before the case speaks. A trap, not a defect.
  • The seat lands - no Tier H path in this PR (no docs/adr, no skills/**, 1460 lines), no human gate; the connection door and capabilities.ts are untouched as the claim's ⛔ requires (feat(spec,runtime): an organization-scoped manage_connector_connections capability gates the connector connection door (ADR-0143 D5) #28400 owns them; file-disjoint).

④ Design judgment

  • Tried: an apiKey scheme in a cookie (ignored: neither provider sends cookies, and the parameter stays an input, which is harmless); a document whose security names a scheme it does not declare (fills nothing, by design); security: [{}] (optional security reads as not anonymous, so the token is sent, the safer reading); a WeCom answer with errcode as a string (not matched, falls to the provider's 401 rule, which WeCom never sends, so the call fails plainly without a loop); concurrency of retryOnVendorTokenRefusal with the handle's single flight (the refresh shares the in-flight exchange). Nothing to add.

Verdict reasoning. The two built-in HTTP providers consume the handle through the one shared call rule with the organization of the run, authentication stays one-way, the OpenAPI document only withholds and fills, the three vendor exchanges are implemented as documented with the secret never carried into a refusal, the app-credential handle and the WeCom wrap are D4's refresh-once rule and nothing more, the materializer serves on opted-in providers and degrades by name elsewhere, the pins hold under sixteen ablations, and every latest check on this head is green or skipped.

Implemented-by: claude/project-thread-8z6igj
Reviewed-by: session_01L9qibw58ehQ2gRHP2h2Puz

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-tesla · session_01L9qibz3ZUbZ8Qcn1rDw9yc

维护者速读

  • 改了什么:内置的 rest 和 openapi 连接器现在能用 OAuth2 与应用凭证:每次调用按该次运行所属组织借用令牌,遇到 401 刷新一次再试一次,第二次 401 报 503。openapi 读取文档里的 security:标了 security: [] 的操作不带凭证,apiKey 参数由平台填充、不再出现在输入里。飞书、钉钉、企业微信三家的应用凭证换取已实现,企业微信的令牌提前失效(40014/42001)也按刷新一次处理。
  • 为什么改:connectors: outbound OAuth in the open tier — connection-scoped authorization-code / client-credentials / app-credential tokens, refreshed per dispatch, for the openapi, mcp and rest providers #26702 S2:S1 只建好了连接与令牌,内置连接器还不会用它;这一步让 OAuth 连接真正能调用第三方接口。
  • 风险与回滚:令牌只在内存里,不进日志和错误;凭证句柄与静态凭证不能同时存在(互斥被拒)。未登记支持句柄的 provider 仍然降级并说明原因。回滚即 revert。
  • 先看:packages/connectors/connector-rest/src/rest-connector.ts,packages/services/service-automation/src/oauth2-grant/app-credential.ts。
  • 两分钟验证:pnpm --filter @objectstack/connector-rest test && pnpm --filter @objectstack/connector-openapi test,全部通过。
  • 需要你做的:S2 的后半段(mcp)要你先裁决策卡:启动时列工具用哪个组织的授权。本 PR 不受影响。

席位意见:契约复审 PASS(6543092852),16 处消融全部先红后恢复;文档与技能文本的过期由席位另立后续卡(skills/** 属 Tier H)。可以直接落地。

席位核对

ACCEPT · seat domain:server#1 · head be639274 · 2026-10-31T12:01Z

  1. Shape: Part of #26702 (S2 first half: rest + openapi + the D2.2 app-credential transport), draft, base main, 21 files, +1352/−108. Inside the claim's surface (6541963747, amendment 6542689344); the connection door and capabilities.ts are untouched (feat(spec,runtime): an organization-scoped manage_connector_connections capability gates the connector connection door (ADR-0143 D5) #28400 owns them, file-disjoint).
  2. Premise measured: at the claim no connector package read credentialHandle; S0 (spec(connector-auth): ADR-0143 D2.1 oauth2 and D2.2 app-credential auth variants, D7 retirement of ConnectorOAuth2Schema, and the per-dispatch credential handle on ConnectorProviderContext (spec slice S0 of #26702) #28137) and S1 (feat(service-automation): serve oauth2 connector instances per dispatch, and connect an authorization_code instance per organization (ADR-0143 S1) #28317) on main.
  3. Gates / check-runs: contract review PASS 6543092852 on this head (69 runs completed, none failed). At this read 58 success / 11 skipped, 0 failed or pending; mergeable_state clean. Dev: 73/73 derived gates exit 0, --ran reconciled, typecheck ×3 and lint 0, 16 ablations red then restored (os-dev-report 6542669233).
  4. Accepted deviations: S2 split by provider (amendment 6542689344).
  5. Out of scope: connectors.mdx and skills/objectstack-automation/references/integration-recipes.md go stale with this PR (Skills-docs: follow-up owed); the seat files that follow-up as its own PR (Tier H for the skills file). The mcp half waits on the maintainer's discovery-organization ruling. Metadata-protocol: semantic.
  6. Landing: check-governed-merges --pr 28399 NOT governed, 1460 changed lines ≤ 3000. The seat readies the PR and arms auto-merge under the maintainer's 「按 pm-dispatch 审过的pr你可以自主落地」.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant