a tested migration script for the capabilities→permissions rename

Other machines have to make the same database change, and pasted SQL is the
wrong way to ship it. `scripts/rename-capabilities-to-permissions.ts` renames
the table, its column, both indexes and its three CHECK constraints in one
transaction, and refuses to guess:

  neither table        nothing to do; db:push will create it
  already renamed      no-op, prints the grant count
  BOTH tables present  stops and says a human must decide
  old table only       migrates, counts before and after, prints every grant

Testing it found a bug that reading it had not. The existence checks were
inside the transaction but ran on the pool rather than on `tx`, so they could
not see the uncommitted rename: `columnExists('role_permissions','capability')`
answered false because that table did not exist yet on that connection, and the
COLUMN rename was silently skipped. The result was a `role_permissions` table
with a `capability` column — half migrated, and only failing on the next query.

That was found by building a scratch database in edge-pertento's exact shape
and running the script against it, rather than by review. Every check now
happens before the transaction, against the old names.

Verified end to end on that scratch: 6 grants migrated intact, a second run
correctly no-ops, and `db:push` afterwards leaves role_permissions and its rows
alone while creating the rest of the schema. Indexes come out as
role_permissions_pkey and uq_role_permissions_role_permission.

Also swept the last all-caps survivors the case-sensitive passes missed:
CORE_CAPABILITIES → CORE_PERMISSIONS, and a react-query key still spelling
['ROLE_CAPABILITIES']. The only CAPABILITIES left in src/ is the wallet's, which
is Lightning and stays.
This commit is contained in:
2026-08-15 16:40:18 +00:00
parent 027b10bd6e
commit 9c2d6a97f7
4 changed files with 128 additions and 4 deletions
@@ -0,0 +1,124 @@
import { db } from 'officerdb/db';
import { sql } from 'drizzle-orm';
// One-time database migration for the 2026-08-15 rename: `role_capabilities` → `role_permissions`.
//
// ── Why this is a script and not `bun db:push` ──
//
// drizzle-kit does not understand renames. It sees a table gone and a table added, and with `--force` it
// resolves that by DROPPING and CREATING — which would delete every grant on the server and silently
// reduce every member to core-only access. There is no prompt to catch it, because `--force` exists to
// answer prompts.
//
// So the database is renamed by hand, first, and `db:push` afterwards should report "No changes
// detected" — which is the proof that the two now agree.
//
// ── Safe to run twice, and safe to run on a server that never had the old names ──
//
// Every step checks first. A machine already migrated prints "already done" and touches nothing; a fresh
// install that never had `role_capabilities` is not an error either. That matters because this will be
// run by hand, on more than one machine, possibly twice on the same one.
//
// Run it BEFORE restarting the platform on the new code. The old code cannot read `role_permissions` and
// the new code cannot read `role_capabilities`, so the window between them is the outage — keep it short:
//
// pm2 stop officer && bun run scripts/rename-capabilities-to-permissions.ts && bun db:push && pm2 restart all
//
// If it goes wrong: the OWNER is unaffected either way. `getEffectivePermissions` short-circuits on
// `role === 'Super Admin'` before it reads the table at all, so the account that can fix things can
// always sign in. Non-owners degrade to core-only until the rename completes.
const q = async (statement: ReturnType<typeof sql>): Promise<Record<string, unknown>[]> => {
const result = (await db.execute(statement)) as unknown as { rows?: Record<string, unknown>[] };
return result.rows ?? (result as unknown as Record<string, unknown>[]);
};
const tableExists = async (name: string): Promise<boolean> =>
((await q(sql`select to_regclass(${`public.${name}`}) as t`))[0]?.t ?? null) !== null;
const columnExists = async (table: string, column: string): Promise<boolean> =>
(await q(sql`select 1 from information_schema.columns where table_name = ${table} and column_name = ${column}`))
.length > 0;
const relationExists = async (name: string): Promise<boolean> =>
((await q(sql`select to_regclass(${`public.${name}`}) as t`))[0]?.t ?? null) !== null;
const constraintExists = async (table: string, name: string): Promise<boolean> =>
(await q(sql`select 1 from pg_constraint where conname = ${name} and conrelid = to_regclass(${`public.${table}`})`))
.length > 0;
async function main() {
const hasOld = await tableExists('role_capabilities');
const hasNew = await tableExists('role_permissions');
if (!hasOld && !hasNew) {
console.log(' Neither table exists — nothing to migrate. `bun db:push` will create role_permissions.');
return;
}
if (!hasOld && hasNew) {
console.log(' Already migrated: role_permissions exists and role_capabilities does not. Nothing to do.');
const rows = await q(sql`select count(*)::int as n from role_permissions`);
console.log(` Grants on this server: ${rows[0]?.n}`);
return;
}
if (hasOld && hasNew) {
console.error(' BOTH tables exist. That is not a state this script can resolve safely — stopping.');
console.error(' Look at both by hand and decide which holds the real grants.');
process.exitCode = 1;
return;
}
// Count before, so the transaction can be checked against something rather than trusted.
const before = Number((await q(sql`select count(*)::int as n from role_capabilities`))[0]?.n ?? 0);
console.log(` Found role_capabilities with ${before} grant(s). Renaming…`);
// EVERY existence check happens HERE, before the transaction, and against the OLD names.
//
// They used to be inside it, and that was a real bug caught by testing against a copy of a production
// database rather than by reading: the helpers run on the pool, not on `tx`, so inside the transaction
// they cannot see its uncommitted rename. `columnExists('role_permissions', 'capability')` answered
// false — the table did not exist yet as far as that connection was concerned — so the column rename
// was silently skipped and the migration produced a `role_permissions` table with a `capability`
// column. Half migrated, and the failure only surfaced on the next query.
const hasOldColumn = await columnExists('role_capabilities', 'capability');
const hasOldUnique = await relationExists('uq_role_capabilities_role_capability');
const hasOldPkey = await relationExists('role_capabilities_pkey');
const oldChecks: string[] = [];
for (const suffix of ['role', 'level', 'not_owner']) {
if (await constraintExists('role_capabilities', `ck_role_capabilities_${suffix}`)) oldChecks.push(suffix);
}
// One transaction. A partial rename leaves drizzle-kit seeing a table it half-recognises, and the next
// `push --force` would resolve that difference by dropping it.
await db.transaction(async (tx) => {
await tx.execute(sql`ALTER TABLE role_capabilities RENAME TO role_permissions`);
if (hasOldColumn) await tx.execute(sql`ALTER TABLE role_permissions RENAME COLUMN capability TO permission`);
// Index and constraint names are renamed too. drizzle-kit diffs on the NAME, so leaving them would
// make every future push want to drop and recreate them.
if (hasOldUnique) {
await tx.execute(
sql`ALTER INDEX uq_role_capabilities_role_capability RENAME TO uq_role_permissions_role_permission`,
);
}
if (hasOldPkey) await tx.execute(sql`ALTER INDEX role_capabilities_pkey RENAME TO role_permissions_pkey`);
for (const suffix of oldChecks) {
await tx.execute(
sql.raw(
`ALTER TABLE role_permissions RENAME CONSTRAINT ck_role_capabilities_${suffix} TO ck_role_permissions_${suffix}`,
),
);
}
});
const after = Number((await q(sql`select count(*)::int as n from role_permissions`))[0]?.n ?? 0);
console.log(` Renamed. Grants after: ${after}${after === before ? ' — unchanged, as expected.' : ' — MISMATCH!'}`);
if (after !== before) process.exitCode = 1;
const rows = await q(sql`select role, permission, level from role_permissions order by role, permission`);
for (const r of rows) console.log(` ${r.role}${r.permission} (${r.level})`);
console.log('\n Next: `bun db:push` (expect "No changes detected"), then `pm2 restart all`.');
}
await main();
process.exit(process.exitCode ?? 0);
@@ -34,7 +34,7 @@ type PermissionsResponse = {
type Level = 'none' | 'read' | 'write'; type Level = 'none' | 'read' | 'write';
const PERMISSIONS_KEY = ['ROLE_CAPABILITIES']; const PERMISSIONS_KEY = ['ROLE_PERMISSIONS'];
export const PermissionsSection = () => { export const PermissionsSection = () => {
const client = useClient(); const client = useClient();
+2 -2
View File
@@ -2,7 +2,7 @@ import { getUserById, getRoleGrants } from 'officerdb';
import type { UserRole } from 'officerdb'; import type { UserRole } from 'officerdb';
import { import {
PERMISSION_BY_KEY, PERMISSION_BY_KEY,
CORE_CAPABILITIES, CORE_PERMISSIONS,
permissionForApiPath, permissionForApiPath,
permissionForWsProvider, permissionForWsProvider,
isRequestAllowedAtLevel, isRequestAllowedAtLevel,
@@ -74,7 +74,7 @@ export async function getEffectivePermissions(userId: number | undefined): Promi
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() }; if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
const grants = new Map<string, PermissionLevel>(); const grants = new Map<string, PermissionLevel>();
for (const permission of CORE_CAPABILITIES) grants.set(permission.key, 'write'); for (const permission of CORE_PERMISSIONS) grants.set(permission.key, 'write');
// Whether the kernel can enforce a boundary for this account. `confined` permissions are dropped // Whether the kernel can enforce a boundary for this account. `confined` permissions are dropped
// without it — see below. // without it — see below.
+1 -1
View File
@@ -461,7 +461,7 @@ export const DEFAULT_ROLE_PERMISSIONS: string[] = CORE_REGISTRY.filter((c) => c.
* From CORE_REGISTRY, so a plugin declaring `kind: 'core'` — which its manifest cannot express, but which * From CORE_REGISTRY, so a plugin declaring `kind: 'core'` — which its manifest cannot express, but which
* a future bug could smuggle in — still could not grant itself to everyone undeniably. * a future bug could smuggle in — still could not grant itself to everyone undeniably.
*/ */
export const CORE_CAPABILITIES = CORE_REGISTRY.filter((c) => c.kind === 'core'); export const CORE_PERMISSIONS = CORE_REGISTRY.filter((c) => c.kind === 'core');
/** /**
* Replace the plugin half of the registry. Called after every install, uninstall, enable and disable. * Replace the plugin half of the registry. Called after every install, uninstall, enable and disable.