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
119 lines
6.5 KiB
TypeScript
119 lines
6.5 KiB
TypeScript
import type { Handler } from 'hono';
|
|
import { createUser, getUserByEmail, getUserByUsername, getRole } from 'officerdb';
|
|
import argon2 from 'argon2';
|
|
import * as errors from '@@/custom-errors';
|
|
import { validatePublicKey } from '@@/os-user-ssh';
|
|
import { provisionOsAccount } from './provision-os';
|
|
import { broadcastProvisionableUsers } from '@@/plugins/user-provisioning';
|
|
import { validatePassword } from '../auth/validate-password';
|
|
import { validateUsername } from '../auth/validate-username';
|
|
import { toPublicUser } from './manage-users';
|
|
|
|
// The owner creating a second account. Until this existed, `createUser` had exactly one call site —
|
|
// `auth/bootstrap.ts`, gated on an empty user table — so every non-owner account on any instance had
|
|
// been inserted into Postgres by hand.
|
|
//
|
|
// ── Why the owner sets the password ──
|
|
//
|
|
// The alternative is an invite: a token emailed to the person, who then sets their own. That is the
|
|
// better shape and it needs a mail path, a token table and an expiry policy. This is the honest
|
|
// intermediate: the owner types a password and tells the person, the same way they would hand over a
|
|
// wifi key. `passwordChangedAt` stays null, so nothing pretends the person chose it.
|
|
//
|
|
// ── Status is 'Active', deliberately ──
|
|
//
|
|
// The column defaults to 'Unverified' and `signin.ts` refuses anything that is not 'Active' with a bare
|
|
// UNAUTHORIZED. So an account created at the default would be indistinguishable from a wrong password,
|
|
// which is precisely the trap the hand-INSERT route fell into. An account the owner created in the admin
|
|
// UI is verified by definition — the owner is the verification.
|
|
|
|
export const createUserHandler: Handler = async function (ctx) {
|
|
const body = (ctx.get('body') ?? {}) as Record<string, unknown>;
|
|
|
|
const email = typeof body.email === 'string' ? body.email.trim().toLowerCase() : '';
|
|
const name = typeof body.name === 'string' ? body.name.trim() : '';
|
|
const password = typeof body.password === 'string' ? body.password : '';
|
|
const role = typeof body.role === 'string' ? body.role : 'Member';
|
|
|
|
if (!email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email)) throw errors.BAD_REQUEST('Invalid email address');
|
|
if (!name) throw errors.BAD_REQUEST('Name is required');
|
|
|
|
// The same validators bootstrap uses. A member's password protects the same surface a member can
|
|
// reach, so there is no argument for a weaker rule here — and two different rules would mean the
|
|
// owner could create an account that could not then change its own password to something similar.
|
|
const username = validateUsername(typeof body.username === 'string' ? body.username : undefined);
|
|
validatePassword(password);
|
|
|
|
// Refused rather than filtered, so the owner is told instead of quietly getting a Member.
|
|
//
|
|
// There is exactly one owner. The database only pins user 1's role — a row-level CHECK cannot say
|
|
// "no OTHER row may hold this" — so a second Super Admin is storable, and `getOwnerUser()` would then
|
|
// return whichever the query reached first. That answer decides the identity the agent sidecar runs
|
|
// as, who reaches the vault and which origin is privileged, so it is not a thing to leave to a query
|
|
// plan. If the owner ever needs to hand the server over, that is a deliberate transfer, not a dropdown.
|
|
//
|
|
// Checked as a literal BEFORE the table lookup: 'Super Admin' is always a real row (it is seeded and
|
|
// isSystem), so a table-only check would let it through and rely on the message below to explain why
|
|
// it still isn't offered here — this way the refusal and its reason are decided in one place.
|
|
if (role === 'Super Admin') {
|
|
throw errors.BAD_REQUEST('There is one server owner and it cannot be created here.');
|
|
}
|
|
// The runtime replacement for what USER_ROLES used to guard at the type level (docs/roles-and-teams.md
|
|
// phase 2 part 4) — `role` is user input from here on, and a role that is not a row in `roles` must
|
|
// never reach `createUser`, or the FK on `users.role` throws a raw Postgres error instead of this
|
|
// message.
|
|
if (!(await getRole(role))) {
|
|
throw errors.BAD_REQUEST(`Unknown role '${role}'`);
|
|
}
|
|
|
|
// The inbound SSH key, if the owner supplied one. Validated HERE rather than at use, so a bad paste is a
|
|
// 400 on the form instead of an account that exists with a confusing warning attached.
|
|
//
|
|
// Optional by design: an account with no inbound key is platform-only, which is a perfectly good state.
|
|
// The OUTBOUND key is generated regardless — see os-user-ssh.ts for why those are not alternatives.
|
|
const rawKey = typeof body.sshPublicKey === 'string' ? body.sshPublicKey.trim() : '';
|
|
let inboundKey: string | null = null;
|
|
if (rawKey) {
|
|
const checked = validatePublicKey(rawKey);
|
|
if (!checked.ok) throw errors.BAD_REQUEST(`SSH public key: ${checked.error}`);
|
|
inboundKey = checked.key;
|
|
}
|
|
|
|
// Checked before the insert purely for the message — both columns are unique, so the database is the
|
|
// real guard and this is a race it can lose harmlessly (the insert then throws).
|
|
if (await getUserByEmail(email)) throw errors.CONFLICT('An account with that email already exists');
|
|
if (await getUserByUsername(username)) throw errors.CONFLICT('That username is taken');
|
|
|
|
const user = await createUser({
|
|
email,
|
|
password: await argon2.hash(password),
|
|
name,
|
|
username,
|
|
status: 'Active',
|
|
role,
|
|
});
|
|
|
|
// The Linux side: directories, the Linux user, the confinement, the keys. Non-fatal and reported rather
|
|
// than thrown — this is a side effect of creating a platform account, and a failed `useradd` must not undo
|
|
// an account that otherwise exists and can sign in. The row simply keeps `osUser: null`, which is what an
|
|
// account created before this feature looks like, and which every consumer already handles.
|
|
//
|
|
// Retryable in place afterwards via POST /users/:id/provision-linux, so a host that was not ready when the
|
|
// account was made does not cost anybody their password and dashboards.
|
|
const os = await provisionOsAccount({ userId: user.id, email, username, role, inboundKey });
|
|
|
|
// Tell any plugin that provisions upstream accounts. Detached and unawaited: a plugin's sidecar being
|
|
// down must not fail the creation of a platform account, and the list is re-sent when it registers.
|
|
void broadcastProvisionableUsers();
|
|
|
|
if (os.error) console.warn(`[users] created ${email} but its Linux side did not finish: ${os.error}`);
|
|
|
|
return ctx.json(
|
|
{
|
|
user: { ...toPublicUser(user), osUser: os.osUser, osSshPublicKey: os.sshPublicKey },
|
|
osUserError: os.error,
|
|
},
|
|
201,
|
|
);
|
|
};
|