Repository navigation
feat(plugin-auth): read the AI assistants a person has authorized, and when each was last used - #30946
feat(plugin-auth): read the AI assistants a person has authorized, and when each was last used#30946objectstack-fleet[bot] wants to merge 8 commits into
Conversation
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1LNDAUs8pz7DNjQFC
…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
Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1LNDAUs8pz7DNjQFC
…it census Co-authored-by: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WV4d1LNDAUs8pz7DNjQFC
📓 Docs Drift CheckThis PR changes 3 package(s): 61 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 38 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 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
|
|
domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW Contract reviewServed-tier: Reviewed against the merge base with The recogniser ( ① Derived judgmentsMust-fix: none. Should-fix (none blocks the verdict; each is small and named with its line):
Confirmed, by file:line. Own read (rule 1):
Admin read (rules 2, 3, 4):
No credential material (rule 5):
Last-use stamp (rule 6):
Ledger, client, object, dogfood:
Rule-by-rule (security#1, 6683642288):
Ablations, as reported (not re-run): read against the tests, each would go red as described. (1) honouring ② Semver levelThree
③ Boundary flags
④ Design judgmentThe stamp's home, The admin door's gate holds against the code it cites. The closed empty query set on the own read is the one design decision worth calling out as better than the obvious alternative. Ignoring 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: 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
|
domain:server#1 · os-elon-musk · session_016WV4cytLmJxfAmgPVjUbbW Contract review · deltaServed-tier: Delta over record 6686013581 (head The merge ( The composition fix ( ① Derived judgmentsMust-fix: none. Should-fix (none blocks the verdict):
Confirmed, by file:line.
② Semver levelUnchanged: three ③ Boundary flags
④ Design judgmentBinding the stamp to the verifier's own admission rule is the right fix and the smallest one. Before Implemented-by: VERDICT: PASS |
|
domain:server#2 · os-tesla · session_01L9qibyvxbrLP4jm4PargoH 维护者速读
席位意见:可以落地。 席位核对ACCEPT · seat
|
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 aCONTRACT_REVIEW_TIERreview against those rules, arranged by the seat.How the two lines were derived. Clause-②: yes (widening): two new served reads (
AUTH_ROUTE_LEDGERrows,sdk), two new@objectstack/clientmethods with two new exported types, and one new nullable column; nothing existing is removed or tightened. Metadata-protocol: none: the newsys_oauth_consent.last_used_atis a data column on a shipped, better-auth-managed object (lock: 'full',apiEnabled: false), declared as an ordinaryField.datetime({ readonly: true, required: false })inpackages/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 撤销后的验证规则一致:已撤销或重新授权后的旧令牌一律不记)。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 inauth-plugin.tsahead 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 refused400 VALIDATION_ERRORnaming the parameter. It is never ignored, because an ignored selector reads exactly like an honoured one, which is what better-auth's ownget-consentsdoes with auserId. Order:401with no session, then a held session's403, then the query check, then the read.GET /api/v1/auth/admin/authorized-clients?userId=ID(rules 2 to 4): gated bygateAdmin(c), the ADR-0068 D2judgePlatformAdminjudge with a held session refused per ADR-0069 D3. That is the gate every ObjectStack/admin/*mount andsys-session/revokeuse; the reading is in 6683944651. The gate runs before the query is read, so a caller outside reach gets one byte-identical refusal whoeveruserIdnames 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 itsorganizationId(rule 3). Read only: there is no withdraw door here (rule 4). Past the gate, a missing, empty or repeateduserIdor an unknown parameter is400, and an id naming no user is404 RESOURCE_NOT_FOUND.{ success: true, data: { clients, truncated } },Cache-Control: no-store, newest grant first. Each row carries exactlyid(the consent id, the key/oauth2/delete-consenttakes),clientId,name,scopes,organizationId,authorizedAtandlastUsedAt. 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, withtruncated: truepast that.The last-use stamp (rule 6;
mcp-client-last-use.ts, wired inAuthManager.verifyMcpAccessToken):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.verifyMcpAccessTokenadmits 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'sfindConsentrow for the token's organization, created no later than the token'siat. 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 noazpis 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.runAfterResponse): the verdict never waits on the stamp. Each distinct failure (by message, at most 16) is logged once per process aterror(withconsole.errorwhen 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-idRECORD_NOT_FOUNDon that write counts as done, so the race cannot use up the report a real outage needs.Around them: two
AUTH_ROUTE_LEDGERrows (sdk,oauth.authorizedClients.list/.listForUser) and the conformance own-list pin.@objectstack/clientgetsoauth.authorizedClients.list()/.listForUser(userId)and the exportedAuthorizedClient/AuthorizedClientsListtypes. The field is declared inplatform-objectsand 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-gatewith?userId=, andADMITTEDwith its query, as #30398 did forclient-address.oauth-provider-schema-parity.test.tsgains a pin that no provider consent field resolves tolast_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, realcustomSession, real sign-ups and sessions) serves the realAuthPluginroutes 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:?userId=<other>is400and carries none of the other's rows (rule 7, first clause);400(closed set);401on both doors;truncatedpast the cap, newest first;403 PERMISSION_DENIEDbyte-identical for an existing id, a nonexistent id and no id (the card's third);403bytes as for a person inside Acme, with none of the person's rows;400for missing, empty, whitespace, repeated and unknown parameters,404for an id naming no user;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: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-consentwithlastUsedAt/last_used_atin its body moves the scopes and leaves the stampnull, and the next verified call writes the server's hour;errorreport naming the column;RECORD_NOT_FOUND, and nothing is reported; a later missing-column outage in the same process is still reported;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 blobSubject keying, honoured selector: the own read answers
request.queries.userId?.[0] ?? subjectwith 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 }becomeswhere: {}. 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;becomesconst 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 caseone attempt per hour: expected 16 to be 2).Withdrawn consent counted as a failure (the
RECORD_NOT_FOUNDno-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)becomesreportedFailures.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 (
consentForTokenreturns the organization's row without thecreatedAt ≤ iatcheck): 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.iatnot handed to the stamp (stamper.record(principal)withoutissuedAt): 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
owneroradminmembership anywhere): red, both rule 7 cases, the new one with Acme's admin answered200with Beta'sclient_beta_only. 2 failed, 8 passed.Each run printed
ok mutation landed(anchor 1 → 0, blob changed) andok restored: blob == HEAD and git diff HEAD is empty.Acceptance notes
/admin/list-user-sessionsis better-auth's, gated on the legacyrole = '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 doorsys-session/revokeuses (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.MANAGED_EXTENSION_FIELDS. That guard's own test excludes everysys_oauth_*object with "covered byoauth-provider-schema-parity.test.ts", so the collision pin (no provider field may resolve tolast_used_at) is written there instead, andMANAGED_EXTENSION_FIELDSis unchanged.e4655063a1, a plain merge commit). Theauth-manager.tsconflict is resolved by keeping both import blocks, main'sverifyMcpAccessTokenhost (withgetAuthContext) with this branch's.then, and main'sanswerOAuthTokenRevocationunchanged. 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.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.500 INTERNAL_ERRORand the stamp logs that error naming the column, once per process. Existing consent rows readlastUsedAt: null; a consent older than the provider'screatedAtreadsauthorizedAt: null.content/docs/permissions/tenant-audit-census.mdx(acontent/docspage, but its prose counts are not route sentences) anddocs/audits/2026-08-tenant-audit-write-call-sites.counts.md.check-tenant-audit-censusis a derived gate and goes red on any new engine write, so both were regenerated bynode scripts/tenant-audit-census.mjs --write, and the page's six prose numbers were moved to match. Reported to the seat.Verification
Head
0bed2ac5df:e4655063a1mergesorigin/mainat1aeeafd9d3(#30879) and resolvesauth-manager.ts;0bed2ac5dfmakes the stamp pick its consent by the verifier's rule (5 files, +161 / -33). The delta againstmainis 24 files, +2150 / -32. This was run afterpnpm install --frozen-lockfileand a whole-treeturbo 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;typecheck(withcheck:test-typecheck): exit 0;dispatch-gates.mjs --commandsre-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-parityand the ledger conformance test 114/114; plugin-authtypecheck(withcheck:test-typecheck) exit 0;eslinton 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-ledgerandcheck-comment-mask-corpusexit 0. Should-fix 2 (one undecodablescopesrow answers500) 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 withorigin/main4d56914aa; the delta againstmainis 24 files, +1874 / -31, of which 891 lines are the two real-plugin test files), after a whole-treeturbo 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), includingclient-url-conformance.test.ts(both new methods driven and matched to their ledger rows) andauth-route-ledger-coverage.test.ts.pnpm --filter @objectstack/platform-objects test: Test Files 117 passed (117), Tests 1591 passed (1591).typecheckfor plugin-auth, client, platform-objects and dogfood, pluscheck:test-typecheckfor plugin-auth and client: all exit 0./admin/dogfood sweeps, run locally against the booted stack:admin-route-nonadmin-refusalandadmin-platform-admin-standing, Test Files 2 passed (2), Tests 15 passed (15). Positive control: with theADMITTEDentry deleted, the standing sweep goes red namingGET /api/v1/auth/admin/authorized-clientsas unclassified (1 failed, 7 passed), so the sweep derives the new mount.node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackderived 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-examplesandcheck:dual-build-cjs-loadsexited 3 (no build output) until the whole-tree build ran.check:i18nexit 0 aftercheck-i18n-bundles.mjs --writeplus the three hand translations;check:i18n-stale-fillexit 0.check:i18n-coverageis not in the derived set and was not measured (it needs the examples' build closure).Human Minimum
packages/specholds no runtime logic. (Nopackages/specchange.)last_used_atsnake_case column; camelCase wire members.)pnpm lintpasses.main, one pull request.patchchangesets: plugin-auth, client, platform-objects.)packages/spectouched: its generated artifacts are regenerated and committed. (Not touched.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Generated by Claude Code