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
@@ -34,7 +34,7 @@ type PermissionsResponse = {
type Level = 'none' | 'read' | 'write';
const PERMISSIONS_KEY = ['ROLE_CAPABILITIES'];
const PERMISSIONS_KEY = ['ROLE_PERMISSIONS'];
export const PermissionsSection = () => {
const client = useClient();
+2 -2
View File
@@ -2,7 +2,7 @@ import { getUserById, getRoleGrants } from 'officerdb';
import type { UserRole } from 'officerdb';
import {
PERMISSION_BY_KEY,
CORE_CAPABILITIES,
CORE_PERMISSIONS,
permissionForApiPath,
permissionForWsProvider,
isRequestAllowedAtLevel,
@@ -74,7 +74,7 @@ export async function getEffectivePermissions(userId: number | undefined): Promi
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
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
// 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
* 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.