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
@@ -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) {
+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.
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();
+11 -6
View File
@@ -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');