Repository navigation
Commit ec0316e
feat(automation): an oauth2 connector with clientSource: 'installer' connects with the administrator's OAuth client (#29097)
domain:server#2 · os-steve · session_01ADHkmQxwoGb5fSnrFkpxmt
<!-- claude-projects-attribution: {"github_login":"os-project-manager"}
-->
_Requested by **Leehom** · [project
thread](https://claude.ai/code/project/chan_01TenzsRopZj4jMbvjX8uz1X?thread=cmsg_01TenzsRopZj4jMbvjX8uz1XKUuqUUMXTeftRSDmw2eTo8)_
Fixes #28866
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: `content/docs/automation/connectors.mdx` teaches
`clientSource: '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 — #29255
Ruling-ref: 6566728470
## 维护者速读
- 改了什么:声明 `clientSource: 'installer'` 的 OAuth 连接器,管理员在「连接」时提交自己在厂商处登记的
client id 和 secret;授权、换取令牌、每次刷新都用这个客户端。secret
只加密存在连接记录里,读接口只显示「已设置客户端、id、有无 secret」。
- 为什么改:#28702 裁决 B 要求开源版 OAuth 应用不带官方客户端、由每个部署的管理员自带;规格半边 #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
- **Contracts** (`packages/spec/src/contracts/automation-service.ts`, TS
only): `ConnectorConnectionRequest.client?: { clientId; clientSecret?
}`, read by `beginConnectorConnection` only;
`ConnectorConnectionState.client?: { source: 'installer'; clientId;
hasSecret }`, never the secret. Inline member types, so no export moves
(`check:api-surface` exit 0).
- **The door** (`packages/runtime/src/domains/automation.ts`): the begin
leg (`POST`) reads a closed body set, `clientId` and `clientSecret`, and
hands it to the service as `client`. No body, or `{}`, begins with no
client as before. An unknown key, a non-object body, or a secret without
its id is refused 400 `VALIDATION_ERROR` naming keys only. The read and
revoke legs still read no body.
- **The HTTP mount** (`packages/runtime/src/dispatcher-plugin.ts`, new
in this round): the route mounted for
`/automation/connectors/:name/connection` dispatched 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. The `POST` leg 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 call
`handleAutomation` with a body directly.
- **The service** (`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: #28920
refuses `clientId` beside `clientSource`); a malformed client is refused
naming the key. The read and revoke members refuse a `client`.
- The client rides the begun connect's sealed pending authorization
(`installerClient`), the code is exchanged as it, and the connection
keeps it in `registered_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 on `sys_connector_connection`: `client_id` and
`client_has_secret`, which the read answers from without decrypting.
- `grant-handle.ts#endpointOf` reads the kept client through
`parseKeptConnectionClient` (new `oauth2-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.
- Servability: `oauth2NotServableReason` no longer lists `clientId` as
missing for the installer form; `mcpDiscoveryServerUrl` treats it as a
supplied client, so MCP discovery finds the endpoints and registers
nothing.
- A complete whose declaration changed between installer and declared
client while the browser was away is refused, and its code is not used.
- Revoke is the existing row delete; the client goes with the row.
- **Spec lane's landing note** (card comment 6570978729): `clientSource`
loses `[PLANNED — not enforced]`, the `connector/auth` liveness row
loses its planned sentence, the PLANNED pin test is flipped, and
`content/docs/references/integration/connector.mdx` is regenerated.
- **Route ledger** (`packages/runtime/src/route-ledger.ts`): the `POST
/automation/connectors/:name/connection` row 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 400
`VALIDATION_ERROR` refusals (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). The `GET` row names the
`client` the read answers. This was should-fix 1 of the contract review.
- **Docs**: `content/docs/automation/connectors.mdx` teaches the
installer form and the connect body.
- **Changeset**: the ADR-0087 disposition `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 "missing `clientId`"; ⛔ 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 URL
`client_id`, Basic on the code exchange, the read's `client`, the plain
`client_id` column, 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_id` in 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 as `client`; no
body or `{}` is unchanged; refusals name keys only; the read and revoke
legs read no body.
- **Forced test edits**:
`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`'s `fakeConnections` double
gains `installerClientHasSecret: false`, the new required member of
`ConnectorConnectionRecord`.
## Acceptance notes
- **Reads checked against the redaction on main.**
`sys_connector_connection` has `apiEnabled: false` and `apiMethods: []`,
so the connection door is the only read, and it answers
`ConnectorConnectionState` from plain columns. `collectMaskedReadFields`
still answers exactly `refresh_token` and `registered_client` (pinned).
- **The narrowing.** Before, the begin leg ignored any body, so a client
offered to a packaged instance, or any body key, was silently dropped.
Both are now refused 400, as is a `client` on the in-process read and
revoke members. Nothing else that was accepted is refused.
- **The Console cannot connect an installer instance yet.** Setup →
Integrations → Connector Connections begins a connect with no body, so
for a `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 #28869.
- **New public key names** (`client`, `clientId`, `clientSecret`,
`hasSecret`, `source` on the two contract members and the connect body):
maintainer approval is owed before landing; the seat asks.
## Verification
Final head `2ca22f19`. It differs from `59630d94` only by the two
route-ledger notes (string literals in one file); the readings below
were taken on `59630d94` unless a line names another, and the new head's
readings are in the os-dev-report on #28866. Heavy runs went through
`os-verify-lock.sh`.
<details><summary>Red first on main, then green</summary>
- Implementation files (`plugin.ts`, `connector-connections.ts`,
`sys-connector-connection.object.ts`,
`oauth2-grant/{authorization,grant-handle,index}.ts`, runtime
`domains/automation.ts`, spec `connector-auth.zod.ts`,
`liveness/connector.json`) restored to `origin/main` fea2dbb blobs,
tests at HEAD 52e2396: service-automation `Tests 13 failed | 47 passed
(60)`, runtime `Tests 2 failed | 33 passed (35)`, spec `Tests 1 failed |
29 passed (30)` — every new test red, every pre-existing one green.
Restored: every blob == HEAD, `git diff HEAD` empty, status clean.
- The new dogfood case on a12d046 (before the mount fix): `Tests 2
failed | 2 passed (4)` — the unknown-key and the connected cases, the
door seeing no body. On 59630d9: `Tests 4 passed (4)`.
</details>
<details><summary>Ablation of every negative pin (each red, each
restored blob == HEAD, `git diff HEAD` empty)</summary>
Through `scripts/ablation-replace.mjs`; the unit suites import their
subject from `src/`.
- A1 installer begin with no client proceeds (`if (offered ===
undefined) { return undefined;`): `× a connect without a client is
refused 400…` — Tests 1 failed | 29 passed (30).
- A2 a client offered to a non-installer instance is ignored: `×
CONTROL: a client offered to an instance that declares its own is
refused 400, not ignored…` — 1 failed.
- A3 unknown begin body keys pass (runtime, `if (false) {`): `× refuses
an unknown key, a non-object body and a secret without its id…` — 1
failed | 34 passed (35).
- A4 a client on read/revoke is accepted: `× the read and revoke legs
refuse a client…` — 1 failed.
- A5 a kept client of the other kind is used (grant handle): `× a kept
client of the other kind than the declaration takes is never used…` — 1
failed | 29 passed (30).
- A6 a declaration that changed during the callback is not checked: `× a
declaration that changed where its client comes from…` — 1 failed.
- Dogfood, through `dist/` (rebuild + `ablation-dist-preflight.mjs`
marker 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).
</details>
<details><summary>Package suites and typecheck</summary>
- `@objectstack/service-automation` test (52e2396): Test Files 257
passed (257), Tests 3323 passed (3323); typecheck (tsc +
`check:test-typecheck`) VERDICT 0.
- `@objectstack/runtime` test (59630d9): Test Files 514 passed (514),
Tests 8322 passed | 19 skipped (8341); typecheck VERDICT 0.
- `@objectstack/spec` test (52e2396): Test Files 790 passed (790),
Tests 23357 passed | 1 todo; typecheck VERDICT 0; `check:generated`
VERDICT 0 (`check:authorable-surface` exit 0 in the derived set). Spec
source is unchanged after 52e2396.
- `@objectstack/dogfood` typecheck (59630d9) VERDICT 0. `eslint
--no-inline-config` over the touched runtime, service-automation and
spec sources and the new dogfood file: exit 0.
</details>
<details><summary>Derived gates and dogfood battery (59630d9)</summary>
- `dispatch-gates --commands`: 109 families; all 109 exit 0 (four first
returned exit 3 PREREQUISITE NOT MET and were re-run after `git fetch
--unshallow` and 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.
- Full dogfood battery, `pnpm --filter @objectstack/dogfood test` under
the lock: Test Files 387 passed | 3 skipped (390), Tests 3199 passed |
28 skipped (3227), VERDICT command-exit 0.
- CI on 59630d9: nine required contexts success (Lint & Repo Gates,
TypeScript Type Check, Test Core, Build Core, Governed Surface Queue
Guard, Type Check UI, Test UI, Build & E2E UI, Live E2E UI) and Lint UI
skipped by its path selector; Check Changeset and Dogfood Regression
Gate success; no check failed.
</details>
---
_Generated by [Claude
Code](https://claude.ai/code/session_01ADHkmSB1MQ9Npyv1kGL3DV)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 5f7d554 commit ec0316e
21 files changed
Lines changed: 1057 additions & 72 deletions
File tree
- .changeset
- content/docs
- automation
- references/integration
- packages
- qa/dogfood/test
- runtime/src
- domains
- services/service-automation/src
- oauth2-grant
- spec
- liveness
- src
- contracts
- integration
| 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 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
558 | 558 | | |
559 | 559 | | |
560 | 560 | | |
561 | | - | |
| 561 | + | |
| 562 | + | |
| 563 | + | |
| 564 | + | |
| 565 | + | |
562 | 566 | | |
563 | 567 | | |
564 | 568 | | |
| |||
640 | 644 | | |
641 | 645 | | |
642 | 646 | | |
| 647 | + | |
| 648 | + | |
| 649 | + | |
| 650 | + | |
| 651 | + | |
| 652 | + | |
| 653 | + | |
| 654 | + | |
| 655 | + | |
| 656 | + | |
| 657 | + | |
643 | 658 | | |
644 | 659 | | |
645 | 660 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
270 | 270 | | |
271 | 271 | | |
272 | 272 | | |
273 | | - | |
| 273 | + | |
274 | 274 | | |
275 | 275 | | |
276 | 276 | | |
| |||
565 | 565 | | |
566 | 566 | | |
567 | 567 | | |
568 | | - | |
| 568 | + | |
569 | 569 | | |
570 | 570 | | |
571 | 571 | | |
| |||
604 | 604 | | |
605 | 605 | | |
606 | 606 | | |
607 | | - | |
| 607 | + | |
608 | 608 | | |
609 | 609 | | |
610 | 610 | | |
| |||
876 | 876 | | |
877 | 877 | | |
878 | 878 | | |
879 | | - | |
| 879 | + | |
880 | 880 | | |
881 | 881 | | |
882 | 882 | | |
| |||
0 commit comments