Skip to content

fix(service-automation): harden the OAuth2 connector connection door (ADR-0143 S1 should-fixes) - #28397

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-9u0vb0
Oct 31, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-9u0vb0

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Sam · project thread

Fixes #28326

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么:OAuth2 连接器"连接"流程的七处加固:等待回调的授权数据加密存放、连接记录按版本号条件写、读不了存储时回 503、令牌端点的重定向一律拒绝、明文 http 不再放行 *.localhost、断开连接不向厂商撤销令牌这一缺口写进路由台账和文档。
  • 为什么改:这是 PR feat(service-automation): serve oauth2 connector instances per dispatch, and connect an authorization_code instance per organization (ADR-0143 S1) #28317(ADR-0143 S1)合同复核留下的七条 should-fix,连接入口已在 main 上,所有接 OAuth2 连接器的组织都会走到。
  • 风险与回滚:升级那一刻正在进行中的连接会被当作过期,管理员重新点一次"连接"即可;回滚就是 revert 本 PR,无数据迁移。
  • 先看:oauth2-grant/authorization.ts(封存方案与 KV 信任边界)、connector-connections.ts(updateAtRevision 条件写)、oauth2-grant/token-endpoint.ts(redirect: 'manual')。
  • 两分钟验证:pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/connector-connections.test.ts src/oauth2-grant,预期 5 个文件全绿。
  • 需要你做的:无,席位落地。

What changes

All seven items on the card, each measured on main at 3ba52069 first; all seven still stood.

Item On main Now
SF2 pending authorization at rest the verifier, user, organization and redirect URI sat in the cluster KV (or memory) as a plain object for 600 s an AES-256-GCM envelope under a key derived (HKDF-SHA-256) from the raw state, which the server keeps only as a digest; the envelope's AAD names its KV slot
SF3 revision invariant recordRefresh / markReconnectRequired read the row, compared the revision, then updated by id one conditional write, update(fields, { where: { id, revision }, multi: true }), judged on the affected-row count; connect()'s reconnect uses the same write and refuses 503 when it loses the race
SF6 vendor revocation DELETE …/connection deletes locally; the docs already said so, the route ledger row did not the ledger row states the gap; docs say why (no revocation endpoint is known until ADR-0143 S2 discovery) and to revoke at the vendor after a leak
readOrRefuse connect() and revoke() did a bare read(), so a throwing store answered an undeclared 500 both route through readOrRefuse (503 ConnectorCredentialUnavailableError, the engine's message not carried)
token endpoint redirect default fetch redirect mode follows a 307/308 with the form body (client secret, refresh token) redirect: 'manual', and any 3xx is refused as OAuth2TokenResponseError (503, configuration)
*.localhost accepted as loopback for plain http: refused; loopback is localhost, 127.0.0.0/8, [::1] only
changeset bullet not in the S1 note in this card's 28326-oauth2-connection-door-hardening.md; the merged 26702 note is untouched

Route changes from the dispatch, said plainly

  • SF2 is sealed with a state-derived key, not the host ICryptoProvider. The provider's CryptoContext.scope is the closed CRYPTO_CONTEXT_SCOPES set (ADR-0128 D1), which has no member for this producer; adding one is a packages/spec change, outside this claim, and borrowing an existing member is what ADR-0128 forbids. Its handle is also persisted as a sys_secret row, a durable record for a ten-minute value. The state-derived key needs no shared secret, so it holds on a cluster whose replicas share only the KV. The trust boundary this leaves is written in the module header and the docs: a KV reader learns nothing; a KV writer cannot alter or move an entry it did not seal, but can still delete one or plant one under a state it chose itself. Cluster KV write access stays inside the platform's trust boundary, as it is for the cluster lock.
  • redirect: 'manual' plus an explicit refusal, not redirect: 'error'. With 'error' a redirect surfaces as a thrown TypeError, which this module reads as "no answer", that is CONNECTOR_UPSTREAM_UNAVAILABLE, retried on the backoff forever. A redirect is a wrong tokenUrl, so it is refused once as a configuration fault. Either way nothing is sent past the first hop.
  • No RFC 7009 call is added. Nothing in the tree can supply a revocation_endpoint today, so a revoke call would be code no path reaches.

Surface beyond the claim's file list

The claim names connector-connections.ts, oauth2-grant/**, route-ledger.ts, connectors.mdx and the changeset. Also touched, each forced by the change:

  • service-automation/src/connector-connections.test.ts (new): the store's tests, over a real ObjectQL engine on SQLite.
  • scripts/engine-double-contract.pinned.json: the two new engine doubles open with assertEngineUpdateDispatch / assertEngineDeleteDispatch, and check:engine-double-contract --write recorded them.
  • content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md: the store has two fewer write call sites, so tenant-audit-census.mjs --write re-measured the generated block, and the hand-written prose counts were moved to match (257 to 255, 63 to 62 and 61, 179 to 177, 147 to 145).

Why

ADR-0143 S1 put the OAuth2 connection door on main (PR #28317). Its contract review (record 6539266538) and delta review left these as should-fixes, and the card carries all of them.

Tests

  • src/oauth2-grant/authorization.test.ts: the KV holds no verifier, user, organization, instance, redirect URI or state, only { v, iv, ct, tag }; an edited envelope, an envelope moved to another state's slot, and a plain value planted in the KV all open as nothing; the memory store round-trips the same envelope.
  • src/connector-connections.test.ts (new, real engine): a refresh, a non-rotating refresh, the reconnect-required mark and a reconnect each write when the row has not moved; a reconnect landing between the refresh (or the refusal, or another reconnect) and its write is never overwritten; a revoked row is not written; connect and revoke over a store whose read throws answer 503 without the engine's message.
  • src/oauth2-grant/token-endpoint.test.ts: a 307, 308 and 302 from a real local server are refused, and the Location server receives nothing.
  • src/oauth2-grant/endpoint-transport.test.ts: http://idp.localhost, http://localhost.localhost, http://localhost@evil.com and http://[::ffff:127.0.0.1] are refused. The last two were already refused on main (the WHATWG parser's host); they are pinned so the rule cannot regress.

Acceptance notes

  • A connect begun before this deploys and completed after it is refused as expired; the administrator begins it again. Stated in the changeset.
  • The ablation ledger is below; every negative pin went red under the behaviour it bans and green again on restore.

Verification

Ablations (each through scripts/ablation-replace.mjs, restore proven blob == HEAD)

The subjects are imported by relative path from src/, so no dist/ build sits between mutation and test.

Mutation (banned behaviour restored) Red pins
put stores the plain pending object 4: digest-key test, no-plaintext, edited envelope, moved envelope
openPendingAuthorization accepts a non-envelope as the value 2: plain value planted, scheme version
updateAtRevision reverts to main's read, compare, update-by-id 3: the three interleave pins (refresh, reconnect-required, reconnect); the seven preservation pins stay green
revoke() bare read() 1: revoke 503
connect() bare read() 1: connect 503
redirect: 'manual' removed 3: 307, 308, 302
*.localhost arm restored 2: idp.localhost, localhost.localhost

A first redirect ablation passed an empty replacement; the tool refused it on its own replacement-count check and restored the file before the tests ran, so nothing was read from it. It was re-run with a marker replacement, as above. A first SF3 ablation that dropped revision from the where also turned the by-id route back on and reddened every write test, so it isolated nothing; the read-compare-update form above is the one that reproduces main.

Local runs (head f957754)
  • pnpm --filter @objectstack/service-automation typecheck and test: 243 files, 3084 tests passed (lock VERDICT command-exit 0).
  • After the last test edit: typecheck plus connector-connections, oauth2-grant/*, connector-connection-door, connector-materialization: 7 files, 157 tests passed.
  • packages/runtime: domains/automation-connector-connection-door.test.ts 12 passed, route-ledger.conformance.test.ts 8 passed, pnpm typecheck exit 0.
  • pnpm lint exit 0.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack: 101 derived families, all run, every one exit 0; --ran reconciliation: 101 run, 0 NOT-MEASURED, 0 UNRUN (pnpm lint recorded beyond the derivation). The first pass on d06a0250 caught check:engine-double-contract and check-tenant-audit-census (and its self-test) red; both fixed in f9577543. Three families needed a full build (check:dual-build-cjs-loads, spec check:skill-examples, teardown-shape --self-test after unshallowing) and passed after it.

Generated by Claude Code

…S1 should-fixes)

- Seal the pending authorization (PKCE verifier, user, organization,
  redirect URI) with AES-256-GCM under a key derived from the raw state,
  which the server keeps only as a digest; an edited or moved KV value
  opens as nothing. Documents the cluster KV trust boundary.
- Write a refresh, the "reconnect required" mark and a reconnect only over
  the revision they read, as one conditional write (where id + revision).
- Route connect()/revoke() reads through readOrRefuse (503, never 500).
- Never follow a token endpoint redirect; refuse it.
- Drop the *.localhost arm of the plain-http loopback allowance.
- State the missing RFC 7009 vendor revocation in the route ledger and docs.

Claude-Session: https://claude.ai/code/session_01QUs3BvFbD3DRY4t9ByMH8B
Co-authored-by: Claude <noreply@anthropic.com>
…e the tenant-audit census

The two new engine doubles open with the update/delete dispatch asserts and
join the pinned ledger. The store's write sites shrank by two (one
conditional write replaces two read-then-update pairs), so the census and
its prose counts are re-measured.

Claude-Session: https://claude.ai/code/session_01QUs3BvFbD3DRY4t9ByMH8B
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l 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 2 package(s): @objectstack/runtime, @objectstack/service-automation, touching 15 documentable anchor(s).

1 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/automation/connectors.mdx (via reconnect_required (literal, a string literal in markReconnectRequired))
What this run could not see
  • 4 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 — 34 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 8c8c5b5255109a7d039225e48e8f6b718247a9af → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 632f9e02b7701298c83021dfd28037e347a5d397 — the merge of head f9577543daaf751d07545d586d3555a8a1387a12 into base 8c8c5b5255109a7d039225e48e8f6b718247a9af, 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 632f9e02b7701298c83021dfd28037e347a5d397 && git checkout 632f9e02b7701298c83021dfd28037e347a5d397
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8c8c5b5255109a7d039225e48e8f6b718247a9af f9577543daaf751d07545d586d3555a8a1387a12 && git checkout -B drift-repro 8c8c5b5255109a7d039225e48e8f6b718247a9af && git merge --no-ff f9577543daaf751d07545d586d3555a8a1387a12

node scripts/docs-audit/affected-docs.mjs --json 8c8c5b5255109a7d039225e48e8f6b718247a9af

⚠️ 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 8c8c5b5255109a7d039225e48e8f6b718247a9af → 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#4 · os-zhuang · session_01QUs3BtyH9zgxKzYrJiH18J

ACCEPT · PR #28397 · Head-sha f9577543daaf751d07545d586d3555a8a1387a12 · 2026-10-31T11:05Z

维护者速读

  • 改了什么: OAuth2 连接器"连接"流程七处加固:等待回调的授权数据以 AES-256-GCM 封存(密钥由原始 state 经 HKDF 派生,服务端只留摘要);连接记录按版本号条件写(where: { id, revision });存储读不了时回 503;令牌端点的重定向一律拒绝不跟随;明文 http 不再放行 *.localhost;断开连接不向厂商撤销令牌这一缺口写进路由台账和文档。
  • 为什么改: PR feat(service-automation): serve oauth2 connector instances per dispatch, and connect an authorization_code instance per organization (ADR-0143 S1) #28317(ADR-0143 S1)合同复核留下的 should-fix,连接入口已在 main 上,所有接 OAuth2 连接器的组织都会走到。
  • 风险与回滚: 升级那一刻正在进行中的连接会被当作过期,管理员重新点一次"连接"即可;回滚即 revert 本 PR,无数据迁移。
  • 先看: oauth2-grant/authorization.ts 的 sealPendingAuthorization / openPendingAuthorization;connector-connections.ts 的 updateAtRevision;oauth2-grant/token-endpoint.ts 的 redirect: 'manual' 与 3xx 拒绝。
  • 两分钟验证: pnpm --filter @objectstack/service-automation exec vitest run --maxWorkers=2 src/connector-connections.test.ts src/oauth2-grant。
  • 需要你做的: 无,席位落地。

席位意见:七条都修在点上,封存方案不需要新的 spec 加密作用域,条件写走真实 ObjectQL 引擎测过;可合。

席位核对

  • Form: 14 files, +572/-73. check-governed-merges.mjs --pr 28397: NOT governed, 645 lines ≤ 3000, Tier S. Clause-②: no holds: no packages/spec/src/** path; the new seal/open helpers and SealedPendingAuthorization are module exports not re-exported from the package index; the 3xx refusal reuses OAuth2TokenResponseError (503); the revoke row's new 503 is the declared ConnectorCredentialUnavailableError, replacing an undeclared 500. Metadata-protocol: none holds.
  • Premise measured: the dev measured all seven on main 3ba52069 first. Seat read the diff: the seal key is HKDF over the raw state with a distinct info, AAD is the slot key, open fails closed to "no connect waiting"; the KV and in-process stores both hold envelopes only. connect() reconnect, recordRefresh and markReconnectRequired each write through one where: { id, revision } update judged on the affected-row count (no count reads as none), and connect() bumps the revision so a stale refresh cannot overwrite a fresh grant. Pinned in the new connector-connections.test.ts over a real ObjectQL + SQLite engine.
  • Gates (check-runs at the pinned head): 50 runs: 38 success, 12 skipped, 0 failed, 0 pending.
  • Accepted deviations: (1) SF2 seals with node crypto under a state-derived key instead of ICryptoProvider, because CRYPTO_CONTEXT_SCOPES is closed by ADR-0128 D1 and a new member is a spec change. Accepted, and the seat agrees with the dev not to file a domain:spec card for option B: the state-derived key needs no shared server secret and holds the KV-read boundary on its own. (2) redirect: 'manual' plus an explicit 3xx refusal, instead of redirect: 'error', which would read as an outage and retry. (3) SF6 is documented, not implemented: no revocation endpoint is known before S2. (4) The file-surface breach (new connector-connections.test.ts, scripts/engine-double-contract.pinned.json, the tenant-audit census page and counts) was each forced by a gate. (5) Ran at the project default effort, not xhigh, recorded on the claim.
  • Out of scope and where it goes: markReconnectRequired writes over revision R without bumping it, so a refresh already in flight on R can still land its rotated token on a reconnect_required row. This predates the PR (the old read-compare had the same window), and status stays reconnect_required. Recorded as an observation for ADR-0143 S2. Skills-docs: none.
  • Landing clause: Tier S ordinary queue landing. The seat flips ready and enables auto-merge through the relay, then confirms MERGED and closes out connector connections (ADR-0143 S1): harden the OAuth2 connection door — seal the pending PKCE verifier, update the connection row by revision, revoke vendor-side on disconnect #28326.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 31, 2026 11:06
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 31, 2026 11:06
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 31, 2026
Merged via the queue into main with commit 4a7b99f Oct 31, 2026
51 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-9u0vb0 branch October 31, 2026 11:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

1 participant