retire the single-user claim from the docs it outlived
CLAUDE.md asserted "single-user is a hard invariant, not a stage" while users held six rows and role_capabilities held grants. Every doc that repeated it is corrected here, in prose and in the code comments that carried the same claim. The accurate statement is narrower: one owner who bypasses every check, other accounts holding only what their role is granted, and a set of capabilities — terminal, chat, files, tasks, items, desktop, browser — that are structurally ungrantable because they execute as the owner's OS user. TODO.md gains a Multi-user section for what the read turned up: no way to create a second account, dashboards.id colliding across users, authorize.ts untested, pty/vault/opencode taking no identity, Radicale still owner_only. claude-sidecar-isolation.md's open question is answered rather than left open — the per-email spawn model is dead weight, because chat is an execution capability and no second account can ever reach it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,14 +2,36 @@
|
||||
|
||||
## Project Overview
|
||||
|
||||
Officer is a self-hosted personal platform for one person: the server owner. It bundles an AI agent,
|
||||
a terminal, a file browser, a code editor, email, a bitcoin wallet, a remote desktop and customisable
|
||||
dashboards behind a single web app.
|
||||
Officer is a self-hosted platform built around one person — the server owner — which since 2026-08-07
|
||||
also admits **additional accounts holding a strict subset of it**. It bundles an AI agent, a terminal,
|
||||
a file browser, a code editor, email, a bitcoin wallet, a remote desktop and customisable dashboards
|
||||
behind a single web app.
|
||||
|
||||
**Single-user is a hard invariant, not a stage.** There is exactly one account, created once by
|
||||
`POST /auth/bootstrap` while the user table is empty. There are no roles, no invitations, no
|
||||
sandboxing of one user from another, and no per-user isolation anywhere in the codebase. If a change
|
||||
seems to need "which user is this", the answer is always the owner.
|
||||
**The owner/member split, and where the line falls.** This file said "single-user is a hard invariant,
|
||||
not a stage" until 2026-08-07. That is no longer true and had already stopped being true when it was
|
||||
written: `users` holds six rows. The accurate statement is narrower and more useful —
|
||||
|
||||
- **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
|
||||
meaning "no", so an empty table is a server where members reach nothing but their own profile.
|
||||
- **Some things can never be shared, structurally.** Terminal, chat, tasks, files, desktop and browser
|
||||
are `kind: 'execution'` in the capability registry: 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.
|
||||
|
||||
So "which user is this" now has a real answer for the **app** surface (gitea, music, photos, email,
|
||||
calendar…), and is still always "the owner" for anything that executes code or touches the disk.
|
||||
|
||||
`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"
|
||||
below before adding one.
|
||||
|
||||
**Still single-user: account creation.** `createUser` has exactly one call site, `auth/bootstrap.ts`,
|
||||
gated on an empty table. There is no signup route, no invite flow and no admin create-user handler, so
|
||||
every existing member was inserted into Postgres by hand. That is the largest gap in the model, not a
|
||||
deliberate boundary.
|
||||
|
||||
## Architecture
|
||||
|
||||
@@ -111,17 +133,50 @@ exceed Postgres's 63-character identifier limit: name it explicitly. See `src/da
|
||||
|
||||
## Security Model
|
||||
|
||||
The perimeter is one credential, so the guards matter:
|
||||
|
||||
- `IS_DEV_BUILD` (`src/servers/build-env.ts`) is true **only** when `PUBLIC_BUILD_ENV` is explicitly
|
||||
`dev`/`development`. Everything else, including unset, is hardened. Origin validation, rate
|
||||
limiting and password rules all key off it — they fail closed.
|
||||
- Allowed origins come from `PUBLIC_URL`. Officer always sits behind an HTTPS reverse proxy, so the
|
||||
forwarded `Host` must equal `PUBLIC_URL`'s authority exactly.
|
||||
- **`ALLOW_ANY_ORIGIN` defaults to ON** — origin checking is off unless the var is explicitly `false`.
|
||||
A deliberate inversion of the usual rule, safe only because the perimeter is the tailnet and a valid
|
||||
token is still required on every protected route. It is defence in depth that is currently switched
|
||||
off, not the lock.
|
||||
- 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
|
||||
|
||||
Authorization is one system, and it is not in `userMiddleware` (which only answers "is this token
|
||||
valid"). It is `originScopeMiddleware` → `capabilities/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 four kinds:
|
||||
`core` (every account, not deniable), `app` (**the grantable surface**), `execution` and `admin`
|
||||
(owner only, and `execution` is never grantable at any level).
|
||||
- `capabilities/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.
|
||||
**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
|
||||
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
|
||||
route table matches the socket before the `/api/*` catch-all that reaches Hono. A patch does not
|
||||
survive the next door; refusing to boot does.
|
||||
|
||||
So **adding a router means adding one line to `CAPABILITIES`**. If the surface genuinely is not
|
||||
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
|
||||
403 is the lock, and an owner locked out by a transient network error is worse than a member clicking
|
||||
into a refusal.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
|
||||
Reference in New Issue
Block a user