Skip to content

Add the generic capability system and /admin capabilities - #34

Merged
NinjaLikesCheez merged 5 commits into
mainfrom
feature/capabilities
Oct 4, 2026
Merged

NinjaLikesCheez merged 5 commits into
mainfrom
feature/capabilities

Conversation

@NinjaLikesCheez

@NinjaLikesCheez NinjaLikesCheez commented Oct 3, 2026 •

Copy link
Copy Markdown
Contributor

Closes #28. Builds on the access store (#25) and /admin set-level (#26).

What changed

  • Capabilities. Named permissions for individual people, declared in code in src/features/capabilities.ts. It's empty: no real capabilities ship. Admins can only grant names listed there, and a command that requires an unknown name stops startup.
  • Commands require a tier and a capability, e.g. access: { minTier: "member", capability: "front-door" }. The dispatcher is the only place it's checked, both must hold, and the refusal is the same generic message as any other (the log records reason: capability).
  • Nothing implies one: not a tier, not a role, not being an admin. A guest never passes, even if the file lists capabilities for them, and Principal.capabilities is empty for guests. So demoting someone takes effect straight away.
  • /help hides commands the caller can't run. A capability command is registered with at least friend tier (guest tier is rejected at startup).
  • /admin capabilities grant | revoke | list (admin, private, audited through the store). Capability is picked from the registry. Grant is refused for guests and bots, and everything says so when nothing would change. With nothing registered they reply "No capabilities are registered yet."
  • Nested subcommand groups. /admin capabilities grant needs a subgroup, so the command framework now supports command → subgroup → subcommand. Access is checked at every level, the registry rejects a looser child, and /help lists full names.
  • Admins are members too. admins.yaml is a list of people, and each admin must also have a members.yaml entry holding the same ids (membership level, capabilities, note). Pixel refuses to start without one. The store still refuses to change an admin's level, but an admin's entry can hold capabilities, so an admin can grant themselves one (audited). The "also listed as admin" warning is gone. /admin whois shows "Also listed as" for admins.
  • Both files use the same shape, with platform-prefixed ids. Each entry has ids: ["discord:<id>"], a list, so one person can have an id per platform later (Secure cross-platform account linking #16). An id may appear in only one entry per file, and an admin entry's ids must all belong to one members entry. Only the ids listed in admins.yaml are admin; another account in the same members entry keeps the person's level but isn't an admin. Only discord: is accepted for now, and inside Pixel lookups still use the bare Discord user ID. Writing a new members entry keeps the format (ids: ["discord:…"], quoted, on one line).
  • Unknown names in the file (e.g. after a rollback) are ignored, warned about in the log and reported to Sentry (names only), at startup and on /admin reload.
  • AGENTS.md, docs/identity-and-access.md (including the threat model: a compromised Discord can't grant a capability) and docs/architecture.md are updated.

Breaking change: the file formats

Both files change, so local and deployed copies need migrating before this runs against them. Pixel refuses the old formats and says which entry is wrong, without echoing the id.

  • members.yaml: discordId: "<id>" becomes ids: ["discord:<id>"].
  • admins.yaml: from - name: … / discordId: "<id>" objects to - ids: ["discord:<id>"]. The name moves to the note of the admin's members entry.
  • Every admin needs a members entry with the same ids (tier: member is fine).

Needs a human reviewer

Touches core/access, core/capabilities, core/identity, core/dispatcher, core/registry and the access store (AGENTS.md rule 12): the two-part check, the guest guard, the admin-entry rule, and the startup validation.

Tests

Capability registry; checkAccess (tier and capability, admin without a grant refused, guest refused even with one); identity (guests get none); command registry (unknown capability and guest-tier capability stop startup, including in subcommands and subgroups); dispatcher (member with, member without, friend below the tier, guest, admin without, admin with, subcommands, instant effect, /help hiding, generic reply plus logged reason); nested groups (registry, dispatcher, Discord JSON and parsing, the handler); /admin capabilities through the real dispatcher and store (persistence, audit, guards, empty registry, stale names); admins and members as ids lists (prefix required, several ids per person, duplicates and cross-person admin entries refused, only listed ids admin); and end-to-end through buildCore. just check passes: lint, types, 728 tests, coverage thresholds.

🤖 Generated with Claude Code

NinjaLikesCheez and others added 2 commits October 3, 2026 21:40
…ubcommand groups

admins.yaml is now just Discord IDs. Every admin must also have a
members.yaml entry, which holds their membership level, capabilities and
note, so admins are members as well as admins. Pixel refuses to start if
one is missing. The store refuses to change an admin's level but lets their
entry hold capabilities.

Commands can now have subgroups: /admin capabilities grant. Access is a
floor at every level and the dispatcher checks all of them.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
…e|list

Capabilities are named permissions for individual people, declared in
code (none yet). A command can require one on top of a minimum tier and
the dispatcher enforces both. Nothing implies one: not a tier, not a
role, not being an admin. A guest never passes. Admins grant, revoke and
list them through the audited access store. Names in the file that no
longer exist are ignored, with a warning and a Sentry report.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
NinjaLikesCheez and others added 3 commits October 4, 2026 05:52
admins.yaml and members.yaml now share one shape: each entry has an id
like discord:<id>, so other platforms can be told apart later and an admin
entry matches its members entry exactly. The old discordId key and bare
IDs are refused with a clear message.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Both access files now describe a person with ids, a list of
platform-prefixed ids (discord:<id>), so one person can have an id per
platform. An admin entry's ids must all belong to one members entry, and
only the listed ids are admin. An id may appear in only one entry per
file. Replaces the single id key from the previous commit.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@NinjaLikesCheez
NinjaLikesCheez merged commit 7b77ec3 into main Oct 4, 2026
2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Generic capability system for restricted features

1 participant