diff --git a/AGENTS.md b/AGENTS.md index d289c0ce..08d5a5ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index e241aab5..ebc36834 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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 diff --git a/SYSTEM_MONITOR_API.md b/SYSTEM_MONITOR_API.md index 97b73283..f165bcf3 100644 --- a/SYSTEM_MONITOR_API.md +++ b/SYSTEM_MONITOR_API.md @@ -6,9 +6,13 @@ Everything the `/system-monitor` web screen renders, for building the same in th - Send the JWT as **`Authorization: Bearer `**, or as **`?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`. --- diff --git a/TODO.md b/TODO.md index b93ef04a..fd3d844c 100644 --- a/TODO.md +++ b/TODO.md @@ -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-` 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//`. 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 diff --git a/docs/claude-sidecar-isolation.md b/docs/claude-sidecar-isolation.md index f4c0e518..45c9b00b 100644 --- a/docs/claude-sidecar-isolation.md +++ b/docs/claude-sidecar-isolation.md @@ -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 diff --git a/docs/file-sync.md b/docs/file-sync.md index 50aa33ee..662ec3a7 100644 --- a/docs/file-sync.md +++ b/docs/file-sync.md @@ -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. --- diff --git a/docs/jobs-unification.md b/docs/jobs-unification.md index 632a036e..91b5cc24 100644 --- a/docs/jobs-unification.md +++ b/docs/jobs-unification.md @@ -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) diff --git a/docs/mobile-dav-provisioning.md b/docs/mobile-dav-provisioning.md index 335e6511..15d750fb 100644 --- a/docs/mobile-dav-provisioning.md +++ b/docs/mobile-dav-provisioning.md @@ -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:///dav/// `` **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 -`//`. 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. +`//`. 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//`.** Two independent guards: the sidecar rejects any `collection` outside that prefix, and Radicale runs `rights type = owner_only`. diff --git a/docs/nextcloud-replacement.md b/docs/nextcloud-replacement.md index 9aa5d96c..9489904e 100644 --- a/docs/nextcloud-replacement.md +++ b/docs/nextcloud-replacement.md @@ -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 diff --git a/docs/working-on-officer.md b/docs/working-on-officer.md index ce99cf86..4b231a23 100644 --- a/docs/working-on-officer.md +++ b/docs/working-on-officer.md @@ -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 diff --git a/src/servers/_middlewares/origin-validation.ts b/src/servers/_middlewares/origin-validation.ts index e2dbb547..65b3e534 100644 --- a/src/servers/_middlewares/origin-validation.ts +++ b/src/servers/_middlewares/origin-validation.ts @@ -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://` // 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) { diff --git a/src/servers/api/users/users-router.ts b/src/servers/api/users/users-router.ts index a63165de..440beaa2 100644 --- a/src/servers/api/users/users-router.ts +++ b/src/servers/api/users/users-router.ts @@ -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(); diff --git a/src/servers/sidecar/claude/user-instance.ts b/src/servers/sidecar/claude/user-instance.ts index 38ed54ca..5f90ea67 100644 --- a/src/servers/sidecar/claude/user-instance.ts +++ b/src/servers/sidecar/claude/user-instance.ts @@ -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');