step 4/4: the docs say permissions too, and capability means one thing again

44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.

Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:

  permissions          renamed (steps 1–2)
  $OFFICER_ROOT/capabilities/  KEPT — the item store, and now the only thing
                               the word means that is ours
  sidecar routing keys renamed to `handles` (step 3)
  Lightning wallet     KEPT — a domain term, and on the wire to the mobile apps
  terminfo queries     KEPT — XTGETTCAP, in the pty sidecar
  InvoiceShelf         KEPT — per-resource { write, bulkDelete } flags

The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:

CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.

And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.

Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.

Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.

The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
This commit is contained in:
2026-08-15 16:31:11 +00:00
parent f9fd002ff4
commit 027b10bd6e
45 changed files with 721 additions and 694 deletions
+16 -15
View File
@@ -14,14 +14,14 @@ written: `users` holds six rows. The accurate statement is narrower and more use
- **One owner.** User id 1, role `Super Admin`, created by `POST /auth/bootstrap` while the table is
empty, pinned there by a CHECK constraint. The owner bypasses every permission check.
- **Other accounts get only what their ROLE is granted.** Roles are `Admin`, `Member`, `Developer`;
grants live in `role_capabilities`, keyed on role, never on user. Absence denies — there is no row
grants live in `role_permissions`, keyed on role, never on user. Absence denies — there is no row
meaning "no", so an empty table is a server where members reach nothing but their own profile.
- **Some things can never be shared, structurally.** Tasks, items, desktop and browser are
`kind: 'execution'`: they run as the owner's OS user in the owner's home, so there is no level of
"read" that makes them safe. They have no level at all and the grants API refuses to store one.
- **And some are shared only because the kernel enforces it.** Terminal, chat and files are
`kind: 'confined'`, added 2026-08-11 with per-user Linux accounts. They still touch the filesystem
and still run processes — but not the *owner's*, because the account has its own Linux user, its own
and still run processes — but not the _owner's_, because the account has its own Linux user, its own
home, and the kernel refusing everything above it.
The distinction earns its keep in one place: **a confined grant means nothing without that Linux
@@ -34,8 +34,8 @@ So "which user is this" has a real answer for the **app** surface (gitea, music,
calendar…) and for the **confined** one (terminal, chat, files), and is still always "the owner" for
anything under `execution`.
`src/servers/capabilities/registry.ts` is the authority and reads as the design document for this.
**Mounting a router without a registry entry makes the server refuse to boot** — see "Capabilities"
`src/servers/permissions/registry.ts` is the authority and reads as the design document for this.
**Mounting a router without a registry entry makes the server refuse to boot** — see "Permissions"
below before adding one.
**Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`,
@@ -84,7 +84,7 @@ src/
│ └── landing/ # marketing landing page
├── servers/
│ ├── hono.ts # router composition; everything under /api
│ ├── _middlewares/ # auth, body parsing, the capability gate, rate limiting
│ ├── _middlewares/ # auth, body parsing, the permission gate, rate limiting
│ ├── api/<feature>/ # one folder per feature, each exporting a router
│ ├── channels/ # send-claude-code / send-opencode — how /chat drives an agent turn
│ ├── queue/ # background job engine
@@ -106,13 +106,14 @@ imported by their package name (`officerdev`, `hooks`, `state`, `types`, `helper
- **Runtime**: Bun (Node 22 or newer is enforced by a `preinstall` check)
That check demanded *exactly* 22 until 2026-08-12. The reason was a `node-pty` build
That check demanded _exactly_ 22 until 2026-08-12. The reason was a `node-pty` build
failure some months earlier, whose details were not recorded. It was relaxed to `>= 22`
after confirming node-pty ships **no Linux prebuilds** — its install script always falls
through to `node-gyp rebuild`, so it compiles against whatever Node is present and there
is no ABI to mismatch. Untested on 24 at the time of the change. If `bun install` fails
building node-pty, or `officer-pty` cannot load its native module, restore the exact pin
first. The source build also needs `build-essential` and `python3`.
- **Language**: TypeScript, strict. `bunx tsgo` is clean — keep it that way.
- **Frontend**: React 19, React Router 7, React Query, Tailwind 4, shadcn/ui + custom components
- **Backend**: Hono
@@ -135,7 +136,7 @@ per-account email SQLite stores — those are the **email sidecar's**, and nothi
them.
**None of those paths is configured.** Since 2026-08-13 `src/servers/data-path.ts` derives the install
root as `resolve(process.cwd(), '..')` and hangs `data/`, `capabilities/` and `dockers/` off it. That
root as `resolve(process.cwd(), '..')` and hangs `data/`, `permissions/` and `dockers/` off it. That
replaced `DATA_PATH`, `OFFICER_ITEMS_DIR` and `HOME_DIR` in `.env` — three values that had to agree with
each other and with the tree on disk. `assertInstallLayout` refuses to boot when the working directory
is not the repo, because otherwise a wrong `cwd` relocates the whole install silently rather than
@@ -172,30 +173,30 @@ exceed Postgres's 63-character identifier limit: name it explicitly. See `src/da
what came out was documented defence in depth that was already switched off. Origin was never
authentication here anyway: an app's `officer://<hex>` origin is chosen by the client, forgeable
outside a browser, and extractable from a shipped binary. The perimeter is the tailnet, and the lock
is a valid token on every protected route plus the capability gate below.
is a valid token on every protected route plus the permission gate below.
- JWTs are 30-day, blacklisted on signout, and invalidated by a password change (`passwordChangedAt`).
**The role is deliberately not a claim** — every authorization decision re-reads `users.role` from
Postgres, so a grant or a revoke takes effect on the next request rather than at next sign-in.
- A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
authenticated request until the server restarts.
### Capabilities — read this before mounting a router
### Permissions — read this before mounting a router
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
valid"). It is `_middlewares/capability-gate.ts``capabilities/authorize.ts`, mounted globally in `hono.ts`
valid"). It is `_middlewares/permission-gate.ts``permissions/authorize.ts`, mounted globally in `hono.ts`
ahead of everything, and it re-verifies the token itself so it covers routes that never mount
`userMiddleware`.
- `capabilities/registry.ts` — the single enumeration of what the platform can do, in five kinds:
- `permissions/registry.ts` — the single enumeration of what the platform can do, in five kinds:
`core` (every account, not deniable), `app` (**the grantable surface**), `confined` (grantable, but
only to an account that has a Linux user), `execution` and `admin` (owner only, and `execution` is
never grantable at any level). 27 entries as of 2026-08-13.
- `capabilities/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
- `permissions/authorize.ts` — resolves "may this account do this". Owner short-circuits first; every
other answer is role grants plus core, with `execution`/`admin` stripped even if a row grants them,
and `confined` stripped for an account with no `osUser`.
**Every catch returns deny.** Grants are cached by role and the cache's whole invalidation contract
is `invalidateRoleGrants`, called by the one writer in `api/users/capabilities-routes.ts`.
- `capabilities/totality.ts``assertCapabilityTotality` runs in `server.tsx` **before `serve()` and
is `invalidateRoleGrants`, called by the one writer in `api/users/permissions-routes.ts`.
- `permissions/totality.ts``assertPermissionTotality` runs in `server.tsx` **before `serve()` and
throws**. Mount a router or a socket without a registry entry and `pm2 restart officer` fails,
naming what is missing. That is deliberate: the hole it closes was a Member 403'ing on
`GET /api/tasks` and opening `/api/tasks/pipeline/ws` with a 101 in the same minute, because Bun's
@@ -206,7 +207,7 @@ So **adding a router means adding one line to `CAPABILITIES`**. If the surface g
user-gated, add it to `EXEMPT_API_PREFIXES` in `totality.ts` _with a reason_ — an unexplained exemption
is how the hole happened the first time.
The frontend hook `useCapabilities` **fails open** on purpose: hiding a dock icon is a courtesy, the
The frontend hook `usePermissions` **fails open** on purpose: hiding a dock icon is a courtesy, the
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
into a refusal.