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:
2026-08-07 21:58:44 +00:00
co-authored by Claude Opus 5
parent ac64a7362b
commit d56be0301d
13 changed files with 227 additions and 75 deletions
+18 -7
View File
@@ -5,16 +5,27 @@ Guidance for any coding agent working in the Officer platform repo.
**The instructions live in [`CLAUDE.md`](CLAUDE.md). Read it first — this file only points there.** **The instructions live in [`CLAUDE.md`](CLAUDE.md). Read it first — this file only points there.**
Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that Kept separate so agents that look for `AGENTS.md` by convention find the same guidance as those that
look for `CLAUDE.md`, without the two drifting apart. The previous contents of this file had drifted look for `CLAUDE.md`, without the two drifting apart.
badly: they described Officer as a multi-user intranet for small businesses, with a user-invitation
API, a `bun dev` serving a separate dashboard on port 5000, and a closing instruction to be
"multi-user aware — always consider user isolation and role-based access". None of that is true.
## Orientation ## Orientation
Officer is a self-hosted personal platform that **serves exactly one person — the owner of the Officer is a self-hosted platform built around **one owner** (user id 1, role `Super Admin`, who
server**. There is no tenancy, no roles, no user management. If a design question turns on "which bypasses every permission check), which since 2026-08-07 also admits **additional accounts holding a
user", the answer is the owner. strict subset of it**. Roles are `Admin` / `Member` / `Developer`; what each may reach is decided by
per-role capability grants, resolved on every request.
If a design question turns on "which user", the answer depends on the surface: real for the **app**
capabilities (gitea, music, photos, email, calendar…), and still always **the owner** for anything
that executes code or touches the disk — terminal, chat, tasks, files, desktop, browser are
`kind: 'execution'` and can never be granted. `src/servers/capabilities/registry.ts` is the authority.
**Mounting a router without a registry entry makes the server refuse to boot.** Read the "Capabilities"
section of `CLAUDE.md` before adding one.
This file previously described Officer as strictly single-user with "no tenancy, no roles, no user
management". That was written to correct an *older* drift in the opposite direction — a fictional
multi-user intranet with a user-invitation API — and it overshot. Both are now superseded by the
paragraph above; treat the capability registry as the source of truth over either.
This repo is one of two. The other, `capabilities/`, holds the agent's tasks, tools and skills as This repo is one of two. The other, `capabilities/`, holds the agent's tasks, tools and skills as
plain files, and is where most changes belong — adding or changing a task needs no code change here plain files, and is where most changes belong — adding or changing a task needs no code change here
+64 -9
View File
@@ -2,14 +2,36 @@
## Project Overview ## Project Overview
Officer is a self-hosted personal platform for one person: the server owner. It bundles an AI agent, Officer is a self-hosted platform built around one person the server owner — which since 2026-08-07
a terminal, a file browser, a code editor, email, a bitcoin wallet, a remote desktop and customisable also admits **additional accounts holding a strict subset of it**. It bundles an AI agent, a terminal,
dashboards behind a single web app. 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 **The owner/member split, and where the line falls.** This file said "single-user is a hard invariant,
`POST /auth/bootstrap` while the user table is empty. There are no roles, no invitations, no not a stage" until 2026-08-07. That is no longer true and had already stopped being true when it was
sandboxing of one user from another, and no per-user isolation anywhere in the codebase. If a change written: `users` holds six rows. The accurate statement is narrower and more useful —
seems to need "which user is this", the answer is always the owner.
- **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 ## Architecture
@@ -111,17 +133,50 @@ exceed Postgres's 63-character identifier limit: name it explicitly. See `src/da
## Security Model ## 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 - `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 `dev`/`development`. Everything else, including unset, is hardened. Origin validation, rate
limiting and password rules all key off it — they fail closed. 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 - 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. 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`). - 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 - A panic lockdown (`src/servers/api/auth/panic.ts`) is in-memory only and refuses every
authenticated request until the server restarts. 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 ## Commands
```bash ```bash
+7 -3
View File
@@ -6,9 +6,13 @@ Everything the `/system-monitor` web screen renders, for building the same in th
- Send the JWT as **`Authorization: Bearer <token>`**, or as **`?token=<token>`** in the query string - Send the JWT as **`Authorization: Bearer <token>`**, or as **`?token=<token>`** in the query string
(required for the SSE endpoints — `EventSource` can't set headers). (required for the SSE endpoints — `EventSource` can't set headers).
- **Owner-only.** These routes are gated to the platform owner. Non-owner accounts (e.g. music-app - **Owner-only.** These routes belong to the `server-admin` capability, which is `kind: 'admin'` and
users) are confined to `/api/auth` + `/api/music` and will get `403` here. The full **officer-mobile** therefore never grantable — a non-owner account gets `403` here whatever its role. The full
client (which authenticates as the owner from an owner-allowed origin) has access; the music app does not. **officer-mobile** client (which authenticates as the owner) has access; the music app does not.
- Note for anyone who read this before 2026-08-07: the old rule was that non-owner accounts were
confined to a hardcoded `/api/auth` + `/api/music`. That list is gone, replaced by per-role
capability grants. The *outcome* for these routes is unchanged — still owner-only — but the reason is
now the capability's kind, not a two-element array.
- All responses are `application/json` except the two `/logs` endpoints, which are `text/event-stream`. - All responses are `application/json` except the two `/logs` endpoints, which are `text/event-stream`.
--- ---
+66 -17
View File
@@ -1,26 +1,75 @@
# TODO # TODO
Deferred work. Context: Officer is collapsing from multi-tenant / open-source-ready to a Deferred work.
**single-user platform**. Treat multi-tenant indirection as accidental complexity, not a requirement.
## Single-user cleanup **Context, corrected 2026-08-07.** This file used to open by saying Officer was "collapsing from
multi-tenant / open-source-ready to a **single-user platform**", and told you to treat multi-tenant
indirection as accidental complexity. **That direction was reversed.** The capability permission model
shipped on 2026-08-07 to serve a real goal — deploy to the company server, onboard people, give each
one their own Gitea account through the platform. Per-user scoping is now a requirement, and the items
below that proposed deleting it have been removed rather than left to mislead the next reader.
- [ ] **Remove dead `username` plumbing.** Mostly resolved by deleting the chat channels — the What did NOT reverse: `execution` capabilities (terminal, chat, tasks, files, desktop, browser) run as
handlers and `send-and-await.ts` that threaded `toShellUsername(...)` through to nothing are gone. the owner's OS user and can never be granted. Indirection there really is accidental complexity.
What remains: `send-claude-code.ts` still declares `username` without using it, and
`toShellUsername` has one real caller left (`provision.ts``generateClaudeSettings`), so it may
be inlinable.
- [x] **Delete or gut `scripts/provision-existing-users.sh`.** Done — deleted the script (it ran ## Multi-user
`sudo useradd …`, the source of the vestigial `andrepadez`/`john-wick`/`fedra`/`miguelbenoliel`
Unix accounts). Also dropped the dead per-user VNC desktop provisioning (`provisionVncEnv`, the
`startxfce4` xstartup) from `provision.ts` — the mirror self-provisions its passwd in
`vnc-manager.ts` — and the Pi `.pi/agent/sessions` seed.
- [ ] **Collapse the rest of the multi-tenant machinery.** Candidates, in rough order of payoff: - [ ] **No way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`, gated
roles (`Super Admin`/`Member`), the sandboxed-vs-unsandboxed path split, per-email home dirs on an empty user table. There is no signup route, no invite flow and no admin create-user
under `dev-data/{email}/home`, per-user server state (dock, user apps, AppRegistry keyed by handler, so every member on this instance was inserted into Postgres by hand. This is the
email), and auth (passkeys-per-origin, JWT signin, unmounted `PasskeyGate`). blocker for onboarding anyone who is not already in the database.
- [ ] **`dashboards.id` is a global primary key, and ids are `slugify(name)`.** Two accounts cannot
both have a dashboard named "Home". Reachable today: six accounts exist. The recommendation on
the table is a composite PK `(user_id, id)` — it matches the `uq_dashboards_user_id` index
already there and keeps every stored `ws-layout-<id>` address valid, which uuid ids would not.
Note the drizzle composite-PK re-diff quirk in `databases/CLAUDE.md`. Full analysis in
`docs/workspace-panel-todo.md` §3.
- [ ] **`capabilities/authorize.ts` has no automated tests.** `registry.test.ts` covers the pure
registry functions and the totality check; the resolver that does the owner bypass, the grant
lookup, the role cache and the fail-closed catches is exercised only by hand. It is the file
standing between a Member and a shell.
- [ ] **No empty state for a denied screen.** A member who reaches a route their role lacks gets a
broken panel or an endless spinner rather than a clean refusal.
- [ ] **`getOwnerHomeDir(email)` ignores its argument** whenever `HOME_DIR` is set, which it is here —
every caller resolves to the owner's real login home. Safe only because all seven callers sit
behind `execution` capabilities. If per-user home confinement is ever attempted, this is the
function to start from.
- [ ] **`pty`, `vault` and `opencode` receive no identity at all.** Every other sidecar validates
`X-Officer-User`. The pty sidecar keys purely on a `sessionId` from the query string and its
`/_officer/sessions` endpoints list and kill *every* session on the box; vault and opencode take
no user argument. All three are covered today only because `terminal`, `vault` and the agent are
owner-only capabilities — that is a correct outcome resting on the wrong layer, and it is the
thing to fix first if any of them is ever granted.
- [ ] **Radicale is configured `type = owner_only`** (`sidecar/caldav/radicale.ts:54`) while the caldav
sidecar itself is fully per-user and confines every JSON read to `/dav/<userId>/`. The platform
side is ready for members; the CalDAV server underneath is not.
- [ ] **The music library is one global index.** `sidecar/music/indexer.ts` reads `HOME_DIR` and serves
every account from it. Favourites, playlists and now-playing *are* per-user. Deliberate for now
(one household, one library) but worth stating rather than discovering.
- [ ] **`markInterruptedJobs()` and `getOldestPendingJob()` are platform-wide.** The pipeline queue is a
single global lane; ownership is enforced one layer up, in `pipeline-jobs-routes.ts`, by an
explicit `job.userId !== user.id → 404` on every by-id route. Correct today, but the queue itself
has no notion of whose work it is running.
- [x] **Cross-user writes in the notify sidecar** (fixed 2026-08-07, this session).
`DELETE /_officer/devices/:token` deleted by token with no user predicate, so any account with the
`notify` capability could deregister another's device; and `POST /_officer/notify` let a request
body's `userId` override the proxy-injected `X-Officer-User`, so the same account could push to
another's devices. `deletePushDevice` now takes an optional `userId` (the route passes it, the
APNs/FCM dead-token paths deliberately do not) and the header now wins over the body.
- [ ] **Remove dead `username` plumbing.** `send-claude-code.ts` declares `username` in two types
without using it. (`toShellUsername` is NOT dead — `server.tsx:190` and
`pipeline-job-manager.ts:267` both call it. The `provision.ts` caller this item used to name no
longer exists.)
## Email ## Email
+11 -6
View File
@@ -284,12 +284,17 @@ is a real design decision and I don't have a confident recommendation.
## Open questions — the ones I'd rather you answered ## Open questions — the ones I'd rather you answered
1. **Is the per-email spawn model dead weight?** `CLAUDE.md` states single-user is a hard invariant 1. ~~**Is the per-email spawn model dead weight?**~~**answered 2026-08-07: yes, it is.** The
("If a change seems to need 'which user is this', the answer is always the owner"), yet the agent question was whether multi-tenancy might later need the per-email fan-out (`claude:${email}`, the
sidecar is keyed per email — `claude:${email}`, a `claudeProcs` Map, a `claudeSpawnWaiters` Map, a `claudeProcs` and `claudeSpawnWaiters` Maps, the per-email PID lock). The capability model settled
per-email PID lock. If there is only ever one owner, Stage 1a becomes trivial: one PM2 entry, no it in the *other* direction from what "the platform is going multi-user" would suggest: `chat` is
fan-out, no registration polling. If you intend multi-tenant later, the fan-out has to stay and `kind: 'execution'` in `capabilities/registry.ts`, which is **never grantable at any level**,
Stage 1 gets harder. **This single answer changes the shape of the whole plan.** because the agent runs as the owner's OS user with `--dangerously-skip-permissions`. Additional
accounts exist now, and not one of them can ever open a chat.
So the fan-out is keyed on a dimension that is structurally guaranteed to have one value. Stage 1a
is the trivial version: one PM2 entry, no fan-out, no registration polling. This only reopens if
per-user home confinement is ever built, which is a project rather than a checkbox.
2. **Relay or redirect?** Officer proxies the agent WebSocket (one origin, keeps your HTTPS reverse 2. **Relay or redirect?** Officer proxies the agent WebSocket (one origin, keeps your HTTPS reverse
proxy and JWT model intact, but a restart still drops the socket for a moment), or officer hands proxy and JWT model intact, but a restart still drops the socket for a moment), or officer hands
+2 -1
View File
@@ -117,7 +117,8 @@ worth serving both from one place.
- **It is not backup.** Sync propagates deletions. A synced folder is not a backup of itself, and - **It is not backup.** Sync propagates deletions. A synced folder is not a backup of itself, and
anyone who believes otherwise finds out at the worst moment. Versioning (Syncthing has several anyone who believes otherwise finds out at the worst moment. Versioning (Syncthing has several
strategies) should be enabled and surfaced in the UI precisely so this is not confused. strategies) should be enabled and surfaced in the UI precisely so this is not confused.
- **It is not sharing.** Single-user remains a hard platform invariant. - **It is not sharing.** Files is an `execution` capability — the owner's disk, never grantable — so
there is still nobody to share with, whatever the account list says since 2026-08-07.
--- ---
+5 -2
View File
@@ -9,8 +9,11 @@ sidecar with its own scheduling, so it does not appear in the Jobs list.
created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop *and* phone, created over REST, streamed live over WebSocket, resumable/attachable, visible on desktop *and* phone,
and ending in a push notification. Replaces today's ephemeral script-task WebSocket path. and ending in a push notification. Replaces today's ephemeral script-task WebSocket path.
**Context:** single user, forever. No multi-tenant concerns — "is anything running?" is a global check. **Context:** jobs belong to the owner. Not because the platform is single-user — it stopped being that
Favor power-user affordances over guardrails. See memory `sole-user-assume-competence`. on 2026-08-07 — but because `tasks` is an `execution` capability: running a job means running a script
as the owner's OS user, so it can never be granted to a member. "Is anything running?" is therefore
still a global check, and the conclusion below is unchanged even though the premise was rewritten.
Favor power-user affordances over guardrails.
## Current state (baseline) ## Current state (baseline)
+9 -4
View File
@@ -58,7 +58,9 @@ speaks DAV.
### 1.2 The credential model ### 1.2 The credential model
- **Username** = the account's email address. (Officer is single-user; there is exactly one.) - **Username** = the account's email address — the signed-in account's own, not a constant. (This said
"Officer is single-user; there is exactly one" until 2026-08-07. `calendar` is now a grantable
capability, so a member can hold their own app passwords and their own collections.)
- **Password** = a **DAV app password**, not the login password. - **Password** = a **DAV app password**, not the login password.
DAV app passwords are argon2-hashed at rest, scoped to `/dav` and nothing else, and **the plaintext is DAV app passwords are argon2-hashed at rest, scoped to `/dav` and nothing else, and **the plaintext is
@@ -100,9 +102,12 @@ A collection https://<host>/dav/<userId>/<collection>/
`<userId>` **is the platform user id, by construction** — the same integer `/auth/me` returns. They `<userId>` **is the platform user id, by construction** — the same integer `/auth/me` returns. They
cannot diverge: `sync-router.ts` sets `X-Officer-User: String(userId)` straight from the app-password cannot diverge: `sync-router.ts` sets `X-Officer-User: String(userId)` straight from the app-password
row, the sidecar forwards it to Radicale as `X-Remote-User`, and Radicale's storage tree is literally row, the sidecar forwards it to Radicale as `X-Remote-User`, and Radicale's storage tree is literally
`/<that value>/`. There is no mapping table to get out of step. On a single-user instance — which every `/<that value>/`. There is no mapping table to get out of step.
Officer instance is — that is `1`. Deriving it from `/auth/me` is safe; so is deriving it from the
collection paths, which is why both work today. **Do not hardcode `1`.** This passage used to say that on a single-user instance — "which every Officer
instance is" — the value is always `1`. That stopped being true on 2026-08-07: members can hold the
`calendar` capability, and a member's id is not 1. Derive it from `/auth/me` or from the collection
paths; both work, and both stay correct when the caller is not the owner.
**A collection cannot live outside `/dav/<userId>/`.** Two independent guards: the sidecar rejects any **A collection cannot live outside `/dav/<userId>/`.** Two independent guards: the sidecar rejects any
`collection` outside that prefix, and Radicale runs `rights type = owner_only`. `collection` outside that prefix, and Radicale runs `rights type = owner_only`.
+6 -4
View File
@@ -135,8 +135,9 @@ Rationale for not reusing the account password: it ends up typed into a phone, s
account manager in recoverable form, and synced to whatever backs that phone up. One password per account manager in recoverable form, and synced to whatever backs that phone up. One password per
device, revocable per device, is the whole point. device, revocable per device, is the whole point.
The single-user invariant holds — every app password belongs to the owner. `user_id` is there for `user_id` was described here as "referential integrity, not multi-tenancy". That is no longer true:
referential integrity, not multi-tenancy. since 2026-08-07 `calendar` is a **grantable** capability, so an app password can belong to a member
and the column decides whose collection tree Radicale serves. It is load-bearing.
--- ---
@@ -182,8 +183,9 @@ Naming these now so they do not creep in later:
- **iTIP/iMIP scheduling** — sending invitations and processing RSVPs by email. Genuinely complex, and - **iTIP/iMIP scheduling** — sending invitations and processing RSVPs by email. Genuinely complex, and
a single-user personal calendar mostly consumes invitations rather than issuing them. Revisit only a single-user personal calendar mostly consumes invitations rather than issuing them. Revisit only
on a concrete need. on a concrete need.
- **Sharing, ACLs, federation** — single-user is a hard invariant of this platform. There is nobody - **Sharing, ACLs, federation** — still out of scope, but the reason weakened on 2026-08-07. Members
to share with. can now hold `calendar`, so there is somebody to share with; what is missing is any notion of one
account granting another access to its own collection. Revisit on a concrete need.
- **Reimplementing RRULE on the server.** The sidecar stores what the client sends. Expansion happens - **Reimplementing RRULE on the server.** The sidecar stores what the client sends. Expansion happens
where it is displayed, using a library. where it is displayed, using a library.
- **A NextCloud-compatible API.** Nothing needs to pretend to be NextCloud. The standards are the - **A NextCloud-compatible API.** Nothing needs to pretend to be NextCloud. The standards are the
+14 -4
View File
@@ -13,10 +13,20 @@ officer/
└── data/ runtime state — NOT version controlled └── data/ runtime state — NOT version controlled
``` ```
Officer is a self-hosted personal platform: an AI agent, a terminal, a file browser, a code editor, Officer is a self-hosted platform: an AI agent, a terminal, a file browser, a code editor, email, a
email, a bitcoin wallet, a remote desktop and dashboards, behind one web app. **It serves exactly one bitcoin wallet, a remote desktop and dashboards, behind one web app. **It is built around one owner**
person — the owner of this server.** There is no tenancy, no roles, no other users. If a question — user id 1, role `Super Admin`, who bypasses every permission check — and since 2026-08-07 also
turns on "which user", the answer is the owner. admits **additional accounts holding a strict subset of it**, governed by per-role capability grants.
So "which user" has two answers depending on the surface. For the **app** capabilities (gitea, music,
photos, email, calendar…) it is a real question with a real answer. For anything that executes code or
touches the disk — terminal, chat, tasks, files, desktop, browser — it is still always the owner:
those are `kind: 'execution'` in `platform/src/servers/capabilities/registry.ts` and can never be
granted, because they run as the owner's OS user in the owner's home.
This paragraph said "there is no tenancy, no roles, no other users" until 2026-08-07. Four roles exist
and five non-owner accounts are live; treat the capability registry as the source of truth over any
prose, here or elsewhere.
`platform/` and `capabilities/` each have their own `CLAUDE.md` with detail. This file is the layer `platform/` and `capabilities/` each have their own `CLAUDE.md` with detail. This file is the layer
above them: where things live, how to change them safely, and the things that are true of the running above them: where things live, how to change them safely, and the things that are true of the running
@@ -104,8 +104,9 @@ if (PUBLIC_ORIGIN) ORIGIN_RULES[PUBLIC_ORIGIN] = {};
// because an app has to reach /api/auth to sign in before it ever calls its own feature. // because an app has to reach /api/auth to sign in before it ever calls its own feature.
// //
// What still holds with these on: every protected route requires a valid token (userMiddleware), and the // What still holds with these on: every protected route requires a valid token (userMiddleware), and the
// account backstop below still confines a non-owner account to /api/auth + /api/music whatever Origin it // capability backstop below still confines a non-owner account to what its ROLE has been granted,
// claims — that one is deliberately NOT disabled, since it is account-based, not origin-based. // whatever Origin it claims — that one is deliberately NOT disabled, since it is account-based, not
// origin-based.
// //
// What is lost: defence in depth, not the lock. Origin was never authentication here — `officer://<hex>` // What is lost: defence in depth, not the lock. Origin was never authentication here — `officer://<hex>`
// is chosen by the client, forgeable outside a browser, and extractable from a shipped app binary. // is chosen by the client, forgeable outside a browser, and extractable from a shipped app binary.
@@ -172,12 +173,12 @@ function resolveOrigin(headerOrigin: string | undefined, referer: string | undef
// Global gate applied to every request (mounted in hono.ts). Reads the Origin header directly (not // Global gate applied to every request (mounted in hono.ts). Reads the Origin header directly (not
// ctx 'origin') so it covers the whole /api tree — the public /api/auth and the protected /api/music // ctx 'origin') so it covers the whole /api tree — the public /api/auth and the protected /api/music
// alike — regardless of which routers mount originMiddleware. Two layers: // alike — regardless of which routers mount originMiddleware. Two layers:
// 1. Account backstop (origin-INDEPENDENT): a valid NON-owner token may reach only /api/auth + // 1. Capability backstop (origin-INDEPENDENT): a valid NON-owner token may reach only what its ROLE
// /api/music, no matter the origin. This is the airtight rule — it holds even if a client omits // has been granted, no matter the origin. This is the airtight rule — it holds even if a client
// or forges the Origin header — and is what confines the music accounts to the music app. // omits or forges the Origin header. It replaced a hardcoded "/api/auth + /api/music" list on
// 2. Per-origin rules (ORIGIN_RULES): path scoping (music app) and super-admin-only origins (web + // 2026-08-07; that list was why a Member could not reach /api/gitea and no UI could change it.
// mobile). Redundant with the backstop for the account dimension, but keeps owner-only origins // 2. Per-origin rules (ORIGIN_RULES): path scoping for the single-feature apps. Redundant with the
// fully off-limits to non-owners (incl. /api/music) and blocks unknown-path access there. // backstop for the account dimension, but blocks unknown-path access from an app origin.
// A missing/invalid token passes both layers (signin needs it; userMiddleware rejects bad tokens on // A missing/invalid token passes both layers (signin needs it; userMiddleware rejects bad tokens on
// protected routes). Only a VALID non-owner token is constrained. // protected routes). Only a VALID non-owner token is constrained.
export const originScopeMiddleware: MiddlewareHandler = async function (ctx, next) { export const originScopeMiddleware: MiddlewareHandler = async function (ctx, next) {
+5 -4
View File
@@ -13,10 +13,11 @@ usersRouter.use(originMiddleware);
// Self-update. Any signed-in account may change its own name, username and avatar. // Self-update. Any signed-in account may change its own name, username and avatar.
usersRouter.put('/', updateUserHandler); usersRouter.put('/', updateUserHandler);
// Everything below manages OTHER accounts and is the owner's alone. The global backstop in // Everything below manages OTHER accounts and is the owner's alone. The global capability backstop in
// originScopeMiddleware already confines a non-owner token to /api/auth + /api/music, so a Member // originScopeMiddleware already refuses a non-owner here — `user-admin` is `kind: 'admin'`, so it is
// cannot reach these at all; this gate is the explicit statement of intent and gives a clear 403 // not grantable — but that router-level rule cannot see the one exception beside it: `PUT /` is
// rather than relying on a rule written for a different purpose. // declared `selfService` so every account can edit its own profile. This gate is what keeps that
// exception from widening to the routes below it, and it is a second lock rather than a restatement.
const ownerGate: MiddlewareHandler = async (ctx, next) => { const ownerGate: MiddlewareHandler = async (ctx, next) => {
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('User management is owner-only'); if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('User management is owner-only');
return next(); return next();
+11 -6
View File
@@ -18,9 +18,13 @@ import { createSidecarConnector } from '../connect';
import { sign } from '../../jwt'; import { sign } from '../../jwt';
import { getUserByEmail, getOwnerUser, getEmailAccounts } from 'officerdb'; import { getUserByEmail, getOwnerUser, getEmailAccounts } from 'officerdb';
// PM2 starts this sidecar with no user in its env. Single-user platform, so resolve the owner from the // PM2 starts this sidecar with no user in its env, so resolve the owner from the database rather than
// database rather than being told who to run as by the main server — one less thing that has to come // being told who to run as by the main server — one less thing that has to come from `officer` before
// from `officer` before this process can work. CLAUDE_USER_EMAIL still wins when set, for manual runs. // this process can work. CLAUDE_USER_EMAIL still wins when set, for manual runs.
//
// "The owner" is not a simplification that multi-user will later invalidate. `chat` is an `execution`
// capability (capabilities/registry.ts) and is never grantable at any level, so no account other than
// the owner can ever reach this sidecar, however many accounts exist.
async function resolveOwner() { async function resolveOwner() {
const explicit = process.env.CLAUDE_USER_EMAIL?.trim(); const explicit = process.env.CLAUDE_USER_EMAIL?.trim();
for (;;) { for (;;) {
@@ -46,9 +50,10 @@ const MCP_SERVER_SCRIPT = resolve(import.meta.dir, '../../mcp-tool-server.ts');
// Mint a long-lived JWT for this user so tools (e.g. gmail) can call back to dev-platform as them // Mint a long-lived JWT for this user so tools (e.g. gmail) can call back to dev-platform as them
const OFFICER_AUTH_TOKEN = await sign({ id: dbUser.id, email, username: dbUser.username }, '30d'); const OFFICER_AUTH_TOKEN = await sign({ id: dbUser.id, email, username: dbUser.username }, '30d');
// Single-user platform: the owner runs Claude with no isolation — real HOME, real ~/.claude — so // The owner runs Claude with no isolation — real HOME, real ~/.claude — so platform sessions have
// platform sessions have perfect parity with terminal sessions (same config, credentials and // perfect parity with terminal sessions (same config, credentials and transcript store,
// transcript store, interchangeable via `claude --resume`). // interchangeable via `claude --resume`). That absence of isolation is precisely why `chat` is an
// `execution` capability and can never be granted: this is a shell, not a feature flag.
const homeDir = process.env.HOME_DIR ?? homedir(); const homeDir = process.env.HOME_DIR ?? homedir();
const globalToolsDir = join(DATA_PATH, 'tools'); const globalToolsDir = join(DATA_PATH, 'tools');