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
This commit is contained in:
@@ -1,6 +1,5 @@
|
||||
import type { Handler } from 'hono';
|
||||
import { createUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'officerdb';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import { createUser, getUserByEmail, getUserByUsername, getRole } from 'officerdb';
|
||||
import argon2 from 'argon2';
|
||||
import * as errors from '@@/custom-errors';
|
||||
import { validatePublicKey } from '@@/os-user-ssh';
|
||||
@@ -28,18 +27,6 @@ import { toPublicUser } from './manage-users';
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* Roles this route may assign. Never 'Super Admin', still — and that is now an asymmetry worth naming.
|
||||
*
|
||||
* Since 2026-08-23 an existing account CAN be promoted to Super Admin (`updateUserRoleHandler` no longer
|
||||
* refuses it), because ownership stopped being carried by the role. Creation deliberately did not follow.
|
||||
*
|
||||
* The two are different acts. Promotion happens to an account somebody has looked at, from a screen showing
|
||||
* who they are; creation is a form filled in once, and the role that provisions an OS account with sudo is
|
||||
* not a dropdown default anybody should reach by accident. Create, then promote.
|
||||
*/
|
||||
const ASSIGNABLE_ROLES = USER_ROLES.filter((r) => r !== 'Super Admin');
|
||||
|
||||
export const createUserHandler: Handler = async function (ctx) {
|
||||
const body = (ctx.get('body') ?? {}) as Record<string, unknown>;
|
||||
|
||||
@@ -64,12 +51,19 @@ export const createUserHandler: Handler = async function (ctx) {
|
||||
// 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.
|
||||
if (!(ASSIGNABLE_ROLES as readonly string[]).includes(role)) {
|
||||
throw errors.BAD_REQUEST(
|
||||
role === 'Super Admin'
|
||||
? 'There is one server owner and it cannot be created here.'
|
||||
: `Role must be one of: ${ASSIGNABLE_ROLES.join(', ')}`,
|
||||
);
|
||||
//
|
||||
// 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
|
||||
@@ -96,7 +90,7 @@ export const createUserHandler: Handler = async function (ctx) {
|
||||
name,
|
||||
username,
|
||||
status: 'Active',
|
||||
role: role as UserRole,
|
||||
role,
|
||||
});
|
||||
|
||||
// The Linux side: directories, the Linux user, the confinement, the keys. Non-fatal and reported rather
|
||||
@@ -106,7 +100,7 @@ export const createUserHandler: Handler = async function (ctx) {
|
||||
//
|
||||
// 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: role as UserRole, inboundKey });
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user