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:
@@ -20,10 +20,11 @@ export {
|
|||||||
cleanupExpiredTokens,
|
cleanupExpiredTokens,
|
||||||
} from './queries';
|
} from './queries';
|
||||||
|
|
||||||
// Exported as a value, not just a type: the API and the UI need to enumerate the roles, and the
|
export { OWNER_USER_ID } from './schema';
|
||||||
// column definition is the only place that list should exist.
|
|
||||||
export { USER_ROLES, OWNER_USER_ID } from './schema';
|
|
||||||
|
|
||||||
|
// A role list is no longer enumerable at the type level — see the comment on `UserRole` in schema.ts.
|
||||||
|
// The API and the UI enumerate roles by querying the `roles` table (`listRoles` in ../roles), not by
|
||||||
|
// importing a value from here.
|
||||||
export type { UserRole } from './schema';
|
export type { UserRole } from './schema';
|
||||||
export { listProvisionableUsers } from './queries';
|
export { listProvisionableUsers } from './queries';
|
||||||
export type { ProvisionableUser } from './queries';
|
export type { ProvisionableUser } from './queries';
|
||||||
|
|||||||
@@ -1,25 +1,28 @@
|
|||||||
import { pgTable, serial, text, integer, timestamp, index, uniqueIndex, check } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, text, integer, timestamp, index, uniqueIndex, check, foreignKey } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
|
import { roles } from '../roles/schema';
|
||||||
|
|
||||||
// The bootstrap account. `id` is a serial starting at 1 and bootstrap is gated on an empty user table,
|
// The bootstrap account. `id` is a serial starting at 1 and bootstrap is gated on an empty user table,
|
||||||
// so the first account created is always 1. Exported so the CHECK below and the delete guard in
|
// so the first account created is always 1. Exported so the CHECK below and the delete guard in
|
||||||
// queries/auth.ts state the number once between them.
|
// queries/auth.ts state the number once between them.
|
||||||
export const OWNER_USER_ID = 1;
|
export const OWNER_USER_ID = 1;
|
||||||
|
|
||||||
// Roles, in descending authority. Exported as a value so the API and the UI can enumerate them from
|
/**
|
||||||
// the same place the column is defined — a second hand-written list of role names is how they drift.
|
* Was a TS union (`(typeof USER_ROLES)[number]`) backed by a hardcoded four-role array. Since
|
||||||
//
|
* docs/roles-and-teams.md phase 2 part 4, roles are data — the `roles` table, administered through
|
||||||
// A text column with a TS enum rather than a Postgres enum type, matching `status` below: the set is
|
* `/api/roles` — and `users.role` is a foreign key onto it rather than a column typed to a fixed set. A
|
||||||
// still settling, and adding a role should be a one-line schema change that `db:push` applies, not an
|
* role this column can legally hold is therefore not enumerable at the type level any more, so `UserRole`
|
||||||
// ALTER TYPE. Postgres will not reject an unknown string — the TS type is the guard.
|
* is plain `string` and the compile-time guard it used to provide moves to a runtime check at the write
|
||||||
//
|
* path: `getRole(name)` returning a row before a caller is allowed to assign or grant that name. See
|
||||||
// 'Super Admin' is the server owner, and this column is the ONLY thing that says so. isSuperAdmin()
|
* `create-user.ts`, `manage-users.ts` and `permissions-routes.ts`.
|
||||||
// and getOwnerUser() both read it; there is no SUPER_ADMIN_EMAIL and no "first account wins" fallback
|
*
|
||||||
// any more. Two consequences worth holding on to: bootstrap must set it explicitly on the first
|
* 'Super Admin' is still the server owner's role by name, and this column is the ONLY thing that says so
|
||||||
// account, and demoting the last Super Admin would leave the platform with no owner. The CHECK on the
|
* — isSuperAdmin() and getOwnerUser() both read it. Two consequences worth holding on to: bootstrap must
|
||||||
// table below stops that for the bootstrap account specifically: user 1 cannot be anything else.
|
* set it explicitly on the first account, and demoting the last Super Admin would leave the platform with
|
||||||
export const USER_ROLES = ['Super Admin', 'Admin', 'Member', 'Developer'] as const;
|
* no owner. The CHECK on the table below stops that for the bootstrap account specifically: user 1 cannot
|
||||||
export type UserRole = (typeof USER_ROLES)[number];
|
* be anything else.
|
||||||
|
*/
|
||||||
|
export type UserRole = string;
|
||||||
|
|
||||||
export const users = pgTable(
|
export const users = pgTable(
|
||||||
'users',
|
'users',
|
||||||
@@ -31,8 +34,9 @@ export const users = pgTable(
|
|||||||
.notNull()
|
.notNull()
|
||||||
.default('Unverified'),
|
.default('Unverified'),
|
||||||
// Defaults to the least privileged role so a row created by any path that does not think about
|
// Defaults to the least privileged role so a row created by any path that does not think about
|
||||||
// authorisation cannot accidentally mint an admin.
|
// authorisation cannot accidentally mint an admin. Foreign key onto `roles.name` is declared below,
|
||||||
role: text('role', { enum: USER_ROLES }).notNull().default('Member'),
|
// in the table's third argument — see `fk_users_role`.
|
||||||
|
role: text('role').notNull().default('Member'),
|
||||||
name: text('name'),
|
name: text('name'),
|
||||||
/**
|
/**
|
||||||
* Required, and unique case-INSENSITIVELY — see the index below.
|
* Required, and unique case-INSENSITIVELY — see the index below.
|
||||||
@@ -110,6 +114,20 @@ export const users = pgTable(
|
|||||||
'ck_users_owner_is_super_admin',
|
'ck_users_owner_is_super_admin',
|
||||||
sql`${table.id} <> ${sql.raw(String(OWNER_USER_ID))} OR ${table.role} = ${sql.raw("'Super Admin'")}`,
|
sql`${table.id} <> ${sql.raw(String(OWNER_USER_ID))} OR ${table.role} = ${sql.raw("'Super Admin'")}`,
|
||||||
),
|
),
|
||||||
|
// docs/roles-and-teams.md D5 and phase 2 part 2. Named explicitly rather than left to drizzle's
|
||||||
|
// generated name — see src/databases/CLAUDE.md → "Composite keys" on why an over-long generated FK
|
||||||
|
// name gets truncated by Postgres and then re-created on every push; named up front so this one is
|
||||||
|
// never at risk of it regardless of length.
|
||||||
|
//
|
||||||
|
// ON UPDATE CASCADE only, no ON DELETE: a role cannot be deleted while any account holds it
|
||||||
|
// (`deleteRole` in roles/queries.ts enforces D4), so there is no delete path for this to react to —
|
||||||
|
// and leaving it at Postgres's default (effectively NO ACTION) makes the constraint itself a backstop
|
||||||
|
// for that guard rather than silently nulling or cascading a delete the application never intended.
|
||||||
|
foreignKey({
|
||||||
|
name: 'fk_users_role',
|
||||||
|
columns: [table.role],
|
||||||
|
foreignColumns: [roles.name],
|
||||||
|
}).onUpdate('cascade'),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { pgTable, serial, text, timestamp, uniqueIndex, check } from 'drizzle-orm/pg-core';
|
import { pgTable, serial, text, timestamp, uniqueIndex, check, foreignKey } from 'drizzle-orm/pg-core';
|
||||||
import { sql } from 'drizzle-orm';
|
import { sql } from 'drizzle-orm';
|
||||||
import { USER_ROLES } from '../auth/schema';
|
import { roles } from '../roles/schema';
|
||||||
|
|
||||||
// What a ROLE may reach. The subject of a grant is a role, never a user.
|
// What a ROLE may reach. The subject of a grant is a role, never a user.
|
||||||
//
|
//
|
||||||
@@ -8,8 +8,8 @@ import { USER_ROLES } from '../auth/schema';
|
|||||||
// look more flexible and are the obvious next request. Per-user grants make "what can this person do" a
|
// look more flexible and are the obvious next request. Per-user grants make "what can this person do" a
|
||||||
// question you answer by reading rows, and "what can a Member do" a question with no answer at all. Roles
|
// question you answer by reading rows, and "what can a Member do" a question with no answer at all. Roles
|
||||||
// keep both answerable: onboarding is picking a role, and auditing is reading one short table. When the
|
// keep both answerable: onboarding is picking a role, and auditing is reading one short table. When the
|
||||||
// answer really has to differ for one person, that person needs a role, and adding one is a line in
|
// answer really has to differ for one person, that person needs a role, and adding one is a row in
|
||||||
// USER_ROLES.
|
// `roles` (see roles/schema.ts and /api/roles).
|
||||||
//
|
//
|
||||||
// A MISSING ROW MEANS NO ACCESS. There is no row that denies; absence denies. This is what makes the table
|
// A MISSING ROW MEANS NO ACCESS. There is no row that denies; absence denies. This is what makes the table
|
||||||
// safe to read as the whole truth — an empty table is a platform where members can reach nothing but their
|
// safe to read as the whole truth — an empty table is a platform where members can reach nothing but their
|
||||||
@@ -27,7 +27,8 @@ export const rolePermissions = pgTable(
|
|||||||
'role_permissions',
|
'role_permissions',
|
||||||
{
|
{
|
||||||
id: serial('id').primaryKey(),
|
id: serial('id').primaryKey(),
|
||||||
role: text('role', { enum: USER_ROLES }).notNull(),
|
// Foreign key onto `roles.name` is declared below — see `fk_role_permissions_role`.
|
||||||
|
role: text('role').notNull(),
|
||||||
/** A permission key from the registry. Validated by the API, not by a constraint — see above. */
|
/** A permission key from the registry. Validated by the API, not by a constraint — see above. */
|
||||||
permission: text('permission').notNull(),
|
permission: text('permission').notNull(),
|
||||||
/**
|
/**
|
||||||
@@ -43,16 +44,6 @@ export const rolePermissions = pgTable(
|
|||||||
// re-creates them on every push, which stops db:push on an unanswerable truncate prompt. See
|
// re-creates them on every push, which stops db:push on an unanswerable truncate prompt. See
|
||||||
// src/databases/CLAUDE.md → "Composite keys".
|
// src/databases/CLAUDE.md → "Composite keys".
|
||||||
uniqueIndex('uq_role_permissions_role_permission').on(table.role, table.permission),
|
uniqueIndex('uq_role_permissions_role_permission').on(table.role, table.permission),
|
||||||
// Postgres will not reject an unknown string in a text column just because TypeScript narrowed it, so
|
|
||||||
// the legal sets are stated here too. sql.raw for the literals: an interpolated JS string binds as a
|
|
||||||
// parameter and Postgres refuses a parameter inside a CHECK, which breaks push for the WHOLE schema.
|
|
||||||
check(
|
|
||||||
'ck_role_permissions_role',
|
|
||||||
sql`${table.role} IN (${sql.join(
|
|
||||||
USER_ROLES.map((r) => sql.raw(`'${r}'`)),
|
|
||||||
sql`, `,
|
|
||||||
)})`,
|
|
||||||
),
|
|
||||||
check(
|
check(
|
||||||
'ck_role_permissions_level',
|
'ck_role_permissions_level',
|
||||||
sql`${table.level} IN (${sql.join(
|
sql`${table.level} IN (${sql.join(
|
||||||
@@ -64,5 +55,19 @@ export const rolePermissions = pgTable(
|
|||||||
// short-circuits every check. A 'Super Admin' row here would be inert, and an inert row that looks
|
// short-circuits every check. A 'Super Admin' row here would be inert, and an inert row that looks
|
||||||
// meaningful is worse than no row: someone would eventually revoke one and expect it to do something.
|
// meaningful is worse than no row: someone would eventually revoke one and expect it to do something.
|
||||||
check('ck_role_permissions_not_owner', sql`${table.role} <> ${sql.raw("'Super Admin'")}`),
|
check('ck_role_permissions_not_owner', sql`${table.role} <> ${sql.raw("'Super Admin'")}`),
|
||||||
|
// docs/roles-and-teams.md D5 and phase 2 part 2. This REPLACES `ck_role_permissions_role`, which used
|
||||||
|
// to list the four built-in role names literally (`role IN ('Super Admin', 'Admin', ...)`). That check
|
||||||
|
// was already wrong the moment role CRUD (phase 2 part 3, `/api/roles`) could create a role at runtime
|
||||||
|
// — a grant for a new role would have been refused by a constraint enforcing a snapshot of a list that
|
||||||
|
// no longer described the actual role set. A foreign key onto the actual table is the correct
|
||||||
|
// enforcement of "must be a real role" and needs no updating when a role is added.
|
||||||
|
//
|
||||||
|
// Named explicitly for the same reason as `fk_users_role` — see the comment there and
|
||||||
|
// src/databases/CLAUDE.md → "Composite keys".
|
||||||
|
foreignKey({
|
||||||
|
name: 'fk_role_permissions_role',
|
||||||
|
columns: [table.role],
|
||||||
|
foreignColumns: [roles.name],
|
||||||
|
}).onUpdate('cascade'),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { and, asc, eq, sql } from 'drizzle-orm';
|
import { and, asc, eq, sql } from 'drizzle-orm';
|
||||||
import { db } from '../db';
|
import { db } from '../db';
|
||||||
import { users } from '../auth/schema';
|
import { users } from '../auth/schema';
|
||||||
import type { UserRole } from '../auth/schema';
|
|
||||||
import { roles } from './schema';
|
import { roles } from './schema';
|
||||||
|
|
||||||
export type RoleRow = {
|
export type RoleRow = {
|
||||||
@@ -69,12 +68,12 @@ export async function seedRole(row: {
|
|||||||
// shares: it re-checks its own invariant inside the write, not just before it, so a race between the
|
// shares: it re-checks its own invariant inside the write, not just before it, so a race between the
|
||||||
// check and the statement fails closed rather than silently succeeding.
|
// check and the statement fails closed rather than silently succeeding.
|
||||||
|
|
||||||
/** How many accounts hold this role. One definition, so rename and delete cannot disagree on "in use". */
|
/** How many accounts hold this role. Used by `deleteRole` — D4 refuses to delete a role anyone holds. */
|
||||||
async function roleHolderCount(name: string): Promise<number> {
|
async function roleHolderCount(name: string): Promise<number> {
|
||||||
const [row] = await db
|
const [row] = await db
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.role, name as UserRole));
|
.where(eq(users.role, name));
|
||||||
return row?.count ?? 0;
|
return row?.count ?? 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,7 +100,7 @@ export async function createRole(input: { name: string; description?: string | n
|
|||||||
|
|
||||||
export type RenameRoleResult =
|
export type RenameRoleResult =
|
||||||
| { ok: true; role: RoleRow }
|
| { ok: true; role: RoleRow }
|
||||||
| { ok: false; reason: 'not-found' | 'system' | 'invalid-name' | 'duplicate' | 'in-use'; holderCount?: number };
|
| { ok: false; reason: 'not-found' | 'system' | 'invalid-name' | 'duplicate' };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Rename a role. Refuses outright on `isSystem` (D4 in docs/roles-and-teams.md): `rolePermitsDevTools()`
|
* Rename a role. Refuses outright on `isSystem` (D4 in docs/roles-and-teams.md): `rolePermitsDevTools()`
|
||||||
@@ -109,10 +108,12 @@ export type RenameRoleResult =
|
|||||||
* `'Member'`. Renaming one of those four is a code change across three call sites that has not happened,
|
* `'Member'`. Renaming one of those four is a code change across three call sites that has not happened,
|
||||||
* not a data edit — so this refuses rather than quietly breaking them.
|
* not a data edit — so this refuses rather than quietly breaking them.
|
||||||
*
|
*
|
||||||
* `users.role` and `role_permissions.role` are NOT yet foreign keys onto this table (phase 2 part 2 of the
|
* `users.role` and `role_permissions.role` are foreign keys onto this table with `ON UPDATE CASCADE`
|
||||||
* plan, shipped as its own release), so this UPDATE does not cascade anywhere today — it only relabels the
|
* (phase 2 part 2), so the `UPDATE` below moves every holder and every grant automatically, in the same
|
||||||
* `roles` row itself, leaving every holder and every grant still naming the OLD name. Once the FK lands
|
* statement, as Postgres's own job rather than this function's. There used to be a guard here refusing the
|
||||||
* with `ON UPDATE CASCADE`, this same statement starts moving them automatically and nothing here changes.
|
* rename while anyone held the role, because before the FK existed a rename would have orphaned them —
|
||||||
|
* silently pointing every holder at a name that no longer existed. That guard is gone now that the
|
||||||
|
* database itself does the moving.
|
||||||
*/
|
*/
|
||||||
export async function renameRole(from: string, to: string): Promise<RenameRoleResult> {
|
export async function renameRole(from: string, to: string): Promise<RenameRoleResult> {
|
||||||
const name = to.trim();
|
const name = to.trim();
|
||||||
@@ -123,24 +124,6 @@ export async function renameRole(from: string, to: string): Promise<RenameRoleRe
|
|||||||
if (existing.isSystem) return { ok: false, reason: 'system' };
|
if (existing.isSystem) return { ok: false, reason: 'system' };
|
||||||
if (name === from) return { ok: true, role: existing };
|
if (name === from) return { ok: true, role: existing };
|
||||||
|
|
||||||
/*
|
|
||||||
Refuse a rename while anybody holds it, for exactly as long as there is no foreign key.
|
|
||||||
|
|
||||||
`users.role` is a plain text column today — phase 2 part 2 of `docs/roles-and-teams.md` ships the
|
|
||||||
foreign key with ON UPDATE CASCADE as its own release — so renaming this row would leave every holder
|
|
||||||
pointing at a name that no longer exists.
|
|
||||||
|
|
||||||
It happens to be unreachable right now: `manage-users.ts` validates an incoming role against the
|
|
||||||
`USER_ROLES` union, so a role created at runtime cannot be assigned and a non-system role has no
|
|
||||||
holders. That is safety by accident, and it evaporates the moment part 4 makes `UserRole` a plain
|
|
||||||
string. This guard means those two parts can land in either order without opening a window.
|
|
||||||
|
|
||||||
Delete it when the cascade exists, and not before — the cascade then does this job properly, by moving
|
|
||||||
the holders rather than refusing.
|
|
||||||
*/
|
|
||||||
const held = await roleHolderCount(from);
|
|
||||||
if (held > 0) return { ok: false, reason: 'in-use', holderCount: held };
|
|
||||||
|
|
||||||
const clash = await getRole(name);
|
const clash = await getRole(name);
|
||||||
if (clash) return { ok: false, reason: 'duplicate' };
|
if (clash) return { ok: false, reason: 'duplicate' };
|
||||||
|
|
||||||
@@ -173,14 +156,12 @@ export async function deleteRole(name: string): Promise<DeleteRoleResult> {
|
|||||||
if (existing.isSystem) return { ok: false, reason: 'system' };
|
if (existing.isSystem) return { ok: false, reason: 'system' };
|
||||||
|
|
||||||
return db.transaction(async (tx) => {
|
return db.transaction(async (tx) => {
|
||||||
// `name` is not necessarily one of the four literal roles the column's TS type enumerates — this
|
// Inline rather than a call to `roleHolderCount`: this one has to run inside the same transaction as
|
||||||
// function accepts any role that exists as a ROW, which by definition this one is not (yet, if it
|
// the delete below, so a role cannot be handed to someone in the gap between counting and deleting.
|
||||||
// never was). The cast is type-level only: `role` is a plain `text` column, so drizzle emits the same
|
|
||||||
// `role = $1` whether or not the value belongs to that union.
|
|
||||||
const [holders] = await tx
|
const [holders] = await tx
|
||||||
.select({ count: sql<number>`count(*)::int` })
|
.select({ count: sql<number>`count(*)::int` })
|
||||||
.from(users)
|
.from(users)
|
||||||
.where(eq(users.role, name as UserRole));
|
.where(eq(users.role, name));
|
||||||
const count = holders?.count ?? 0;
|
const count = holders?.count ?? 0;
|
||||||
if (count > 0) return { ok: false, reason: 'in-use', holderCount: count };
|
if (count > 0) return { ok: false, reason: 'in-use', holderCount: count };
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import type { Handler } from 'hono';
|
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 { DEFAULT_ROLE_PERMISSIONS } from '@@/permissions/registry';
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import * as errors from '@@/custom-errors';
|
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
|
// 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.
|
// 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;
|
// 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.
|
// failing bootstrap over it would leave a platform with no account at all.
|
||||||
try {
|
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(
|
await replaceRoleGrants(
|
||||||
role,
|
role.name,
|
||||||
DEFAULT_ROLE_PERMISSIONS.map((permission) => ({ permission, level: 'write' as const })),
|
DEFAULT_ROLE_PERMISSIONS.map((permission) => ({ permission, level: 'write' as const })),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -75,13 +75,6 @@ rolesRouter.patch('/:name', async (ctx) => {
|
|||||||
throw errors.BAD_REQUEST('a role name cannot be empty');
|
throw errors.BAD_REQUEST('a role name cannot be empty');
|
||||||
case 'duplicate':
|
case 'duplicate':
|
||||||
throw errors.CONFLICT(`a role named '${body.name.trim()}' already exists`);
|
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 });
|
return ctx.json({ role: result.role });
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import { createUser, getUserByEmail, getUserByUsername, USER_ROLES } from 'officerdb';
|
import { createUser, getUserByEmail, getUserByUsername, getRole } from 'officerdb';
|
||||||
import type { UserRole } from 'officerdb';
|
|
||||||
import argon2 from 'argon2';
|
import argon2 from 'argon2';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { validatePublicKey } from '@@/os-user-ssh';
|
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
|
// 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.
|
// 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) {
|
export const createUserHandler: Handler = async function (ctx) {
|
||||||
const body = (ctx.get('body') ?? {}) as Record<string, unknown>;
|
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
|
// 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
|
// 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.
|
// 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(
|
// Checked as a literal BEFORE the table lookup: 'Super Admin' is always a real row (it is seeded and
|
||||||
role === 'Super Admin'
|
// isSystem), so a table-only check would let it through and rely on the message below to explain why
|
||||||
? 'There is one server owner and it cannot be created here.'
|
// it still isn't offered here — this way the refusal and its reason are decided in one place.
|
||||||
: `Role must be one of: ${ASSIGNABLE_ROLES.join(', ')}`,
|
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
|
// 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,
|
name,
|
||||||
username,
|
username,
|
||||||
status: 'Active',
|
status: 'Active',
|
||||||
role: role as UserRole,
|
role,
|
||||||
});
|
});
|
||||||
|
|
||||||
// The Linux side: directories, the Linux user, the confinement, the keys. Non-fatal and reported rather
|
// 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
|
// 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.
|
// 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
|
// 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.
|
// down must not fail the creation of a platform account, and the list is re-sent when it registers.
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { Handler } from 'hono';
|
import type { Handler } from 'hono';
|
||||||
import { getUsers, getUserById, updateUser, deleteUser, USER_ROLES, OWNER_USER_ID } from 'officerdb';
|
import { getUsers, getUserById, updateUser, deleteUser, listRoles, getRole, OWNER_USER_ID } from 'officerdb';
|
||||||
import type { UserRole } from 'officerdb';
|
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { deprovisionOsAccount } from '@@/os-user-deprovision';
|
import { deprovisionOsAccount } from '@@/os-user-deprovision';
|
||||||
import { dropPostgresRole, provisionPostgresRole, rolePermitsDevTools } from '@@/os-user-postgres';
|
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) {
|
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({
|
return ctx.json({
|
||||||
users: users.sort((a, b) => a.id - b.id).map(toPublicUser),
|
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
|
// 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.
|
// picker — both write paths refuse it — which is what `assignableRoles` is for. Read from the `roles`
|
||||||
roles: USER_ROLES,
|
// table rather than a hardcoded list (docs/roles-and-teams.md phase 2 part 4) — a role created at
|
||||||
assignableRoles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
// runtime through /api/roles must show up here without a deploy.
|
||||||
|
roles: roleNames,
|
||||||
|
assignableRoles: roleNames.filter((r) => r !== 'Super Admin'),
|
||||||
ownerId: OWNER_USER_ID,
|
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');
|
if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');
|
||||||
|
|
||||||
const { role } = ctx.get('body') as { role?: unknown };
|
const { role } = ctx.get('body') as { role?: unknown };
|
||||||
if (typeof role !== 'string' || !(USER_ROLES as readonly string[]).includes(role)) {
|
// The runtime replacement for what USER_ROLES used to guard at the type level (docs/roles-and-teams.md
|
||||||
throw errors.BAD_REQUEST(`Role must be one of: ${USER_ROLES.join(', ')}`);
|
// 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
|
// 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
|
// 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.
|
// leave a Member holding database access with nothing in the row to indicate it.
|
||||||
const had = rolePermitsDevTools(existing.role);
|
const had = rolePermitsDevTools(existing.role);
|
||||||
const wants = rolePermitsDevTools(role as UserRole);
|
const wants = rolePermitsDevTools(role);
|
||||||
|
|
||||||
if (had && !wants && existing.osUser) {
|
if (had && !wants && existing.osUser) {
|
||||||
const dropped = await dropPostgresRole(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');
|
if (!updated) throw errors.NOT_FOUND('User not found');
|
||||||
|
|
||||||
// Promotion. Non-fatal and reported, like every other provisioning step: the role change itself has
|
// Promotion. Non-fatal and reported, like every other provisioning step: the role change itself has
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ import type { MiddlewareHandler } from 'hono';
|
|||||||
import { createRouter } from '../../create-router';
|
import { createRouter } from '../../create-router';
|
||||||
import * as errors from '@@/custom-errors';
|
import * as errors from '@@/custom-errors';
|
||||||
import { isSuperAdmin } from '../../super-admin';
|
import { isSuperAdmin } from '../../super-admin';
|
||||||
import { getAllRoleGrants, replaceRoleGrants, USER_ROLES } from 'officerdb';
|
import { getAllRoleGrants, replaceRoleGrants, listRoles, getRole } from 'officerdb';
|
||||||
import type { UserRole } from 'officerdb';
|
|
||||||
import { PERMISSIONS, GRANTABLE_PERMISSIONS, PERMISSION_BY_KEY } from '../../permissions/registry';
|
import { PERMISSIONS, GRANTABLE_PERMISSIONS, PERMISSION_BY_KEY } from '../../permissions/registry';
|
||||||
import { getEffectivePermissions, invalidateRoleGrants } from '../../permissions/authorize';
|
import { getEffectivePermissions, invalidateRoleGrants } from '../../permissions/authorize';
|
||||||
import { permissionAvailability } from '../../app-store/availability';
|
import { permissionAvailability } from '../../app-store/availability';
|
||||||
@@ -101,13 +100,17 @@ permissionAdminRouter.get('/permissions', ownerGate, async (ctx) => {
|
|||||||
hasPersonalWrites: !!c.personal?.length,
|
hasPersonalWrites: !!c.personal?.length,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const allRoles = await listRoles();
|
||||||
|
|
||||||
return ctx.json({
|
return ctx.json({
|
||||||
// Only the grantable kinds are offered. `execution` and `admin` are deliberately absent: a UI that
|
// 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.
|
// 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),
|
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
|
// Roles a grant may name. Read from the `roles` table (docs/roles-and-teams.md phase 2 part 4) rather
|
||||||
// database refuses a row for that role.
|
// than a hardcoded list, so a role created at runtime through /api/roles is grantable without a
|
||||||
roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
|
// 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
|
// 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
|
// mapping — the wire said `permission` while the column still said `permission` — which is what let
|
||||||
// the table be renamed without any client noticing.
|
// 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) => {
|
permissionAdminRouter.put('/permissions/:role', ownerGate, async (ctx) => {
|
||||||
const role = ctx.req.param('role') as UserRole;
|
const role = ctx.req.param('role');
|
||||||
if (!USER_ROLES.includes(role)) throw errors.BAD_REQUEST(`Unknown role '${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');
|
if (role === 'Super Admin') throw errors.BAD_REQUEST('The owner is not governed by grants');
|
||||||
|
|
||||||
const body = ctx.get('body') as { grants?: unknown } | undefined;
|
const body = ctx.get('body') as { grants?: unknown } | undefined;
|
||||||
|
|||||||
@@ -241,6 +241,75 @@ const BUNDLED: CatalogueEntry[] = [
|
|||||||
// No db/ — the mirror keeps nothing. The password lives in the owner's ~/.vnc, written by the sidecar.
|
// No db/ — the mirror keeps nothing. The password lives in the owner's ~/.vnc, written by the sidecar.
|
||||||
has: { api: true, schema: false, sidecar: true, web: true },
|
has: { api: true, schema: false, sidecar: true, web: true },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
appName: 'minesweeper',
|
||||||
|
publisher: 'officerdev',
|
||||||
|
version: '1.0.0',
|
||||||
|
platform: '>=1.0.0',
|
||||||
|
label: 'Minesweeper',
|
||||||
|
summary: 'Minesweeper X under wine, on a headless display, streamed to the browser',
|
||||||
|
color: '#64748b',
|
||||||
|
icon: 'Bomb',
|
||||||
|
source: 'https://gitea.officer.dev/plugins/minesweeper.git',
|
||||||
|
permissions: [
|
||||||
|
{
|
||||||
|
key: 'minesweeper',
|
||||||
|
label: 'Minesweeper',
|
||||||
|
description:
|
||||||
|
'Play Minesweeper on the server display. The game is confined to its own wine prefix and can reach nothing else on the machine.',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// Six binaries, and this is the entry where the duplication above earns its keep: the owner sees what
|
||||||
|
// installing this will make the machine acquire BEFORE consenting, and for wine that is not a small ask.
|
||||||
|
//
|
||||||
|
// `apt: 'wine'` is deliberately not the whole story and cannot be. The game is a 32-bit VB6 executable,
|
||||||
|
// so the host also needs `dpkg --add-architecture i386` and `wine32:i386` — a system-wide change that no
|
||||||
|
// package name can express, which is why the reason carries the command instead of pretending.
|
||||||
|
//
|
||||||
|
// Xvfb has no dnf entry because Fedora spells it `xorg-x11-server-Xvfb`, with a capital X, and a
|
||||||
|
// manifest package name must be lowercase. Absent means "cannot be installed automatically here", which
|
||||||
|
// becomes an instruction rather than a wrong guess.
|
||||||
|
osDependencies: [
|
||||||
|
{
|
||||||
|
binary: 'wine',
|
||||||
|
reason:
|
||||||
|
'Runs the game. Minesweeper X is a 32-bit Windows executable, so this host also needs i386 multiarch (`dpkg --add-architecture i386`) and the 32-bit wine package — a 64-bit-only wine cannot load it.',
|
||||||
|
packages: { apt: 'wine', dnf: 'wine', pacman: 'wine', brew: 'wine-stable' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binary: 'Xvfb',
|
||||||
|
reason:
|
||||||
|
'The virtual display the game draws on. A framebuffer with no hardware behind it, so nothing appears on any real monitor.',
|
||||||
|
packages: { apt: 'xvfb', pacman: 'xorg-server-xvfb' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binary: 'x11vnc',
|
||||||
|
reason:
|
||||||
|
'Reads that display and serves it, so the browser has something to draw. Bound to a private unix socket, never a port.',
|
||||||
|
packages: { apt: 'x11vnc', dnf: 'x11vnc', pacman: 'x11vnc' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binary: 'xdotool',
|
||||||
|
reason:
|
||||||
|
'Finds the game window and reads its size, so the panel can fit its canvas to the board instead of the whole framebuffer.',
|
||||||
|
packages: { apt: 'xdotool', dnf: 'xdotool', pacman: 'xdotool' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binary: 'xdpyinfo',
|
||||||
|
reason:
|
||||||
|
'Waits for the display to be usable. A live Xvfb process is not the same as a display that answers, and starting the game too early fails silently.',
|
||||||
|
packages: { apt: 'x11-utils', dnf: 'xorg-x11-utils', pacman: 'xorg-xdpyinfo' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
binary: 'winetricks',
|
||||||
|
reason:
|
||||||
|
'Installs the Visual Basic 6 runtime into the wine prefix on first start. Minesweeper X needs MSVBVM60.DLL, which wine does not ship; without it the process exits immediately with nothing but an unresolved-import line.',
|
||||||
|
packages: { apt: 'winetricks', dnf: 'winetricks', pacman: 'winetricks' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
// No schema. The session is processes and a wine prefix on disk, and none of that belongs in Postgres.
|
||||||
|
has: { api: true, schema: false, sidecar: true, web: true },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
appName: 'jellyfin',
|
appName: 'jellyfin',
|
||||||
publisher: 'officerdev',
|
publisher: 'officerdev',
|
||||||
|
|||||||
Reference in New Issue
Block a user