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) {