Skip to content

fix(plugin-auth,platform-objects): revoke a session by its row id, resolved server-side - #24791

Merged
objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/session-revoke-action
Oct 20, 2026
Merged

objectstack-fleet[bot] merged 4 commits into
mainfrom
claude/session-revoke-action

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Security fix (maintainer-ordered; no public card; detail withheld under the disclosure ruling).

Clause-②: no

The invariant

Setup → Diagnostics → Sessions (and Account → Active Sessions): a row's Revoke Session ends the session it names, and that session's next request is refused. It works for the session's owner and for a platform administrator, and for nobody else. No request to or answer from the revoke path carries a session token, and sys_session.token stays internal: true.

What changed

  • packages/platform-objects: sys_session revoke_session is keyed on the row's id, which every list row carries. It sets recordIdParam: 'sessionId', and recordIdField falls back to its default id. It posts to POST /api/v1/auth/sys-session/revoke. The token field is unchanged: internal, hidden, readonly. The console runtime is generic, so objectui needs no change.
  • packages/plugins/plugin-auth: new ObjectStack mount POST /api/v1/auth/sys-session/revoke. The handler is sys-session-revoke.ts, the mount is in auth-plugin.ts, and the route is in auth-route-ledger.ts plus its conformance pin. The server looks up the session from the id through better-auth's own storage adapter, which hides ended sessions and re-attaches the internal column server-side. The token is used only inside the server.
    • The owner lane forwards the request in-process to better-auth's POST /revoke-session, with the caller's own credentials. That route's ownership and freshness checks still apply, and so do the existing admission guard and the user_revoked tombstone, all unchanged.
    • The administrator lane handles another user's session. The ADR-0068 D2 judgePlatformAdmin admits the caller (held sessions are refused per ADR-0069 D3). The session is then tombstoned in place with revoke_reason: admin (ADR-0069 D4). This is the same row shape membership-ended-session.ts and forced-reset-revocation.ts write. The reason value comes from the interactive-revoke ledger, so the two cannot drift. better-auth's /admin/revoke-user-session is not used as the delegate: it authorizes on the legacy role scalar, which ADR-0068 D2 administrators do not carry.
    • Every other signed-in caller gets one 404 RESOURCE_NOT_FOUND, byte-identical to the answer for an id that names no live session. An anonymous caller gets 401 UNAUTHENTICATED, and a body without sessionId gets 400 INVALID_REQUEST.
    • The route is not under /api/v1/auth/admin/, because its first lane admits the owner. It belongs to the sys-oauth-application/register family.
  • session-tombstone.ts: one comment sentence. The admin reason now has a third writer.
  • Tenant-audit census: content/docs/permissions/tenant-audit-census.mdx and docs/audits/2026-08-tenant-audit-write-call-sites.counts.md were regenerated with node scripts/tenant-audit-census.mjs --write, and the page's hand-written figures were moved from 231 to 232. The administrator lane's tombstone write is a new engine write call site, and check-tenant-audit-census requires it to be counted.
  • Changeset: .changeset/sys-session-revoke-by-id.md, a patch for @objectstack/plugin-auth and @objectstack/platform-objects.

Tests

All on this branch. plugin-auth resolves the subject through relative source imports, so its runs read src/.

  • New pins, in packages/plugins/plugin-auth/src/sys-session-revoke.test.ts. They run through the plugin's real route registration on a real Hono app, in front of a real AuthManager, with cookies minted by better-auth's own sign-in. Every revoke asserts both halves: the cookie stops authenticating, and the row survives with its cause. Every refusal asserts that the target still authenticates and its row is untouched, as status plus code.
    • an administrator revoking another user's session by id ends it, so that session's next request is refused (revoke_reason: admin, and the administrator's own session is untouched);
    • a session's owner revoking their own other session by id ends that one and keeps the current one (user_revoked);
    • an already-ended session reads not-found, and its record keeps the first ending;
    • a signed-in caller without platform-admin standing cannot revoke another user's session (404 RESOURCE_NOT_FOUND, byte-identical to an id that names nothing);
    • an anonymous caller is refused before anything is revoked (401 UNAUTHENTICATED);
    • a request that names no session is refused, and nothing is revoked (400 INVALID_REQUEST);
    • the administrator lane records the interactive-revoke ledger's own admin value.
    • Each answer above is also asserted to carry no session token.
  • New pins, in packages/platform-objects/src/platform-objects.test.ts:
    • revoke_session identifies its session by the row id and posts sessionId to the new route;
    • sys_session.token stays internal, hidden and readonly;
    • no identity action identifies its record by a field the server never serves. This sweep covers every identity object and has a positive control.
  • pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 reported Test Files 166 passed (166) and Tests 3185 passed, 10 skipped (3195).
  • pnpm --filter @objectstack/plugin-auth typecheck passed: src, examples and the test layer (check:test-typecheck: OK).
  • pnpm --filter @objectstack/platform-objects test reported Test Files 79 passed (79) and Tests 1198 passed (1198). typecheck passed.
  • After merging origin/main (74f7815): pnpm install --frozen-lockfile, @objectstack/spec build, then check:generated ("All 14 generated artifacts are up to date"), then plugin-auth and platform-objects typecheck. All passed.
  • Ablations. Each one mutated one line, through scripts/ablation-replace.mjs in wrap mode (anchor hit 1 to 0, blob changed). Each restore was proven: blob equals HEAD, and git diff HEAD is empty. The direction was predicted before each run, and every result matched the prediction.
    • A1, the standing check removed from the administrator lane: 1 red, the non-admin pin.
    • A2, the administrator tombstone write removed: 2 red, the admin pin and the already-ended pin.
    • A3, the owner lane removed: 1 red, the owner pin.
    • A4, the action re-keyed to token: 2 red, the declaration pin and the sweep.

Gates

Derived by node scripts/pm/dispatch-gates.mjs --commands at 3270bb1 (the final head), then reconciled with --ran against recorded exit codes. Result: 103 derived, 100 run with exit 0, 3 not measured, 0 unrun.

  • Code-reading families ran at 300e9d4. The only later commit (3270bb1) changes the two census docs. Every family that reads those docs was derived again and re-run at 3270bb1: the 30 doc families, plus the census, doc-authoring, docs-audit, nul-bytes, issue-citations, comment-mask and auth-mount-ledger families.
  • NOT MEASURED (each exited 3 on a prerequisite; CI runs all three):
    • check-plugin-teardown-shape --self-test, reason: this shallow checkout cannot reach the fixture commit;
    • check:dual-build-cjs-loads, reason: it needs every package's dist/;
    • check:i18n, reason: it needs the built CLI closure. No translatable string changed: the action's label, confirm text and success message are byte-identical.
  • check:tenant-audit-census went red on the new write site. It passed after the census was regenerated (232 write call sites).
  • test:repo and the full CI battery are left to CI.

Acceptance notes

  • content/docs/permissions/tenant-audit-census.mdx records its measurement commit as the merge head the census was regenerated on. Per that page, the measurement date and sha are unenforced.

Generated by Claude Code

…solved server-side

The sys_session Revoke Session row action now posts the row's id to a new
plugin-auth door, POST /api/v1/auth/sys-session/revoke. The server resolves
the session from the id, so the session token stays internal and is never
sent to or from the console.

The session's owner is re-dispatched to better-auth's own /revoke-session;
another user's session is revocable by a platform administrator only
(ADR-0068 D2 judge) and is tombstoned with reason admin (ADR-0069 D4). Every
other caller reads one not-found answer.

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

`node scripts/tenant-audit-census.mjs --write`, plus the page's hand-written
figures moved to what the census now reports (231 -> 232 write call sites).

Claude-Session: https://claude.ai/code/session_01KqkTQNLcxVc588eZpJmp1r
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Oct 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

45 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 46bb5e3e5405fad31ccba6fe571e98b9234aac78.

⛔ 10 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/session-tombstone.ts) — pages documenting those are invisible to this run
  • 1 cross-cutting symbol(s) contributed no route anchor: basePath (4 routes)
  • 6 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 18 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 46bb5e3e5405fad31ccba6fe571e98b9234aac78 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 46bb5e3e5405fad31ccba6fe571e98b9234aac78

⚠️ 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 46bb5e3e5405fad31ccba6fe571e98b9234aac78 → 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

席位核对

ACCEPT · PR #24791 at 3270bb1d8 · repo:hotcrm seat 2 (the maintainer's direct dispatch; no public card, by the maintainer's ruling) · os-support-ai · session session_01KqkTQNLcxVc588eZpJmp1r · 2026-10-20T06:33Z

  1. Shape:
    • Draft, base main. It opens with the mandated security first line and Clause-②: no, and the body carries no closing keyword and names no issue. Assignee os-support-ai.
    • 11 files, +708 / −26: platform-objects (the action), plugin-auth (the new mount, its ledger row and conformance pin, the handler and its tests, one comment), the tenant-audit census docs, and one changeset. check-governed-merges --pr: 0 of 11 paths hit the register, 734 lines.
    • The commits carry the model-free trailer pair, with 0 model-identifier lines.
  2. The handler, read (sys-session-revoke.ts):
    • Identity comes first: an anonymous caller is refused before the body is read.
    • The target is looked up by id through better-auth's storage adapter, which hides ended sessions and keeps the token server-side.
    • The owner lane re-dispatches to the vendor's /revoke-session with the caller's own credentials, so the existing ownership and freshness checks still decide.
    • Another user's session is for platform administrators only (isPlatformAdminUser, then judgePlatformAdmin). Every other caller gets the same 404 as an id that names nothing.
    • sys_session.token stays internal. No request or answer of the route carries it, and the tests assert that on every answer.
  3. The open questions, the seat's calls:
    • The file surface: A, accept. The census rows and the auth-route ledger row are what the gates require for any new write site and any new mount. They change no behaviour.
    • The administrator lane's write: A, ship the direct tombstone. It is the same row shape as the platform's other server-side revocations. Anything shared by that whole family is not this PR's to change.
  4. Changeset, read sentence by sentence: the action, who may revoke, every refusal and what stays unchanged each match the handler and its pins.
  5. Gates:
    • The dev's local run: plugin-auth 166 files / 3185 tests passed, platform-objects 79 files / 1198 tests passed, both typechecks passed, and 100 of 103 derived families run with exit 0.
    • 3 are NOT MEASURED locally, for shallow-clone or build-closure reasons; CI runs them. check-tenant-audit-census is green at 232 after the census refresh.
    • Four ablations went red as predicted, and each restore is proven blob-equal.
    • CI on 3270bb1d8 at this review: 16 checks success, 5 skipped, 17 running. Landing waits for every check.

Landing: once every check on the head is green, this seat brings main in through the relay if it has moved, then flips the PR ready and arms auto-merge.


Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 20, 2026 06:58
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 20, 2026 06:58
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 20, 2026
Merged via the queue into main with commit 9d13b5f Oct 20, 2026
42 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/session-revoke-action branch October 20, 2026 07:28
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Landed · repo:hotcrm seat 2 · session session_01KqkTQNLcxVc588eZpJmp1r · 2026-10-20T07:29Z

Reading Probe Result
PR #24791 GET /pulls/24791 Merged 2026-10-20T07:28Z through the merge queue, on the reviewed head 3270bb1d8 (ACCEPT 6284946305 on this PR). Every check was success on that head (36 success, 5 skipped), and the queue re-ran the subscribing workflows on its rebuilt generation, after #24790.
origin/main GET /commits/main 9d13b5fc8, the merge commit. Its message carries 0 model-identifier lines.

State: there is no card to close. This was the maintainer's direct dispatch, with no public card by their ruling. Revoke Session by row id ships in the next @objectstack/plugin-auth and @objectstack/platform-objects releases.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants