Repository navigation
feat(automation): an oauth2 connector with clientSource: 'installer' connects with the administrator's OAuth client - #29097
Conversation
…th client An authorization_code instance declaring clientSource: 'installer' takes its client from the administrator on the connect door, keeps it encrypted in the connection record (id repeated in plain text for reads), and uses it for the authorization request, the code exchange and every refresh. A connect without one, or a client offered to an instance that declares its own, is refused 400. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5p21mBsw5qoeMKcPA2
…ource key Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FEkQ5p21mBsw5qoeMKcPA2
📓 Docs Drift CheckThis PR changes 3 package(s): 28 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 7 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 173 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c993cc6a5ffddb01157d6e2ec9c1163116316f5d && git checkout c993cc6a5ffddb01157d6e2ec9c1163116316f5d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 47ee57fea329bbcdab28be42e57644fa5b70dcb2 2ca22f19045a28236809170971ef6968ad5f1a23 && git checkout -B drift-repro 47ee57fea329bbcdab28be42e57644fa5b70dcb2 && git merge --no-ff 2ca22f19045a28236809170971ef6968ad5f1a23
node scripts/docs-audit/affected-docs.mjs --json 47ee57fea329bbcdab28be42e57644fa5b70dcb2
|
Claude-Session: https://claude.ai/code/session_01ADHkmSB1MQ9Npyv1kGL3DV Co-authored-by: Claude <noreply@anthropic.com>
…d call shapes of the installer-client connect Claude-Session: https://claude.ai/code/session_01ADHkmSB1MQ9Npyv1kGL3DV Co-authored-by: Claude <noreply@anthropic.com>
…e real connection door; docs teach clientSource installer Claude-Session: https://claude.ai/code/session_01ADHkmSB1MQ9Npyv1kGL3DV Co-authored-by: Claude <noreply@anthropic.com>
…gin leg its body The begin leg reads the installer's OAuth client from the request body, but the route mounted for /automation/connectors/:name/connection dispatched every verb with no body, so over HTTP a clientSource: 'installer' instance could never be connected. The POST leg now defers its body to the domain, which reads it once the floor has admitted the caller. Claude-Session: https://claude.ai/code/session_01ADHkmSB1MQ9Npyv1kGL3DV Co-authored-by: Claude <noreply@anthropic.com>
|
domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt Contract reviewServed-tier: Faces: schema (two ① Derived judgments
② Semver levelChangeset ③ Boundary flags
④ Design judgment
Implemented-by: VERDICT: PASS |
… leg's closed body set and its refusals Claude-Session: https://claude.ai/code/session_01ADHkmSB1MQ9Npyv1kGL3DV Co-authored-by: Claude <noreply@anthropic.com>
|
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U Contract-review-claim: session_01AavokzJ5DndAwitDXvKy4U · head 2ca22f1 · 2026-11-02T11:59Z Taken under (b): the head has waited 88 min since the lane's PASS at Generated by Claude Code |
|
triage#1 · os-zhuang · session_01AavokzJ5DndAwitDXvKy4U Contract reviewServed-tier: Inputs read: the lane's PASS record 6574689504 at ① Derived judgments
② Semver levelUnchanged by the delta. A ledger note is no published surface, and the changeset and PR-body declarations are as the lane record judged them. Right. ③ Boundary flags
④ Design judgmentThe ledger is the route's record of record. Before this commit the POST row said "No body", which became false once the door took an installer client. The rewrite names the closed set and the refusals by class, and it does not restate values. Tried to break it:
Must-fix: none. Should-fix: none new. Nit, optional: add "a VERDICT: PASS Generated by Claude Code |
|
domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt 席位核对ACCEPT · Two maintainer rulings apply:
Generated by Claude Code |
|
domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt Landing record · PR #29097
Generated by Claude Code |
domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt
Requested by Leehom · project thread
Fixes #28866
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs:
content/docs/automation/connectors.mdxteachesclientSource: 'installer'in this PR (the publish-door bullet and the connect body).skills/objectstack-automation/references/integration-recipes.md(governed Tier H) is not edited here. Follow-up owed — #29255Ruling-ref: 6566728470
维护者速读
clientSource: 'installer'的 OAuth 连接器,管理员在「连接」时提交自己在厂商处登记的 client id 和 secret;授权、换取令牌、每次刷新都用这个客户端。secret 只加密存在连接记录里,读接口只显示「已设置客户端、id、有无 secret」。clientIdis fixed by the package and the redirect URI is per deployment #28702 裁决 B 要求开源版 OAuth 应用不带官方客户端、由每个部署的管理员自带;规格半边 feat(spec): anoauth2authorization_codeconnector instance can take its OAuth client from the installer —clientSource: 'installer'#28920 已合并,这是服务端半边。clientId或交给 MCP 动态注册的实例行为不变。新增三种拒绝(400):安装方实例连接时没给客户端;给其他实例传了客户端;连接请求体带了别的键(以前都被静默忽略)。回滚即撤掉本 PR。packages/services/service-automation/src/plugin.ts的installerClientOfBegin和completeConnectorConnection;packages/runtime/src/dispatcher-plugin.ts连接门挂载点(这轮修的真缺陷:以前 HTTP 请求体根本没传进门);oauth2-grant/grant-handle.ts的endpointOf。pnpm --filter @objectstack/dogfood exec vitest run test/connector-installer-oauth-client.dogfood.test.ts,应看到 4 条全过(真实 HTTP 门上:无客户端被拒、多余键被拒、用管理员客户端完成连接并撤销)。What changes
packages/spec/src/contracts/automation-service.ts, TS only):ConnectorConnectionRequest.client?: { clientId; clientSecret? }, read bybeginConnectorConnectiononly;ConnectorConnectionState.client?: { source: 'installer'; clientId; hasSecret }, never the secret. Inline member types, so no export moves (check:api-surfaceexit 0).packages/runtime/src/domains/automation.ts): the begin leg (POST) reads a closed body set,clientIdandclientSecret, and hands it to the service asclient. No body, or{}, begins with no client as before. An unknown key, a non-object body, or a secret without its id is refused 400VALIDATION_ERRORnaming keys only. The read and revoke legs still read no body.packages/runtime/src/dispatcher-plugin.ts, new in this round): the route mounted for/automation/connectors/:name/connectiondispatched every verb with no body, so over HTTP the begin leg never saw the installer's client and such an instance could not be connected at all. ThePOSTleg now defers its body (deferRequestBody), which the domain reads once the floor has admitted the caller, as every other body-bearing automation route does. Found by the new dogfood case (its first run was red on exactly this), invisible to the domain-level tests, which callhandleAutomationwith a body directly.packages/services/service-automation):installerClientOfBegin: an installer instance begun without a client is refused 400 naming the instance, the vendor registration step and the redirect URI to register; a client offered to any other instance is refused 400 rather than ignored (the spec has no override form: feat(spec): anoauth2authorization_codeconnector instance can take its OAuth client from the installer —clientSource: 'installer'#28920 refusesclientIdbesideclientSource); a malformed client is refused naming the key. The read and revoke members refuse aclient.installerClient), the code is exchanged as it, and the connection keeps it inregistered_client(Field.secret, the store and channel the refresh token uses), as{ source: 'installer', clientId, clientSecret? }plus the discovered endpoints on MCP over http. Two new plain columns onsys_connector_connection:client_idandclient_has_secret, which the read answers from without decrypting.grant-handle.ts#endpointOfreads the kept client throughparseKeptConnectionClient(newoauth2-grant/installer-client.ts) and uses a kept client only when its kind matches the declaration: an installer's client under a declaration that no longer takes one, or the reverse, is "reconnect required", never used.oauth2NotServableReasonno longer listsclientIdas missing for the installer form;mcpDiscoveryServerUrltreats it as a supplied client, so MCP discovery finds the endpoints and registers nothing.clientSourceloses[PLANNED — not enforced], theconnector/authliveness row loses its planned sentence, the PLANNED pin test is flipped, andcontent/docs/references/integration/connector.mdxis regenerated.packages/runtime/src/route-ledger.ts): thePOST /automation/connectors/:name/connectionrow no longer says "No body". It names the closed body set{ clientId, clientSecret? }, says that no body or{}begins with no client, and lists the three 400VALIDATION_ERRORrefusals (another body key, an installer instance begun with no client, a client offered to an instance that declares its own or leaves it to dynamic registration). TheGETrow names theclientthe read answers. This was should-fix 1 of the contract review.content/docs/automation/connectors.mdxteaches the installer form and the connect body.not-required (no-migration-prescription)and a BREAKING paragraph naming the three newly refused call shapes and what to do.Tests
packages/qa/dogfood/test/connector-installer-oauth-client.dogfood.test.ts(new, 4 tests, showcase boot, real HTTP door, a loopback token endpoint): served "not connected", never "missingclientId"; ⛔ a begin with no client is refused 400 and stores nothing; ⛔ a begin body with another key is refused 400 naming it, never echoing the secret; connected end to end as the administrator's client (authorize URLclient_id, Basic on the code exchange, the read'sclient, the plainclient_idcolumn, no secret in any answer or row), then revoked.packages/services/service-automation/src/connector-connection-door.test.ts, new describe (10 tests) over a real ObjectQL on SQLite and a local token endpoint: connect, dispatch, refresh on a 401 and revoke as the installer's client (Basic for a confidential one,client_idin the form for a public one); per-organization clients and reconnect replacement; the missing-client refusal; malformed clients; CONTROL: a packaged instance refuses an offered client and connects as before without one; read and revoke refuse a client; a declaration that changed while away; the secret absent from logs, descriptors, degraded reason, door answers and engine reads.oauth2-grant/grant-handle.test.ts, new describe (4 tests): refresh as the installer's client at the declared endpoint and at a kept discovered one; per-organization clients; a kept client of the other kind is never used.packages/runtime/src/domains/automation-connector-connection-door.test.ts, new describe (4 tests): the begin body is handed over asclient; no body or{}is unchanged; refusals name keys only; the read and revoke legs read no body.packages/spec/src/integration/connector-auth-grant-kinds.test.ts, the PLANNED pin flipped to "served" (the spec lane's landing note asks for it);oauth2-grant/grant-handle.test.ts'sfakeConnectionsdouble gainsinstallerClientHasSecret: false, the new required member ofConnectorConnectionRecord.Acceptance notes
sys_connector_connectionhasapiEnabled: falseandapiMethods: [], so the connection door is the only read, and it answersConnectorConnectionStatefrom plain columns.collectMaskedReadFieldsstill answers exactlyrefresh_tokenandregistered_client(pinned).clienton the in-process read and revoke members. Nothing else that was accepted is refused.clientSource: 'installer'instance it now gets the 400 that names the registration step. The server half and the API are complete; a Console form for the client id and secret is ui-lane work, already carried by console: the connector connection form asks the administrator for the OAuth client (client id + secret) when the instance takes an installer-supplied client (#28702 B, ui slice) #28869.client,clientId,clientSecret,hasSecret,sourceon the two contract members and the connect body): maintainer approval is owed before landing; the seat asks.Verification
Final head
2ca22f19. It differs from59630d94only by the two route-ledger notes (string literals in one file); the readings below were taken on59630d94unless a line names another, and the new head's readings are in the os-dev-report on #28866. Heavy runs went throughos-verify-lock.sh.Red first on main, then green
plugin.ts,connector-connections.ts,sys-connector-connection.object.ts,oauth2-grant/{authorization,grant-handle,index}.ts, runtimedomains/automation.ts, specconnector-auth.zod.ts,liveness/connector.json) restored toorigin/mainfea2dbb blobs, tests at HEAD 52e2396: service-automationTests 13 failed | 47 passed (60), runtimeTests 2 failed | 33 passed (35), specTests 1 failed | 29 passed (30)— every new test red, every pre-existing one green. Restored: every blob == HEAD,git diff HEADempty, status clean.Tests 2 failed | 2 passed (4)— the unknown-key and the connected cases, the door seeing no body. On 59630d9:Tests 4 passed (4).Ablation of every negative pin (each red, each restored blob == HEAD, `git diff HEAD` empty)
Through
scripts/ablation-replace.mjs; the unit suites import their subject fromsrc/.if (offered === undefined) { return undefined;):× a connect without a client is refused 400…— Tests 1 failed | 29 passed (30).× CONTROL: a client offered to an instance that declares its own is refused 400, not ignored…— 1 failed.if (false) {):× refuses an unknown key, a non-object body and a secret without its id…— 1 failed | 34 passed (35).× the read and revoke legs refuse a client…— 1 failed.× a kept client of the other kind than the declaration takes is never used…— 1 failed | 29 passed (30).× a declaration that changed where its client comes from…— 1 failed.dist/(rebuild +ablation-dist-preflight.mjsmarker present; restore rebuild +--absent, tree clean): D1 the mount drops the body again → 2 failed | 2 passed (4); D2 = A1 in service-automation →× a begin with no client is refused 400 VALIDATION_ERROR…1 failed | 3 passed (4).Package suites and typecheck
@objectstack/service-automationtest (52e2396): Test Files 257 passed (257), Tests 3323 passed (3323); typecheck (tsc +check:test-typecheck) VERDICT 0.@objectstack/runtimetest (59630d9): Test Files 514 passed (514), Tests 8322 passed | 19 skipped (8341); typecheck VERDICT 0.@objectstack/spectest (52e2396): Test Files 790 passed (790), Tests 23357 passed | 1 todo; typecheck VERDICT 0;check:generatedVERDICT 0 (check:authorable-surfaceexit 0 in the derived set). Spec source is unchanged after 52e2396.@objectstack/dogfoodtypecheck (59630d9) VERDICT 0.eslint --no-inline-configover the touched runtime, service-automation and spec sources and the new dogfood file: exit 0.Derived gates and dogfood battery (59630d9)
dispatch-gates --commands: 109 families; all 109 exit 0 (four first returned exit 3 PREREQUISITE NOT MET and were re-run aftergit fetch --unshallowand the missing builds:check-plugin-teardown-shape --self-test,check:skill-examples,check:dual-build-cjs-loads,check:type-check-debt).--ran: 109 derived, 109 run, 0 NOT-MEASURED, 0 UNRUN.check-adr-0087-registration --base origin/main:[BREAKING+clause-②-narrowing] not-required (no-migration-prescription), exit 0.pnpm --filter @objectstack/dogfood testunder the lock: Test Files 387 passed | 3 skipped (390), Tests 3199 passed | 28 skipped (3227), VERDICT command-exit 0.Generated by Claude Code