The word meant four different things in this repo, not the three the offscale
doc records:
1. the permission registry → RENAMED here
2. $OFFICER_ROOT/capabilities/ items → kept; this is what capabilities are
3. sidecar routing keys → step 3, becoming `handles`
4. Lightning wallet features → kept; a domain term, and on the wire
to the mobile apps
The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.
Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.
Three breaks the typechecker could not see, all found by exercising it live.
The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.
The response FIELD moved too. `client.get<SelfPermissions>()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.
And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.
The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.
The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.
Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.
tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
DEFAULTS. Every role now starts with the three confined capabilities at write, seeded in
bootstrap. These are what the platform is FOR — an account that signs in and reaches none of
them is not restricted, it is useless, and making the owner grant them by hand first is a
step with no decision in it.
Seeded as real rows rather than implied by absence, which keeps the table's one rule intact:
a missing row means no access, always, with no exception to remember. Revoking one therefore
works like revoking anything else — the row goes and nothing puts it back. Done in bootstrap
because that happens exactly once per install, so seeding can never fight a later revocation.
Non-fatal: an owner whose roles hold nothing is a one-click fix, while failing bootstrap over
it leaves a platform with no account at all.
`app` capabilities are deliberately not defaulted — they reach data the owner may not intend
to share, and each needs a sidecar before it means anything.
SCREEN. Role selection is tabs rather than a dropdown: three roles are the axis you move
along, and a select hid two of them behind a click while giving no sense of which one you are
editing. Row descriptions are gone — with three rows called Terminal, Chat and Files they
explained nothing — and the "needs a Linux account" warning went with them, since every
account now gets one at creation, so it was noise about a state that no longer occurs on its
own. `needsOsAccount` is removed from the API too, not just hidden.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There were three answers to "who is the owner" and nothing kept them agreeing:
SUPER_ADMIN_EMAIL in .env, the lowest user id, and now the role column. Any two
of them part company the moment one is edited, and the failure is silent — an
account quietly gains or loses the vault, the platform origin, and the identity
the agent sidecar runs as. The column wins; the other two are gone.
- isSuperAdmin() reads users.role. SUPER_ADMIN_EMAIL is deleted from the code and
from .env.
- getOwnerUser() selects on the role instead of `order by id limit 1`. It returns
undefined when no row holds it rather than falling back: the agent sidecar
refusing to start beats it silently running as the wrong person.
- bootstrap creates the first account with role 'Super Admin'. Without this a
fresh install would take the column's 'Member' default and come up with NO
owner at all — no vault, no agent identity, and the web origin locked to a
Super Admin that does not exist. That bug was live the moment the column
landed; SUPER_ADMIN_EMAIL was masking it here.
Deliberately not cached. The old resolver cached an owner id, justified by "the
owner never changes at runtime (bootstrap is closed after user #1)" — which stops
being true as soon as roles are editable, and a cache with no invalidation
contract is a staleness bug waiting for whoever builds the role UI. It is one
primary-key lookup on a request that has already verified a JWT.
Verified against the live database: getOwnerUser resolves to id 1, isSuperAdmin
is true for it and false for the three Members, for a null payload and for an id
that does not exist. Then temporarily set id 13 to 'Admin' (false) and to
'Super Admin' (true) with no restart in between, which is what proves the column
is doing the deciding rather than a cache or the old lowest-id path. Reverted.
Not solved here: nothing stops the last Super Admin being demoted, which would
leave the platform ownerless. The schema cannot express it; whatever eventually
edits roles has to. Noted in the column's comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
provisionUserEnvironment seeded a managed home under DATA_PATH/<email> — shell
configs from a template dir, plus a generated CLAUDE.md and settings.json — and
bootstrap called it fire-and-forget when the owner account was created. It is
outdated: the managed home is not where anything runs. Task runs, terminals and
chats all execute in getOwnerHomeDir (the real login home, HOME_DIR), not the
getHomeDir tree this was populating.
Removed the function, its four template files, and the call in bootstrap. No
setup script referenced it — checked all of scripts/*.sh — and bootstrap was its
only caller anywhere in the tree.
getHomeDir and toShellUsername stay in data-path.ts: pipeline-executor and
pipeline-job-manager still use them.
This leaves src/servers/generate-container-context.ts with zero consumers, since
provision was the only thing importing it. Left in place rather than deleted in
the same commit — it is 173 lines that build a CLAUDE.md and a settings.json for
an agent, which is plausibly wanted somewhere else, and that is a call for the
owner rather than a side effect of this cleanup.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Officer is single-user: the server owner is the only account, created once by
/auth/bootstrap. Everything that existed to serve additional users was
unreachable, so it is gone rather than left looking like it does something.
Accounts: drop the invite / resend-invite / delete / list-users routes and the
Users settings screen, the inert /auth/signup handler, and the account
verification chain it fed (verify, resend-verification, VerifyScreen, the
UserInvite + VerifyAdmin + VerifyRegistration templates). /auth/verify-token
survives for password resets only, and now requires a reset-password token
rather than accepting any signed JWT.
Roles: drop the users.role column and the four-value USER_ROLES enum. The
permissions table granted every role identical methods, and every
role === 'Super Admin' check was permanently true. The JWT no longer carries a
role claim.
Sandbox: remove sidecar/sandbox.ts and its five call sites. bwrap was selected
only for non-Super-Admin users, so it never ran. It was also not a usable agent
jail as written — --share-net, the project root (with .env) bound read-only,
and runuser dropping to the server's own uid. Rebuilding it for agent
containment would be a different construction, and git history keeps this one.
getHomeDir keeps its DATA_PATH meaning; the new getOwnerHomeDir resolves the
owner's real login home, which is what terminals, chats and task runs use.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Collapse the two-phase bootstrap (email a verification link → verify screen) into
one direct step: the Bootstrap form collects name/email/username/password and posts
once to /bootstrap, which creates the first user directly as an active Super Admin
(+ provisions DATA_PATH/<email>). Still gated to an empty user table.
The invite flow (/verify, /verify-token) is untouched.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the multi-user provisioning leftovers (the provision-existing-users.sh
migration was already deleted in the prior commit):
- gut provisionVncEnv from provision.ts (per-user startxfce4 virtual desktop, dead
since the switch to mirroring :0 — vnc-manager.ts self-provisions its own passwd)
- drop the Pi `.pi/agent/sessions` seed and the now-orphaned `run` helper
Wire provisioning into bootstrapHandler: the super admin (first user) is created via
createUser, which never called provisionUserEnvironment — only the invite/verify flow
did. So the single user's DATA_PATH/<email> was never provisioned up front. Now it is.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
auth.json stores api keys, models.json stores local providers directly.
no more copies, sync layers, or per-user config generation.
docker containers mount pi config read-only.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>