Skip to content

feat(plugin-auth): read the AI assistants a person has authorized, and when each was last used - #30946

Queued
objectstack-fleet[bot] wants to merge 8 commits into
mainfrom
claude/project-thread-tmgp4d
Queued

objectstack-fleet[bot] wants to merge 8 commits into
mainfrom
claude/project-thread-tmgp4d

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

Requested by Elon · project thread

domain:server#1 · os-elon-musk · session_016WV4d1LNDAUs8pz7DNjQFC

Fixes #30918

Clause-②: yes (widening)
Metadata-protocol: none

Claim: 6683953156 (the full re-post of 6683760626 with this branch, the stamp's home and the dogfood classification rows) · Branch: claude/project-thread-tmgp4d · Access boundary: security#1's 6683642288, rules 1 to 7, with the seat's rule-2 reading 6683944651 · Draft: owes a CONTRACT_REVIEW_TIER review against those rules, arranged by the seat.

How the two lines were derived. Clause-②: yes (widening): two new served reads (AUTH_ROUTE_LEDGER rows, sdk), two new @objectstack/client methods with two new exported types, and one new nullable column; nothing existing is removed or tightened. Metadata-protocol: none: the new sys_oauth_consent.last_used_at is a data column on a shipped, better-auth-managed object (lock: 'full', apiEnabled: false), declared as an ordinary Field.datetime({ readonly: true, required: false }) in packages/platform-objects/src/identity/sys-oauth-consent.object.ts. No metadata author can write it, and no key, accepted value or metadata type changes.

维护者速读

  • 改了什么:新增两个只读接口。GET /api/v1/auth/authorized-clients 让本人看到自己授权过的 AI 助手(Claude Desktop、IDE 助手等 MCP 客户端)及每个最近一次使用的时间。GET /api/v1/auth/admin/authorized-clients?userId= 让平台管理员查看某个人的授权,逐行标明所属组织。@objectstack/client 对应新增 oauth.authorizedClients.list() / .listForUser()。服务端在 MCP 令牌验证通过后记录“最近使用时间”,精确到小时,且只记在放行这次调用的那条授权上(与 fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879 撤销后的验证规则一致:已撤销或重新授权后的旧令牌一律不记)。
  • 为什么改:同意页告诉用户“可随时撤回授权”,但用户和管理员都看不到有哪些 AI 助手能以他的身份行事、最近还在不在用。控制台页面 The OAuth consent page promises you can revoke access from your account settings, but no page lists the AI assistants you authorized #30894 需要这两个接口。
  • 风险与回滚:本人接口只按会话本人取数,带任何参数都直接 400,不会读别人的。管理员接口与管理端会话接口同一道平台管理员门槛:组织所有者、组织管理员和普通成员都拿到同一个 403,目标人是否存在不影响回答。回答里没有任何令牌、授权码或密钥。时间戳每个进程内每个授权每小时最多写一次,写在请求结束之后,写失败不影响 MCP 调用,每种不同的失败在每个进程里报一次。回滚即撤 PR,新增的那一列为空列,可以留着不管。
  • 先看:packages/plugins/plugin-auth/src/authorized-clients-read.ts(两个接口的拒绝顺序和返回字段),packages/plugins/plugin-auth/src/auth-plugin.ts 里 admin/authorized-clients 那段(门槛在读参数之前),packages/plugins/plugin-auth/src/mcp-client-last-use.ts 的 record(每小时一次的判断)。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/authorized-clients-read.real-plugin.test.ts src/mcp-client-last-use.real-engine.test.ts,预期 20/20 通过。
  • 需要你做的:无,席位落地。

What changes

Two reads (authorized-clients-read.ts, mounted in auth-plugin.ts ahead of the better-auth catch-all):

  • GET /api/v1/auth/authorized-clients (rule 1): the session user's own OAuth consents. The query set is closed and empty, so ?userId= or any other parameter is refused 400 VALIDATION_ERROR naming the parameter. It is never ignored, because an ignored selector reads exactly like an honoured one, which is what better-auth's own get-consents does with a userId. Order: 401 with no session, then a held session's 403, then the query check, then the read.
  • GET /api/v1/auth/admin/authorized-clients?userId=ID (rules 2 to 4): gated by gateAdmin(c), the ADR-0068 D2 judgePlatformAdmin judge with a held session refused per ADR-0069 D3. That is the gate every ObjectStack /admin/* mount and sys-session/revoke use; the reading is in 6683944651. The gate runs before the query is read, so a caller outside reach gets one byte-identical refusal whoever userId names and whether that person exists. An organization owner or admin is not a platform admin and is refused. Rows span every organization the person granted in, and each names its organizationId (rule 3). Read only: there is no withdraw door here (rule 4). Past the gate, a missing, empty or repeated userId or an unknown parameter is 400, and an id naming no user is 404 RESOURCE_NOT_FOUND.
  • The answer (rule 5): { success: true, data: { clients, truncated } }, Cache-Control: no-store, newest grant first. Each row carries exactly id (the consent id, the key /oauth2/delete-consent takes), clientId, name, scopes, organizationId, authorizedAt and lastUsedAt. Nothing identifies or reconstructs a token: the consent and client reads name their columns, so no secret column is ever loaded. At most 200 rows, with truncated: true past that.

The last-use stamp (rule 6; mcp-client-last-use.ts, wired in AuthManager.verifyMcpAccessToken):

  • Home: sys_oauth_consent.last_used_at (option A of the claim). A consent is the grant the person sees and withdraws, it is held per organization, and a withdrawn or revoked consent is a deleted row, so no usage record outlives its grant.
  • Writer: only after verifyMcpAccessToken admits a token, from the verified token (sub, azp, organization_id, iat) and the server clock. The consent is chosen by the rule the verifier admits the token by since fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879 (mcpConsentAdmits): the provider's findConsent row for the token's organization, created no later than the token's iat. So the stamp can only land on the consent that admitted the call. A revoked consent refuses the call and stamps nothing, and a consent granted again is never stamped with a token of the earlier grant, even when the re-grant lands between the verdict and the after-response stamp. A token with no azp is refused by the verifier. No request field reaches it, and the provider's consent doors do not carry it: update-consent's zod body strips it, which is pinned.
  • Coarse, one clock hour: the value is the hour's start, and a row is written only when its stored hour is older than the current one. Each process also remembers the hour it settled per (client, user, organization), so a busy assistant costs no I/O inside an hour: at most one read and one write per consent per hour per process. The hour is the granularity that answers "is this laptop's assistant still acting as me?" and that an administrator can be shown without exposing a minute-by-minute activity trail.
  • Off the request path (runAfterResponse): the verdict never waits on the stamp. Each distinct failure (by message, at most 16) is logged once per process at error (with console.error when the manager has no logger) and retried next hour, never next call. A consent withdrawn between the stamp's read and its write is not a failure: the engine's by-id RECORD_NOT_FOUND on that write counts as done, so the race cannot use up the report a real outage needs.

Around them: two AUTH_ROUTE_LEDGER rows (sdk, oauth.authorizedClients.list / .listForUser) and the conformance own-list pin. @objectstack/client gets oauth.authorizedClients.list() / .listForUser(userId) and the exported AuthorizedClient / AuthorizedClientsList types. The field is declared in platform-objects and its four regenerated translation bundles carry hand-written zh-CN / ja-JP / es-ES text. The two /admin/ dogfood sweeps get classification rows only: objectstack-gate with ?userId=, and ADMITTED with its query, as #30398 did for client-address. oauth-provider-schema-parity.test.ts gains a pin that no provider consent field resolves to last_used_at. The tenant audit census counts the stamp's one system-context write: 261 → 262, regenerated by its own script, with the page's prose numbers moved to match.

Tests

On the real plugin. A real AuthManager (real better-auth, real customSession, real sign-ups and sessions) serves the real AuthPlugin routes on a Hono app. Consents are written through better-auth's own adapter, so the read decodes exactly what the provider stores.

authorized-clients-read.real-plugin.test.ts, 10 cases:

  • a member reads exactly their own grants, newest first, exact key set, no client secret, access/refresh token or session token in the text (the card's first; rules 1 and 5);
  • a member's ?userId=<other> is 400 and carries none of the other's rows (rule 7, first clause);
  • every other parameter is 400 (closed set);
  • anonymous is 401 on both doors;
  • truncated past the cap, newest first;
  • a platform admin reads a person's grants across two organizations plus one with none, each naming its organization (the card's second; rule 3);
  • rule 7: a member, an organization OWNER and an organization ADMIN of the target's organization, and the target themself, each get 403 PERMISSION_DENIED byte-identical for an existing id, a nonexistent id and no id (the card's third);
  • rule 7, outside their organizations: the organization owner and admin of Acme, naming a person who is only in Beta, get the same 403 bytes as for a person inside Acme, with none of the person's rows;
  • admin 400 for missing, empty, whitespace, repeated and unknown parameters, 404 for an id naming no user;
  • an empty list, not a refusal, for a person with no grants.

mcp-client-last-use.real-engine.test.ts, 10 cases, on ObjectQL + better-sqlite3, so the column is the one schema sync creates and tokens are minted by the real jwt plugin:

  • 25 verified calls in one hour make 1 write and 1 read with the hour's start; later in the same hour, 0 I/O; the next hour, 1 more write; the admin read answers that hour start;
  • a second process on the same store makes 1 read and 0 writes in an hour already stamped;
  • the stamp follows the token's organization claim and touches no sibling consent;
  • wrong audience, an unpublished key, an expired token, garbage and a client_id-only token (refused by the verifier since fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879) stamp nothing, against a positive control in the same process;
  • update-consent with lastUsedAt / last_used_at in its body moves the scopes and leaves the stamp null, and the next verified call writes the server's hour;
  • a failing store: 16 calls over two hours, every verdict still answered, 2 attempts, 1 error report naming the column;
  • two different failures over three hours (locked store, missing column, locked store again) give 3 attempts and 2 reports, one per distinct message;
  • the withdrawal race on the real engine: the consent is deleted after the stamp's read, the write meets RECORD_NOT_FOUND, and nothing is reported; a later missing-column outage in the same process is still reported;
  • with fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879: a withdrawn consent refuses its token and stamps nothing in this hour or the next; after a re-grant the old token is refused and the new consent stays unstamped until a token of its own grant is used, which stamps it;
  • the re-grant race on the real engine: the verdict admits the old token, the consent is revoked and granted again before the stamp reads, and nothing is written.

Plus oauth-authorized-clients.test.ts (client URLs, encoding, unwrap, throw on refusal, types), the parity pin and the ledger conformance pin.

Ablations: one-shot, uncommitted, through scripts/ablation-replace.mjs, each restored to the HEAD blob
  • Subject keying, honoured selector: the own read answers request.queries.userId?.[0] ?? subject with the closed-query check removed. Red: "a member who names another person with ?userId= is REFUSED 400", "any other parameter is refused the same way". 2 failed, 7 passed.

  • Subject keying, filter dropped: the consent read's where: { user_id: userId } becomes where: {}. Red: "a member reads exactly their own grants". 1 failed, 8 passed.

  • Admin reach check removed: const actor = await gateAdmin(c); if (actor instanceof Response) return actor; becomes const actor = {} as any;. Red: "anonymous is 401 UNAUTHENTICATED on both doors", "rule 7: a member, an organization OWNER and an organization ADMIN are refused with ONE answer". 2 failed, 7 passed.

  • Coarseness removed: the per-process hour memo is deleted and the stored-hour check is replaced by a per-call write of the precise time. Red: all 6 stamp cases (expected '2026-11-06T10:17:00.050Z' to be '2026-11-06T10:00:00.000Z'; the failure case one attempt per hour: expected 16 to be 2).

  • Withdrawn consent counted as a failure (the RECORD_NOT_FOUND no-op deleted): red, "a consent withdrawn between the stamp's read and its write is no failure" (expected … to have a length of +0 but got 1). 1 failed, 7 passed.

  • First failure only (reportedFailures.has(message) becomes reportedFailures.size > 0): red, "reports each DISTINCT failure once" (expected [ Array(1) ] to deeply equal [ …(2) ]). 1 failed, 7 passed.

  • Stamp ignores the token's age (consentForToken returns the organization's row without the createdAt ≤ iat check): red, "a re-grant that lands between the verdict and the stamp is not stamped with the old token's use" (expected 1 to be +0). 1 failed, 9 passed.

  • iat not handed to the stamp (stamper.record(principal) without issuedAt): red, 9 of the 10 stamp cases, since no consent admits a token without one. 9 failed, 1 passed.

  • An organization-admin lane (the admin door also admits any caller holding an owner or admin membership anywhere): red, both rule 7 cases, the new one with Acme's admin answered 200 with Beta's client_beta_only. 2 failed, 8 passed.

Each run printed ok mutation landed (anchor 1 → 0, blob changed) and ok restored: blob == HEAD and git diff HEAD is empty.

Acceptance notes

  • Rule 2 reading. /admin/list-user-sessions is better-auth's, gated on the legacy role = 'admin' scalar, and refuses a real ObjectStack platform admin by design (finding: seven better-auth /admin/ routes refuse platform admins and have no ObjectStack consumer — decide whether they are capability or just vendor surface #9969), so its gate cannot be copied literally. This PR uses the gate ObjectStack's admin session door sys-session/revoke uses (judgePlatformAdmin), as recorded in 6683944651 for security#1 to confirm or correct. A platform admin's reach crosses the tenant wall on better-auth objects (ADR-0095 D1), so the admin read is not bounded to one organization, and every row names its organization instead.
  • Extension field mechanism. The claim names ADR-0105 D7's MANAGED_EXTENSION_FIELDS. That guard's own test excludes every sys_oauth_* object with "covered by oauth-provider-schema-parity.test.ts", so the collision pin (no provider field may resolve to last_used_at) is written there instead, and MANAGED_EXTENSION_FIELDS is unchanged.
  • fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879 (revoke) is merged, and this branch is merged with it (e4655063a1, a plain merge commit). The auth-manager.ts conflict is resolved by keeping both import blocks, main's verifyMcpAccessToken host (with getAuthContext) with this branch's .then, and main's answerOAuthTokenRevocation unchanged. The commit after it makes the stamp pick its consent by the verifier's rule (above). The verifier's per-call consent read and the stamp's hourly read could share one read; not done here. One edge remains: in the re-grant race, that process's hour stays claimed, so the new consent's first stamp waits for the next hour.
  • A banned subject's verified token still stamps. The ban refusal runs after verifyMcpAccessToken, in the runtime's MCP door. The stamp then records an attempt the door refused, still at most once an hour. Moving the ban check is outside this card, and the seat filed it as MCP: a banned person's AI assistant is still recorded as used, because the last-use stamp (#30918) runs before the ban refusal #30949.
  • Deployments without schema sync lack the column: both reads answer 500 INTERNAL_ERROR and the stamp logs that error naming the column, once per process. Existing consent rows read lastUsedAt: null; a consent older than the provider's createdAt reads authorizedAt: null.
  • Docs. No page lists these routes or claims there is no such read, so no sentence had to change. The console page is The OAuth consent page promises you can revoke access from your account settings, but no page lists the AI assistants you authorized #30894.
  • Outside the claimed file surface: two generated census files. content/docs/permissions/tenant-audit-census.mdx (a content/docs page, but its prose counts are not route sentences) and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md. check-tenant-audit-census is a derived gate and goes red on any new engine write, so both were regenerated by node scripts/tenant-audit-census.mjs --write, and the page's six prose numbers were moved to match. Reported to the seat.

Verification

Head 0bed2ac5df: e4655063a1 merges origin/main at 1aeeafd9d3 (#30879) and resolves auth-manager.ts; 0bed2ac5df makes the stamp pick its consent by the verifier's rule (5 files, +161 / -33). The delta against main is 24 files, +2150 / -32. This was run after pnpm install --frozen-lockfile and a whole-tree turbo run build --filter=!@objectstack/docs (118/118 tasks, lock VERDICT command-exit 0):

  • pnpm --filter @objectstack/plugin-auth test: Test Files 231 passed (231), Tests 4168 passed, 10 skipped (4178), exit 0;
  • pnpm --filter @objectstack/client test: 55 files, 708 tests, exit 0;
  • pnpm --filter @objectstack/platform-objects test: 117 files, 1591 tests, exit 0;
  • plugin-auth typecheck (with check:test-typecheck): exit 0;
  • dispatch-gates.mjs --commands re-derived 98 families on this tree, each run sequentially with its exit code captured, all 98 exit 0; --ran: "98 derived famil(ies) accounted for — 98 run, 0 NOT-MEASURED"; pnpm lint: exit 0.

Delta on head 9688e17ce3 (contract review 6686013581: should-fix 1, 3 and 4; 5 files, +170 / -23, all in plugin-auth and its changeset): the two real-plugin files 18/18; auth-manager.mcp-oauth, -mcp-verifier-keyring, -mcp-oauth-resource, oauth-provider-schema-parity and the ledger conformance test 114/114; plugin-auth typecheck (with check:test-typecheck) exit 0; eslint on the four changed source and test files exit 0; check-tenant-audit-census, check-changeset-no-major, check-empty-changeset, check-issue-citations, check:auth-mount-ledger and check-comment-mask-corpus exit 0. Should-fix 2 (one undecodable scopes row answers 500) is left as built, as the seat directed.

The rest of this section is the original run, on head 121bdd35a4:

All on head 121bdd35a4 (this branch merged with origin/main 4d56914aa; the delta against main is 24 files, +1874 / -31, of which 891 lines are the two real-plugin test files), after a whole-tree turbo run build --filter=!@objectstack/docs (118/118 tasks, lock VERDICT command-exit 0).

  • pnpm --filter @objectstack/plugin-auth test: Test Files 230 passed (230), Tests 4112 passed, 10 skipped (4122), VERDICT command-exit 0.
  • pnpm --filter @objectstack/client test: Test Files 55 passed (55), Tests 708 passed (708), including client-url-conformance.test.ts (both new methods driven and matched to their ledger rows) and auth-route-ledger-coverage.test.ts.
  • pnpm --filter @objectstack/platform-objects test: Test Files 117 passed (117), Tests 1591 passed (1591).
  • typecheck for plugin-auth, client, platform-objects and dogfood, plus check:test-typecheck for plugin-auth and client: all exit 0.
  • The two /admin/ dogfood sweeps, run locally against the booted stack: admin-route-nonadmin-refusal and admin-platform-admin-standing, Test Files 2 passed (2), Tests 15 passed (15). Positive control: with the ADMITTED entry deleted, the standing sweep goes red naming GET /api/v1/auth/admin/authorized-clients as unclassified (1 failed, 7 passed), so the sweep derives the new mount.
  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derived 98 families on this head. Each ran sequentially with its exit code captured before any pipe: all 98 exit 0. --ran: "98 derived famil(ies) accounted for — 98 run, 0 NOT-MEASURED". pnpm lint: exit 0. On the first pass, check-tenant-audit-census (and its self-test, which reads the live tree) went red on the stamp's new write site, which is fixed by the census commit. check:skill-examples and check:dual-build-cjs-loads exited 3 (no build output) until the whole-tree build ran.
  • check:i18n exit 0 after check-i18n-bundles.mjs --write plus the three hand translations; check:i18n-stale-fill exit 0. check:i18n-coverage is not in the derived set and was not measured (it needs the examples' build closure).
  • Every family CI runs on this PR is read from the PR's check runs, not re-run here.

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No packages/spec change.)
  • 2. Every name I added uses the casing for its kind. (last_used_at snake_case column; camelCase wire members.)
  • 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, and both declaration lines are filled in here and in the changeset. (Three patch changesets: plugin-auth, client, platform-objects.)
  • 6. packages/spec touched: its generated artifacts are regenerated and committed. (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 by Claude Code

…th a coarse last-use stamp

Two ObjectStack reads under the auth route: the caller's own OAuth consents,
keyed by the session alone with a closed empty query set, and another
person's for a platform administrator, gated as the admin session doors are.
Each row carries the consent id, client id and name, scopes, organization,
authorized-at and last-used-at, and no token material.

The last-use stamp is sys_oauth_consent.last_used_at: written server-side
after an MCP access token verifies, off the request path, at most once per
consent per hour, with the hour's start.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4d1LNDAUs8pz7DNjQFC
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface labels Nov 6, 2026
@github-actions

github-actions Bot commented Nov 6, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 3 package(s): @objectstack/client, @objectstack/platform-objects, @objectstack/plugin-auth, touching 82 documentable anchor(s). ⚠️ 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/auth-manager-mcp-access-token.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

61 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 000a4c0d36397398e856f0c34e9e0cdbbf3d96b0.

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

What this run could not see
  • 1 changed file(s) yielded no anchor (packages/plugins/plugin-auth/src/auth-manager-mcp-access-token.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: userId (5 routes)
  • 2 anchor(s) matched too much of the corpus to be a work list: created_at (literal, 43 pages), sys_user (literal, 49 pages)
  • 12 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 63 of 224 client-bound route-ledger rows — the other 161 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 161: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 57 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 104 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 — 38 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 000a4c0d36397398e856f0c34e9e0cdbbf3d96b0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1b55032c3b43f3ad26d24a994de6262a080d4e59 — the merge of head 0bed2ac5df1598185b23fac4fefa131b349f5bcf into base 000a4c0d36397398e856f0c34e9e0cdbbf3d96b0, 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 1b55032c3b43f3ad26d24a994de6262a080d4e59 && git checkout 1b55032c3b43f3ad26d24a994de6262a080d4e59
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 000a4c0d36397398e856f0c34e9e0cdbbf3d96b0 0bed2ac5df1598185b23fac4fefa131b349f5bcf && git checkout -B drift-repro 000a4c0d36397398e856f0c34e9e0cdbbf3d96b0 && git merge --no-ff 0bed2ac5df1598185b23fac4fefa131b349f5bcf

node scripts/docs-audit/affected-docs.mjs --json 000a4c0d36397398e856f0c34e9e0cdbbf3d96b0

⚠️ 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 000a4c0d36397398e856f0c34e9e0cdbbf3d96b0 → 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-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 121bdd35a438a614878d1b2907766fbbb9bb7512
Local-runs: none
Scope: packages/plugins/plugin-auth/src/authorized-clients-read.ts, packages/plugins/plugin-auth/src/mcp-client-last-use.ts, packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/auth-manager-mcp-access-token.ts, packages/plugins/plugin-auth/src/auth-plugin.ts, packages/plugins/plugin-auth/src/auth-route-ledger.ts, packages/plugins/plugin-auth/src/auth-route-ledger.conformance.test.ts, packages/plugins/plugin-auth/src/oauth-provider-schema-parity.test.ts, packages/plugins/plugin-auth/src/authorized-clients-read.real-plugin.test.ts, packages/plugins/plugin-auth/src/mcp-client-last-use.real-engine.test.ts, packages/client/src/index.ts, packages/client/src/oauth-authorized-clients.test.ts, packages/platform-objects/src/identity/sys-oauth-consent.object.ts, packages/platform-objects/src/apps/translations/en.objects.generated.ts, packages/platform-objects/src/apps/translations/es-ES.objects.generated.ts, packages/platform-objects/src/apps/translations/ja-JP.objects.generated.ts, packages/platform-objects/src/apps/translations/zh-CN.objects.generated.ts, packages/qa/dogfood/test/admin-platform-admin-standing.dogfood.test.ts, packages/qa/dogfood/test/admin-route-nonadmin-refusal.dogfood.test.ts, content/docs/permissions/tenant-audit-census.mdx, docs/audits/2026-08-tenant-audit-write-call-sites.counts.md, .changeset/30918-plugin-auth-authorized-clients.md, .changeset/30918-client-authorized-clients.md, .changeset/30918-platform-objects-consent-last-used.md + packages/plugins/plugin-auth/src/platform-admin-gate.ts, packages/plugins/plugin-auth/src/run-after-response.ts, packages/plugins/plugin-auth/src/identity-write-guard.ts, packages/plugins/plugin-auth/src/managed-extension-fields.ts, packages/plugins/plugin-auth/src/managed-extension-fields.test.ts, packages/plugins/plugin-auth/src/sys-session-revoke.ts, packages/rest/src/query-allowlist.ts, packages/runtime/src/security/resolve-execution-context.ts, packages/runtime/src/http-dispatcher.ts, packages/client/src/auth-route-ledger-coverage.test.ts, packages/client/src/client-url-conformance.test.ts, docs/adr/0068-unified-user-context-and-built-in-identity-roles.md, docs/adr/0069-enterprise-authentication-hardening.md, docs/adr/0092-sys-user-profile-field-delegation.md, docs/adr/0095-authz-kernel-tenant-layer-and-posture-ladder.md, docs/adr/0105-group-tenancy-posture-and-first-class-org-scope.md, the installed @better-auth/oauth-provider 1.7.12 consent endpoints, PR #30879 at 36d3a9fd823694d741e71173b38c783753ffdaaf (its auth-manager.ts, auth-manager-mcp-access-token.ts, auth-manager-request-routing.ts, mcp-token-revocation.ts)

Reviewed against the merge base with origin/main, 4d56914aaa8c2cf388986309364eef23eea10282: 24 files, +1874 / -31. The head did not move during this review. Nothing was built, run or re-run; the one local operation beyond reading was a git merge-tree of this head against #30879's head, which is a diff computation, not a run.

The recogniser (record-recognisers.mjs --template --paths ...) says the changed paths decide no face on their own and the changesets' Clause-②: yes (widening) line maps to the declaration face (TIER_DEFAULT). This record is served at CONTRACT_REVIEW_TIER because the claim (6683953156) and the seat's order owe one for a security-labelled access boundary; the owed tier is a floor, not an exact match (contract-review.md, "档恰等不作下限"). The seat should keep the two in view when it writes the ACCEPT.

① Derived judgments

Must-fix: none.

Should-fix (none blocks the verdict; each is small and named with its line):

  1. packages/plugins/plugin-auth/src/mcp-client-last-use.ts:205-213: the stamper reports only the FIRST failure in the process and then goes silent forever (failureReported). A benign race can consume that one report: the token verifies while the consent still exists, the person withdraws the consent before the after-response task runs, and stamp(consent.id, ...) then updates a row that is gone. If the engine rejects that update, the one error line in this process is spent on a race, and a later real outage (the missing column, an unreachable store) is never reported. Suggested fix: report once per DISTINCT error message, or treat a not-found on the update as a no-op before the catch. Not a security defect; an observability gap.
  2. packages/plugins/plugin-auth/src/authorized-clients-read.ts:206-217 (scopesOf): one consent row whose scopes text does not decode makes the whole read answer 500 for that person, on both doors. The module says this is deliberate ("refused loudly rather than answered as a grant of nothing"). It is a reasonable choice, but a single corrupt row then hides every other grant from the person and from the administrator. Consider answering the bad row with scopes: [] plus a logged error, or skipping it with a log line, so one row cannot blank the list. Not a security defect.
  3. The dev report's summary says "at most once per consent per hour". The code (mcp-client-last-use.ts:45-50, 186-204) makes that true per PROCESS: each process reads once per consent per hour, and the first calls of an hour in several processes can each write the same hour start. The PR body says this honestly ("or a few times with the same value when first calls race"). The card's requirement is "no per-request write storm, a coarse timestamp is enough", which is met. The ledger note and changeset wording ("at most once per consent per hour") should say "per process", or the seat should accept the PR body's fuller wording as the one of record.
  4. packages/plugins/plugin-auth/src/authorized-clients-read.real-plugin.test.ts:453-477 (rule 7): the refused callers are a member with no organization, the OWNER and the ADMIN of an organization the target is in, and the target themself. There is no explicit case for an organization admin of an organization the target is NOT in. The gate reads no organization at all (platform-admin-gate.ts:176-213), so the case is covered by construction, but rule 7's "outside their organizations" clause would be cheap to pin with one more sign-up.
  5. Acknowledged by the dev, outside this card's surface, owed a follow-up card: a banned subject's verified token still stamps last_used_at, because the ban read runs after verifyMcpAccessToken in packages/runtime/src/security/resolve-execution-context.ts:200-208. The stamp then records an hour in which the MCP door refused the call. Rule 6 says "recorded only on a verified MCP call"; the token verified, the call did not run. An administrator reading the list would see a banned person's assistant as "used" at that hour. The seat should file it rather than leave it in Acceptance notes.

Confirmed, by file:line.

Own read (rule 1):

  • Subject comes from the session only: authorized-clients-read.ts:325-328 reads deps.readCallerSession(request.headers) and takes session.user.id; nothing from the query or body reaches readAuthorizedClients(deps, subject) at :336. The session read is better-auth's own getSession with inProcessSessionReadInput(headers) (:235-238).
  • Closed empty query set: AUTHORIZED_CLIENTS_QUERY_PARAMS is a frozen empty array (:103); unknownQueryParamRefusalMessage (packages/rest/src/query-allowlist.ts:185-196) refuses ANY key, including userId, with 400 VALIDATION_ERROR (authorized-clients-read.ts:333-334). Hono's c.req.queries() gives { userId: [''] } for ?userId=, so the empty-valued spelling is refused too. A parameter is refused, never ignored: the card's rule 1 and the dev's claim hold.
  • Order: 401 (:328), held-session 403 (:330-331), 400 on a parameter (:333-334), then the read. An anonymous caller is answered 401 before any query is read.

Admin read (rules 2, 3, 4):

  • Gate before query: auth-plugin.ts:3816-3820 runs gateAdmin(c) and returns its Response before the handler module is even imported; the query is read only inside runAdminAuthorizedClientsRead (authorized-clients-read.ts:349-362). gateAdmin (auth-plugin.ts:3602-3615) reads the session from headers and calls judgePlatformAdmin, the same judge sys-session/revoke uses (sys-session-revoke.ts:281).
  • Byte-identical 403: judgePlatformAdmin (platform-admin-gate.ts:176-213) answers 401 UNAUTHENTICATED for no session, then the held-session refusal, then 403 PERMISSION_DENIED with the constant PLATFORM_ADMIN_REFUSAL_MESSAGES[403]. The verdict depends on the caller's session alone, never on userId or on whether the target exists. The test pins the text equal across existing id, nonexistent id and no id for member, org owner, org admin and self (authorized-clients-read.real-plugin.test.ts:463-476).
  • Reach: isPlatformAdminUser is the ADR-0068 D2 standing (the admin_full_access permission set in the test fixture, :114-125). Organization owners and admins are not platform admins and are refused (test :456-462 with isPlatformAdmin precondition false at :464).
  • Per organization: every row carries organizationId from the consent's reference_id (authorized-clients-read.ts:258, :313); the test pins a person's grants across two organizations plus one with none (:436-451). The platform admin's reach across the tenant wall on a better-auth-managed object is ADR-0095 D1 (docs/adr/0095-...md:130-134).
  • Read only (rule 4): the only mount is rawApp.get (auth-plugin.ts:3816); there is no withdraw path in the module. The consent id the admin door hands out cannot be turned into a withdrawal of another person's grant: the installed provider's deleteConsentEndpoint and updateConsentEndpoint (1.7.12, dist/authorize-D_jy3s-1.mjs:3141-3176) both refuse unless consent.userId === session.user.id. So no widening leaks through the id.
  • Past the gate: missing, empty, whitespace, repeated and unknown parameters are 400 (authorized-clients-read.ts:349-362); an id naming no user is 404 (:363-365, userExists at :280-285). The 404 is answered only to a platform admin, who can already list users.

No credential material (rule 5):

  • The consent read names its columns: ['id','client_id','reference_id','scopes','created_at','last_used_at'] (authorized-clients-read.ts:245); the client read names ['client_id','name'] only (:269); the user read names ['id'] (:282). No token, code, secret or session column is ever loaded.
  • Each answered row is built from exactly seven members (:308-316); the test pins the exact key set (ROW_KEYS, test :64, :362, :449) and that the client secret, an access token, a refresh token and every sys_session.token are absent from the response text (:254-262).
  • Refusals carry only code and message (:289-297); the 500 path answers a fixed message and logs the error server-side (auth-plugin.ts:3825-3829, :4665-4669). The only id that can appear in a logged error is a consent id (scopesOf, :216), not token material.
  • Cache-Control: no-store on every answer (:183, :296, :317).

Last-use stamp (rule 6):

  • Written only after a verified token: auth-manager.ts:4445-4448 attaches .then to the verifier's promise and calls scheduleMcpClientLastUse(principal) only when a principal came back. The principal's userId, clientId (azp) and organizationId are read from the verified JWT (auth-manager-mcp-access-token.ts), never from a request field; a token with no azp stamps nothing (auth-manager.ts:4464, mcp-client-last-use.ts:188).
  • Keyed to the right consent: consentsFor(clientId, userId) reads where: { client_id, user_id } (mcp-client-last-use.ts:140-150) and consentForToken picks the row whose reference_id equals the token's organization claim, or the row with none (:99-106). That is the provider's findConsent rule and matches fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879's isConsentFor (mcp-token-revocation.ts:147-149). The test pins that a token for organization A stamps only the A consent and a token with no claim stamps only the no-organization consent (mcp-client-last-use.real-engine.test.ts:240-259).
  • Coarse and rate-limited: the value is the hour start (:109-111, :204); a row is written only when its stored hour is older (:203); a per-process memo skips every later call of the hour (:190-195), claimed before the I/O and kept on failure. Across processes: each reads once per hour; a process whose clock lags cannot overwrite a newer stamp, because the row is left alone whenever its stored hour is not older than the current one (:203). The test counts engine reads and writes: 25 calls in one hour give 1 read and 1 write; a second manager on the same store gives 1 read and 0 writes (test:181-238).
  • Off the request path: runAfterResponse (run-after-response.ts:35-47) schedules with setImmediate, never awaited; record never rejects (mcp-client-last-use.ts:205-213); the .then returns the principal unchanged. The test pins a store that throws on every write: every verdict still answered, 2 attempts over two hours, 1 error report naming the column (test:354-384).
  • The only caller: verifyMcpAccessToken is called from packages/runtime/src/security/resolve-execution-context.ts:200 and only when acceptOAuthAccessToken is set, which only packages/runtime/src/http-dispatcher.ts:753 sets, for /mcp paths. No REST or GraphQL call can produce a stamp. fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879's /oauth2/revoke uses verifyMcpAccessTokenClaims directly, not the manager method, so a revoke does not stamp.
  • Not settable through any consent door: the provider's update-consent body is z.object({ id, update: z.object({ scopes }) }) (1.7.12, dist/authorize-D_jy3s-1.mjs:3219-3229), so lastUsedAt and last_used_at are stripped at the top level and inside update; the real-engine test posts both spellings in both places and reads the stamp back as null while the scopes moved (test:300-351). The object is apiEnabled: false (sys-oauth-consent.object.ts:141), so no data-API write reaches the column, and the parity pin (oauth-provider-schema-parity.test.ts:92-101) fails if a provider bump ever declares a consent field that lands on last_used_at.
  • System-context write: mcp-client-last-use.ts:113, :158 writes under isSystem: true. Justified: the object is managedBy: 'better-auth', and ADR-0092 D2 says only internal writes bypass the identity write guard (identity-write-guard.ts:96-99, docs/adr/0092-...md:29-35); a user-context write would be refused by design. The write is censused: the regenerated ledger row mcp-client-last-use.ts | update | sys_oauth_consent | enabled | elevated | 1 (docs/audits/2026-08-tenant-audit-write-call-sites.counts.md:156) and the counts 261 to 262.

Ledger, client, object, dogfood:

  • Two AUTH_ROUTE_LEDGER rows, family: 'objectstack-mount', source: 'objectstack', disposition: 'sdk', client: naming the two SDK methods; the tracker id is in the comment, not the note (auth-route-ledger.ts:191-199). The conformance own-list pin adds both routes in sorted position (auth-route-ledger.conformance.test.ts:223-236), and the live.has(route) loop holds them to the live mount.
  • packages/client/src/index.ts:1242-1270 exports AuthorizedClient and AuthorizedClientsList; :4999-5031 adds oauth.authorizedClients.list() (sends no parameter) and .listForUser(userId) (encodes the id). Both unwrap the envelope and throw on a refusal (test :38-71). auth-route-ledger-coverage.test.ts:57 and client-url-conformance.test.ts:121 derive from the ledger, so the new client: names are driven.
  • last_used_at is Field.datetime({ required: false, readonly: true }) (sys-oauth-consent.object.ts:124-130), with a comment naming its one writer. Four translation bundles carry the label and help, the three non-English ones hand-written and faithful to the English.
  • Dogfood: objectstack-gate row with query (admin-route-nonadmin-refusal.dogfood.test.ts:286-291; RouteExpectation.query exists at :196) and the ADMITTED row plus a bodies query entry (admin-platform-admin-standing.dogfood.test.ts:244-248, :718-721). Without the query the admin answer would be 400 past the gate, which diedBeforeTheGate (:771-777) reads as a void measurement, so the entry is part of the classification, as the dev says.

Rule-by-rule (security#1, 6683642288):

  1. Own read keyed by the session subject only; a selector is refused, not ignored. MET: authorized-clients-read.ts:103, :325-336; test :372-394.
  2. Admin read as a separate door, same gate and reach as the admin session doors, never wider; same refusal whether or not the target exists. MET under the dev's reading A (judgePlatformAdmin, the gate of sys-session/revoke; auth-plugin.ts:3815, platform-admin-gate.ts:176-213); test :453-477. The reading itself is OWED security#1's confirmation (③).
  3. Per organization; an administrator sees grants only inside their reach. MET under reading A: a platform admin's reach is every organization (ADR-0095 D1), and every row names its organizationId (:313); test :436-451. Owed security#1's confirmation of the reading (③).
  4. Read only; no admin withdrawal. MET: a GET-only mount, no withdraw path; the provider's own consent doors bind the id to the session user (dist:3154, :3176).
  5. No credential material. MET: named columns only (:245, :269, :282), seven fixed members (:308-316); test :254-262, :362-363, :449-450.
  6. Last use server-written, only on a verified MCP call, coarse, nothing a client sends sets it. MET: auth-manager.ts:4445-4448, :4464, mcp-client-last-use.ts:99-111, :186-204, the MCP-only caller (resolve-execution-context.ts:200, http-dispatcher.ts:753), the provider's zod body (dist:3222-3225), apiEnabled: false; real-engine test :262-351. One caveat in should-fix 5 (a banned subject's refused call still stamps).
  7. Tests on the real plugin, in addition to the card's three: a member naming another's id refused; an admin refused outside reach and outside their organizations; no token material. MET: real AuthManager, real AuthPlugin routes, consents written through better-auth's adapter (test:1-25, :216-246); member ?userId= 400 with none of the other's rows (:372-384); org owner and org admin of the target's organization refused identically (:453-477); no token material (:254-262). The one gap is should-fix 4 (no explicit org admin of a foreign organization).

Ablations, as reported (not re-run): read against the tests, each would go red as described. (1) honouring userId on the own read fails :372-384 and :386-394; (1b) dropping the user_id filter fails :348-370 because Alice would see Bob's rows; (2) removing gateAdmin fails the anonymous 401 case (:396-405) and rule 7 (:453-477); (3) a per-call precise write fails every stamp case, since each compares the stored value to the hour start and counts writes; (4) deleting the ADMITTED entry trips the standing sweep's first assertion on an unclassified mount.

② Semver level

Three patch changesets, each carrying Clause-②: yes (widening) and Metadata-protocol: none as line-initial declarations; the PR body carries the same two lines. Per AGENTS.md Post-Task Checklist step 3, the level follows the Metadata-protocol kind alone: none takes patch, and a widening that is not a protocol change ships patch. Correct.

Clause-②: yes (widening) is right: two new served routes in AUTH_ROUTE_LEDGER, two new @objectstack/client methods and two exported types, one new nullable column on a shipped object, nothing removed or tightened. Metadata-protocol: none is defensible and consistent with precedent (platform-objects CHANGELOG, "platform object metadata only, no authorable key"): the object is lock: 'full', managedBy: 'better-auth', apiEnabled: false, so no metadata author gains a key, value or type to write; the served meta shape of sys_oauth_consent gains one field, which the Clause-② widening line already covers. No Skills-docs: line is owed (kind none, no narrowing arm). No packages/spec/** change, no governed path, no breaking banner needed.

③ Boundary flags

  1. Reading A of rules 2 and 3 is owed security#1's confirmation (6683944651, not yet answered). The build matches that reading exactly: platform admins only (judgePlatformAdmin), across every organization, each row labelled with organizationId; organization owners and admins refused byte-identically to a stranger. It admits nobody the card's rules do not: the gate is the one sys-session/revoke uses for one person acting on another's sessions, and no organization-admin lane exists. This record does not rule on whether reading A is the right policy. If security#1 corrects the reading, the admin door changes and the new head owes a fresh review. The dev's open question (A keep as built / B add an org-admin lane) is the maintainer's, not this reviewer's.
  2. Serial constraint with fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879 (revoke, draft). Textually: a three-way merge of this head with fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879's head (36d3a9fd) CONFLICTS in packages/plugins/plugin-auth/src/auth-manager.ts (both add imports after the auth-manager-mcp-access-token.js import block, and both edit the host literal and the return of verifyMcpAccessToken in adjacent lines); auth-manager-mcp-access-token.ts auto-merges (this PR edits only its header comment at :26-31, fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879 edits from :53 down). Semantically they compose: after fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879 the verifier reads the consent per request and returns null when it is gone, newer than the token, or unreadable, so the .then here schedules a stamp only for a token whose consent still stands; the consent-pick rule is the same on both sides (consentForToken vs isConsentFor); a skip_consent client admitted by fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879 has no row and stamps nothing. One ordering note: fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879 reads the consent inside the verdict, this PR reads it again after the response; both are reads, neither writes what the other reads, and the dev already names the shared-read follow-up. The claim binds this PR to land after fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879 and rebase on it; the conflict resolution touches the security-relevant method, so the rebased head is a substantive head and owes a delta review by this reviewer before landing. Not a defect in this head; a landing condition.
  3. Served tier. The recogniser maps this diff to the declaration face (TIER_DEFAULT); the record is served at CONTRACT_REVIEW_TIER by the seat's order for a security-labelled access boundary. The owed tier is a floor. The seat writes the ACCEPT knowing both.
  4. Three deviations in the dev report, each judged acceptable:
    • Collision pin in oauth-provider-schema-parity.test.ts instead of MANAGED_EXTENSION_FIELDS: correct, not merely acceptable. managed-extension-fields.test.ts:291 excludes sys_oauth_consent from the derived better-auth surface ("covered by oauth-provider-schema-parity.test.ts"), and :738-752 FAILS any object listed in MANAGED_EXTENSION_FIELDS without a derived surface unless it is registered as having no better-auth columns, which is false for this table. The parity test is the file that holds the provider's consent model, so ADR-0105 D7's collision lint lives there for this column (:92-101). MANAGED_EXTENSION_EDITABLE_FIELDS is rightly untouched: the column must not be editable through the generic path.
    • Two census files outside the claimed surface: forced by the derived gate check-tenant-audit-census on the one new system-context write, regenerated by the repo's own script, prose counts moved to match. content/docs/** is a prose face for the dispatch seat's ACCEPT, not this record; the seat should name the six moved numbers there. The "Measured on" line names the WIP commit the generator ran on, which the gate says is required to be present and dated, not compared.
    • The bodies query entry in the standing sweep: part of the classification row, as the diedBeforeTheGate predicate shows (:771-777); without it the sweep would record a void reading for the new mount. Acceptable, and consistent with the set-user-manager and list-platform-admins entries beside it.
  5. Author. PR user.login is objectstack-fleet[bot]; no human-author note is owed.
  6. Check runs on the head at the time of this review: 62 runs, the lint, type-check, spec-liveness, PR-body guard and one Test Core shard green; Build Core, the remaining Test Core shards, Test UI and Type Check UI still in progress. Landing waits on all green, per the landing rule; nothing here is cited as green that was not.
  7. Follow-ups the seat should file (not conditions on this head): the banned-subject stamp (should-fix 5); the shared consent read once fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879 lands (the dev's own note); the single-failure-report race (should-fix 1).

④ Design judgment

The stamp's home, sys_oauth_consent.last_used_at, holds against the card and the cited code. The card asked for a coarse "last used" with no per-request write storm; the consent is the row the person sees and withdraws, it is already keyed per organization through reference_id, and a withdrawn or revoked consent is a deleted row, so no usage trail outlives the grant. The alternatives were worse: sys_oauth_access_token holds no row for a JWT (the card measured 0 rows after two MCP calls), and a refresh token moves only on refresh. The hour granularity answers the product question ("is this laptop's assistant still acting as me?") without giving an administrator a minute-by-minute activity trail, which is the right trade for a security-labelled read. The per-process memo keeps a busy assistant at zero I/O inside the hour; the multi-process cost is one read per process per hour and a few same-valued writes at the hour boundary, which the card's "coarse" allows.

The admin door's gate holds against the code it cites. admin/list-user-sessions could not be copied literally (better-auth gates it on the legacy role='admin' scalar and refuses a real platform admin by design, #9969), so the dev chose the gate ObjectStack's own door for acting on another person's sessions uses, sys-session/revoke (judgePlatformAdmin, held refused). Running that gate before the query is read is what makes the refusal byte-identical across targets, and the test pins it. Whether a narrower organization-admin lane is wanted is a policy question for security#1 and the maintainer (③ flag 1); the build does not pre-empt it, because adding such a lane later would be a widening on its own card.

The closed empty query set on the own read is the one design decision worth calling out as better than the obvious alternative. Ignoring userId (what better-auth's get-consents does) reads exactly like honouring it; refusing it tells the console author at once that the door has no selector. The 400 before the read also means a wrong client never triggers a consent read it did not mean to make.

The serial interaction with #30879 was designed for rather than against: the stamp hangs off the verdict's promise and reads the consent by the same rule the revoke lane will read it, so the two compose once the textual conflict is resolved.

Implemented-by: claude/project-thread-tmgp4d
Reviewed-by: session_016WV4cytLmJxfAmgPVjUbbW

VERDICT: PASS

…and a withdrawn consent is no failure

Contract review 6686013581, should-fix 1, 3 and 4.

- Should-fix 1. The stamper reported only the first failure in a process.
  A consent withdrawn between the stamp's read and its write made the
  engine's by-id update throw RECORD_NOT_FOUND, which spent that one report,
  so a later real outage went unreported. The real store now answers
  RECORD_NOT_FOUND on the stamp as done, since there is no grant left to
  stamp. The stamper reports each DISTINCT failure message once per process,
  capped at 16 messages. Pinned on the real engine by a withdrawal race and
  a three-hour sequence of two different errors.
- Should-fix 3. The module and method docs and the plugin-auth changeset now
  say "at most once per consent per hour in each process".
- Should-fix 4. Rule 7 gains a case for an organization owner and an
  organization admin naming a person who is only in another organization.
  They get the same 403 bytes as for a person inside their organization.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4d1LNDAUs8pz7DNjQFC
…-tmgp4d

Resolves the auth-manager.ts conflict with #30879 (1aeeafd): both import
blocks are kept, verifyMcpAccessToken keeps main's host (with getAuthContext)
and gains this branch's .then, and answerOAuthTokenRevocation is main's,
unchanged.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4d1LNDAUs8pz7DNjQFC
… call

Since #30879, verifyMcpAccessToken admits a token only while its consent
stands: the consent for (azp, sub, organization) exists and was created no
later than the token's iat (mcpConsentAdmits). The stamp now picks its
consent by that same rule, so it can only land on the consent that admitted
the call.

- A revoked consent refuses the call, so nothing is stamped.
- After a re-grant, a token of the earlier grant is refused. The new consent
  is not stamped with the old token's use even when the re-grant lands
  between the verdict and the after-response stamp, because it is newer than
  the token.

AuthManager hands the stamp the verified token's iat (decoded from the token
the verifier has just admitted). The stamp's consent read also selects
created_at.

The real-engine tests mint tokens with the iat the provider mints. A
client_id-only token is now refused by the verifier. Two new cases cover the
composition: revoke, then re-grant; and the re-grant race on the real engine.

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016WV4d1LNDAUs8pz7DNjQFC
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW

Contract review · delta

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 0bed2ac5df1598185b23fac4fefa131b349f5bcf
Local-runs: probe — one attempt at node scripts/check-tenant-audit-census.mjs in a throw-away worktree of the head, to read the census row this PR adds; it exited 3 ("nothing measured": no pnpm install in that checkout), so it graded nothing. git merge-tree --write-tree 9688e17ce3 1aeeafd9d3 was used as a diff read only. Nothing was built or tested.
Scope: packages/plugins/plugin-auth/src/auth-manager.ts, packages/plugins/plugin-auth/src/auth-manager-mcp-access-token.ts, packages/plugins/plugin-auth/src/mcp-client-last-use.ts, packages/plugins/plugin-auth/src/mcp-client-last-use.real-engine.test.ts, packages/plugins/plugin-auth/src/authorized-clients-read.real-plugin.test.ts, .changeset/30918-plugin-auth-authorized-clients.md + (read, not changed by this PR) packages/plugins/plugin-auth/src/mcp-token-revocation.ts at the head, docs/audits/2026-08-tenant-audit-write-call-sites.counts.md, content/docs/permissions/tenant-audit-census.mdx, .github/workflows/lint.yml

Delta over record 6686013581 (head 121bdd35…). The branch gained three commits: 9688e17ce3 (own fix: each distinct stamp failure reported once, a withdrawn consent is no failure, plus the rule-7 foreign-organization test), e4655063a1 (merge of origin/main at 1aeeafd9d3, where #30879 landed), and 0bed2ac5df (the composition fix: the stamp picks its consent by the verifier's own admission rule). The dev report names the last two; the first is also in the range and is reviewed here. Merge base with origin/main is now 1aeeafd9d3; the PR is 24 files, +2150 / -32 against it. Everything in the prior record that this delta does not touch stands as written.

The merge (e4655063a1). A local three-way merge of 9688e17ce3 with 1aeeafd9d3 conflicts in exactly one file, auth-manager.ts, and auto-merges auth-manager-mcp-access-token.ts and auth-plugin.ts. The tree the recorded merge commits differs from that auto-merge tree in that one file only. The resolution is correct: git diff 1aeeafd9d3 e4655063a1 -- auth-manager.ts is exactly this branch's own additions (the two import lines, the [#30918] doc paragraph, the .then on verifyMcpAccessToken.call(host, token), and scheduleMcpClientLastUse). Main's verifyMcpAccessToken host literal with getAuthContext is kept whole, and main's answerOAuthTokenRevocation is byte-identical to 1aeeafd9d3. No conflict marker survives. The stamp hook still fires only inside the .then and only when the verifier returned a principal; after the merge that verifier includes #30879's per-call consent read, so a refused token (revoked, re-granted, no azp, store unreadable) never reaches the stamp.

The composition fix (0bed2ac5df). consentForToken (mcp-client-last-use.ts:134-144) now takes the token's iat and admits a row only when Math.floor(createdAt/1000) <= iat. mcpConsentAdmits (mcp-token-revocation.ts:188-203) is the same test on the same axis: rows for (clientId, userId), filtered to referenceId === organizationId or, with no claim, to a falsy referenceId (isConsentFor, :147-149), then Math.floor(createdAtMs/1000) <= issuedAt (:202). The stamp's store reads where: { client_id, user_id } and normalises an empty reference_id to null (:180-193), so the organization axis matches isConsentFor's "missing, null or empty" wording. The tie (<=) matches. The date coercion matches: both accept a Date, string or number and refuse NaN; the stamp's toDate (:159-163) returns null for anything else, and consentForToken then stamps nothing (:142), which is the safe direction. A missing or non-finite iat stamps nothing (:142), as the verifier refuses it (:200). AuthManager reads iat with decodeJwt on the very string jwtVerify just admitted (auth-manager.ts:4582-4589), and the after-response closure captures the principal and the number, not the bearer (:4605).

① Derived judgments

Must-fix: none.

Should-fix (none blocks the verdict):

  1. mcp-client-last-use.ts:239-244, :250-251: the "hour stays claimed" edge the dev names. In the re-grant race the memo key (client, user, organization) is claimed for hour H before the read; the read then finds only the new consent, which is newer than the old token, so nothing is written. The new grant's first call in hour H hits the memo and is skipped; its first stamp lands in hour H+1. This can only delay a stamp, never misplace one: the only write (:253) is reached through consentForToken with the current call's own iat and the lastUsedAt >= hour check, and the memo only ever causes a skip. The authorized-clients reads show lastUsedAt: null for a fresh re-grant for up to an hour. A small fix is to release the claim (this.settled.delete(key)) when consentForToken returns undefined but the store did return a row on that organization axis (the re-grant case), while keeping the claim when no row exists at all, so a skip_consent client (no consent row, admitted every call) does not turn into a read per request. Not a security defect.
  2. auth-manager.ts:4582-4589: the verifier already has the token's iat (verifyMcpAccessTokenClaims answers issuedAt, auth-manager-mcp-access-token.ts:196) and verifyMcpAccessToken drops it at :226 to keep the IAuthService return shape. The manager then decodes the token a second time. Correct as written (same bytes, same payload, exceptions caught), but passing issuedAt through an internal variant of the verifier would remove the second decode and the try/catch. Cosmetic.
  3. Carried from the prior record, unchanged by this delta: should-fix 2 (one undecodable scopes row blanks the whole read, authorized-clients-read.ts:206-217) and should-fix 5 (a banned subject's verified token still stamps, resolve-execution-context.ts reads the ban after verifyMcpAccessToken; owed a follow-up card).

Confirmed, by file:line.

  • Prior should-fix 1 is fixed by 9688e17ce3: the stamper keys its report on the failure message (mcp-client-last-use.ts:255-258, cap 16 at :97), and the real store treats RECORD_NOT_FOUND on the update as done (:196-205). Pinned: three hours with locked / missing-column / locked give exactly two reports (real-engine.test.ts:409-439); a consent deleted between the stamp's read and write reports nothing and a later outage is still reported (:441-484).
  • Prior should-fix 3 is fixed: the changeset and the two doc comments now say "at most once per consent per hour in each process (several processes may each write the same hour start)".
  • Prior should-fix 4 is fixed by 9688e17ce3: an organization admin and owner of Acme naming Alice, who is in Beta only, get the same 403 bytes as for Bob inside Acme, with no Beta row and no Alice id in the text (authorized-clients-read.real-plugin.test.ts:479-520).
  • The stamp cannot land on a consent that did not admit the token. The verifier's consentsOf and the stamp's consentsFor read the same table by the same two keys; both then take the single row on the organization axis and require createdAt <= iat. A row for another person is excluded by user_id; a row for another organization by reference_id equality; a row newer than the token by the iat test. The one residual is that both sides take the first row of an unordered read; if two rows ever existed on the same axis (the provider's consent door updates the existing row rather than inserting), the stamp's pick could differ from the verifier's, but it would still be a row of the same person, client and organization, older than the token. A re-grant in the same second as the token's iat is admitted by the verifier and stamped by the stamper alike, which is fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879's second-granularity rule, not this PR's.
  • Pinned on the real engine: revoke refuses the old token and stamps nothing; a re-grant stays unstamped under the old token and is stamped by a token of its own grant (real-engine.test.ts:488-518); a re-grant that lands inside the stamp's read, two minutes after the old token's iat, gives zero writes and a null stamp (:519-547). The isStampRead filter (:96-100) counts only the stamp's own read (the one selecting last_used_at), so the verifier's per-call adapter read does not inflate the read counts the earlier cases pin.
  • "A client_id-only token is now refused" is main's behaviour, not this PR's. verifyMcpAccessToken refuses a principal with no clientId (auth-manager-mcp-access-token.ts:217), and clientId comes only from azp (:193); both lines are fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879's, landed in 1aeeafd9d3 under its own fix(plugin-auth)! changeset. This PR's diff over main in that file is four comment lines (:30-32). The test change at real-engine.test.ts:311-313 only updates the pin to match main. The served MCP door is not narrowed by this PR.
  • Ablations, as reported, read against the tests: dropping createdAt <= iat would stamp regranted in :519-547 (writes 1, not 0); not passing issuedAt from the manager makes consentForToken return undefined at :142 on every call, so every case that expects a stamp goes red. Consistent with "fails the race case" and "fails 9 of 10 stamp cases". Not re-run.

② Semver level

Unchanged: three patch changesets, each with Clause-②: yes (widening) and Metadata-protocol: none; the PR body carries the same two lines. Clause-②: yes (widening) still holds. The delta adds no narrowing to a served contract: the two reads are unchanged, the column is unchanged, and the only refusal that is new on this branch since the prior record (a token without azp) arrived with the merge of #30879, which declared it. The stamp's new iat requirement narrows which row can be stamped, not what any caller is served; a token the verifier admits without a consent row (skip_consent) stamped nothing before and stamps nothing now. The changeset prose for the plugin-auth package now states the admission-rule binding ("only on the consent that admitted it … created no later than the token was issued"), which is the right place for it.

③ Boundary flags

  1. Reading A of rules 2 and 3 is unchanged by this delta and still owed security#1's confirmation (prior flag 1).
  2. Serial constraint with fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879 is discharged. fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879 landed at 1aeeafd9d3; this head merges it and 0bed2ac5df composes with it by the same admission rule. The prior flag 2 asked for a delta review of the rebased head by this reviewer before landing; this record is that review.
  3. Census. The head's committed docs/audits/2026-08-tenant-audit-write-call-sites.counts.md is main's file plus this PR's one row (mcp-client-last-use.ts | update | sys_oauth_consent | enabled | elevated | 1) and the five moved totals (261→262, 184→185, 178→179, 198→199, 144→145); the .mdx prose carries the same numbers. The "Measured on" line still names the pre-merge WIP sha 2ca181937, which the prior record judged acceptable (present and dated is what the gate asks). A local dry run was not possible without pnpm install; the CI job that runs the gate, "Lint · repo & tooling gates" (.github/workflows/lint.yml:1958-1962), was in progress on this head at review time, so the head's census is not yet confirmed green by CI. Data export writes no sys_audit_log row and the export action value is retired, so no export of any object (the audit ledger included) can be audited #30955 is a card, not a PR, so no second census change is on this head; whichever of the two PRs lands second will have to regenerate the census, a landing-order note, not a defect here.
  4. Check runs on 0bed2ac5df at review time: 65 runs; 18 success, 10 skipped, 37 in progress, 0 failure, 0 cancelled. Green: both Check Changeset, both PR-body guards, Governed Surface Queue Guard, Spec property liveness, Type Check · source gates, Dogfood Regression Gate, Lint · PM dispatch-gates and the two scoped lints, Check Documentation Links, Build UI Deps. Still running: Build Core, all Test Core and Test UI shards, Type Check (UI, workspace, consumer gates, debt ledger), Lint · ESLint, Lint · repo & tooling gates, Lint · CI/release/engine gates, Lint · docs & code guards, Build Docs, Build & E2E UI, Live E2E UI. Nothing red. Landing waits on all green.
  5. Author. Still objectstack-fleet[bot]; no human-author note owed.
  6. Follow-ups the seat should file (not conditions on this head): the banned-subject stamp (prior should-fix 5); the claimed-hour delay (should-fix 1 above) if the seat wants the first stamp of a re-grant inside the same hour.

④ Design judgment

Binding the stamp to the verifier's own admission rule is the right fix and the smallest one. Before 0bed2ac5df the stamp and the verdict agreed on the consent's axis but not on its age, so a re-grant that landed in the gap between the verdict and the after-response task could be marked as used by a token the verdict would have refused a moment later. Carrying iat across and applying the same <= in whole seconds closes that gap with one comparison and no new read; the stamp still costs one read per process per hour. The trade the dev accepted (a re-grant inside a claimed hour waits for the next hour) errs in the safe direction: a missing stamp understates use, a wrong stamp would misattribute it, and this design can only do the former. Reading iat from the just-verified bearer rather than threading it through the IAuthService return type keeps the public verifier shape stable; the second decode is cheap and cannot disagree with the first. The merge resolution kept #30879's method bodies untouched and layered this branch's hook on top, which is the composition the prior record anticipated.

Implemented-by: claude/project-thread-tmgp4d
Reviewed-by: session_016WV4cytLmJxfAmgPVjUbbW

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH

维护者速读

  • 改了什么:新增两个只读接口:本人查看自己授权过的 AI 助手(MCP 客户端)及各自最近使用时间;平台管理员按 userId 查看某人的授权,逐行标明所属组织。@objectstack/client 新增对应两个方法;MCP 令牌验证通过后按小时记录最近使用时间。
  • 为什么改:同意页承诺「可随时撤回授权」,但用户和管理员都看不到有哪些 AI 助手能以他的身份行事;控制台页面 The OAuth consent page promises you can revoke access from your account settings, but no page lists the AI assistants you authorized #30894 需要这两个接口。
  • 风险与回滚:本人接口带任何参数直接 400;管理员接口用平台管理员门槛(judgePlatformAdmin,与 sys-session/revoke 相同),组织所有者/管理员/成员一律同一个 403;回答不含令牌或密钥。时间戳写失败不影响 MCP 调用。回滚即撤 PR,新增列为空列可保留。
  • 先看:packages/plugins/plugin-auth/src/authorized-clients-read.ts(拒绝顺序与返回字段)、auth-plugin.ts 的 admin/authorized-clients 段(门槛在读参数之前)、mcp-client-last-use.ts 的 record。
  • 两分钟验证:pnpm --filter @objectstack/plugin-auth exec vitest run src/authorized-clients-read.real-plugin.test.ts src/mcp-client-last-use.real-engine.test.ts,预期 20/20 通过。
  • 需要你做的:无。规则 2 的读法已由你在席位线程裁定 A(接受 judgePlatformAdmin),席位落地。

席位意见:可以落地。

席位核对

ACCEPT · seat domain:server#2 · head 0bed2ac5 · 2026-11-06T17:37Z

  1. Shape: Fixes #30918, draft, base main, 24 files, +2150/−32 (2,182 changed lines, under 3,000; check-governed-merges --pr 30946: no governed path). Clause-②: yes (widening) (two served reads, two client methods, one nullable column) and Metadata-protocol: none, matching claim 6683953156.
  2. Premise and boundary: security#1's access rules 6683642288 (rules 1–7). Rule 2 says "the same gate and reach as admin/list-user-sessions"; that door gates on the legacy role = 'admin' scalar and refuses real platform admins, so the PR gates the admin read with judgePlatformAdmin (ADR-0068 D2), the judge sys-session/revoke and every /admin/* mount already use (seat reading 6683944651). The maintainer ruled this reading A in the seat thread 2026-11-06T17:35Z ("accept judgePlatformAdmin"), so the open question in hand-over 6690407718 is closed and security#1's confirmation is not awaited.
  3. Review: contract review 6686013581 (PASS on 121bdd35) and delta 6689344864 (PASS on this head: the merge of main after fix(plugin-auth)!: revoke an MCP access token through its OAuth consent #30879 and the last-use stamp choosing its consent by mcpConsentAdmits' own rule), both by domain:server#1, adopted by this seat on its hand-over. Carried, not blocking: the re-grant race delays a new consent's first stamp to the next hour (never the wrong row); the scopesOf 500 on one bad row and the banned-subject stamp from the first review.
  4. Gates / check-runs: on head 0bed2ac5: 59 success, 10 skipped, 0 failed; mergeable_state: clean.
  5. Census race: this PR adds one tenant-audit write site (mcp-client-last-use.ts, sys_oauth_consent); feat(audit): every data export writes one run-level sys_audit_log row, and the export action comes back with its writer #31000 (Data export writes no sys_audit_log row and the export action value is retired, so no export of any object (the audit ledger included) can be audited #30955) adds others and is red today, so feat(audit): every data export writes one run-level sys_audit_log row, and the export action comes back with its writer #31000 lands second and owes the merge of main plus node scripts/tenant-audit-census.mjs --write and the tenant-audit-census.mdx figures.
  6. Landing: not governed: the seat readies the PR and arms auto-merge on the maintainer's word in the seat thread 「你应该使用 pm-dispatch 技能审核和落地pr」(2026-11-06T16:49Z), taken over from domain:server#1 (hand-over 6690407718; landing request on mailbox pm:mailbox — domain:server#2 · os-tesla #30985).

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 6, 2026 17:39
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 6, 2026
Any commits made after this event will not be merged.

This branch has not been deployed

No deployments
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/xl tests tooling Fix lands in tooling, not a product package — enters pm:queue only with Unblocks: or a named surface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Auth: no ledgered read lists the AI assistants a person has authorized, records when one was last used, or lets an admin see a managed person's

2 participants