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>
This commit is contained in:
brunorezio
2026-07-25 23:30:20 +01:00
co-authored by Claude Opus 5
parent 92de996412
commit 044aacf4d5
85 changed files with 2761 additions and 2121 deletions
+2 -6
View File
@@ -10,10 +10,7 @@ import {
} from '../../_middlewares';
import { signinHandler } from './signin';
import { signoutHandler } from './signout';
import { signupHandler } from './signup';
import { verifyHandler } from './verify';
import { verifyTokenHandler } from './verify-token';
import { resendVerificationHandler } from './resend-verification';
import { changePasswordHandler } from './change-password';
import { forgotPasswordHandler } from './forgot-password';
import { resetPasswordHandler } from './reset-password';
@@ -39,11 +36,10 @@ authRouter.post('/signout', userMiddleware, signoutHandler);
authRouter.post('/revoke', userMiddleware, revokeHandler);
// Trigger the panic lockdown — authenticated, no password in the body.
authRouter.post('/panic', userMiddleware, panicHandler);
authRouter.post('/signup', signupRateLimiter, signupHandler);
// Creates the single server-owner account. Only succeeds while the user table is empty.
authRouter.post('/bootstrap', signupRateLimiter, bootstrapHandler);
authRouter.post('/verify', verifyHandler);
// Validates a password-reset link before the reset form is shown.
authRouter.post('/verify-token', verifyTokenHandler);
authRouter.post('/resend-verification', resendVerificationHandler);
authRouter.post('/change-password', userMiddleware, changePasswordHandler);
authRouter.post('/forgot-password', forgotPasswordRateLimiter, forgotPasswordHandler);
authRouter.post('/reset-password', resetPasswordHandler);
+2 -3
View File
@@ -6,8 +6,8 @@ import { validatePassword } from './validate-password';
import { validateUsername } from './validate-username';
import { provisionUserEnvironment } from '../users/provision';
// Single-step super-admin bootstrap: the first user is created directly as an active Super Admin, with
// no email-verification round-trip. Gated to an empty user table (registration is otherwise closed).
// Single-step bootstrap for the one account Officer supports: the server owner is created directly as
// active, with no email-verification round-trip. Gated to an empty user table.
export const bootstrapHandler: Handler = async function (ctx) {
const body = ctx.get('body');
@@ -35,7 +35,6 @@ export const bootstrapHandler: Handler = async function (ctx) {
password: passwordHash,
name: name.trim(),
username: validUsername,
role: 'Super Admin',
status: 'Active',
});
+1 -3
View File
@@ -168,13 +168,12 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
const passkeys = await getPasskeysByUserIdAndOrigin(dbUser.id, origin);
const { id, name, username, role } = dbUser;
const { id, name, username } = dbUser;
const token = await sign({
id,
email,
name,
username,
role,
passkeys: passkeys.length,
});
@@ -185,7 +184,6 @@ const passkeyRouterPostVerify: Handler = async (ctx) => {
email,
name,
username,
role,
passkeys: passkeys.length,
},
});
@@ -1,28 +0,0 @@
import type { Handler } from 'hono';
import { getUserByEmail } from 'officerdb';
import { sign } from '@@/jwt';
import * as errors from '@@/custom-errors';
import { sendMail } from 'emailer';
export const resendVerificationHandler: Handler = async function (ctx) {
const { email } = ctx.get('body');
const origin = ctx.get('origin');
if (!email || typeof email !== 'string') throw errors.BAD_REQUEST('Email is required');
const user = await getUserByEmail(email);
if (!user) throw errors.NOT_FOUND('User not found');
if (user.status !== 'Unverified') throw errors.BAD_REQUEST('Account is already verified');
const verificationCode = await sign({ id: user.id, email: user.email }, '24h');
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
await sendMail({
template: 'VerifyAdmin',
subject: 'Verify your officer.dev account',
to: user.email,
data: { name: user.email, url },
});
return ctx.json({ ok: true });
};
+2 -2
View File
@@ -28,9 +28,9 @@ export const signinHandler: Handler = async function (ctx) {
const isValidPassword = TEST_USERS.includes(dbUser.id) || (await argon2.verify(dbUser.password, password));
if (!isValidPassword) throw errors.UNAUTHORIZED();
const { id, name, username, role } = dbUser;
const { id, name, username } = dbUser;
const tokenUser = { id, email, name, username, role, passkeys: passkeys.length };
const tokenUser = { id, email, name, username, passkeys: passkeys.length };
if (passkeys.length > 0 && !origin.startsWith('chrome-extension://') && !TEST_USERS.includes(dbUser.id)) {
return ctx.json({ user: tokenUser });
-36
View File
@@ -1,36 +0,0 @@
import type { Handler } from 'hono';
import { getUserCount, createUser } from 'officerdb';
import { sign } from '@@/jwt';
import type { USER_ROLES, USER_STATUSES } from 'definitions';
import * as errors from '@@/custom-errors';
import { sendMail } from 'emailer';
export const signupHandler: Handler = async function (ctx) {
const body = ctx.get('body');
const origin = ctx.get('origin');
if (!body.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(body.email)) {
throw errors.BAD_REQUEST('Invalid email address');
}
const userCount = await getUserCount();
if (userCount > 0) throw errors.FORBIDDEN('Registration is closed');
const dbUser = await createUser({
email: body.email as string,
status: 'Unverified' as (typeof USER_STATUSES)[number],
role: 'Admin' as (typeof USER_ROLES)[number],
});
const verificationCode = await sign({ id: dbUser.id, email: dbUser.email }, '24h');
const url = `${origin}/auth/verify?verificationCode=${verificationCode}`;
await sendMail({
template: 'VerifyAdmin',
subject: 'Verify your officer.dev account',
to: dbUser.email,
data: { name: dbUser.email, url },
});
return ctx.json({ ok: true, user: { id: dbUser.id, email: dbUser.email } });
};
+7 -12
View File
@@ -4,30 +4,25 @@ import { getUserById } from 'officerdb';
import { verify } from '@@/jwt';
import * as errors from '@@/custom-errors';
// Validates a password-reset link before the reset form is rendered. Officer is single-user, so the
// account-verification and invitation flows this used to serve no longer exist — the sole account is
// created directly by /auth/bootstrap.
export const verifyTokenHandler: Handler = async function (ctx) {
const { verificationCode } = ctx.get('body');
if (!verificationCode) throw errors.BAD_REQUEST('Missing verification code');
let userInfo: User;
let userInfo: User & { purpose?: string };
try {
userInfo = (await verify(verificationCode)) as User;
userInfo = (await verify(verificationCode)) as User & { purpose?: string };
} catch {
throw errors.BAD_REQUEST('Token is invalid or expired');
}
// Bootstrap token: has email but no id (user not yet created)
if (userInfo?.email && !userInfo?.id) {
return ctx.json({ ok: true, email: userInfo.email, flow: 'bootstrap' });
}
if (userInfo?.purpose !== 'reset-password') throw errors.BAD_REQUEST('Token is invalid or expired');
if (!userInfo?.id) throw errors.BAD_REQUEST('Token is invalid or expired');
const user = await getUserById(userInfo.id);
if (!user) throw errors.NOT_FOUND('User not found');
// Reset-password tokens skip the verification status check
const isResetToken = (userInfo as Record<string, unknown>).purpose === 'reset-password';
if (!isResetToken && user.status !== 'Unverified' && user.status !== 'Invited') throw errors.BAD_REQUEST('Account is already verified');
return ctx.json({ ok: true, email: user.email, flow: user.status === 'Invited' ? 'invite' : 'verify' });
return ctx.json({ ok: true, email: user.email });
};
-61
View File
@@ -1,61 +0,0 @@
import type { Handler } from 'hono';
import type { User } from 'types';
import { getUserById, updateUser } from 'officerdb';
import { verify as verifyJwt, sign } from '@@/jwt';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
import { validatePassword } from './validate-password';
import { validateUsername } from './validate-username';
import { provisionUserEnvironment } from '../users/provision';
export const verifyHandler: Handler = async function (ctx) {
const { verificationCode, name, username, password, confirmPassword } = ctx.get('body');
const userInfo = (await verifyJwt(verificationCode)) as User;
if (!userInfo) throw errors.BAD_REQUEST();
const user = await getUserById(userInfo.id);
if (!user) throw errors.NOT_FOUND('User not found');
const updates: Record<string, unknown> = { status: 'Active' };
if (name) {
if (typeof name !== 'string' || !name.trim() || name.length > 128) {
throw errors.BAD_REQUEST('Name must be between 1 and 128 characters');
}
updates.name = name.trim();
}
if (username && typeof username === 'string' && username.trim()) {
updates.username = validateUsername(username);
}
if (password) {
validatePassword(password);
if (password !== confirmPassword) {
throw errors.BAD_REQUEST('Passwords do not match');
}
updates.password = await argon2.hash(password);
}
await updateUser(userInfo.id, updates);
// Re-fetch user to get final values after update
const finalUser = await getUserById(userInfo.id);
if (!finalUser) throw errors.NOT_FOUND('User not found');
// Provision user environment (directories, configs)
provisionUserEnvironment(finalUser.email, finalUser.username ?? '').catch((err) => {
console.error('[verify] failed to provision user environment:', err);
});
// Issue a token so the user is logged in immediately
const token = await sign({
id: finalUser.id,
email: finalUser.email,
name: finalUser.name,
username: finalUser.username,
role: finalUser.role,
});
return ctx.json({ ok: true, token });
};