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:
2026-08-23 18:07:50 +00:00
co-authored by Claude Opus 5
parent 1ff271bb5e
commit 6e092c32ea
10 changed files with 196 additions and 115 deletions
+12 -3
View File
@@ -1,5 +1,5 @@
import type { Handler } from 'hono';
import { getUserCount, createUser, replaceRoleGrants, USER_ROLES } from 'officerdb';
import { getUserCount, createUser, replaceRoleGrants, listRoles } from 'officerdb';
import { DEFAULT_ROLE_PERMISSIONS } from '@@/permissions/registry';
import argon2 from 'argon2';
import * as errors from '@@/custom-errors';
@@ -49,12 +49,21 @@ export const bootstrapHandler: Handler = async function (ctx) {
// bootstrap is the one moment that happens exactly once per install, so seeding cannot fight a later
// revocation — take one of these away and nothing puts it back.
//
// Read from the `roles` table rather than the old USER_ROLES list (docs/roles-and-teams.md phase 2 part
// 4) — `scripts/seed-roles.ts` runs as part of `postdb:push`, before the server ever serves a request,
// so by the time this handler can run the four built-in rows already exist. Filtered to `isSystem`
// deliberately: this is seeding the platform's own baseline roles, not every role that happens to exist
// — a role created at runtime through /api/roles before anyone has bootstrapped (which `roles` being
// owner-gated makes unreachable today, but nothing here should assume that stays true) gets no grants
// from this loop, same as it would get none from any other seed step.
//
// Non-fatal. An owner who exists but whose roles hold nothing is a working server with a one-click fix;
// failing bootstrap over it would leave a platform with no account at all.
try {
for (const role of USER_ROLES.filter((r) => r !== 'Super Admin')) {
const builtIn = (await listRoles()).filter((r) => r.isSystem && r.name !== 'Super Admin');
for (const role of builtIn) {
await replaceRoleGrants(
role,
role.name,
DEFAULT_ROLE_PERMISSIONS.map((permission) => ({ permission, level: 'write' as const })),
);
}
-7
View File
@@ -75,13 +75,6 @@ rolesRouter.patch('/:name', async (ctx) => {
throw errors.BAD_REQUEST('a role name cannot be empty');
case 'duplicate':
throw errors.CONFLICT(`a role named '${body.name.trim()}' already exists`);
case 'in-use':
// Refused while held, because there is no ON UPDATE CASCADE yet — see `renameRole`. The count is
// in the message because 'in use' without a number is something nobody can act on.
throw errors.CONFLICT(
`'${from}' is held by ${result.holderCount ?? 'some'} account(s) — renaming it would leave them ` +
'pointing at a role that no longer exists',
);
}
}
return ctx.json({ role: result.role });
+16 -22
View File
@@ -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.
+15 -10
View File
@@ -1,6 +1,5 @@
import type { Handler } from 'hono';
import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb';
import type { UserRole } from 'officerdb';
import { getUsers, getUserById, updateUser, deleteUser, listRoles, getRole, OWNER_USER_ID } from 'officerdb';
import * as errors from '@@/custom-errors';
import { deprovisionOsAccount } from '@@/os-user-deprovision';
import { dropPostgresRole, provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres';
@@ -50,13 +49,16 @@ export const toPublicUser = (u: Awaited<ReturnType<typeof getUsers>>[number]): P
});
export const listUsersHandler: Handler = async function (ctx) {
const users = await getUsers();
const [users, roles] = await Promise.all([getUsers(), listRoles()]);
const roleNames = roles.map((r) => r.name);
return ctx.json({
users: users.sort((a, b) => a.id - b.id).map(toPublicUser),
// Every role, so the owner's own row can display its value. The UI must not offer 'Super Admin' in a
// picker — both write paths refuse it — which is what `assignableRoles` is for.
roles: USER_ROLES,
assignableRoles: USER_ROLES.filter((r) => r !== 'Super Admin'),
// picker — both write paths refuse it — which is what `assignableRoles` is for. Read from the `roles`
// table rather than a hardcoded list (docs/roles-and-teams.md phase 2 part 4) — a role created at
// runtime through /api/roles must show up here without a deploy.
roles: roleNames,
assignableRoles: roleNames.filter((r) => r !== 'Super Admin'),
ownerId: OWNER_USER_ID,
});
};
@@ -66,8 +68,11 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');
const { role } = ctx.get('body') as { role?: unknown };
if (typeof role !== 'string' || !(USER_ROLES as readonly string[]).includes(role)) {
throw errors.BAD_REQUEST(`Role must be one of: ${USER_ROLES.join(', ')}`);
// The runtime replacement for what USER_ROLES used to guard at the type level (docs/roles-and-teams.md
// phase 2 part 4): a role that is not a row in `roles` must never reach `updateUser`, or the FK on
// `users.role` throws a raw Postgres error instead of this message.
if (typeof role !== 'string' || !(await getRole(role))) {
throw errors.BAD_REQUEST(`Unknown role '${String(role)}'`);
}
// ck_users_owner_is_super_admin would reject this anyway — the point of catching it here is the
@@ -105,7 +110,7 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
// the account still says Developer and the whole thing can be retried. Doing it the other way round would
// leave a Member holding database access with nothing in the row to indicate it.
const had = rolePermitsDevTools(existing.role);
const wants = rolePermitsDevTools(role as UserRole);
const wants = rolePermitsDevTools(role);
if (had && !wants && existing.osUser) {
const dropped = await dropPostgresRole(existing.osUser);
@@ -125,7 +130,7 @@ export const updateUserRoleHandler: Handler = async function (ctx) {
}
}
const updated = await updateUser(id, { role: role as UserRole });
const updated = await updateUser(id, { role });
if (!updated) throw errors.NOT_FOUND('User not found');
// Promotion. Non-fatal and reported, like every other provisioning step: the role change itself has
+13 -7
View File
@@ -2,8 +2,7 @@ import type { MiddlewareHandler } from 'hono';
import { createRouter } from '../../create-router';
import * as errors from '@@/custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { getAllRoleGrants, replaceRoleGrants, USER_ROLES } from 'officerdb';
import type { UserRole } from 'officerdb';
import { getAllRoleGrants, replaceRoleGrants, listRoles, getRole } from 'officerdb';
import { PERMISSIONS, GRANTABLE_PERMISSIONS, PERMISSION_BY_KEY } from '../../permissions/registry';
import { getEffectivePermissions, invalidateRoleGrants } from '../../permissions/authorize';
import { permissionAvailability } from '../../app-store/availability';
@@ -101,13 +100,17 @@ permissionAdminRouter.get('/permissions', ownerGate, async (ctx) => {
hasPersonalWrites: !!c.personal?.length,
});
const allRoles = await listRoles();
return ctx.json({
// Only the grantable kinds are offered. `execution` and `admin` are deliberately absent: a UI that
// shows a checkbox it will refuse to honour is worse than one that never offered it.
permissions: GRANTABLE_PERMISSIONS.filter((c) => !unavailable.has(c.key)).map(describe),
// Roles a grant may name. Super Admin is excluded: the owner bypasses this table entirely, and the
// database refuses a row for that role.
roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
// Roles a grant may name. Read from the `roles` table (docs/roles-and-teams.md phase 2 part 4) rather
// than a hardcoded list, so a role created at runtime through /api/roles is grantable without a
// deploy. Super Admin is excluded: the owner bypasses this table entirely, and the database refuses a
// row for that role.
roles: allRoles.map((r) => r.name).filter((r) => r !== 'Super Admin'),
// The column is `permission` now too, so this is a straight pass-through again. It was briefly a
// mapping — the wire said `permission` while the column still said `permission` — which is what let
// the table be renamed without any client noticing.
@@ -116,8 +119,11 @@ permissionAdminRouter.get('/permissions', ownerGate, async (ctx) => {
});
permissionAdminRouter.put('/permissions/:role', ownerGate, async (ctx) => {
const role = ctx.req.param('role') as UserRole;
if (!USER_ROLES.includes(role)) throw errors.BAD_REQUEST(`Unknown role '${role}'`);
const role = ctx.req.param('role');
// The runtime replacement for what USER_ROLES used to guard at the type level (docs/roles-and-teams.md
// phase 2 part 4): a role that is not a row in `roles` must never reach `replaceRoleGrants`, or the FK
// on `role_permissions.role` throws a raw Postgres error instead of this message.
if (!(await getRole(role))) throw errors.BAD_REQUEST(`Unknown role '${role}'`);
if (role === 'Super Admin') throw errors.BAD_REQUEST('The owner is not governed by grants');
const body = ctx.get('body') as { grants?: unknown } | undefined;