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:
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user