0ef1bbc3c46d446741315986ba787f0fff8aa02b
57
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
c38f02afa3 |
roles phase 3: dev tools are a row, not a hardcoded name
rolePermitsDevTools() was `role === 'Developer'` — one line gating two real grants, a Postgres login role with CREATEDB and a rootless Docker daemon. A role the owner created at runtime could never have either, which defeats the point of roles being data. Two boolean columns on `roles` now carry it, and roleDevGrants() reads the row. A role that no longer exists reads as neither grant, matching "absence denies" everywhere else. NOT the `provisioningScript` D8 asked for, and that column is removed rather than left dead. A shell script stored on a role row and run at provisioning time is arbitrary root-level code in a database column, and D8's own warning says why that is worse than it looks: `usermod -aG docker` is root by a longer route. So the row CHOOSES from a vocabulary the platform already knows how to carry out — the same shape as `multiarch` on a plugin's osDependencies, for the same reason. The seed re-asserts both flags on every run for system roles, and only for them. Without that, existing rows take the column default of false the moment it lands and Developer silently loses its tools on the next deploy — every host already has these four rows, so an insert-only seed would have shipped that. `description` stays insert-only, because an edit made on one server must survive a deploy. `db:push` could not apply this and said nothing. Dropping a column while adding another makes drizzle-kit ask whether it is a rename; with no terminal it bails and the wrapper still exits 0, leaving the seed to fail against an unchanged schema. Recorded in src/databases/CLAUDE.md with the SQL, because it will hit every host on the next deploy and no agent driving a rollout has a terminal. Verified here: SQL applied guarded, Developer keeps both grants and no other role gained any, second push emits zero DDL and no prompt, 1423 pass / 0 fail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
6e092c32ea |
marketplace: minesweeper in the catalogue
Without an entry the plugin is invisible to every server but this one — the catalogue is what a remote host reads, and the bundled constant is only the fallback when marketplace.officer.dev cannot be reached. Its git remote was the tailnet address, which marketplace-sync would have published verbatim: a clone URL that works on this network and nowhere else, failing at authentication in a way that reads as a broken plugin rather than a wrong catalogue. Repointed at gitea.officer.dev, which is the rule the sync script's own comment already spells out. Synced. 18/18, and the entry is live with the right source, permissions and six osDependencies; the repo answers an anonymous https clone. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
44ee444cac |
the owner is an identity, not a role
Roles and teams, phase 1. `isSuperAdmin` asked `user.role === 'Super Admin'`, which was right while roles were a hardcoded union and nothing could write a new one. It stopped being right the moment roles became a table: from then on "add a role" and "become the owner" were the same feature, reachable from whatever UI manages roles. So ownership is `OWNER_USER_ID` and nothing else. `isOwner` is the new name and `isSuperAdmin` delegates to it, so no call site changes who it admits — widening any "owner-only" route to "owner or Super Admin" is a separate decision per route, not a side effect of this. Still a database read rather than a bare comparison: a deleted or blocked account must not remain the owner because its id happens to match. `getOwnerUser` becomes a primary-key lookup. It used to select on the role with `order by id` as a tiebreak, because two holders would otherwise make "who owns this server" whatever Postgres returned first — an answer that decides the agent sidecar's identity, vault access and origin scoping. That tiebreak, the write-path refusals and the CHECK all protected an answer that is now a primary key. Several Super Admins are allowed. What the role carries is real and belongs in the UI rather than a refusal here: it provisions an OS account with sudo, which puts the holder outside Officer's permission system and cannot be undone by revoking the role afterwards. That is the operator's decision, not this endpoint's. Two departures from docs/roles-and-teams.md, both recorded there. The CHECK pinning user 1's role is KEPT — redundant for ownership, but it still forbids the owner being set to Member, which is a nonsense state that costs nothing to prevent. And promotion to Super Admin is allowed while creating an account directly as one is not: promotion happens to an account somebody has looked at, creation is a form filled once, and the role that grants sudo should not be a dropdown default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 |
||
|
|
a6e61d0c26 |
user provisioning: the platform pushes the account list to plugins that declare it
a plugin that creates accounts in an upstream service — jellyfin first, immich next — needs to know who the platform's users are. it now gets told. DECLARED IN THE MANIFEST, not asserted at registration. `name` and `handles` arrive on an unauthenticated socket and are believed, so a capability claimed there would be a plugin granting itself one. the manifest was reviewed when the owner installed it, and sits beside `permissions` where "this plugin will be told who your users are" belongs — on the install screen, not discovered later. PUSHED, not requested. a sidecar has no authenticated way to ask, so a request could only be answered, never authorised. a push inverts that: the platform decides who gets it, and a caller registering under a name with no such manifest gets nothing. `listProvisionableUsers` is a hand-written projection — id, email, username, name, role — and NOT `select *`. `users` holds `password` and `os_user`, and `getUsers()` returns both, so a plugin reaching for the obvious function would get every account's password hash. naming the columns means adding a secret to the table cannot silently add it to what plugins receive. pinned by a test that asserts the key set, not just the absence of today's secrets. the owner is excluded. they bypass every permission check and already have whatever upstream account they want; provisioning them is at best a no-op and at worst a second admin nobody asked for. sent after the ack rather than inside it, so registration never waits on a database query — a slow postgres would otherwise stall every sidecar's startup, and none of them need the list to start. and re-sent on every account create and delete, because the registration copy is a snapshot: a plugin still holding a deleted user would keep an upstream login alive for someone the platform has removed. read once per broadcast, so two plugins cannot be told different things about the same moment. |
||
|
|
bf57201a11 |
bun run doctor, and provisioning checks its own work
the tenth install in a row with something new wrong, and the pattern behind them is not the individual faults. every step in provisionOsAccount is deliberately non-fatal — a member whose account provisioned fine should not be rolled back because their shell config failed. that is right, and it means "nothing reported an error" is a far weaker statement than it reads as. the acl-mask bug was the sharp version: a clean provisioning run, every named entry correct, and an account that could reach nothing. it was found by a person three days later, through a file browser message three steps removed from the cause. so provisioning now VERIFIES rather than assuming, and it verifies by making the same readdir the file browser makes. deliberately not by inspecting the acl: reading getfacl and reasoning about masks is exactly how this was missed, because the acl looked right. a check that tests something adjacent to the failure is how you get a green report and a broken server. a failed check is reported FIRST, ahead of keys and shells — an account that cannot read its own home is broken in a way that working ssh does not make up for. and `bun run doctor` for the nine servers that already exist: same check across every account, plus the one that is invisible by construction — a confined grant held by an account with no linux user, which sits in the table, shows as ticked in the UI, and resolves to nothing. read-only, so it is safe on a production box. exits 1, so it can gate the end of a setup run. same posture as assertPermissionTotality, except this reports instead of refusing to boot: one broken member must not stop the platform serving everyone else. |
||
|
|
069dd4b23f |
provisioning: re-assert the acl mask, last
on a fresh server an Admin granted files, chat and terminal reached none of them. the grants were right, the linux account existed, the named acl entries were on the home — and the file browser still raised EACCES. a posix acl mask is not independent of the mode bits. chmod recomputes it from the GROUP bits, so a directory closed to 700 gets `mask::---`, which clamps every named entry to nothing. the platform's grant survived as `user:pastilhas:rwx #effective:---`: present, correct-looking, and inert. it reads as a working acl in every place anyone thought to check. ensureOsUser already knows this and orders itself correctly — chmod, then setfacl, with a comment saying why. verified that order is right by running both ways on the affected host: chmod-then-setfacl gives mask::rwx, setfacl-then-chmod gives mask::---. what it cannot account for is the steps AFTER it, each of which creates a directory and then closes it: .ssh at 700 from the key setup, .config and .local from the claude installer. each inherits the default acl at creation and clamps its own mask a moment later. the correlation on the live host was exact — every 770 directory had mask::rwx and every 700 one had mask::---. so the mask is re-asserted at the very end of provisionOsAccount, recursively, and nothing else is touched: the named entries were never wrong, and re-granting them here would hide it if they ever were. non-fatal if it fails, like every other side effect in that function. but it is the difference between an account that works and one that only appears to. repaired on the affected server by hand first, and confirmed: home readable by the service user, zero `effective:---` entries left under it. |
||
|
|
0fd89082fe |
an uninstalled plugin's grants stop breaking the permissions screen
Uninstalling music left its grants in role_permissions. The keys stop existing when the plugin unmounts, so the settings page — which loads every grant and submits them all back — failed with "Unknown permission 'music'" for a row nobody could see, edit or remove through the UI. Nothing could be saved again without editing the database. Two changes, because either alone leaves half the problem. Uninstall now revokes the plugin's grants, across every role rather than one. A deliberate exception to "uninstall deletes nothing": a grant is the PLATFORM's record of who may reach the plugin, not anything the plugin stored. Reinstalling means granting again, which is the honest behaviour — the alternative is access silently returning. And the write DROPS an unknown key instead of refusing the request, so every server already in this state is unblocked without touching its database. That is what the resolver already does on read, so the write now agrees with it. The cost is that a typo in a hand-made API call is ignored rather than reported — worth less than a settings page that cannot be used. The response returns what was dropped, so it is not silent. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
027b10bd6e |
step 4/4: the docs say permissions too, and capability means one thing again
44 files of prose — CLAUDE.md, AGENTS.md, TODO.md, 20 docs, both plugin design
documents, and the comment surface the earlier steps could not reach.
Applied against an explicit keep-list, not swept, because the word turned out to
have SIX meanings in this repository rather than the three the offscale doc
recorded:
permissions renamed (steps 1–2)
$OFFICER_ROOT/capabilities/ KEPT — the item store, and now the only thing
the word means that is ours
sidecar routing keys renamed to `handles` (step 3)
Lightning wallet KEPT — a domain term, and on the wire to the mobile apps
terminfo queries KEPT — XTGETTCAP, in the pty sidecar
InvoiceShelf KEPT — per-resource { write, bulkDelete } flags
The sweep still falsified two things, both caught by checking rather than by
review, and both in prose that discusses more than one meaning at once:
CLAUDE.md began claiming the item store lives at `$OFFICER_ROOT/permissions`.
It does not; that directory is on disk and full of skills and tools.
And the offscale doc's own note about the collision became
"Named `permissions`, NOT `permissions`" — a sentence that had eaten the thing
it existed to warn about.
Both restored, and the note rewritten to say what is now true: capability means
one thing of ours, and three that belong to somebody else's vocabulary.
Verified live after restart: self and admin permission endpoints 200, gated
route 200, agent-status 200, 9 grants intact with 6 permissions offered.
tsgo clean, 797 tests, 787 pass, same 7.
The rename is done. Four steps, no data lost, no client break that survived
the step it was introduced in.
|
||
|
|
5ec354cfcb |
step 2/4: role_capabilities becomes role_permissions, without losing a grant
The dangerous one. `drizzle-kit push` does not understand renames — it sees a
table gone and a table added, and with --force it resolves that by dropping and
creating. The 9 live grants would have vanished silently and every member would
have lost terminal, chat and files until someone noticed and re-granted by hand.
So the database moved FIRST, by explicit ALTER, and the code followed:
ALTER TABLE role_capabilities RENAME TO role_permissions
ALTER TABLE role_permissions RENAME COLUMN capability TO permission
ALTER INDEX uq_role_capabilities_role_capability RENAME TO uq_role_permissions_role_permission
ALTER TABLE ... RENAME CONSTRAINT ck_role_capabilities_{role,level,not_owner} TO ck_role_permissions_*
ALTER INDEX role_capabilities_pkey RENAME TO role_permissions_pkey
Seven objects, not one, and all in a single transaction: a partial rename would
leave drizzle-kit seeing a table it half-recognised, which is the same drop.
The names were read out of pg_indexes and pg_constraint rather than assumed.
`bun db:push` then reported **No changes detected** — which is the whole proof.
It means the ALTERs matched the schema code exactly, so there was nothing for
push to reconcile and nothing for --force to destroy.
Grants verified against a JSON backup taken before the first ALTER: 9 rows,
byte-identical, `role_capabilities` gone from the database.
No client noticed. Step 1 had already moved the wire to `permission` by mapping
in the route while the column still said `capability`, so this step deleted the
mapping rather than changing any response. The two call sites that carried a
`capability` key with a comment explaining why now say `permission` and the
comments are gone.
Also renamed officer_db/src/capabilities/ → permissions/, roleCapabilities →
rolePermissions, CapabilityLevelValue → PermissionLevelValue.
Verified live after restart: self 200, admin list 200, gated route 200, no
token 401, grants read back as {role, permission, level}, and a PUT round trip
left 9 grants intact.
tsgo clean. 797 tests, 787 pass, same 7.
|
||
|
|
2e8ec845c8 |
step 1/4: the permission engine is called permissions, not capabilities
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".
|
||
|
|
8b6cb34ae0 |
plugins contribute dock tiles, and the doc says what is actually built
offscale had no dock tile: that field comes from the app store's catalogue, so a plugin installed through the plugin system was reachable only by typing its url or following the link on /plugins. built at runtime rather than baked into the generated bundle, deliberately — WHO sees a tile is a permission question, and a grant takes effect on the next request rather than the next build. presentation comes from the manifest and the route from mountPrefix, so there is one source for both, and is the plugin's first permission so the endpoint can filter a tile out for an account that cannot reach the screen. two sources for tiles today, because the app store still has its own catalogue. one when it is rebuilt on this. the doc now records the system as complete rather than half-stubbed, including the three bugs the extraction found — the sidecar-before-mount ordering, the missing tailwind, and the build that could delete its own shell — and what is genuinely still open. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e13128846b |
offscale is a plugin
headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4d4606d4a2 |
close the owner's dotfiles once somebody else has a shell
ubuntu's default umask is 002 with user-private groups, so everything the owner
creates lands 775/664. alone on a machine that is harmless. it stops being
harmless the moment a member has a login — and member homes are NESTED inside
the owner's, so the owner's home must stay traversable AND readable (the
ancestor-read requirement bun exposed today) and every dotfile in it is legible
by default.
measured as green before writing this: ~/.pm2/logs (all 12 files, every log the
platform has written), ~/.pm2/dump.pm2, ~/.claude/projects (names every
directory the owner works in), ~/.config, ~/.local, ~/.cache, ~/.npm, ~/.bun,
~/.opencode — all listable. assertSecretsClosed was already holding the line
that matters: .env, .ssh, .zsh_history, .claude.json and the credentials are
denied, and dump.pm2 turned out to hold no secret values because bun loads .env
at runtime rather than through pm2.
so this is the tier below fatal: not tokens, but logs and the shape of the
owner's work.
it runs from PROVISIONING, not from setup, and that is the point. ~/.claude does
not exist until the agent has run once; a chmod at install time finds half the
list missing and silently does nothing — the same failure mode as the ACL mask
earlier today. every member's arrival re-closes whatever appeared since.
two directories are left open on purpose, and both are the same latent bug:
/usr/local/bin/bun -> /home/pastilhas/.bun/bin/bun
/usr/local/bin/gh -> /home/pastilhas/.local/bin/gh
system-wide tools installed into one user's home, so every member resolves them
through it. i found this by closing them and breaking bun and gh for green.
~/.local/share and ~/.local/state ARE closed; only the bin directory is
reachable. the honest fix is installing them outside the owner's home.
verified both directions on this host: green is denied .claude, .pm2, .config,
.local/share, .local/state, .cache — and still has working bun, gh, psql, their
own claude, and their own project tree.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
b6feca8350 |
owner can reset a member's platform password
The gap at the other end of create-user.ts: the owner could set a password once, at creation, and never again. Losing it meant a hand-written UPDATE with an argon2 hash — the same "edit Postgres by hand" hole that creating accounts used to have. POST /api/users/:id/password, owner-gated, with a button on the row. GENERATED, not typed. The failure this exists for is "I created the account and forgot to copy the password down", and an owner typing a replacement can lose it the same way on the second go. Shown once in a dialog built to be copied — a dialog and not a toast, because a toast that times out while somebody finds a pen loses the one thing they came for. The generator satisfies validatePassword BY CONSTRUCTION rather than by luck: one character drawn from each of the four required classes, the rest from the union, then Fisher-Yates shuffled so the first four positions are not always lower/upper/digit/special. Rejection sampling throughout — `% n` on a byte biases the early characters. Then it runs validatePassword on its own output, so if the rules ever gain a requirement the alphabets do not cover it throws at the one call site instead of minting passwords the login form rejects. Measured: 20,000 generations, all four classes present every time. l, I, 1, O and 0 are absent from the alphabets. This gets read off a screen and typed somewhere else. Signs them out everywhere, as asked: passwordChangedAt = now, and userMiddleware already refuses any token whose iat predates it. That overwrites the null create-user leaves to mean "the owner chose this, not them" — checked, nothing reads that column except the token check. The Linux account is deliberately untouched, and the dialog says so. Members have no Linux password and never had one: ensureOsUser runs useradd with no -p, so it is created locked. Their terminal goes through setpriv, which does not authenticate; their SSH is the key the owner pasted; `su - <member>` as root does not ask. And machine-setup sets PasswordAuthentication no — verified on this host — so one could not be used to log in even if it existed. Setting one would be a new way in, not a repair. The owner is excluded: they have change-password, which asks for the current one, and resetting themselves here would end the session doing it. Verified: transpiles, all lucide icons exist, 20k generator runs. tsgo next. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
fb0286e1a9 |
put rootless docker back, behind the developer gate
Uncommented at all three sites: the call and import in provisionOsAccount, the ~/.local/dockers bind-mount directory in confineUserTree, and the DOCKER_HOST block in the member zshrc. Not restored unconditionally, which is how it was before. It now sits behind the same Developer check as the Postgres role — the gate that prompted disabling it in the first place. So rolePermitsDatabase is renamed rolePermitsDevTools: it gates two things now and a name saying "database" while deciding whether you get containers is the kind of comment that goes stale silently. ~/.local/dockers is created for EVERY account rather than only Developers. It is two install calls, and confineUserTree is the function that places the layout, not the one that knows who is a Developer — so a member promoted later finds it already correct. Postgres role work is untouched and still in place. Verified: transpiles. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
bf7e919593 |
only a Developer gets a postgres role
The gate rootless Docker was under, which I did not know about when the Postgres role replaced it. It inherits the rule along with the purpose: a member not trusted to run containers is not thereby trusted to run databases. rolePermitsDatabase() is the single place that rule is written. Admin is deliberately NOT included — administering the platform is not developing on it, and they are separate roles precisely so they can be held separately. Say so if that is wrong. provisionOsAccount now takes the role. Two call sites: create-user passes what the owner picked, provision-linux-route reads it from the row — which makes that route the way a member promoted to Developer gets the database role they did not qualify for when their account was made. The half that makes the gate real is in updateUserRoleHandler. Without it the rule would decide what a Developer gets at creation and never look again, so demoting one would leave their role, their databases and a working password in their ~/.zshenv — a permission surviving its own revocation, with the UI then saying something untrue. Revoke before recording, grant after: the drop runs BEFORE updateUser so a failure aborts with the role unchanged and the whole thing retryable. Promotion runs after and is non-fatal, like every other provisioning step. Demotion keeps their data, same as deletion — databases are reassigned to the platform role, not dropped — and logs where it went, so it does not look deleted. KNOWN, not handled: a demoted member keeps a stale ~/.pgpass and ~/.zshenv naming a role that no longer exists. Harmless (the connection just fails) but untidy, and it means `cat ~/.zshenv` shows a password that no longer works. Verified: transpiles, both call sites updated. Still no tsgo. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2ac58e2007 |
drop the postgres role when a member is decommissioned
It was written and never called — dropPostgresRole had zero call sites, so deleting a
member left their role and databases on the cluster.
That is the uid trap in a different id space, and worse. provisionPostgresRole ADOPTS
an existing role, so a role left behind is inherited whole, with its databases, by the
next member who gets the same username. useradd hands out the lowest free uid by
accident; the owner hands out usernames on purpose, so reuse is likelier here, not
less.
Rewritten to PRESERVE rather than destroy. The first version dropped the databases,
which is inconsistent with severMemberTree three files away — that chowns a member's
files to the service user rather than deleting them, and a database is the same kind
of thing. The owner removing an account has not necessarily asked to destroy the work
in it, and dropping is the one choice that cannot be walked back.
Needs the full idiom, per database, and both halves matter:
ALTER DATABASE .. OWNER TO REASSIGN OWNED does not move database ownership
REASSIGN OWNED BY .. TO .. moves tables, schemas, functions
DROP OWNED BY .. removes what is left, which after a reassign is
only the GRANTS — without it DROP ROLE still
refuses, an ACL entry is a dependency too
Both statements act only on the database they are connected to, so it is a connection
per database rather than a loop over `db`.
Ordered after the Linux teardown (which can fail and abort, and must not do so after
something irreversible) and before deleteUser (the row is what remembers there is
anything to clean up).
Verified live: a member with a database, a table, a row and a schema. Naive DROP ROLE
refused with "2 objects in database carol_app". After the sequence: role gone, row
intact, table owned by postgres. Then recreated the same username and confirmed she is
REFUSED from the old database — the login trigger holds because ownership moved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
2273941e71 |
give each member a postgres role instead of a docker daemon
A Postgres login role named the same as their Linux account, with CREATEDB, plus a
~/.pgpass so psql never prompts. This is what replaces rootless Docker: the case it
was really there for was "let me run a database to develop against", and a container
per member answered it with a daemon, an image cache and a subuid range each.
Measured on postgres:18-alpine before writing any of it, because three things I
asserted turned out to be wrong:
- a fresh LOGIN role CAN connect to `officer` (datacl NULL = PUBLIC has CONNECT),
but CANNOT read any application table — privileges are owner-only, so
has_table_privilege('users','UPDATE') is false. The capability model was never
reachable from here.
- the `trust` line in pg_hba does not cover host connections: Docker's NAT rewrites
the source, so they fall through to scram-sha-256. Verified with a wrong password.
- revoking from the ROLE does nothing. Privileges are additive and there is no DENY;
only revoking from PUBLIC is a lock.
So ensureAppDatabaseClosed revokes CONNECT+TEMPORARY on the platform's own database
from PUBLIC, and it runs inside provisionPostgresRole rather than in the setup script
— an install set up before today, or restored from a dump, then still cannot end up
with a member who can connect to `officer`.
Password is generated per member, 40 chars, rejection-sampled over an alphanumeric
alphabet: CREATE ROLE is a utility statement and cannot take a bind parameter, so the
safety comes from the alphabet rather than from escaping. Not stored anywhere — it
lives in their 600 ~/.pgpass, the same posture as their SSH key, where we keep only
the public half. Only (re)set when .pgpass is missing, so a reprovision does not
rotate a credential they may have pasted into an app config.
KNOWN RESIDUE, not handled: a database one member creates is metadata-readable by
another. datacl is not inherited from the template (measured: closing template1 and
creating from it still produced NULL), and CREATE DATABASE fires no event trigger, so
nothing can close it at creation. A second member can read table and column NAMES from
the catalogue. They cannot read a row and cannot create anything. Closing it needs a
sweep or a pg_hba rule per member; both are decisions, not details.
Verified live against the running cluster: role creation, refusal on `officer`,
creating and using two databases, and the DROP ... WITH (FORCE) teardown. All probe
roles and databases dropped afterwards.
NOT verified: bunx tsgo, still — node_modules is empty in this tree. The drizzle
return shape was checked by reading PostgresJsQueryResultHKT (RowList<T[]>, extends
Array) rather than by running it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
7b32f5bc2f |
don't provision rootless docker for new members
Commented out at the call site in provisionOsAccount, with the import. The code in os-user-docker.ts stays and is now unreferenced — turning it back on is uncommenting two blocks. Only affects NEW accounts. Members provisioned before this keep their daemon, and deprovisionOsAccount still tears one down, which is what those accounts need. Verified: transpiles. tsgo has still not run in this tree (node_modules is empty). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
f063fc0c08 |
remove origin validation
ALLOW_ANY_ORIGIN, ALLOW_ANY_ORIGIN_MUSIC, and everything they gated. The flag defaulted to ON, so none of it ran on a real install — what comes out is documented defence in depth that was already switched off. The file said so itself: "Both flags and their call sites come out once the tailnet is the perimeter." Origin was never authentication here in any case. An app's `officer://<hex>` origin is chosen by the client, forgeable outside a browser, and extractable from a shipped binary. Gone: the two flags, isOriginAllowed, isOriginCheckDisabled, isMusicOriginExempt, originValidationMiddleware, ORIGIN_RULES and the whole OFFICER_<APP>_ORIGIN scheme, PUBLIC_URL's origin/host derivation, and origin-validation.test.ts, which existed only to pin them. CORS now echoes whatever Origin it is given, which is what every install already did. What SURVIVES is the reason this needed care. origin-validation.ts held two unrelated things, and the second was the global authorization gate — a valid non-owner token reaches only what its role grants, deliberately NOT under the flag because it is account-based rather than origin-based. Its own comment called it "the airtight half". Deleting the file wholesale would have deleted authorization. So it moves to _middlewares/capability-gate.ts as capabilityGateMiddleware, with the name matching what it does: nothing in it reads an Origin header any more. hono.ts mounts it in the same position, ahead of every router. origin-middleware.ts stays and is untouched — it extracts the Origin for six auth handlers that log it, and for passkeys. Extraction, not validation. Also updates every claim that rested on the old model: CLAUDE.md's security section and repo map, docs/secret-store.md, docs/mobile-api-keys.md, and five messages in machine-setup's Tailscale section which told the owner to set ALLOW_ANY_ORIGIN=false when declining a tailnet. That advice is now impossible to follow, and the honest version is different: with no tailnet the token is the whole lock, so put a proxy in front and restrict who can reach it. Not typechecked (empty node_modules, frozen installs). Every changed file parses; the setup section was run and writes four variables now. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
040ea41dbc |
per-user linux accounts are not optional any more
OFFICER_OS_USERS is gone. The platform behaves as it always would have with the flag on, and there is nothing to enable. Six conditionals, five of which were dead weight — provisionOsAccount, deprovisionOsAccount and the create/delete paths each opened with an early "not enabled on this server" return, and the API told the frontend whether to render the Linux controls at all. Those go, along with the 'disabled' DeprovisionResult stage, which nothing can produce now. The sixth is the one with teeth. assertSecretsClosed opened with `if (!OS_USERS_ENABLED) return`, described in its own comment as "a no-op when the feature is off, so an existing install is unaffected until the owner opts in". It is now unconditional: the server refuses to boot while any .env in the project root is group- or world-readable. A member's shell reading .env and printing JWT_SECRET was confirmed exploitable when this check was written, and a prerequisite that only holds when somebody remembers to set a variable is not a prerequisite. Nothing to remove on the environment side — the flag was never in .env.example or in the setup script. Not typechecked: node_modules is empty in this tree and installs are frozen, so tsgo could not run. All six files parse under `bun build --no-bundle`, and the changes are deletions of dead branches plus one removed early return. Formatted with prettier 3.9.6 via bunx rather than the pinned resolution, for the same reason; its one unrelated reformat was reverted by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
cec8fbe57e |
the acl check could not fail, because sudo drops DATA_PATH
Review of
|
||
|
|
46799dada8 |
deprovision a member's linux account when the platform account goes
Implements docs/deprovision-os-account.md. Until now deleteUserHandler removed the row, cascaded the
database, and left the entire Linux side running — measured on production on 2026-08-12: working login
shell, healthy postgres container, 454M of data, uid queued for the next useradd to reissue along with
everything still owned by it.
The load-bearing rule from the spec: sever the data from the uid BEFORE releasing the uid, and if
severing fails, do not release. A failed deprovision is not a broken account, it is a trap for whoever
is created next.
Sequence: disable-linger, terminate-user, reap-and-prove, chown -R, userdel (never -r).
reap terminate-user is not a barrier. Production measured a three-hour-old `/bin/zsh -i` surviving
it AND the removal of /run/user/<uid>. So: pkill, bounded wait, pkill -9, bounded wait, and a
final count that must be zero or the account is not released.
chown fixes the uid and subuid halves in one pass — it rewrites every file it walks whatever owned
it. The range is still captured first, because userdel removes the /etc/subuid entry and after
that nothing on the machine remembers what it was. It is returned on every path including the
failures, and logged as the exact assert-uid-free.sh command line.
Two guards the spec did not ask for, both pure and unit-tested:
guardDeletable ensureOsUser's adoption rule backwards. Deletable only if the passwd home is the one
the platform would have confined, and uid >= 1000. Without it `userdel root` is one
bad users.osUser away and nothing else in the sequence would object.
guardMemberTree the tree must resolve to a direct child of DATA_PATH. The email reaches join() from a
database row and the result is the argument to a recursive chown.
chown runs with -h. Measured here that `chown -R` already declines to follow a symlink out of the tree and
re-owns the link itself, but the argv should say so rather than rest on traversal semantics — and
re-owning links is what makes `find -uid` (lstat) a meaningful check afterwards.
destroy exists, has no call site, and is chown-then-delete-as-the-service-user rather than sudo rm -rf, so
a recursive root delete built from a database column does not exist in this codebase.
deleteUserHandler now runs this FIRST and refuses to delete the row if it fails: the row is what remembers
there is anything to clean up, so deleting it first makes a failure unrecoverable through the UI.
NOT YET RUN AGAINST A REAL ACCOUNT. Only the pure guards have tests. The five-step validation is in the
doc; it needs the production host, a shell left open, and a container writing as a non-root user — the two
cases the quiet path passes vacuously.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
9833822625 |
pass the member's gid instead of reusing their uid
host caught that provisionRootlessDocker had no gid field, so the new install -d passed uid in the group position. Correct on this host only because useradd allocates a per-user group; wrong on any account whose gid is not its uid — one created by hand, one on a host whose login.defs uses a shared group, or one ensureOsUser adopted rather than created. Mode 700 means the group triad grants nothing, so nothing breaks today. That is what makes it worth fixing now rather than later: it would surface only after somebody widened the mode for an unrelated reason, and then not obviously. The call site already held account.gid from ensureOsUser — the same value the .local fix used correctly earlier the same night. Threaded through rather than derived, and the field carries a comment saying why it is separate from uid, since they are equal here and a reader would ask. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
62e98dff2e |
a member's claude is their own binary and their own login
First half of per-user Claude. Provisioning and the privilege drop, not yet
wired to a turn — the chat gates stay up and behaviour is unchanged for
everyone. Committed unfinished on purpose so the reasoning is on the record
before the server agent runs any of it; the state is written up in
COMMS/sidecar-app-store/2026-08-11-per-user-claude-handoff.md.
THE CLAIM THAT CHANGED. docs/per-user-linux-accounts.md:226-229 says the Agent
SDK "has nowhere to put a uid", so a member's turn has to become its own
process — a change of shape rather than a flag. It is a flag:
sdk.d.ts:951 exposes spawnClaudeCodeProcess, documented for exactly this ("run
Claude Code in VMs, containers, or remote environments"), and node's spawn
already satisfies the SpawnedProcess shape it wants. So no second sidecar, no
PM2 entry, no inverted transport, and none of the registry rework a second
instance would have forced (registration is name-keyed and evicts its
namesake; the nine claude verbs resolve by capability with no selector).
THE PLATFORM NEVER RUNS AS A MEMBER. The tempting reading of "each member runs
their own Claude" is a second officer-agent under their uid, and it is wrong:
that sidecar needs POSTGRES_URL and the JWT signing secret, so a member-uid
process holding them could read every account and sign a token as the owner —
strictly more than their shell can do, and already forbidden by the .env boot
check. The harness stays the service user's; the thing that runs the member's
code and holds the member's credential is theirs. That is the pty sidecar's
shape, not a new one.
PER-MEMBER BINARY, deliberately, over one shared /usr/local/bin/claude. The
private part is the credential, not the executable — but claude updates itself,
and a root-owned binary is one a member cannot update, which turns "my agent is
a version behind" into a request to the owner. Same installer the owner's own
install uses, run as them, in their home. Idempotent by skipping when present
rather than re-running: the retry button reprovisions on every press.
ALLOWLIST, NOT A FILTER, for the child's environment. At the moment of the call
the calling process holds POSTGRES_URL, the JWT secret and the owner's
ANTHROPIC_API_KEY; setpriv --reset-env means nothing crosses unless written
into the argv, so an allowlist is the complete answer to what a turn can see,
and a denylist would have to be right about every variable added later.
NEVER_ENV throws rather than leaks if someone widens it.
Login is the member's own act against their own account. The platform cannot do
it for them and must not try — the alternative is lending them the owner's
credential. claudeLoginState only reports whether the credential has appeared,
and reads it as the member, so a true answer means their process can reach it.
NOT VERIFIED: any of it at runtime. tsgo passes; nothing has been provisioned
and the spawn hook has never been called. If it turns out setpriv breaks how
the SDK reaches the process, this approach is wrong and the fallback is the
earlier plan.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
f0af7237db |
terminal, chat and files are granted by default; permissions screen simplified
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> |
||
|
|
3bea46f2d7 |
rootless docker per member — provisioning works, running a container does not yet
Not finished. Committed because the diagnosis is worth more than the code. WHY ROOTLESS AND NOT THE DOCKER GROUP. `usermod -aG docker <user>` is the one-line version and it is root: `docker run -v /:/host -it alpine chroot /host` is a root shell, which reads .env, every other member's home and the wallet seed. Every boundary from today, bypassed by one documented command. Rootless gives what was actually asked for — a daemon per account, containers in that account's user namespace, images in their own home. VERIFIED on this host: provisioning succeeds, the server reports 29.5.0, the daemon runs as the member, `docker pull` puts 403 MB under their own home, and `docker ps -a` shows nothing while the owner has four containers. That last line is the isolation, measured. NOT VERIFIED: actually running a container. It failed, and the cause is an interaction between two things built today: failed to copy xattrs: failed to set xattr "system.posix_acl_default" on …/volumes/…/_data Creating a volume copies xattrs, and the DEFAULT ACLs on a member's home — added so the file browser could read their files — are inherited by Docker's storage, where a mapped id inside a user namespace is not a valid id to set. Both features correct alone. The fix here strips default ACLs from ~/.local/share/docker only, leaving the access ACLs the file browser needs. That fix is UNPROVEN. The re-test failed for a different, environmental reason: probe users recycle uid 1001, and a stale lingering systemd user manager from a previous probe answered `systemctl --user`, so the unit appeared not to exist. Cleaned with `loginctl terminate-user`. Retest on a machine that has not had a uid-1001 user, or on a fresh uid. Also worth knowing before this ships: uid reuse after deleting a member is a real hazard, not just a test artefact — the next member gets the previous member's uid, and anything left lingering belongs to them. setup.sh gains uidmap and dbus-user-session as core packages; the shell template exports DOCKER_HOST from $XDG_RUNTIME_DIR when the socket exists. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
71589aee99 |
a member's terminal looks like the owner's
A new Linux account opens a shell with nothing: useradd copies /etc/skel, which on Ubuntu is a bash rc, and the account's shell is zsh — so it got no prompt, no history, no completion, no colour. "Their own account" should not mean a worse terminal than the owner's. src/servers/shell-skel/zshrc is the template, and scripts/starship.toml is reused rather than copied: setup.sh already deploys it for the owner, so one file serves both audiences and they cannot drift. Seeded by provisionOsAccount, which means the retry button applies it to accounts that already exist — no delete-and-recreate. The template depends on nothing but zsh. Starship, eza, nvim, bun, deno and cargo are each used only if present, and every path is $HOME-relative — the owner's own .zshrc has three absolute /home/pastilhas paths in it, which is exactly what a template must not inherit. Without starship it falls back to a zsh prompt showing the same information, because a shell that opens with a broken prompt reads as a broken machine. Never overwrites: written only when the file is ABSENT. ~/.zshrc.local is sourced last and never written, so there is somewhere to put your own config that no future template can reach. Three fixes found by running it: - install -D creates missing parents but applies -o/-g only to the FILE, so ~/.config came out root:root — readable but not writable by its owner, which would have surfaced weeks later as one tool mysteriously failing. The parent is now created explicitly. - useradd took its shell from process.env.SHELL, which under PM2 is whatever PM2 was launched from. A member's shell depended on how the server happened to be started. Now chosen from what is installed: zsh, else bash. - the pty sidecar spawned ITS $SHELL for a member, not theirs. It now execs their passwd shell via sh -c, so the login shell in /etc/passwd is the one they get. starship moves out of the light-profile skip. The light profile exists to serve a file browser, a terminal and chat — the terminal is one of its three reasons to be, and it is what every member gets. Leaving starship out meant the fallback prompt on exactly the installs most likely to have members. oh-my-zsh, eza and lazygit stay full-only. Verified in a real member shell: zsh from passwd, HISTFILE in their own home, eza-backed ll, starship active, EDITOR=nvim, and an edit to .zshrc surviving a reprovision. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
eda004a46d |
a naked platform does not describe what it does not have
Reversing my own call from an hour ago. I built the denied-route screen to EXPLAIN the absence — "Music is not installed", with a link to the app store — and argued a redirect erases what you asked for. The owner's correction is the better principle: a server should not know about a sidecar it does not have. Explaining Music is the app describing a feature that, as far as this install is concerned, does not exist, and it leaks the whole catalogue of what could be installed to any member who types a URL. So a denied path is now indistinguishable from an unknown one: redirect home, the same answer App.tsx's path="*" already gave. One behaviour for a member without a grant, an owner without the sidecar, and a typo. Nothing disclosed. The Permissions screen loses both explanatory blocks for the same reason. One listed every capability whose sidecar is absent — a catalogue of uninstallable features presented as a permissions decision. The other described chat, tasks, the desktop and the wallet as "not grantable" to an owner who may have none of them installed. `notInstalled` is gone from the API too, not just hidden in the UI. What is on that screen is what this server can actually do. Still short of what the owner described, and worth naming rather than implying otherwise: routes are DECLARED in App.tsx for every screen and this hides the ones that should not resolve. The end state is routes REGISTERED from the manifests of installed sidecars, so an uninstalled feature has no route to hide. The manifests already exist and the dock is already built from them; the router is not, yet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
e393d0f5c2 |
a member's screens render, and the shell stops asking for things it cannot have
Three findings from granting Files to a role and signing in as the member. THE BLANK SCREEN. WorkspaceView returns null until workspace.isLoaded, and isLoaded was the success flag of GET /api/dashboards — which the `dashboards` capability gated. So a member with files granted got a completely blank Files screen and no request to /api/file-browser at all: the panel never mounted. Terminal, Chat and every other workspace screen were the same. /api/dashboards is not a feature. It is the per-user key-value store where every screen keeps its layout, entirely `personal`, every row keyed to the caller. Gating it does not restrict an account, it breaks it — which is the definition of `core` at the top of the registry. Moved there. And the failure mode was wrong independently: `isLoaded` now covers a failed fetch as well as a successful one, with `loadFailed` for the difference, so a screen that cannot remember its layout still renders with defaults instead of showing nothing and explaining nothing. THE STRAY REQUESTS. Six shell-level queries gated on isAuthenticated but not on capability, so a member's first paint fired 403s at /server-settings/settings, /jobs/counts (every three seconds, forever), /chat/models, /plans, /music/now-playing and the chat access policy. Each now checks the capability it needs. JobsIndicator and RescanButton also render nothing without `tasks` and `items` — the header was offering two links to a screen the member cannot open and a button that would 403. THE PERMISSIONS SCREEN. It listed all fourteen app capabilities on a server where none of their sidecars are installed. Offering to grant Photos on a machine with no Immich is not a permission decision. It now shows only what is installed, lists the rest as "nothing installed for these yet" so their absence reads as a fact rather than a bug, and marks confined rows as needing a Linux account. Fails open on a degraded read. Found while checking that: the headscale catalogue entry claimed only the `headscale` capability, but the same sidecar also serves `vpn` — a member enrolling their own device — so vpn was never subtracted. Hence `alsoServes`. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
2c9d4e55aa |
retry a linux account in place instead of deleting the person
POST /users/:id/provision-linux, and a terminal button on each user row. One
operation covering three needs that were all previously answered by "delete the
account and make it again":
backfill an account created before the feature existed, or while the host was not
set up for it
retry the first attempt failed for something since fixed — the traversable
ancestor chmod being the one everybody hits once
re-key replace authorized_keys with a new public key
Deleting to redo a retryable side effect throws away the password, the dashboards and
everything else keyed to the row.
The provisioning block moves out of create-user into provisionOsAccount, shared by
both entry points for the same reason app-store/members.ts is shaped that way: two
moments, one piece of work.
Found by testing the retry rather than the create: provisionUserDirs re-chmods every
directory including home, and home belongs to the MEMBER after the first successful
run — chmod requires ownership, so it threw EPERM and took every retry down before it
started. Those chmods are now a default for directories being created, not an
assertion about ones that already exist; os-user.ts sets the home's mode through sudo
and is the authority for it.
The route answers 200 with the error in the body, because the interesting cases are
partial: "the account exists and is confined but the keys failed" is not nothing
having happened, and the row shows both halves.
Verified end to end: blocked ancestor reports the chmod and leaves osUser null, the
retry after that chmod succeeds and records the row, and a re-key replaces
authorized_keys without rotating the outbound key.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
4d513c0e13 |
files, for a member, in their own home
Introduces a fifth capability kind. `files` was `execution` — never grantable, because it meant the OWNER'S filesystem. It is now `confined`: execution-shaped, but the kernel enforces the boundary because the account has its own Linux user, its own home, and no permission above it. The rule that makes `confined` mean something lives in authorize.ts, once: a confined grant is DROPPED for an account with no osUser. So "granted but unconfined" resolves to no access rather than to the owner's home — which is what it would otherwise resolve to, since getOwnerHomeDir ignores the email it is handed whenever HOME_DIR is set. One rule covers the HTTP routes, the websocket doors and the dock, instead of each router remembering. resolveHomeDir(userId) is the new seam and it reads the row rather than the token, for the same reason authorize.ts re-reads role: provisioning a Linux account for an existing member has to take effect on the next request, not in thirty days. The file browser resolves it in middleware and puts it on ctx user, because getRootDir is called from fifteen places in that router. Making it async would have meant editing fifteen call sites, and the cost of missing one is serving the owner's home to a member. Now a handler cannot run without the answer. Two things a real run caught: - /ls seeds Downloads/Documents into the home as the service user, which is EPERM against a 700 home owned by the member — it took the whole listing down. Seeding is now best-effort there and happens at provision time instead, as the member. - .unique() on os_user made db:push ask whether to TRUNCATE users, which is unanswerable non-interactively. uniqueIndex instead, per databases/CLAUDE.md. Verified: a member without a Linux account is refused by name; with one, resolves to their own home and NOT to HOME_DIR; the owner still resolves to HOME_DIR; and every .. escape is refused while an absolute path is rebased under the root. Terminal is still execution — that is the next stage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
0fb9a29e64 |
ssh for a member's linux account, both directions
Inbound and outbound are two keys doing two jobs, and treating them as
alternatives breaks the goal:
inbound ~/.ssh/authorized_keys, from an optional public key the owner pastes
on the create form. Their private half stays on their laptop.
outbound ~/.ssh/id_ed25519, generated in their home, never leaves the machine.
"They pasted a key, so skip generating one" is the obvious simplification. Agent
forwarding covers a human in an interactive session, but a platform-spawned agent
has no agent socket to borrow — so an edge checkout it is asked to commit and push
needs a key that lives on the box. The inbound key is therefore optional and the
outbound one is not.
No linux password, ever: useradd sets none, which blocks password login and does
not block key auth. So "real user, reachable over SSH, no password anywhere" is
the resting state, and the platform password stays the platform's business.
Validation is about line count, not key shape. Every line of authorized_keys is a
credential, so a pasted value with a newline would install a SECOND key silently.
Multi-line refused, a private key refused by name, an options prefix refused.
Every write goes through sudo install: the home is 700 and the member's, so the
service user cannot even create .ssh. install sets content, owner and mode in one
step, and content travels as a temp path so nothing quotes a form value into a
shell. ssh-keygen runs AS the member so the private key is never briefly root's.
known_hosts is not seeded — StrictHostKeyChecking accept-new instead. The Gitea
SSH endpoint is not knowable at create time, and the default setting makes a first
connection prompt, which in a non-interactive agent turn is a hang rather than an
error. accept-new still refuses a changed host key.
The generated public key is stored on the row and shown twice: on the after-create
panel and behind a key button on the user's row. It has an errand attached that
nothing else will remind anyone about — it must be added to their Gitea account.
Verified with a real useradd: .ssh 700 and id_ed25519 600 both owned by the member
and usable by them, authorized_keys byte-identical to the paste, no key rotation on
a second run, and a multi-line paste refused with authorized_keys untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
5c7ceb2283 |
per-user linux accounts, stage 1: the account and the privilege drop
A member gets a real Linux account whose home is the directory the platform already
provisions for them. Nothing uses it yet — this is the mechanism plus the account,
deliberately with no behaviour change, so the file browser and terminal can be moved
onto something already proven.
Bun.spawn silently ignores uid/gid. Verified on 1.3.10: from uid 1000,
Bun.spawn(['id','-u'], {uid: 65534}) exits 0 and prints 1000. No throw, no warning.
Bun's types don't declare the option so typed code can't reach it by accident, but the
runtime accepts it, and a silently absent isolation boundary is the worst outcome this
feature could have. So privilege drops go through sudo -n setpriv, and a test pins Bun's
behaviour — if it's ever implemented, that test tells us we may simplify.
sudo is required for the drop and not because of the uid: --init-groups fails with
"Operation not permitted" for an unprivileged caller even when reuid'ing to its own
account, because setgroups(2) is root-only. --reset-env is what stops the platform's
environment crossing; verified POSTGRES_URL is unset on the far side and HOME arrives
from the target's passwd entry.
Three bugs that only a real run with a real useradd could find:
- chmod after chown fails forever, because chmod needs ownership. Both orderings fail
unprivileged. Both operations now go through sudo, which is what makes it re-runnable.
- a member could read ANOTHER member's home: provisionUserDirs created at the default
umask (755) and only the account being created got confined. An unlistable parent is
no protection when the child is world-readable and emails are guessable. The skeleton
is now created closed, 711 on the account dir and 700 inside.
- platform/.env was 664 and a member's shell printed JWT_SECRET, which is enough to mint
an owner token and bypass every capability check. Now a boot check that refuses to
start with OFFICER_OS_USERS on while any .env in the project root is group- or
world-readable.
Design, the measured results and the staging plan: docs/per-user-linux-accounts.md.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
69a31051ac |
the owner can create accounts
POST /api/users plus an Add-account form in Settings > User management. Until now createUser had one call site — bootstrap, gated on an empty user table — so every non-owner account anywhere had been inserted into Postgres by hand. Created accounts are Active. The column defaults to Unverified and signin refuses anything else with a bare UNAUTHORIZED, which is exactly what made the hand-INSERT route look like a wrong password. Also closes a hole found while reading the write path: a second Super Admin was storable. The CHECK constraint pins user 1's role but cannot see other rows, and getOwnerUser() was LIMIT 1 with no ORDER BY, so two holders would have made "who owns this server" a question the query plan answered — and that answer feeds the agent sidecar's identity, vault access and origin scoping. Both write paths now refuse the role and getOwnerUser() orders by id. USER_DIRS and provisionUserDirs move into data-path.ts so the create handler and scripts/provision-user-dirs.ts cannot disagree about what an account's skeleton is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
82e9fdacc2 |
dock: the shell keeps its own items, sidecars contribute theirs
ALL_DOCK_ITEMS was a hardcoded list of everything, so a fresh machine offered Photos, Jellyfin, Transmission and the rest — each leading to a screen reporting itself unavailable — and adding a sidecar meant editing the shell. Neither survives sidecars shipping from their own repositories. Split in two. CORE_DOCK_ITEMS is the baseline that exists on every install: chat, files, terminal, the app's own screens, and Gitea, which is in the light profile because it fronts a remote instance. Everything else is derived from installed sidecars' UI manifests, delivered with /capabilities. Sent with the capability answer rather than fetched separately so the dock has ONE source. Two requests means two moments, and a dock rendered between them shows a tile for something uninstalled or nothing for something installed. Filtered by capability server-side too: a member is not handed the manifest of a feature they cannot use, because "hidden in the client" is the kind of privacy that lasts until someone opens the network tab. Verified live. The owner — who bypasses every permission check — does not bypass this: /photos is absent from routes and present in deniedRoutes because Photos is not installed. Flipping a row's `enabled` makes its tile leave and return with no process touched. Two things fell out. A manifest can declare extraTiles, because CalDAV is one sidecar presenting as Calendar AND Contacts, and collapsing them to keep the model tidy would make the app worse. And DEFAULT_DOCK_PATHS no longer pins /music: useDock drops a path with nothing behind it, so the default dock came up a tile short on any machine where Music was never installed — a default that references an optional feature is how an app looks subtly wrong on a fresh install for no stated reason. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
27c1331b3c |
app store: a sidecar carries its own dock tile and routes, and an uninstalled one has neither
Two gaps, both from the same root: the app knew what an account MAY use and not what this server actually HAS. Availability is now subtracted server-side in the /capabilities answer. "Installed" is orthogonal to "permitted" and the owner is subject to it — the owner bypasses every permission check, but a capability they hold unconditionally still means nothing if its sidecar was never installed. Without this the dock on a fresh machine lists Photos, Jellyfin, Transmission and the rest, each leading to a screen that reports itself unavailable. Computed on the server rather than intersected in the client, so the rule lives in one place: the dock already reads `/capabilities`, and making it read a second list and combine them is how a member's dock and an owner's dock drift apart. `unavailable` is returned alongside `deniedRoutes` because the two mean different things to a UI — "not yours" versus "not here yet, install it". A disabled sidecar counts as unavailable: disable stops the process and its container, so the feature genuinely does not work, and leaving its icon would make disable look broken rather than effective. Reading install state failing subtracts NOTHING, matching useCapabilities' deliberate fail-open. Each entry now also carries a UI manifest — name, icon, colour, rootRoute, routes — because a sidecar shipping from its own repository has to be able to say what it looks like. The icon is a NAME rather than an imported component: a manifest has to survive being JSON from marketplace.officer.dev, which a lucide import cannot make. Tests pin the manifests against the capability registry, so a tile cannot appear for a route the server guards differently, and against each other, so two sidecars cannot claim one root route. No backfill, by decision: this is proven on a blank machine first and applied to alpha from scratch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
d56be0301d |
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> |
||
|
|
f3a3ae3b64 |
capabilities: send the denied routes too
the server half of the previous commit, which belonged with it. the frontend guard needs both lists: absence from `routes` cannot tell a route this account lacks from a route no capability claims, so without this the guard permits everything. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
dbd471c32d |
capabilities: the api, and one honest exception
GET /api/user/capabilities is what the caller may reach, and every account may ask — it is mounted on a core capability so an account granted almost nothing can still find out what it has. the dock and route guards read it. it is a courtesy, never enforcement: hiding an icon is not access control and the 403 in origin-validation stays the lock. GET/PUT /api/users/capabilities edit the policy, owner-gated. the write path is where the registry's authority over capability keys is applied, which is why the column has no CHECK: unknown keys and non-app kinds are refused rather than stored for the resolver to drop on read. the exception is `selfService`. useAuth calls PUT /api/users to change your own name and avatar, and that route has always lived on the same router as the owner-only account administration around it — so declaring /users an admin capability locked every member out of their own profile. moving it to /api/user would be tidier and would break every shipped mobile client, so instead the registry says out loud that this one route is not what the capability around it is. exact method and exact path, so it cannot widen: verified that PUT /api/users passes while GET /api/users, PATCH /api/users/:id/role, DELETE /api/users/:id and PUT /api/users/:id are all still refused. 23 unit tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
a328451250 |
add /settings/user-management
The role column now has somewhere to be used from. Lists every account, changes
roles, removes members.
API — all owner-only, mounted on the existing users router:
GET /api/users list, plus the role enum so the UI never
hand-writes the names
PATCH /api/users/:id/role change a role
DELETE /api/users/:id remove an account
ownerGate uses isSuperAdmin, which now reads the role column. The global backstop
in originScopeMiddleware already confines a non-owner token to /api/auth +
/api/music, so a Member cannot reach any of this — the gate is the explicit
statement of intent and gives a clear 403 rather than leaning on a rule written
for another purpose.
The password hash never leaves the handler: listing accounts is not a reason to
hand out hashes, so the response is an explicit shape rather than the row.
The owner is refused twice over, in both handlers, before the database has to.
ck_users_owner_is_super_admin and deleteUser() would each reject it anyway, but a
raw CHECK violation surfaces as a 500 in Postgres wording, which tells the person
clicking a dropdown nothing. Same reason `isOwner` is on the wire: the UI locks
that row rather than offering an action that cannot succeed.
The menu entry is shown to the owner only. That is tidiness, not access control —
the route stays reachable and the endpoints are gated server-side, because a
hidden menu item is not a permission and anything relying on it being hidden is
already wrong. Said so in the code, next to both.
Deleting cascades — passkeys, dashboards, screens, email accounts, playlists —
and there is no undo, so it asks first and says what goes.
Not built: invitations. Creating an account still means bootstrap or a row by
hand; an invite flow needs a token, an email and an acceptance screen, which is
its own piece of work.
Untested at runtime: the routes 404 on the running server because platform TS
does not hot-reload. Everything typechecks, the token path was verified against
/api/dashboards, /api/tasks and /api/jobs returning 200, and the 404 is the
restart asymmetry rather than the wiring. Needs `pm2 restart officer`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
99f1006669 |
auth: drop the user environment provisioning
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> |
||
|
|
ab261cf52d |
move the pty sidecar under src/servers/sidecar
it was the only sidecar living outside src/servers/sidecar/ — it sat in api/terminal/ next to the bridge that talks to it, which is the one place a reader looking for "the sidecars" would not check. now src/servers/sidecar/pty/index.mjs, matching every peer, with a note on the pm2 entry about why this one is node and .mjs (node-pty is a native addon) rather than bun and typescript like the rest. the templates/ directory went to api/users/, next to provision.ts:seedShellConfigs, which is now its only consumer — the sidecar's duplicate seeder went with the sandbox branch in the previous commit. api/terminal/ is left holding exactly one thing: the websocket bridge. no behaviour change. the pm2 entry's script path changed, so `pm2 restart officer-pty` is not enough — pm2 remembers the old path until the entry is deleted and started again. commands are in SIDECAR_WORK_LOG.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> |
||
|
|
044aacf4d5 |
remove the dead multi-user surface
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> |
||
|
|
d604b1e722 |
provisioning: drop multi-user remnants, provision the super admin on bootstrap
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> |
||
|
|
56f8da8907 |
remove seed directory, clean up provisioning and sync modules
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
daf5580c39 |
vnc sidecar: per-user desktop sessions via sidecar architecture
replaces the single hardcoded systemd VNC service with a dynamic sidecar that manages per-user VNC sessions on demand. any authenticated user can now access their own desktop, not just Super Admin. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
e966a71180 |
isolate user data with personal group ownership
- chown user dirs to pastilhas:<username> instead of pastilhas:officerdev so users cannot access each other's data - chmod 2770 (setgid) gives only the owning user terminal access - setup.sh: ensure home dir is traversable (o+x) for provisioned users Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
3df47bb48d |
shared group provisioning, upload context menu, go path fix
- provision linux users with pastilhas:officerdev ownership so server can always read/write, terminal users get group access - add officerdev shared group setup to setup.sh - move go install to ~/.local/go with GOPATH at ~/.local/go-path - add upload file/folder items to file browser context menu Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> |
||
|
|
3249378984 |
revert: use chmod 770 instead of 775 for user directories
770 (rwxrwx---) is more secure - only owner and group can access, excluding 'others'. The service user can still access because it's added to the user's group via: usermod -aG shellUsername serviceUser The real fix was wrapping chmodSync in try/catch in email-db.ts so it doesn't crash when trying to chmod files owned by other users. |