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:
co-authored by
Claude Opus 5
parent
92de996412
commit
044aacf4d5
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 } });
|
||||
};
|
||||
@@ -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 });
|
||||
};
|
||||
|
||||
@@ -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 });
|
||||
};
|
||||
Reference in New Issue
Block a user