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:
@@ -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