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:
@@ -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.**
|
||||
|
||||
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
|
||||
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.
|
||||
look for `CLAUDE.md`, without the two drifting apart.
|
||||
|
||||
## Orientation
|
||||
|
||||
Officer is a self-hosted personal platform that **serves exactly one person — the owner of the
|
||||
server**. There is no tenancy, no roles, no user management. If a design question turns on "which
|
||||
user", the answer is the owner.
|
||||
Officer is a self-hosted platform built around **one owner** (user id 1, role `Super Admin`, who
|
||||
bypasses every permission check), which since 2026-08-07 also admits **additional accounts holding a
|
||||
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
|
||||
plain files, and is where most changes belong — adding or changing a task needs no code change here
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
(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
|
||||
users) are confined to `/api/auth` + `/api/music` and will get `403` here. The full **officer-mobile**
|
||||
client (which authenticates as the owner from an owner-allowed origin) has access; the music app does not.
|
||||
- **Owner-only.** These routes belong to the `server-admin` capability, which is `kind: 'admin'` and
|
||||
therefore never grantable — a non-owner account gets `403` here whatever its role. The full
|
||||
**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`.
|
||||
|
||||
---
|
||||
|
||||
@@ -1,26 +1,75 @@
|
||||
# TODO
|
||||
|
||||
Deferred work. Context: Officer is collapsing from multi-tenant / open-source-ready to a
|
||||
**single-user platform**. Treat multi-tenant indirection as accidental complexity, not a requirement.
|
||||
Deferred work.
|
||||
|
||||
## 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
|
||||
handlers and `send-and-await.ts` that threaded `toShellUsername(...)` through to nothing are gone.
|
||||
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.
|
||||
What did NOT reverse: `execution` capabilities (terminal, chat, tasks, files, desktop, browser) run as
|
||||
the owner's OS user and can never be granted. Indirection there really is accidental complexity.
|
||||
|
||||
- [x] **Delete or gut `scripts/provision-existing-users.sh`.** Done — deleted the script (it ran
|
||||
`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.
|
||||
## Multi-user
|
||||
|
||||
- [ ] **Collapse the rest of the multi-tenant machinery.** Candidates, in rough order of payoff:
|
||||
roles (`Super Admin`/`Member`), the sandboxed-vs-unsandboxed path split, per-email home dirs
|
||||
under `dev-data/{email}/home`, per-user server state (dock, user apps, AppRegistry keyed by
|
||||
email), and auth (passkeys-per-origin, JWT signin, unmounted `PasskeyGate`).
|
||||
- [ ] **No way to create a second account.** `createUser` has one call site, `auth/bootstrap.ts`, gated
|
||||
on an empty user table. There is no signup route, no invite flow and no admin create-user
|
||||
handler, so every member on this instance was inserted into Postgres by hand. This is the
|
||||
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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
1. **Is the per-email spawn model dead weight?** `CLAUDE.md` states single-user is a hard invariant
|
||||
("If a change seems to need 'which user is this', the answer is always the owner"), yet the agent
|
||||
sidecar is keyed per email — `claude:${email}`, a `claudeProcs` Map, a `claudeSpawnWaiters` Map, a
|
||||
per-email PID lock. If there is only ever one owner, Stage 1a becomes trivial: one PM2 entry, no
|
||||
fan-out, no registration polling. If you intend multi-tenant later, the fan-out has to stay and
|
||||
Stage 1 gets harder. **This single answer changes the shape of the whole plan.**
|
||||
1. ~~**Is the per-email spawn model dead weight?**~~ — **answered 2026-08-07: yes, it is.** The
|
||||
question was whether multi-tenancy might later need the per-email fan-out (`claude:${email}`, the
|
||||
`claudeProcs` and `claudeSpawnWaiters` Maps, the per-email PID lock). The capability model settled
|
||||
it in the *other* direction from what "the platform is going multi-user" would suggest: `chat` is
|
||||
`kind: 'execution'` in `capabilities/registry.ts`, which is **never grantable at any level**,
|
||||
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
|
||||
proxy and JWT model intact, but a restart still drops the socket for a moment), or officer hands
|
||||
|
||||
+2
-1
@@ -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
|
||||
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.
|
||||
- **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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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,
|
||||
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.
|
||||
Favor power-user affordances over guardrails. See memory `sole-user-assume-competence`.
|
||||
**Context:** jobs belong to the owner. Not because the platform is single-user — it stopped being that
|
||||
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)
|
||||
|
||||
|
||||
@@ -58,7 +58,9 @@ speaks DAV.
|
||||
|
||||
### 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.
|
||||
|
||||
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
|
||||
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
|
||||
`/<that value>/`. There is no mapping table to get out of step. On a single-user instance — which every
|
||||
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.
|
||||
`/<that value>/`. There is no mapping table to get out of step.
|
||||
|
||||
**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
|
||||
`collection` outside that prefix, and Radicale runs `rights type = owner_only`.
|
||||
|
||||
@@ -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
|
||||
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
|
||||
referential integrity, not multi-tenancy.
|
||||
`user_id` was described here as "referential integrity, not multi-tenancy". That is no longer true:
|
||||
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
|
||||
a single-user personal calendar mostly consumes invitations rather than issuing them. Revisit only
|
||||
on a concrete need.
|
||||
- **Sharing, ACLs, federation** — single-user is a hard invariant of this platform. There is nobody
|
||||
to share with.
|
||||
- **Sharing, ACLs, federation** — still out of scope, but the reason weakened on 2026-08-07. Members
|
||||
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
|
||||
where it is displayed, using a library.
|
||||
- **A NextCloud-compatible API.** Nothing needs to pretend to be NextCloud. The standards are the
|
||||
|
||||
@@ -13,10 +13,20 @@ officer/
|
||||
└── data/ runtime state — NOT version controlled
|
||||
```
|
||||
|
||||
Officer is a self-hosted personal platform: an AI agent, a terminal, a file browser, a code editor,
|
||||
email, a bitcoin wallet, a remote desktop and dashboards, behind one web app. **It serves exactly one
|
||||
person — the owner of this server.** There is no tenancy, no roles, no other users. If a question
|
||||
turns on "which user", the answer is the owner.
|
||||
Officer is a self-hosted platform: an AI agent, a terminal, a file browser, a code editor, email, a
|
||||
bitcoin wallet, a remote desktop and dashboards, behind one web app. **It is built around one owner**
|
||||
— user id 1, role `Super Admin`, who bypasses every permission check — and since 2026-08-07 also
|
||||
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
|
||||
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.
|
||||
//
|
||||
// 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
|
||||
// claims — that one is deliberately NOT disabled, since it is account-based, not origin-based.
|
||||
// capability backstop below still confines a non-owner account to what its ROLE has been granted,
|
||||
// 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>`
|
||||
// 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
|
||||
// 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:
|
||||
// 1. Account backstop (origin-INDEPENDENT): a valid NON-owner token may reach only /api/auth +
|
||||
// /api/music, no matter the origin. This is the airtight rule — it holds even if a client omits
|
||||
// or forges the Origin header — and is what confines the music accounts to the music app.
|
||||
// 2. Per-origin rules (ORIGIN_RULES): path scoping (music app) and super-admin-only origins (web +
|
||||
// mobile). Redundant with the backstop for the account dimension, but keeps owner-only origins
|
||||
// fully off-limits to non-owners (incl. /api/music) and blocks unknown-path access there.
|
||||
// 1. Capability backstop (origin-INDEPENDENT): a valid NON-owner token may reach only what its ROLE
|
||||
// has been granted, no matter the origin. This is the airtight rule — it holds even if a client
|
||||
// omits or forges the Origin header. It replaced a hardcoded "/api/auth + /api/music" list on
|
||||
// 2026-08-07; that list was why a Member could not reach /api/gitea and no UI could change it.
|
||||
// 2. Per-origin rules (ORIGIN_RULES): path scoping for the single-feature apps. Redundant with the
|
||||
// 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
|
||||
// protected routes). Only a VALID non-owner token is constrained.
|
||||
export const originScopeMiddleware: MiddlewareHandler = async function (ctx, next) {
|
||||
|
||||
@@ -13,10 +13,11 @@ usersRouter.use(originMiddleware);
|
||||
// Self-update. Any signed-in account may change its own name, username and avatar.
|
||||
usersRouter.put('/', updateUserHandler);
|
||||
|
||||
// Everything below manages OTHER accounts and is the owner's alone. The global backstop in
|
||||
// originScopeMiddleware already confines a non-owner token to /api/auth + /api/music, so a Member
|
||||
// cannot reach these at all; this gate is the explicit statement of intent and gives a clear 403
|
||||
// rather than relying on a rule written for a different purpose.
|
||||
// Everything below manages OTHER accounts and is the owner's alone. The global capability backstop in
|
||||
// originScopeMiddleware already refuses a non-owner here — `user-admin` is `kind: 'admin'`, so it is
|
||||
// not grantable — but that router-level rule cannot see the one exception beside it: `PUT /` is
|
||||
// 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) => {
|
||||
if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('User management is owner-only');
|
||||
return next();
|
||||
|
||||
@@ -18,9 +18,13 @@ import { createSidecarConnector } from '../connect';
|
||||
import { sign } from '../../jwt';
|
||||
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
|
||||
// database rather than being told who to run as by the main server — one less thing that has to come
|
||||
// from `officer` before this process can work. CLAUDE_USER_EMAIL still wins when set, for manual runs.
|
||||
// PM2 starts this sidecar with no user in its env, so resolve the owner from the database rather than
|
||||
// being told who to run as by the main server — one less thing that has to come from `officer` before
|
||||
// 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() {
|
||||
const explicit = process.env.CLAUDE_USER_EMAIL?.trim();
|
||||
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
|
||||
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
|
||||
// platform sessions have perfect parity with terminal sessions (same config, credentials and
|
||||
// transcript store, interchangeable via `claude --resume`).
|
||||
// The owner runs Claude with no isolation — real HOME, real ~/.claude — so platform sessions have
|
||||
// perfect parity with terminal sessions (same config, credentials and transcript store,
|
||||
// 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 globalToolsDir = join(DATA_PATH, 'tools');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user