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