From 2e8ec845c8c7fd0f8923145c144fc7349e271420 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Andr=C3=A9=20Padez?=
Date: Sat, 15 Aug 2026 16:03:22 +0000
Subject: [PATCH] step 1/4: the permission engine is called permissions, not
capabilities
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
The word meant four different things in this repo, not the three the offscale
doc records:
1. the permission registry → RENAMED here
2. $OFFICER_ROOT/capabilities/ items → kept; this is what capabilities are
3. sidecar routing keys → step 3, becoming `handles`
4. Lightning wallet features → kept; a domain term, and on the wire
to the mobile apps
The fourth was not in the doc and a global find-and-replace would have broken
the mobile wallet, which reads `{ kind, capabilities: Capability[] }` from the
wallet sidecar. So this renamed against an explicit file allowlist rather than
by sweeping the tree, and `CapabilityPage.tsx` — the UI for the item store, and
correctly named already — was left alone.
Moved: servers/capabilities/ → servers/permissions/, capability-gate.ts →
permission-gate.ts, users/capabilities-routes.ts → permissions-routes.ts,
hooks/useCapabilities.ts → usePermissions.ts. Identifiers follow.
Three breaks the typechecker could not see, all found by exercising it live.
The route paths moved with the prose sweep, so the server served
/user/permissions while the frontend still called /user/capabilities. A 404 on
every page load, and tsgo clean throughout.
The response FIELD moved too. `client.get()` is an unchecked
cast, so `data.capabilities` became `undefined` at runtime with no compile
error — `can()` would have answered "no" to everything and the dock would have
emptied itself.
And the grants list was passed straight out of the database, so it arrived as
`{ role, capability, level }` while the screen read `grant.permission`. Every
role would have rendered as holding nothing. It is now mapped in the route:
the wire says `permission`, the column still says `capability`, and step 2
therefore changes nothing any client can see.
The stale react-query keys were the quiet one: two files still invalidated
['self-capabilities'] after the hook moved to ['self-permissions'], so
installing a plugin would have silently stopped refreshing the dock.
The database is untouched — `role_capabilities` and its `capability` column are
step 2, and the two call sites that cross that boundary say so in a comment.
Round-tripped the 9 live grants through the admin endpoint to prove the PUT
contract survived: 9 before, 9 after, Member's three intact.
Also reverted prettier churn on five landing-page files that a broad --write
picked up. Second time today; the lesson is not sticking.
tsgo clean. 797 tests, 787 pass, same 7 pre-existing failures — two of which
now read "path → permission" rather than "path → capability".
---
plugins/music/web/MusicPlayerHost.tsx | 4 +-
.../Dashboard/Layout/DashboardLayout.tsx | 4 +-
.../Screens/Dashboard/Layout/Dock.tsx | 2 +-
.../Dashboard/Layout/Header/JobsIndicator.tsx | 4 +-
.../Dashboard/Layout/Rescan/RescanButton.tsx | 4 +-
.../Screens/Dashboard/Layout/RouteGate.tsx | 4 +-
.../Settings/ProfileSettings/DockSettings.tsx | 4 +-
.../UserManagement/PermissionsSection.tsx | 44 +++---
src/server.tsx | 6 +-
src/servers/_middlewares/index.ts | 2 +-
...{capability-gate.ts => permission-gate.ts} | 6 +-
src/servers/_middlewares/user-middleware.ts | 2 +-
src/servers/api/activity/progress.ts | 4 +-
src/servers/api/agent-handoff/router.ts | 2 +-
src/servers/api/agent-status/router.ts | 2 +-
src/servers/api/agents/agent-runner.ts | 2 +-
src/servers/api/api-keys/router.ts | 2 +-
src/servers/api/app-store/router.ts | 2 +-
src/servers/api/auth/bootstrap.ts | 7 +-
src/servers/api/auth/signin.ts | 2 +-
src/servers/api/chat/agent-panels-routes.ts | 4 +-
src/servers/api/chat/chat.ts | 4 +-
src/servers/api/chat/list-models.ts | 10 +-
src/servers/api/plugins/router.ts | 2 +-
src/servers/api/settings/settings.ts | 6 +-
...lities-routes.ts => permissions-routes.ts} | 93 ++++++-----
src/servers/api/users/users-router.ts | 8 +-
src/servers/app-store/availability.ts | 34 ++--
src/servers/app-store/catalogue.test.ts | 20 +--
src/servers/app-store/catalogue.ts | 42 ++---
src/servers/app-store/paths.ts | 2 +-
src/servers/auth-token.ts | 4 +-
src/servers/data-path.ts | 2 +-
src/servers/hono.ts | 22 +--
src/servers/os-user-docker.ts | 2 +-
src/servers/os-user-postgres.ts | 2 +-
src/servers/os-user.ts | 8 +-
.../authorize.ts | 92 +++++------
.../registry.test.ts | 114 +++++++-------
.../{capabilities => permissions}/registry.ts | 148 +++++++++---------
.../{capabilities => permissions}/totality.ts | 64 ++++----
src/servers/plugins/manifest.ts | 2 +-
src/servers/plugins/mount.ts | 10 +-
.../{useCapabilities.ts => usePermissions.ts} | 34 ++--
.../src/apps/AppStore/useAppStore.ts | 2 +-
.../src/apps/FileBrowser/useAgents.ts | 4 +-
.../src/apps/FileBrowser/useTasks.ts | 4 +-
.../officerdev/src/apps/Plugins/usePlugins.ts | 4 +-
.../officerdev/src/apps/Wallet/CoinsView.tsx | 4 +-
.../src/apps/Wallet/LightningView.tsx | 4 +-
.../src/apps/Wallet/OverviewView.tsx | 4 +-
.../src/apps/Wallet/ReceiveView.tsx | 4 +-
.../officerdev/src/apps/Wallet/SendView.tsx | 6 +-
.../officerdev/src/apps/Wallet/WalletNav.tsx | 4 +-
.../src/apps/Wallet/WalletSettingsView.tsx | 4 +-
.../src/apps/Wallet/useWalletData.ts | 2 +-
src/workspaces/state/src/useAccessPolicy.ts | 4 +-
src/workspaces/state/src/useModels.ts | 4 +-
src/workspaces/state/src/useServerSettings.ts | 4 +-
59 files changed, 454 insertions(+), 442 deletions(-)
rename src/servers/_middlewares/{capability-gate.ts => permission-gate.ts} (93%)
rename src/servers/api/users/{capabilities-routes.ts => permissions-routes.ts} (61%)
rename src/servers/{capabilities => permissions}/authorize.ts (68%)
rename src/servers/{capabilities => permissions}/registry.test.ts (70%)
rename src/servers/{capabilities => permissions}/registry.ts (83%)
rename src/servers/{capabilities => permissions}/totality.ts (75%)
rename src/workspaces/hooks/src/{useCapabilities.ts => usePermissions.ts} (83%)
diff --git a/plugins/music/web/MusicPlayerHost.tsx b/plugins/music/web/MusicPlayerHost.tsx
index 5c5a270e..1f46057d 100644
--- a/plugins/music/web/MusicPlayerHost.tsx
+++ b/plugins/music/web/MusicPlayerHost.tsx
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient';
-import { useCapabilities } from 'hooks/useCapabilities';
+import { usePermissions } from 'hooks/usePermissions';
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
import { SeekBar } from 'officerdev';
import { MusicHeart } from './MusicHeart';
@@ -24,7 +24,7 @@ const MUSIC_API = '/api/music';
export const MusicPlayerHost = () => {
const { token, get, put, delete: del } = useClient();
- const { can } = useCapabilities();
+ const { can } = usePermissions();
const canUseMusic = can('music');
const navigate = useNavigate();
const { pathname } = useLocation();
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
index 1842358d..0c1727cc 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/DashboardLayout.tsx
@@ -1,7 +1,7 @@
import { useMemo, useRef } from 'react';
import { useLocation } from 'react-router';
import { useDock, usePanelFullscreen } from 'officerdev';
-import { useCapabilities } from 'hooks/useCapabilities';
+import { usePermissions } from 'hooks/usePermissions';
import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback';
import { Background } from './Background';
@@ -15,7 +15,7 @@ type DashboardLayoutProps = {
children?: React.ReactNode;
};
export function DashboardLayout({ children }: DashboardLayoutProps) {
- const { canVisit, plugins } = useCapabilities();
+ const { canVisit, plugins } = usePermissions();
// Filtered BEFORE useDock, so a member's saved dock order cannot resurrect an icon their role no longer
// reaches, and so the pinned-item defaults fall back to something they can actually open. Cosmetic
// either way — every one of these routes is refused server-side too — but an app that offers a door it
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
index 05656b3b..4a61fdff 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Dock.tsx
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { NavLink } from 'react-router';
import type { LucideIcon } from 'lucide-react';
import { resolveIcon } from 'officerdev';
-import type { PluginManifest } from 'hooks/useCapabilities';
+import type { PluginManifest } from 'hooks/usePermissions';
export type DockItem = {
label: string;
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Header/JobsIndicator.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Header/JobsIndicator.tsx
index 64924d25..e97b5a29 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Header/JobsIndicator.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Header/JobsIndicator.tsx
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { Link } from 'react-router';
import { Loader2, ListOrdered } from 'lucide-react';
import { useClient } from 'hooks/useClient';
-import { useCapabilities } from 'hooks/useCapabilities';
+import { usePermissions } from 'hooks/usePermissions';
type Counts = { running: number; runningJobId: string | null; queued: number };
@@ -14,7 +14,7 @@ type Counts = { running: number; runningJobId: string | null; queued: number };
// links to a screen they cannot open. Neither is a security problem; both are the app lying about what it is.
export const JobsIndicator = () => {
const client = useClient();
- const { can } = useCapabilities();
+ const { can } = usePermissions();
const allowed = can('tasks');
const [counts, setCounts] = useState({ running: 0, runningJobId: null, queued: 0 });
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/Rescan/RescanButton.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/Rescan/RescanButton.tsx
index ea4412f5..cc73d7f9 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/Rescan/RescanButton.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/Rescan/RescanButton.tsx
@@ -3,7 +3,7 @@ import { RotateCw } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { useClient } from 'hooks/useClient';
-import { useCapabilities } from 'hooks/useCapabilities';
+import { usePermissions } from 'hooks/usePermissions';
type RescanResponse = { ok: boolean; counts: Record };
@@ -14,7 +14,7 @@ const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'process
export function RescanButton() {
const client = useClient();
const qc = useQueryClient();
- const { can } = useCapabilities();
+ const { can } = usePermissions();
const [loading, setLoading] = useState(false);
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
diff --git a/src/apps/officer-web/Screens/Dashboard/Layout/RouteGate.tsx b/src/apps/officer-web/Screens/Dashboard/Layout/RouteGate.tsx
index f785b52a..69e59a2d 100644
--- a/src/apps/officer-web/Screens/Dashboard/Layout/RouteGate.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Layout/RouteGate.tsx
@@ -1,5 +1,5 @@
import { Navigate, useLocation } from 'react-router';
-import { useCapabilities } from 'hooks/useCapabilities';
+import { usePermissions } from 'hooks/usePermissions';
// A screen exists only if this server has the thing behind it and this account may reach it. Otherwise the
// path is treated exactly as an unknown one: redirect home, same as App.tsx's `path="*"`.
@@ -27,7 +27,7 @@ import { useCapabilities } from 'hooks/useCapabilities';
export function RouteGate({ children }: { children?: React.ReactNode }) {
const { pathname } = useLocation();
- const { denialReason } = useCapabilities();
+ const { denialReason } = usePermissions();
// `replace`, so Back does not bounce between the denied path and home.
if (denialReason(pathname)) return ;
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx
index c1604e83..56538248 100644
--- a/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/ProfileSettings/DockSettings.tsx
@@ -2,7 +2,7 @@ import { useMemo, useState, useCallback, type DragEvent } from 'react';
import { X, Plus, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useDock } from 'officerdev';
-import { useCapabilities } from 'hooks/useCapabilities';
+import { usePermissions } from 'hooks/usePermissions';
import { CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock';
type DockPillProps = {
@@ -106,7 +106,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
export const DockSettings = () => {
// Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone
// pin a tile that cannot appear, which reads as the setting being broken.
- const { plugins } = useCapabilities();
+ const { plugins } = usePermissions();
const allDockItems = useMemo(() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)], [plugins]);
const { items, allItems, setItems, reset } = useDock(allDockItems, DEFAULT_DOCK_PATHS);
const [dropTarget, setDropTarget] = useState<{ path: string; side: 'left' | 'right' } | null>(null);
diff --git a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx
index 86fbe11b..d820c5d8 100644
--- a/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx
+++ b/src/apps/officer-web/Screens/Dashboard/Settings/UserManagement/PermissionsSection.tsx
@@ -3,18 +3,18 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';
import { Loader2 } from 'lucide-react';
import { useClient } from 'hooks/useClient';
-import { CAPABILITIES_QUERY_KEY } from 'hooks/useCapabilities';
+import { PERMISSIONS_QUERY_KEY } from 'hooks/usePermissions';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
-// What each ROLE may reach. Not each user — see the table comment in schema/capabilities.ts for why.
+// What each ROLE may reach. Not each user — see the table comment in schema/permissions.ts for why.
//
-// The screen is one role at a time on purpose. A grid of every role against every capability is the
+// The screen is one role at a time on purpose. A grid of every role against every permission is the
// obvious design and it is the wrong one: it invites reading across rows, which is not a question anyone
// has, and it makes the destructive action ("uncheck Gitea for Members") a single click among fifty. One
// role, an explicit Save, and a visible dirty state instead.
-type CapabilityInfo = {
+type PermissionInfo = {
key: string;
label: string;
description: string;
@@ -22,11 +22,11 @@ type CapabilityInfo = {
hasPersonalWrites: boolean;
};
-type Grant = { role: string; capability: string; level: 'read' | 'write' };
+type Grant = { role: string; permission: string; level: 'read' | 'write' };
-type CapabilitiesResponse = {
+type PermissionsResponse = {
/** Grantable AND installed. What this server can currently do. */
- capabilities: CapabilityInfo[];
+ permissions: PermissionInfo[];
roles: string[];
grants: Grant[];
@@ -43,9 +43,9 @@ export const PermissionsSection = () => {
const [draft, setDraft] = useState>({});
const [saving, setSaving] = useState(false);
- const { data, isLoading, isError } = useQuery({
+ const { data, isLoading, isError } = useQuery({
queryKey: PERMISSIONS_KEY,
- queryFn: () => client.get('/users/capabilities'),
+ queryFn: () => client.get('/users/permissions'),
});
const activeRole = role ?? data?.roles[0] ?? null;
@@ -53,9 +53,9 @@ export const PermissionsSection = () => {
// What the server currently says, for this role. The comparison baseline for the dirty state below.
const saved = useMemo(() => {
const levels: Record = {};
- for (const capability of data?.capabilities ?? []) levels[capability.key] = 'none';
+ for (const permission of data?.permissions ?? []) levels[permission.key] = 'none';
for (const grant of data?.grants ?? []) {
- if (grant.role === activeRole) levels[grant.capability] = grant.level;
+ if (grant.role === activeRole) levels[grant.permission] = grant.level;
}
return levels;
}, [data, activeRole]);
@@ -72,12 +72,12 @@ export const PermissionsSection = () => {
try {
const grants = Object.entries(draft)
.filter(([, level]) => level !== 'none')
- .map(([capability, level]) => ({ capability, level }));
- await client.put(`/users/capabilities/${encodeURIComponent(activeRole)}`, { grants });
+ .map(([permission, level]) => ({ permission, level }));
+ await client.put(`/users/permissions/${encodeURIComponent(activeRole)}`, { grants });
await queryClient.invalidateQueries({ queryKey: PERMISSIONS_KEY });
// The owner may be editing their own view's inputs — and anyone already signed in needs the dock to
// catch up without a reload.
- await queryClient.invalidateQueries({ queryKey: CAPABILITIES_QUERY_KEY });
+ await queryClient.invalidateQueries({ queryKey: PERMISSIONS_QUERY_KEY });
toast.success(`Saved what ${activeRole}s can reach`);
} catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not save');
@@ -89,12 +89,12 @@ export const PermissionsSection = () => {
if (isLoading) {
return (
{/* Label only. The descriptions went because with three rows called Terminal, Chat and Files
they explained nothing anyone needed — and the "needs a Linux account" line went with them:
every account gets one at creation, so warning about it on every row was noise about a state
that no longer occurs on its own. */}
-
{capability.label}
+
{permission.label}
- {/* Two explanatory blocks used to sit here: one naming every capability whose sidecar is not installed,
+ {/* Two explanatory blocks used to sit here: one naming every permission whose sidecar is not installed,
and one naming everything that can never be granted. Both are gone, and for the same reason — a
server should not enumerate what it does not have. The first was a catalogue of uninstallable
features presented as a permissions decision; the second described chat, tasks, the desktop and the
diff --git a/src/server.tsx b/src/server.tsx
index e15c2aa3..600e6926 100644
--- a/src/server.tsx
+++ b/src/server.tsx
@@ -3,7 +3,7 @@ import type { ServerWebSocket } from 'bun';
import { serve } from 'bun';
import { join } from 'node:path';
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono';
-import { assertCapabilityTotality } from './servers/capabilities/totality';
+import { assertPermissionTotality } from './servers/permissions/totality';
import { refreshPluginMounts, snapshotPlugins } from './servers/plugins/mount';
import { generatePluginsModule, rebuildFrontend, BUILD_DIR, SHELL_FILE } from './servers/plugins/generate';
import { assertInstallLayout } from './servers/data-path';
@@ -11,7 +11,7 @@ import { PORT } from './servers/officer-url.mjs';
import { assertSecretsClosed } from './servers/os-user';
import { resolveHomeDir } from './servers/user-home';
import { resolveAuthToken } from './servers/auth-token';
-import { isWsProviderAllowed } from './servers/capabilities/authorize';
+import { isWsProviderAllowed } from './servers/permissions/authorize';
import { isTokenBlacklisted, getUserById } from 'officerdb';
import { terminalWebsocket } from './servers/api/terminal/websocket';
import { chatWebsocket } from './servers/api/chat/websocket';
@@ -151,7 +151,7 @@ const handlers: Record = {
// This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated
// cannot be reached even once. The HTTP half comes from hono.ts's own mount table and the socket half
// from the map directly above, so neither list can be a stale copy of the thing it describes.
-assertCapabilityTotality({
+assertPermissionTotality({
apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES],
wsProviders: Object.keys(handlers),
});
diff --git a/src/servers/_middlewares/index.ts b/src/servers/_middlewares/index.ts
index bdc6fd55..f29ec7cb 100644
--- a/src/servers/_middlewares/index.ts
+++ b/src/servers/_middlewares/index.ts
@@ -1,7 +1,7 @@
export * from './body-parser';
export * from './user-middleware';
export * from './origin-middleware';
-export * from './capability-gate';
+export * from './permission-gate';
export * from './rate-limiter';
export * from './known-users';
export * from './auth-audit';
diff --git a/src/servers/_middlewares/capability-gate.ts b/src/servers/_middlewares/permission-gate.ts
similarity index 93%
rename from src/servers/_middlewares/capability-gate.ts
rename to src/servers/_middlewares/permission-gate.ts
index e5748ff9..e40c635a 100644
--- a/src/servers/_middlewares/capability-gate.ts
+++ b/src/servers/_middlewares/permission-gate.ts
@@ -2,8 +2,8 @@ import type { MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors';
import { resolveAuthToken } from '../auth-token';
import { isSuperAdmin } from '../super-admin';
-import { isApiRequestAllowed } from '../capabilities/authorize';
-import { isExemptApiPath } from '../capabilities/totality';
+import { isApiRequestAllowed } from '../permissions/authorize';
+import { isExemptApiPath } from '../permissions/totality';
// The global authorization gate: a valid NON-owner token may reach only what its ROLE has been granted.
//
@@ -29,7 +29,7 @@ import { isExemptApiPath } from '../capabilities/totality';
// This half was deliberately NOT under that flag, because it is account-based rather than origin-based.
// Its old comment called it "the airtight half", and separating the two is why the name changed: nothing
// in here looks at an Origin header any more.
-export const capabilityGateMiddleware: MiddlewareHandler = async function (ctx, next) {
+export const permissionGateMiddleware: MiddlewareHandler = async function (ctx, next) {
const path = ctx.req.path;
const authorization = ctx.req.header('authorization');
diff --git a/src/servers/_middlewares/user-middleware.ts b/src/servers/_middlewares/user-middleware.ts
index 1d4cc494..f401e012 100644
--- a/src/servers/_middlewares/user-middleware.ts
+++ b/src/servers/_middlewares/user-middleware.ts
@@ -25,7 +25,7 @@ export const userMiddleware: MiddlewareHandler = async function (ctx, next) {
// There was an Origin check here until 2026-08-13. It is gone with the rest of origin validation —
// it had defaulted to off, so it ran on no real install. A valid token is required below, and the
- // capability gate in hono.ts confines a non-owner to what their role grants.
+ // permission gate in hono.ts confines a non-owner to what their role grants.
try {
// Both credentials resolve here — see auth-token.ts. Everything below applies to a session JWT only:
diff --git a/src/servers/api/activity/progress.ts b/src/servers/api/activity/progress.ts
index 877ce51e..cf65903a 100644
--- a/src/servers/api/activity/progress.ts
+++ b/src/servers/api/activity/progress.ts
@@ -1,6 +1,6 @@
// Shared structured-progress contract for the Activity feature.
//
-// Any capability that wants live progress bars / phase chips (instead of the UI regexing prose) appends
+// Any permission that wants live progress bars / phase chips (instead of the UI regexing prose) appends
// NDJSON lines of this shape to its output/log file, alongside whatever human text it already writes:
//
// {"job":"dearly-devoted","cap":"split-audiobook","phase":"transcription","status":"running","pct":9,"detail":"0:50:00 / 9:28:24","ts":1730000000000}
@@ -8,7 +8,7 @@
//
// The parser is deliberately tolerant: a line is treated as structured progress iff it JSON-parses to
// an object carrying a `phase` or `status`; everything else is passed through as a raw log line. So a
-// capability can dual-write (human lines + NDJSON) and both render correctly.
+// permission can dual-write (human lines + NDJSON) and both render correctly.
export type ProgressLine = {
job?: string;
diff --git a/src/servers/api/agent-handoff/router.ts b/src/servers/api/agent-handoff/router.ts
index 8f4bd37e..6feb73b7 100644
--- a/src/servers/api/agent-handoff/router.ts
+++ b/src/servers/api/agent-handoff/router.ts
@@ -7,7 +7,7 @@ import { logger } from '../chat/logger';
* The door an AGENT knocks on — not a human, and not a browser.
*
* This is mounted above the account gate, so it does not carry a platform JWT and does not go through
- * `userMiddleware`. That exemption is declared and justified in `capabilities/totality.ts`; the claim
+ * `userMiddleware`. That exemption is declared and justified in `permissions/totality.ts`; the claim
* it makes is that this surface is authenticated by a per-panel bearer token instead, and that the
* token's authority is tiny by construction:
*
diff --git a/src/servers/api/agent-status/router.ts b/src/servers/api/agent-status/router.ts
index d581eadf..77092ab5 100644
--- a/src/servers/api/agent-status/router.ts
+++ b/src/servers/api/agent-status/router.ts
@@ -12,7 +12,7 @@ import { createRouter } from '../../create-router';
// there leak the owner's project directory names — but it means the answer to "why is my agent not working"
// has to live somewhere a member can reach.
//
-// So this is its own router under the `chat` capability. Same grant, no owner gate, and nothing here reports
+// So this is its own router under the `chat` permission. Same grant, no owner gate, and nothing here reports
// on anyone but the caller: two booleans about their own home. Denying it would not restrict an account, it
// would just replace an explanation with a silence.
//
diff --git a/src/servers/api/agents/agent-runner.ts b/src/servers/api/agents/agent-runner.ts
index c6348850..6d372d06 100644
--- a/src/servers/api/agents/agent-runner.ts
+++ b/src/servers/api/agents/agent-runner.ts
@@ -174,7 +174,7 @@ export async function startAgentRun(params: StartAgentRunParams): Promise r !== 'Super Admin')) {
await replaceRoleGrants(
role,
- DEFAULT_ROLE_CAPABILITIES.map((capability) => ({ capability, level: 'write' as const })),
+ // `capability` is the DATABASE's column name, renamed in the step that renames the table.
+ DEFAULT_ROLE_PERMISSIONS.map((permission) => ({ capability: permission, level: 'write' as const })),
);
}
} catch (ex) {
- console.warn('[bootstrap] could not seed default role capabilities', ex);
+ console.warn('[bootstrap] could not seed default role permissions', ex);
}
// The launch-time snapshot was taken while the user table was still empty. Without this the owner's
diff --git a/src/servers/api/auth/signin.ts b/src/servers/api/auth/signin.ts
index 9099f36e..d4a93a11 100755
--- a/src/servers/api/auth/signin.ts
+++ b/src/servers/api/auth/signin.ts
@@ -37,7 +37,7 @@ export const signinHandler: Handler = async function (ctx) {
// There used to be a check here refusing any non-owner signing in through the web or mobile platform
// origin. It is gone deliberately: onboarding members who use the platform in a browser is the point,
- // and what they can reach once inside is decided by their role's capabilities at every request. A rule
+ // and what they can reach once inside is decided by their role's permissions at every request. A rule
// that let a member hold a Gitea grant they could never sign in to use was not defence, just a wall.
const { id, name, username } = dbUser;
diff --git a/src/servers/api/chat/agent-panels-routes.ts b/src/servers/api/chat/agent-panels-routes.ts
index 4b165857..0ae3bf0b 100644
--- a/src/servers/api/chat/agent-panels-routes.ts
+++ b/src/servers/api/chat/agent-panels-routes.ts
@@ -14,9 +14,9 @@ import { OFFICER_API_URL } from '../../officer-url.mjs';
/**
* The browser's half of the address book: name a panel, look up what a panel is, rename, remove.
*
- * Mounted on the chat router rather than given its own prefix, because it is the same capability —
+ * Mounted on the chat router rather than given its own prefix, because it is the same permission —
* these routes create and name Claude sessions, which is what `chat` already grants. A new top-level
- * mount would have meant a new capability entry claiming the same authority under a second name.
+ * mount would have meant a new permission entry claiming the same authority under a second name.
*
* The agent-facing door is separate and deliberately so: `servers/api/agent-handoff/router.ts`.
*/
diff --git a/src/servers/api/chat/chat.ts b/src/servers/api/chat/chat.ts
index 67c5a09a..c80604f0 100644
--- a/src/servers/api/chat/chat.ts
+++ b/src/servers/api/chat/chat.ts
@@ -59,8 +59,8 @@ async function chatIdentity(user: { id: number; email: string }): Promise {
provider: providerId,
contextWindow: 200000,
maxTokens: 8192,
- reasoning: model?.capabilities?.reasoning ?? false,
+ reasoning: model?.permissions?.reasoning ?? false,
// Was hardcoded `false`, correctly, while nothing carried images — the composer gates on this
- // flag, so advertising `true` offered a capability that did not exist. Images are now plumbed
+ // flag, so advertising `true` offered a permission that did not exist. Images are now plumbed
// through `OpenCodeRunParams` to `opencode run --file`, so the honest answer is the model's
// own: OpenCode publishes it per model and we had never read it. Defaults to false, so a model
- // that does not declare the capability keeps the affordance hidden rather than offering it.
- images: model?.capabilities?.input?.image ?? false,
+ // that does not declare the permission keeps the affordance hidden rather than offering it.
+ images: model?.permissions?.input?.image ?? false,
});
}
}
diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts
index 9473d29a..3ae99cb0 100644
--- a/src/servers/api/plugins/router.ts
+++ b/src/servers/api/plugins/router.ts
@@ -16,7 +16,7 @@ import {
// /api/plugins — what is on this machine, what is installed, and the four verbs that change it.
//
// Owner only, in its own right. Installing a plugin mounts routes and (later) starts a process, which is
-// an administrative act however many members share the server. The capability layer covers it too; this
+// an administrative act however many members share the server. The permission layer covers it too; this
// is the belt to that braces, the same shape `/api/app-store` uses.
//
// ── This is not the app store ──
diff --git a/src/servers/api/settings/settings.ts b/src/servers/api/settings/settings.ts
index 93b2182c..017c92e6 100644
--- a/src/servers/api/settings/settings.ts
+++ b/src/servers/api/settings/settings.ts
@@ -1,6 +1,6 @@
import { createRouter } from '../../create-router';
import { getUserSettings, setUserSettings, getUserState, patchUserState } from 'officerdb';
-import { selfCapabilitiesRouter } from '../users/capabilities-routes';
+import { selfPermissionsRouter } from '../users/permissions-routes';
const DEFAULT_SETTINGS = {
chat: {
@@ -17,9 +17,9 @@ const DEFAULT_SETTINGS = {
export const settingsRouter = createRouter();
-// What the caller may reach. Under /api/user because it is a `core` capability — every account can ask
+// What the caller may reach. Under /api/user because it is a `core` permission — every account can ask
// what it is allowed to do, including an account that is allowed almost nothing.
-settingsRouter.route('/', selfCapabilitiesRouter);
+settingsRouter.route('/', selfPermissionsRouter);
// GET /settings — return user settings from DB, default if empty
settingsRouter.get('/settings', async (ctx) => {
diff --git a/src/servers/api/users/capabilities-routes.ts b/src/servers/api/users/permissions-routes.ts
similarity index 61%
rename from src/servers/api/users/capabilities-routes.ts
rename to src/servers/api/users/permissions-routes.ts
index 0d05af10..7754c610 100644
--- a/src/servers/api/users/capabilities-routes.ts
+++ b/src/servers/api/users/permissions-routes.ts
@@ -4,36 +4,36 @@ import * as errors from '@@/custom-errors';
import { isSuperAdmin } from '../../super-admin';
import { getAllRoleGrants, replaceRoleGrants, USER_ROLES } from 'officerdb';
import type { UserRole } from 'officerdb';
-import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry';
-import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize';
-import { capabilityAvailability } from '../../app-store/availability';
+import { PERMISSIONS, GRANTABLE_PERMISSIONS, PERMISSION_BY_KEY } from '../../permissions/registry';
+import { getEffectivePermissions, invalidateRoleGrants } from '../../permissions/authorize';
+import { permissionAvailability } from '../../app-store/availability';
import { pluginDockManifests } from '../../plugins/mount';
// Two audiences, deliberately split.
//
-// `/user/capabilities` answers "what may I do" for the caller, and every account may ask. The dock, the
+// `/user/permissions` answers "what may I do" for the caller, and every account may ask. The dock, the
// app registry and the route guards all read it, so it is the frontend's whole view of the permission
// model — and it must never be the frontend's ENFORCEMENT of it. Hiding a dock icon is a courtesy; the
-// 403 from the capability gate is the lock.
+// 403 from the permission gate is the lock.
//
// Everything else here is owner-only and edits the policy itself.
const ownerGate: MiddlewareHandler = async (ctx, next) => {
- if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Capability management is owner-only');
+ if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('Permission management is owner-only');
return next();
};
-/** What the caller may reach. Mounted under /api/user, which is a `core` capability, so nobody is 403'd. */
-export const selfCapabilitiesRouter = createRouter();
+/** What the caller may reach. Mounted under /api/user, which is a `core` permission, so nobody is 403'd. */
+export const selfPermissionsRouter = createRouter();
-selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
+selfPermissionsRouter.get('/permissions', async (ctx) => {
const userId = ctx.get('user').id as number;
- const { isOwner, grants } = await getEffectiveCapabilities(userId);
+ const { isOwner, grants } = await getEffectivePermissions(userId);
// What EXISTS on this server, which is a different question from what this account may use. A
- // capability the owner holds unconditionally still means nothing if its sidecar was never installed,
+ // permission the owner holds unconditionally still means nothing if its sidecar was never installed,
// and the owner is as subject to that as a member — see app-store/availability.ts.
- const { unavailable, manifests } = await capabilityAvailability();
+ const { unavailable, manifests } = await permissionAvailability();
// Plugin tiles, alongside the app store's. Two sources today because the app store still has its own
// catalogue; when it is rebuilt on the plugin system this becomes one.
const pluginManifests = await pluginDockManifests();
@@ -41,58 +41,58 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
// The owner holds everything, and says so by listing it rather than by a flag the frontend has to
// remember to special-case. One shape for both audiences means one code path in the UI.
const held = isOwner
- ? CAPABILITIES.map((c) => ({ key: c.key, level: 'write' as const }))
+ ? PERMISSIONS.map((c) => ({ key: c.key, level: 'write' as const }))
: [...grants].map(([key, level]) => ({ key, level }));
const heldKeys = new Set(held.map((h) => h.key));
// Held AND present. Two subtractions rather than one because they mean different things to the UI: a
- // capability withheld is "not yours", one whose sidecar is absent is "not here yet, install it".
+ // permission withheld is "not yours", one whose sidecar is absent is "not here yet, install it".
const usable = held.filter(({ key }) => !unavailable.has(key));
return ctx.json({
isOwner,
- capabilities: held,
- /** Capabilities the account holds whose sidecar is not installed or is disabled. */
+ permissions: held,
+ /** Permissions the account holds whose sidecar is not installed or is disabled. */
unavailable: [...unavailable].filter((key) => heldKeys.has(key)),
/**
* Dock tiles and routes belonging to installed sidecars the account may reach.
*
- * Filtered by capability here rather than in the client: a member must not be handed the manifest
+ * Filtered by permission here rather than in the client: a member must not be handed the manifest
* of a feature they cannot use, even to hide it, because "hidden in the client" is the kind of
* privacy that lasts until someone opens the network tab.
*/
- plugins: [...manifests, ...pluginManifests].filter((m) => !m.capability || heldKeys.has(m.capability)),
- // Flattened for the dock and the route guard, which care about paths rather than capability keys.
- routes: usable.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []),
+ plugins: [...manifests, ...pluginManifests].filter((m) => !m.permission || heldKeys.has(m.permission)),
+ // Flattened for the dock and the route guard, which care about paths rather than permission keys.
+ routes: usable.flatMap(({ key }) => PERMISSION_BY_KEY.get(key)?.routes ?? []),
// The complement, and the frontend genuinely needs both. "Not in `routes`" cannot distinguish a route
- // this account lacks from a route no capability claims at all — `/`, the settings shell, the sign-in
+ // this account lacks from a route no permission claims at all — `/`, the settings shell, the sign-in
// screens — and a guard that cannot tell those apart either blanks the app or guards nothing.
- // Routes of capabilities this account does not hold, PLUS those whose sidecar is not installed. The
+ // Routes of permissions this account does not hold, PLUS those whose sidecar is not installed. The
// guard treats both the same — there is nothing to show — while `unavailable` above lets the UI
// explain the second case as something the owner can fix by installing it.
- deniedRoutes: CAPABILITIES.filter((c) => !heldKeys.has(c.key) || unavailable.has(c.key)).flatMap(
+ deniedRoutes: PERMISSIONS.filter((c) => !heldKeys.has(c.key) || unavailable.has(c.key)).flatMap(
(c) => c.routes ?? [],
),
});
});
/** Policy administration. Owner-only, mounted under /api/users. */
-export const capabilityAdminRouter = createRouter();
+export const permissionAdminRouter = createRouter();
-capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
+permissionAdminRouter.get('/permissions', ownerGate, async (ctx) => {
// Only what this server can actually do RIGHT NOW.
//
// The same subtraction the dock already makes, applied to the granting UI — which was showing all
- // fourteen app capabilities on a fresh install where none of their sidecars existed. Offering to grant
+ // fourteen app permissions on a fresh install where none of their sidecars existed. Offering to grant
// Photos on a machine with no Immich is not a permission decision, it is a menu of things that would
// 403 for a different reason than the owner thinks.
//
- // Fail open on a degraded read: `capabilityAvailability` returns an empty `unavailable` set when it
+ // Fail open on a degraded read: `permissionAvailability` returns an empty `unavailable` set when it
// cannot see install state, so the list falls back to everything rather than to nothing. An owner whose
// Permissions screen emptied itself because one query failed would reasonably conclude the feature broke.
- const { unavailable } = await capabilityAvailability();
+ const { unavailable } = await permissionAvailability();
- const describe = (c: (typeof GRANTABLE_CAPABILITIES)[number]) => ({
+ const describe = (c: (typeof GRANTABLE_PERMISSIONS)[number]) => ({
key: c.key,
label: c.label,
description: c.description,
@@ -104,34 +104,44 @@ capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
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.
- capabilities: GRANTABLE_CAPABILITIES.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
// database refuses a row for that role.
roles: USER_ROLES.filter((r) => r !== 'Super Admin'),
- grants: await getAllRoleGrants(),
+ // Mapped rather than passed through: `capability` is the DATABASE's column name, and the wire should
+ // not leak it. Doing it here means renaming the column changes nothing any client can see — and the
+ // round trip below already expects `permission`, so passing the row straight out left the screen
+ // reading `grant.permission` on an object that only had `grant.capability`. Every role rendered as
+ // holding nothing, with no error anywhere.
+ grants: (await getAllRoleGrants()).map(({ role, capability, level }) => ({
+ role,
+ permission: capability,
+ level,
+ })),
});
});
-capabilityAdminRouter.put('/capabilities/:role', 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}'`);
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 raw = body?.grants;
- if (!Array.isArray(raw)) throw errors.BAD_REQUEST('Expected { grants: [{ capability, level }] }');
+ if (!Array.isArray(raw)) throw errors.BAD_REQUEST('Expected { grants: [{ permission, level }] }');
+ // `capability` is the DATABASE's column name; it becomes `permission` when the table is renamed.
const grants: { capability: string; level: 'read' | 'write' }[] = [];
for (const entry of raw) {
- const { capability, level } = (entry ?? {}) as { capability?: unknown; level?: unknown };
- if (typeof capability !== 'string') throw errors.BAD_REQUEST('Each grant needs a capability key');
- if (level !== 'read' && level !== 'write') throw errors.BAD_REQUEST(`Bad level for '${capability}'`);
+ const { permission, level } = (entry ?? {}) as { permission?: unknown; level?: unknown };
+ if (typeof permission !== 'string') throw errors.BAD_REQUEST('Each grant needs a permission key');
+ if (level !== 'read' && level !== 'write') throw errors.BAD_REQUEST(`Bad level for '${permission}'`);
- // The registry is the authority on what a capability key means, which is why the column has no CHECK.
+ // The registry is the authority on what a permission key means, which is why the column has no CHECK.
// This is where that authority is applied — rejecting a name nothing defines, and refusing to store a
// grant the resolver would drop on read anyway.
- const known = CAPABILITY_BY_KEY.get(capability);
- if (!known) throw errors.BAD_REQUEST(`Unknown capability '${capability}'`);
+ const known = PERMISSION_BY_KEY.get(permission);
+ if (!known) throw errors.BAD_REQUEST(`Unknown permission '${permission}'`);
if (known.kind !== 'app' && known.kind !== 'confined') {
throw errors.BAD_REQUEST(
known.kind === 'execution'
@@ -139,12 +149,13 @@ capabilityAdminRouter.put('/capabilities/:role', ownerGate, async (ctx) => {
: `${known.label} is not grantable`,
);
}
- grants.push({ capability, level });
+ // `capability` is the DATABASE's column name until the table rename.
+ grants.push({ capability: permission, level });
}
await replaceRoleGrants(role, grants);
// The cache's entire invalidation contract, discharged here. Adding a second writer means adding a
- // second call to this — see the note on grantCache in capabilities/authorize.ts.
+ // second call to this — see the note on grantCache in permissions/authorize.ts.
invalidateRoleGrants(role);
return ctx.json({ role, grants });
diff --git a/src/servers/api/users/users-router.ts b/src/servers/api/users/users-router.ts
index ba0114a4..9b30b091 100644
--- a/src/servers/api/users/users-router.ts
+++ b/src/servers/api/users/users-router.ts
@@ -8,7 +8,7 @@ import { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './ma
import { createUserHandler } from './create-user';
import { provisionLinuxHandler } from './provision-linux-route';
import { resetUserPasswordHandler } from './reset-user-password';
-import { capabilityAdminRouter } from './capabilities-routes';
+import { permissionAdminRouter } from './permissions-routes';
export const usersRouter = createRouter();
usersRouter.use(originMiddleware);
@@ -16,7 +16,7 @@ usersRouter.use(originMiddleware);
// Self-update. Any signed-in account may change its own name, username and avatar.
usersRouter.put('/', updateUserHandler);
-// Everything below manages OTHER accounts and is the owner's alone. The global capability gate in
+// Everything below manages OTHER accounts and is the owner's alone. The global permission gate in
// hono.ts already refuses a non-owner here — `user-admin` is `kind: 'admin'`, so it is
// not grantable — but that router-level rule cannot see the one exception beside it: `PUT /` is
// declared `selfService` so every account can edit its own profile. This gate is what keeps that
@@ -37,5 +37,5 @@ usersRouter.post('/:id/provision-linux', ownerGate, provisionLinuxHandler);
usersRouter.post('/:id/password', ownerGate, resetUserPasswordHandler);
usersRouter.delete('/:id', ownerGate, deleteUserHandler);
-// Which capabilities each role holds. Owner-gated inside its own router.
-usersRouter.route('/', capabilityAdminRouter);
+// Which permissions each role holds. Owner-gated inside its own router.
+usersRouter.route('/', permissionAdminRouter);
diff --git a/src/servers/app-store/availability.ts b/src/servers/app-store/availability.ts
index fa775a7e..b44d8812 100644
--- a/src/servers/app-store/availability.ts
+++ b/src/servers/app-store/availability.ts
@@ -4,9 +4,9 @@ import { CATALOGUE, type CatalogueEntry } from './catalogue';
// Which features actually EXIST on this server right now — as opposed to which the account is permitted
// to use.
//
-// ── Why this is separate from capabilities ──
+// ── Why this is separate from permissions ──
//
-// They answer different questions and combining them would get the owner wrong. A capability asks "may
+// They answer different questions and combining them would get the owner wrong. A permission asks "may
// this account use Photos"; the owner bypasses that entirely and always may. Installation asks "is there
// a Photos on this machine at all", and the owner is as subject to it as anyone — installing nothing
// leaves nothing to use.
@@ -17,20 +17,20 @@ import { CATALOGUE, type CatalogueEntry } from './catalogue';
//
// ── Why it is computed here and not in the client ──
//
-// The dock already reads one list from `/capabilities`. Making it read a second and intersect the two
+// The dock already reads one list from `/permissions`. Making it read a second and intersect the two
// puts the rule in the UI, where a member's dock and an owner's dock can drift apart, and where a
// third-party plugin would have to be taught about it. Subtracting server-side keeps one answer.
/**
- * Capability key → the sidecar that has to be installed for it to mean anything.
+ * Permission key → the sidecar that has to be installed for it to mean anything.
*
- * Includes `alsoServes`, because one sidecar can back more than one capability: Headscale serves both the
+ * Includes `alsoServes`, because one sidecar can back more than one permission: Headscale serves both the
* owner's tailnet administration and a member enrolling their own device, and only listing the first left the
* second looking available on a machine that had no Headscale.
*/
-const CAPABILITY_TO_SIDECAR = new Map(
+const PERMISSION_TO_SIDECAR = new Map(
CATALOGUE.flatMap((entry) =>
- [entry.capability, ...(entry.alsoServes ?? [])].filter((key): key is string => !!key).map((key) => [key, entry.id]),
+ [entry.permission, ...(entry.alsoServes ?? [])].filter((key): key is string => !!key).map((key) => [key, entry.id]),
) as Array<[string, string]>,
);
@@ -38,17 +38,17 @@ export type Availability = {
/**
* UI manifests of the sidecars that ARE usable — what the dock should show beyond the baseline.
*
- * Sent with the capability answer rather than fetched separately so the dock has one source. Two
+ * Sent with the permission answer rather than fetched separately so the dock has one source. Two
* requests would mean two moments, and a dock rendered between them shows either a tile for something
* uninstalled or nothing for something installed.
*/
- manifests: Array<{ sidecarId: string; capability: string | null } & NonNullable>;
- /** Capability keys whose sidecar is not installed, or is installed but disabled. */
+ manifests: Array<{ sidecarId: string; permission: string | null } & NonNullable>;
+ /** Permission keys whose sidecar is not installed, or is installed but disabled. */
unavailable: Set;
/**
* True when install state could not be read.
*
- * The caller then subtracts NOTHING. Same reasoning as `useCapabilities` failing open: a member seeing
+ * The caller then subtracts NOTHING. Same reasoning as `usePermissions` failing open: a member seeing
* an icon that leads to an unavailable screen is a bad minute, while an owner whose whole dock vanished
* because a query failed is an incident. Absence of evidence is not evidence of absence.
*/
@@ -56,13 +56,13 @@ export type Availability = {
};
/**
- * What is missing on this server, by capability key.
+ * What is missing on this server, by permission key.
*
* A sidecar that is installed but DISABLED counts as unavailable, deliberately. Disable stops the
* process and its container, so the feature genuinely does not work — leaving its icon in place would
* make disable look broken rather than effective.
*/
-export async function capabilityAvailability(): Promise {
+export async function permissionAvailability(): Promise {
const unavailable = new Set();
let installs;
@@ -70,7 +70,7 @@ export async function capabilityAvailability(): Promise {
installs = await listSidecarInstalls();
} catch {
// Degraded: subtract nothing, and offer no manifests. The dock keeps its baseline rather than
- // guessing, which is the same fail-open posture as useCapabilities.
+ // guessing, which is the same fail-open posture as usePermissions.
return { unavailable, manifests: [], degraded: true };
}
@@ -78,13 +78,13 @@ export async function capabilityAvailability(): Promise {
installs.filter((row) => row.status === 'installed' && row.enabled).map((row) => row.sidecarId),
);
- for (const [capability, sidecarId] of CAPABILITY_TO_SIDECAR) {
- if (!usable.has(sidecarId)) unavailable.add(capability);
+ for (const [permission, sidecarId] of PERMISSION_TO_SIDECAR) {
+ if (!usable.has(sidecarId)) unavailable.add(permission);
}
const manifests = CATALOGUE.filter((e) => e.ui && usable.has(e.id)).map((e) => ({
sidecarId: e.id,
- capability: e.capability,
+ permission: e.permission,
...e.ui!,
}));
diff --git a/src/servers/app-store/catalogue.test.ts b/src/servers/app-store/catalogue.test.ts
index 995e65ea..9609f7ab 100644
--- a/src/servers/app-store/catalogue.test.ts
+++ b/src/servers/app-store/catalogue.test.ts
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { CATALOGUE, byId } from './catalogue';
-import { CAPABILITIES } from '../capabilities/registry';
+import { PERMISSIONS } from '../permissions/registry';
// The catalogue is a hand-written list describing machinery that lives elsewhere, which is the shape of
// thing that rots silently. These tests pin it to what it claims to agree with.
@@ -80,14 +80,14 @@ describe('entries are internally coherent', () => {
});
});
-describe('capabilities it claims to back', () => {
- it('names a capability that exists, or explicitly none', () => {
+describe('permissions it claims to back', () => {
+ it('names a permission that exists, or explicitly none', () => {
// `null` is a real answer — notify has no surface of its own — but a WRONG key would silently
// detach the store entry from the permission that governs the feature.
- const keys = new Set(CAPABILITIES.map((c) => c.key));
+ const keys = new Set(PERMISSIONS.map((c) => c.key));
for (const entry of CATALOGUE) {
- if (entry.capability === null) continue;
- expect(keys).toContain(entry.capability);
+ if (entry.permission === null) continue;
+ expect(keys).toContain(entry.permission);
}
});
});
@@ -148,13 +148,13 @@ describe('the UI manifest each sidecar carries', () => {
}
});
- it('claims routes that the capability registry agrees it owns', () => {
+ it('claims routes that the permission registry agrees it owns', () => {
// The manifest drives the dock; the registry drives the server-side guard. If they disagree, a tile
// appears for a route the account is refused — or worse, a route is guarded by nothing.
- const byKey = new Map(CAPABILITIES.map((c) => [c.key, c]));
+ const byKey = new Map(PERMISSIONS.map((c) => [c.key, c]));
for (const entry of CATALOGUE) {
- if (!entry.ui || !entry.capability) continue;
- const declared = byKey.get(entry.capability)?.routes ?? [];
+ if (!entry.ui || !entry.permission) continue;
+ const declared = byKey.get(entry.permission)?.routes ?? [];
for (const route of entry.ui.routes) expect(declared).toContain(route);
}
});
diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts
index 89146b7e..b60a5efc 100644
--- a/src/servers/app-store/catalogue.ts
+++ b/src/servers/app-store/catalogue.ts
@@ -80,7 +80,7 @@ export type UiManifest = {
/**
* Every frontend route this sidecar owns, `rootRoute` included.
*
- * Separate from `rootRoute` because a feature can own more than one path — the capability registry
+ * Separate from `rootRoute` because a feature can own more than one path — the permission registry
* already lists `/caldav` and `/dav` together — and the guard needs all of them while the dock needs
* exactly one.
*/
@@ -108,18 +108,18 @@ export type CatalogueEntry = {
/** Which of the three shapes this sidecar supports, in the order the UI should offer them. */
modes: InstallMode[];
/**
- * The capability this sidecar backs, from `capabilities/registry.ts`. Null where the sidecar has no
+ * The permission this sidecar backs, from `permissions/registry.ts`. Null where the sidecar has no
* user-facing surface of its own (notify produces notifications for other features).
*
* This is also the key the sidecar's dock manifest is filtered by, which is why it is one value and not a
* list — a tile belongs to one feature.
*/
- capability: string | null;
+ permission: string | null;
/**
- * Other capabilities that stop working when this sidecar is absent, for availability only.
+ * Other permissions that stop working when this sidecar is absent, for availability only.
*
* Headscale is the case: one sidecar serves both `headscale` (administering the tailnet, owner-only) and
- * `vpn` (a member enrolling their own device). With only `capability` to go on, `vpn` was never subtracted,
+ * `vpn` (a member enrolling their own device). With only `permission` to go on, `vpn` was never subtracted,
* so the Permissions screen offered it on a machine with no Headscale at all — a grant that would have
* produced a refusal the owner could not account for.
*/
@@ -171,7 +171,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'Your Immich library — browse, search, upload from the phone',
members: 'accounts',
modes: ['existing', 'provisioned'],
- capability: 'photos',
+ permission: 'photos',
composeTemplate: 'immich',
existingFields: [
{ key: 'url', label: 'Immich URL', type: 'url', required: true, placeholder: 'https://photos.example.com' },
@@ -192,7 +192,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'Films and shows, with a player that handles direct, HLS and progressive',
members: 'accounts',
modes: ['existing', 'provisioned'],
- capability: 'jellyfin',
+ permission: 'jellyfin',
composeTemplate: 'jellyfin',
existingFields: [
{ key: 'url', label: 'Jellyfin URL', type: 'url', required: true, placeholder: 'https://jellyfin.example.com' },
@@ -207,7 +207,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'Quick notes, tagged and searchable',
members: 'accounts',
modes: ['existing', 'provisioned'],
- capability: 'memos',
+ permission: 'memos',
composeTemplate: 'memos',
existingFields: [
{ key: 'url', label: 'Memos URL', type: 'url', required: true },
@@ -222,7 +222,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'InvoiceShelf — clients, estimates and invoices',
members: 'accounts',
modes: ['existing', 'provisioned'],
- capability: 'invoices',
+ permission: 'invoices',
composeTemplate: 'invoiceshelf',
existingFields: [
{ key: 'url', label: 'InvoiceShelf URL', type: 'url', required: true },
@@ -237,16 +237,16 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'Vaultwarden — passwords, reachable by the Bitwarden apps',
members: 'invite',
modes: ['existing', 'provisioned'],
- // No capability entry exists for this one, and the reason is about CREDENTIALS, not routing.
+ // No permission entry exists for this one, and the reason is about CREDENTIALS, not routing.
//
// Every request still goes through us: `/api/vault` is mounted on vaultRouter and forwarded by the
// officer-vault sidecar to Vaultwarden. The Bitwarden clients never reach Vaultwarden directly.
//
// What they do NOT carry is a platform JWT — they present their own Vaultwarden bearer token — so
- // `userMiddleware` would 401 them and a capability lookup would have no account to resolve. Hence
+ // `userMiddleware` would 401 them and a permission lookup would have no account to resolve. Hence
// `/vault` sits in EXEMPT_API_PREFIXES, gated by origin scoping and Vaultwarden's own auth instead.
// The install still governs whether the sidecar runs at all.
- capability: null,
+ permission: null,
composeTemplate: 'vaultwarden',
existingFields: [{ key: 'url', label: 'Vaultwarden URL', type: 'url', required: true }],
},
@@ -264,7 +264,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'Torrents, with the daemon Officer talks to over RPC',
members: 'none',
modes: ['existing', 'provisioned'],
- capability: 'transmission',
+ permission: 'transmission',
composeTemplate: 'transmission',
existingFields: [
{
@@ -306,7 +306,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'slskd — search and download from the Soulseek network',
members: 'none',
modes: ['existing', 'provisioned'],
- capability: 'soulseek',
+ permission: 'soulseek',
composeTemplate: 'slskd',
existingFields: [
{ key: 'url', label: 'slskd URL', type: 'url', required: true },
@@ -327,7 +327,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'Radicale — calendars and contacts over CalDAV/CardDAV',
members: 'accounts',
modes: ['existing', 'provisioned'],
- capability: 'calendar',
+ permission: 'calendar',
composeTemplate: 'radicale',
existingFields: [{ key: 'url', label: 'CalDAV URL', type: 'url', required: true }],
},
@@ -347,7 +347,7 @@ export const CATALOGUE: CatalogueEntry[] = [
// runs — on this machine, on another, or hosted. Provisioning one would mean owning the migration,
// backup and upgrade story for a service that is nobody's side feature.
modes: ['existing'],
- capability: 'gitea',
+ permission: 'gitea',
existingFields: [
{ key: 'url', label: 'Gitea URL', type: 'url', required: true, placeholder: 'https://gitea.example.com' },
{
@@ -381,7 +381,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'Your IMAP accounts, synced and searchable',
members: 'none',
modes: ['config'],
- capability: 'email',
+ permission: 'email',
// Deliberately empty: accounts are added from /email, which already has a working multi-account
// form. Duplicating it here would be a second place to maintain the same credentials.
configFields: [],
@@ -389,7 +389,7 @@ export const CATALOGUE: CatalogueEntry[] = [
// Music was here until 2026-08-15, when it became `plugins/music/`. Removing it was not tidying — it
// was the headscale bug above, exactly, and it would have fired on the first install.
//
- // `capabilityAvailability` reads `sidecar_installs`, and a PLUGIN never gets a row there: its install
+ // `permissionAvailability` reads `sidecar_installs`, and a PLUGIN never gets a row there: its install
// state lives in `plugin_installs`. So `music` would have been permanently `unavailable`, which puts
// `/music` into `deniedRoutes` — the screen blank and the dock tile withheld on a server where the
// plugin was installed, enabled and healthy. The same shape as headscale, found by reading that note
@@ -410,7 +410,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'Bitcoin and Lightning, with keys held by the sidecar alone',
members: 'none',
modes: ['config'],
- capability: 'wallet',
+ permission: 'wallet',
configFields: [],
},
{
@@ -420,7 +420,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'Push to your phone when a job finishes or a turn needs you',
members: 'none',
modes: ['config'],
- capability: 'notify',
+ permission: 'notify',
configFields: [],
},
{
@@ -431,7 +431,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'Mirror this machine’s display in the browser',
members: 'none',
modes: ['config'],
- capability: 'desktop',
+ permission: 'desktop',
// x11vnc against an Xorg display. There is nothing to mirror on a headless box or on macOS, so the
// store should say so rather than install something that starts and immediately fails.
requires: 'linux-display',
diff --git a/src/servers/app-store/paths.ts b/src/servers/app-store/paths.ts
index cd106f80..6f1c75af 100644
--- a/src/servers/app-store/paths.ts
+++ b/src/servers/app-store/paths.ts
@@ -11,7 +11,7 @@ import { OFFICER_ROOT } from '../data-path';
// platform/ the app
// data/ DATA_PATH — managed homes, attachments, job logs
// dockers/ services the app store provisioned <- this file
-// capabilities/ the file-based item store
+// permissions/ the file-based item store
//
// One root, everything under it, nothing scattered. `OFFICER_ROOT` is derived from `DATA_PATH` rather
// than configured separately, because a second environment variable that must agree with the first is a
diff --git a/src/servers/auth-token.ts b/src/servers/auth-token.ts
index 0e62be03..1fe68ab9 100644
--- a/src/servers/auth-token.ts
+++ b/src/servers/auth-token.ts
@@ -9,13 +9,13 @@ import { verify } from './jwt';
// ── One resolver, two doors ──
//
// Identity is decided in TWO independent middlewares: `userMiddleware`, which every protected router
-// mounts, and `capabilityGateMiddleware`, which runs globally in hono.ts and re-verifies the token itself
+// mounts, and `permissionGateMiddleware`, which runs globally in hono.ts and re-verifies the token itself
// because it must also cover routes that never mount `userMiddleware`. They have to agree about who a
// caller is, and the way they stop agreeing is somebody teaching one of them a credential format the
// other has never heard of — the second door would then see an unrecognisable token, resolve nobody, and
// wave the request through its account backstop.
//
-// That is not a hypothetical failure here: the capability totality check exists because a Member was
+// That is not a hypothetical failure here: the permission totality check exists because a Member was
// 403ing on GET /api/tasks and opening /api/tasks/pipeline/ws in the same minute. So `resolveAuthToken`
// is the only function that turns a bearer string into a caller, and both doors call it.
diff --git a/src/servers/data-path.ts b/src/servers/data-path.ts
index 18f8126b..55976fb0 100644
--- a/src/servers/data-path.ts
+++ b/src/servers/data-path.ts
@@ -41,7 +41,7 @@ export const OFFICER_ITEMS_DIR = join(OFFICER_ROOT, 'capabilities');
/**
* Refuse to boot when the working directory is not the platform repo.
*
- * Same posture as `assertCapabilityTotality` and `assertSecretsClosed`: a prerequisite that silently
+ * Same posture as `assertPermissionTotality` and `assertSecretsClosed`: a prerequisite that silently
* not holding is worse than one that fails. Every path in this file hangs off `resolve(process.cwd(), '..')`,
* so a process started from the wrong directory does not error — it computes a plausible root somewhere
* else and writes managed homes, capabilities and agent runs into it. The install looks empty and the
diff --git a/src/servers/hono.ts b/src/servers/hono.ts
index 8e8a6388..9e3d5df7 100644
--- a/src/servers/hono.ts
+++ b/src/servers/hono.ts
@@ -48,7 +48,7 @@ import { integrationsRouter, googleCallbackHandler } from './api/integrations/in
import { queueRouter } from './api/queue/queue';
// import { emailRouter } from './api/email/router';
// The browser relay is switched off — see server.tsx. Restoring this mount means restoring the
-// registry's claim on '/browser' in the same commit, or assertCapabilityTotality refuses to boot.
+// registry's claim on '/browser' in the same commit, or assertPermissionTotality refuses to boot.
// import { browserRouter } from './api/browser/router';
// import { desktopRouter } from './api/desktop/rest';
import { bugReportRouter } from './api/bug-report/bug-report';
@@ -56,7 +56,7 @@ import { agentStatusRouter } from './api/agent-status/router';
import { chatRouter } from './api/chat/chat';
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
import { CustomError } from './custom-errors';
-import { userMiddleware, bodyParser, capabilityGateMiddleware } from './_middlewares';
+import { userMiddleware, bodyParser, permissionGateMiddleware } from './_middlewares';
export { Hono };
export { createRouter };
@@ -89,7 +89,7 @@ export type MountedPlugin = { prefix: string; router: ReturnType origin ?? '*',
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
@@ -110,8 +110,8 @@ const isDavPath = (path: string) =>
// The mount table, as DATA rather than forty statements.
//
-// The reason is the capability registry: assertCapabilityTotality refuses to boot unless every mounted
-// prefix maps to exactly one capability, and that check is only worth anything if it reads the real mount
+// The reason is the permission registry: assertPermissionTotality refuses to boot unless every mounted
+// prefix maps to exactly one permission, and that check is only worth anything if it reads the real mount
// list. A hand-copied second list would drift, and the drift would be invisible until someone tried a
// prefix nobody had gated — which is precisely how the websocket hole happened.
//
@@ -129,7 +129,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType
['/scrape', scrapeRouter],
['/upload', uploadRouter],
['/user', settingsRouter],
- ['/api-keys', apiKeysRouter], // your own keys; the `account` core capability covers it
+ ['/api-keys', apiKeysRouter], // your own keys; the `account` core permission covers it
['/dashboards', dashboardsRouter],
['/file-browser', fileBrowserRouter],
// ['/slskd', slskdRouter], // plugin — switched off 2026-08-13
@@ -161,12 +161,12 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType
// ['/desktop', desktopRouter], // plugin — switched off 2026-08-13
];
-/** Every prefix served behind the account gate. Read by the capability totality check at boot. */
+/** Every prefix served behind the account gate. Read by the permission totality check at boot. */
export const PROTECTED_API_PREFIXES: string[] = PROTECTED_MOUNTS.map(([prefix]) => prefix);
/**
- * Mounted above the account gate, and so exempt from capability checks — see EXEMPT_API_PREFIXES in
- * capabilities/totality.ts, which has to justify each one.
+ * Mounted above the account gate, and so exempt from permission checks — see EXEMPT_API_PREFIXES in
+ * permissions/totality.ts, which has to justify each one.
*/
export const UNPROTECTED_API_PREFIXES: string[] = [
'/auth',
@@ -191,7 +191,7 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H
// The authorization gate: a valid non-owner token reaches only what its role grants. Ahead of every
// router, and it re-verifies the token itself so it covers routes that never mount userMiddleware.
- app.use(capabilityGateMiddleware);
+ app.use(permissionGateMiddleware);
app.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
app.route('/api/auth', authRouter);
@@ -226,7 +226,7 @@ export function buildHonoApp(plugins: MountedPlugin[] = []): Hono<{ Variables: H
// Agent-to-agent handoff — mounted TOP-LEVEL for the same reason the vault is: the caller is a Claude
// session running a curl, and it carries a per-panel bearer token rather than a platform session JWT,
- // so userMiddleware would 401 it and a capability lookup would have no account to resolve. The token
+ // so userMiddleware would 401 it and a permission lookup would have no account to resolve. The token
// identifies exactly one agent panel and authorises exactly one action: deliver a prompt to a named
// peer on that panel's own dashboard. See servers/api/agent-handoff/router.ts.
app.route('/api/agent-handoff', agentHandoffRouter);
diff --git a/src/servers/os-user-docker.ts b/src/servers/os-user-docker.ts
index d209944d..191c6666 100644
--- a/src/servers/os-user-docker.ts
+++ b/src/servers/os-user-docker.ts
@@ -31,7 +31,7 @@ import { runAs } from './os-user';
// ── The costs, stated rather than discovered ──
//
// Each account has its own image cache, so three members pulling postgres:16 store it three times. Ports
-// below 1024 need an explicit capability grant. Both are acceptable for what this buys; neither is hidden.
+// below 1024 need an explicit permission grant. Both are acceptable for what this buys; neither is hidden.
/** Their own daemon's socket. The value `DOCKER_HOST` must point at. */
export const dockerSocketFor = (uid: number): string => `/run/user/${uid}/docker.sock`;
diff --git a/src/servers/os-user-postgres.ts b/src/servers/os-user-postgres.ts
index 32dacc1d..e0c2d367 100644
--- a/src/servers/os-user-postgres.ts
+++ b/src/servers/os-user-postgres.ts
@@ -34,7 +34,7 @@ import { osUserHome, runAs } from './os-user';
// default privileges, which for a database means PUBLIC holds CONNECT and TEMPORARY. That is why
// `ensureAppDatabaseClosed` exists and why it runs before any role is created.
// - Once connected it can NOT read an application table. Table privileges default to owner-only and
-// nothing grants to PUBLIC — `has_table_privilege('users','UPDATE')` is false. So the capability model
+// nothing grants to PUBLIC — `has_table_privilege('users','UPDATE')` is false. So the permission model
// was never reachable from here; the exposure was catalogue metadata, not data.
// - Revoking from the ROLE does not help. Postgres privileges are additive and there is no DENY, so a
// PUBLIC grant is not overridden by a role-level revoke. Revoking from PUBLIC is the only lock.
diff --git a/src/servers/os-user.ts b/src/servers/os-user.ts
index 1d91410a..d58b8d62 100644
--- a/src/servers/os-user.ts
+++ b/src/servers/os-user.ts
@@ -556,7 +556,7 @@ export async function hardenOwnerHome(): Promise<{ ok: boolean; closed: string[]
*
* `platform/.env` was 664 on this machine when this was written — world-readable, holding the JWT signing
* secret and `POSTGRES_URL`. A member with a shell could read it and mint an owner token, which would
- * leave the capability model intact and entirely bypassed.
+ * leave the permission model intact and entirely bypassed.
*
* Checked at boot rather than documented, because a prerequisite that is only written down is one that
* gets skipped. Returns the offending paths; the caller decides whether that is fatal.
@@ -599,10 +599,10 @@ export async function findReadableSecrets(projectDir: string): Promise
/**
* Refuse to boot while a secret in the project tree is readable by other accounts on this machine.
*
- * Same posture as `assertCapabilityTotality`, and for the same reason: this is a prerequisite that
+ * Same posture as `assertPermissionTotality`, and for the same reason: this is a prerequisite that
* silently not holding would make the whole feature theatre. Confirmed exploitable while testing — a
* member's shell read `platform/.env` and printed `JWT_SECRET`, which is enough to mint an owner token and
- * bypass every capability check in the codebase.
+ * bypass every permission check in the codebase.
*
* Unconditional. It was a no-op unless `OFFICER_OS_USERS` was set, which made the guarantee opt-in — and
* a security prerequisite that only holds when someone remembers a flag is not a prerequisite.
@@ -618,7 +618,7 @@ export async function assertSecretsClosed(projectDir: string): Promise {
...readable.map((p) => ` • ${p}`),
'',
'A member with a shell can read them. JWT_SECRET alone is enough to mint an owner token, which',
- 'bypasses every capability check. Fix with:',
+ 'bypasses every permission check. Fix with:',
'',
...readable.map((p) => ` chmod 600 ${p}`),
'',
diff --git a/src/servers/capabilities/authorize.ts b/src/servers/permissions/authorize.ts
similarity index 68%
rename from src/servers/capabilities/authorize.ts
rename to src/servers/permissions/authorize.ts
index 276cdd47..dabc6016 100644
--- a/src/servers/capabilities/authorize.ts
+++ b/src/servers/permissions/authorize.ts
@@ -1,13 +1,13 @@
import { getUserById, getRoleGrants } from 'officerdb';
import type { UserRole } from 'officerdb';
import {
- CAPABILITY_BY_KEY,
+ PERMISSION_BY_KEY,
CORE_CAPABILITIES,
- capabilityForApiPath,
- capabilityForWsProvider,
+ permissionForApiPath,
+ permissionForWsProvider,
isRequestAllowedAtLevel,
isSelfServiceRoute,
- type CapabilityLevel,
+ type PermissionLevel,
} from './registry';
// Resolving "may this account do this". Every deny path in the platform ends up here.
@@ -16,17 +16,17 @@ import {
//
// 1. The owner bypasses everything. isSuperAdmin is the single question asked first, and a Super Admin
// never consults the grants table — which is why the schema refuses to store a row for that role.
-// 2. Everyone else gets core capabilities plus whatever their ROLE has been granted, and nothing else.
-// An unrecognised capability, a missing row, a database error, a user who no longer exists: all deny.
+// 2. Everyone else gets core permissions plus whatever their ROLE has been granted, and nothing else.
+// An unrecognised permission, a missing row, a database error, a user who no longer exists: all deny.
//
// Fail-closed is not decoration here. This function is what stands between a Member and a shell, and the
// failure mode of a permissive default is not a bug report — it is someone else's session. Every catch in
// this file returns "no", and none of them log-and-continue.
-export type EffectiveCapabilities = {
+export type EffectivePermissions = {
isOwner: boolean;
- /** Capability key → level. Empty for an account with nothing granted; the owner's is never consulted. */
- grants: Map;
+ /** Permission key → level. Empty for an account with nothing granted; the owner's is never consulted. */
+ grants: Map;
};
// ── Grant cache ───────────────────────────────────────────────────────────────────────────────────
@@ -38,7 +38,7 @@ export type EffectiveCapabilities = {
// grants API in api/users, which calls invalidateRoleGrants on every mutation, in this same process. That
// is the entire set of writers; if a second one ever appears it has to call this too, which is why the
// cache and its invalidator live in the same file as the reader that depends on them.
-const grantCache = new Map>();
+const grantCache = new Map>();
/** Called by every path that writes a grant. Clears one role, or all of them. */
export function invalidateRoleGrants(role?: UserRole): void {
@@ -46,10 +46,10 @@ export function invalidateRoleGrants(role?: UserRole): void {
else grantCache.clear();
}
-async function grantsForRole(role: UserRole): Promise