step 1/4: the permission engine is called permissions, not capabilities

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<SelfPermissions>()` 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".
This commit is contained in:
2026-08-15 16:03:22 +00:00
parent b0fcd8b81b
commit 2e8ec845c8
59 changed files with 454 additions and 442 deletions
@@ -14,20 +14,20 @@ import { useClient } from './useClient';
// minute; an owner locked out of their own platform by a transient network error is an incident. The
// server refuses what it should refuse either way.
export type CapabilityLevel = 'read' | 'write';
export type PermissionLevel = 'read' | 'write';
export type SelfCapabilities = {
export type SelfPermissions = {
isOwner: boolean;
capabilities: { key: string; level: CapabilityLevel }[];
/** Frontend route prefixes the account holds, flattened across its capabilities. */
permissions: { key: string; level: PermissionLevel }[];
/** Frontend route prefixes the account holds, flattened across its permissions. */
routes: string[];
/**
* Route prefixes claimed by capabilities the account does NOT hold. Both lists are needed: absence from
* `routes` cannot tell a denied route from one no capability claims (`/`, the settings shell), and a
* Route prefixes claimed by permissions the account does NOT hold. Both lists are needed: absence from
* `routes` cannot tell a denied route from one no permission claims (`/`, the settings shell), and a
* guard that cannot tell those apart either blanks the app or guards nothing.
*/
deniedRoutes: string[];
/** Capabilities the account holds whose sidecar is not installed, or is installed but disabled. */
/** Permissions the account holds whose sidecar is not installed, or is installed but disabled. */
unavailable?: string[];
/**
* Dock tiles and routes of the sidecars actually installed on this server.
@@ -54,20 +54,20 @@ export type PluginManifest = {
extraTiles?: Array<{ name: string; icon?: string; image?: string; color: string; route: string }>;
};
export const CAPABILITIES_QUERY_KEY = ['self-capabilities'];
export const PERMISSIONS_QUERY_KEY = ['self-permissions'];
export function useCapabilities() {
export function usePermissions() {
const client = useClient();
const { data, isLoading, isError } = useQuery<SelfCapabilities>({
queryKey: CAPABILITIES_QUERY_KEY,
queryFn: () => client.get<SelfCapabilities>('/user/capabilities'),
const { data, isLoading, isError } = useQuery<SelfPermissions>({
queryKey: PERMISSIONS_QUERY_KEY,
queryFn: () => client.get<SelfPermissions>('/user/permissions'),
// Grants change rarely and only by an owner action, but they change the shape of the whole app when
// they do. A minute matches the `tasks` / `task-categories` caches the rest of the app uses.
staleTime: 60_000,
});
const held = useMemo(() => new Map((data?.capabilities ?? []).map((c) => [c.key, c.level])), [data]);
const held = useMemo(() => new Map((data?.permissions ?? []).map((c) => [c.key, c.level])), [data]);
// `can` and `canVisit` are memoised on `data` alone, and that matters more than it looks. A verb rebuilt
// every render gets a new identity every render, so anything putting one in a useCallback or useMemo
@@ -75,9 +75,9 @@ export function useCapabilities() {
// Jellyfin player was disabled for days by an unmount cleanup re-running mid-playback. `data` is a React
// Query value with a stable reference between fetches, so these change only when the answer does.
/** Whether the account holds a capability, optionally at write level. */
/** Whether the account holds a permission, optionally at write level. */
const can = useCallback(
(key: string, level: CapabilityLevel = 'read'): boolean => {
(key: string, level: PermissionLevel = 'read'): boolean => {
if (!data) return true; // see the note above: fail open, the server does not
if (data.isOwner) return true;
const granted = held.get(key);
@@ -118,7 +118,7 @@ export function useCapabilities() {
// Held but unavailable → the sidecar is missing. Checked against the capability that claims the route,
// which is why `unavailable` is returned as capability keys rather than routes.
const unavailable = new Set(data.unavailable ?? []);
const heldAndUnavailable = data.capabilities.some(({ key }) => unavailable.has(key));
const heldAndUnavailable = data.permissions.some(({ key }) => unavailable.has(key));
if (data.isOwner || heldAndUnavailable) return 'not-installed';
return 'not-granted';
},
@@ -127,7 +127,7 @@ export function useCapabilities() {
return {
isOwner: data?.isOwner ?? false,
capabilities: held,
permissions: held,
routes: data?.routes ?? [],
unavailable: data?.unavailable ?? [],
denialReason,
@@ -54,7 +54,7 @@ export function useAppStore() {
// the same screen.
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: STORE_KEY });
void queryClient.invalidateQueries({ queryKey: ['self-capabilities'] });
void queryClient.invalidateQueries({ queryKey: ['self-permissions'] });
};
const install = useMutation({
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities';
import { usePermissions } from 'hooks/usePermissions';
import { type TriggerConfig, groupByCategory, matchesTrigger } from './useTasks';
export type AgentSummary = {
@@ -20,7 +20,7 @@ export const useAgents = () => {
const client = useClient();
// Agents are the `items` capability — skills, tools and agents on the owner's disk — and running one
// starts a chat session, which is `chat`. Both are execution-only, so a member gets no agent submenu.
const { can } = useCapabilities();
const { can } = usePermissions();
const allowed = can('items');
const { data: agents = [] } = useQuery<AgentSummary[]>({
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities';
import { usePermissions } from 'hooks/usePermissions';
export type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
@@ -74,7 +74,7 @@ export const useTasks = () => {
// `tasks` is `kind: 'execution'`: a task run executes a script as the server owner. A member browsing
// their own files has a file browser, not a task runner — so the context menu simply has no Run Task
// submenu, and these two requests are not made. Without the guard they 403'd on every Files render.
const { can } = useCapabilities();
const { can } = usePermissions();
const allowed = can('tasks');
const { data: tasks = [] } = useQuery<TaskSummary[]>({
@@ -90,11 +90,11 @@ export function usePlugins() {
queryFn: () => client.get<{ plugins: PluginItem[]; broken: BrokenPlugin[] }>('/plugins'),
});
// Every verb invalidates the plugin list AND self-capabilities: installing a plugin can add a dock tile
// Every verb invalidates the plugin list AND self-permissions: installing a plugin can add a dock tile
// and a route the shell has to know about, so refreshing one without the other leaves the two disagreeing.
const invalidate = useCallback(() => {
queryClient.invalidateQueries({ queryKey: PLUGINS_KEY });
queryClient.invalidateQueries({ queryKey: ['self-capabilities'] });
queryClient.invalidateQueries({ queryKey: ['self-permissions'] });
}, [queryClient]);
const [steps, setSteps] = useState<string[]>([]);
@@ -8,7 +8,7 @@ import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
import { SyncBadge } from './SyncBadge';
import { useSelectedWallet } from './useSelectedWallet';
import { useCoinSelection } from './useCoinSelection';
import { useCapabilities, useUtxos, useWalletOperations } from './useWalletData';
import { usePermissions, useUtxos, useWalletOperations } from './useWalletData';
// Coin control. Works while locked: which coins exist and which are frozen is watch-only information, and
// freezing is Officer's own flag rather than anything signed.
@@ -17,7 +17,7 @@ import { useCapabilities, useUtxos, useWalletOperations } from './useWalletData'
export const CoinsView = () => {
const { walletId, isLoading } = useSelectedWallet();
const { capabilities } = useCapabilities(walletId);
const { capabilities } = usePermissions(walletId);
const supported = capabilities.includes('coinControl');
const { utxos, sync, isLoading: utxosLoading } = useUtxos(walletId, supported);
@@ -4,7 +4,7 @@ import { formatSats, formatTimestamp, truncateMiddle, PAYMENT_TONES } from './fo
import { Amount } from './Amount';
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
import { useSelectedWallet } from './useSelectedWallet';
import { useCapabilities, useChannels, usePayments, usePeers } from './useWalletData';
import { usePermissions, useChannels, usePayments, usePeers } from './useWalletData';
// The node's own view of itself: payments it has made, channels it holds, peers it is connected to.
//
@@ -14,7 +14,7 @@ import { useCapabilities, useChannels, usePayments, usePeers } from './useWallet
export const LightningView = () => {
const { walletId, isLoading } = useSelectedWallet();
const { capabilities } = useCapabilities(walletId);
const { capabilities } = usePermissions(walletId);
const hasChannels = capabilities.includes('channels');
const hasPeers = capabilities.includes('peers');
const hasPayments = capabilities.includes('lightningSend');
@@ -8,7 +8,7 @@ import { Amount, UnitToggle } from './Amount';
import { EmptyWallet } from './EmptyWallet';
import { SyncBadge } from './SyncBadge';
import { useSelectedWallet } from './useSelectedWallet';
import { useBalances, useCapabilities, useTransactions, useWalletInfo } from './useWalletData';
import { useBalances, usePermissions, useTransactions, useWalletInfo } from './useWalletData';
// The at-a-glance section: what you hold, whether the node agrees with the chain, and the last few moves.
//
@@ -32,7 +32,7 @@ function onchainHint(balances: Balances | null | undefined): string | undefined
export const OverviewView = () => {
const { wallet, walletId, isLoading } = useSelectedWallet();
const { capabilities } = useCapabilities(walletId);
const { capabilities } = usePermissions(walletId);
const { balances, sync, isLoading: balancesLoading } = useBalances(walletId);
const { info } = useWalletInfo(walletId);
const { transactions } = useTransactions(walletId, 5);
@@ -7,7 +7,7 @@ import { Amount } from './Amount';
import { CopyField } from './CopyField';
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
import { useSelectedWallet } from './useSelectedWallet';
import { errorMessage, useCapabilities, useInvoices, useReceiveAddress } from './useWalletData';
import { errorMessage, usePermissions, useInvoices, useReceiveAddress } from './useWalletData';
import { CreateInvoiceDialog } from './dialogs/CreateInvoiceDialog';
// Receiving. Works while locked — deriving an address needs the account xpub, not the seed, which is why
@@ -18,7 +18,7 @@ import { CreateInvoiceDialog } from './dialogs/CreateInvoiceDialog';
export const ReceiveView = () => {
const { walletId, isLoading } = useSelectedWallet();
const { capabilities } = useCapabilities(walletId);
const { capabilities } = usePermissions(walletId);
const canOnchain = capabilities.includes('onchainReceive');
const canLightning = capabilities.includes('lightningReceive');
@@ -15,7 +15,7 @@ import { UnlockPrompt } from './LockBadge';
import { useSelectedWallet } from './useSelectedWallet';
import { useCoinSelection } from './useCoinSelection';
import { useLockCountdown } from './useLockCountdown';
import { useBalances, useCapabilities, useFees, useUtxos, useWalletOperations } from './useWalletData';
import { useBalances, usePermissions, useFees, useUtxos, useWalletOperations } from './useWalletData';
import { PayInvoiceDialog } from './dialogs/PayInvoiceDialog';
// Sending — the only part of the app that genuinely needs an unlocked wallet.
@@ -38,7 +38,7 @@ const FEE_PRESETS: { key: keyof FeeEstimates; label: string; hint: string }[] =
export const SendView = () => {
const { wallet, walletId, isLoading } = useSelectedWallet();
const { capabilities } = useCapabilities(walletId);
const { capabilities } = usePermissions(walletId);
const canOnchain = capabilities.includes('onchainSend');
const canLightning = capabilities.includes('lightningSend');
@@ -81,7 +81,7 @@ type FormProps = { walletId: number; walletName: string };
const OnchainSendForm = ({ walletId, walletName }: FormProps) => {
const { balances } = useBalances(walletId);
const { fees } = useFees(walletId);
const { capabilities } = useCapabilities(walletId);
const { capabilities } = usePermissions(walletId);
const { selected, clear } = useCoinSelection();
const { utxos } = useUtxos(walletId, capabilities.includes('coinControl'));
const { send } = useWalletOperations(walletId);
@@ -15,7 +15,7 @@ import { Amount, UnitToggle } from './Amount';
import { LockBadge } from './LockBadge';
import { useWalletSection } from './useWalletSection';
import { useSelectedWallet } from './useSelectedWallet';
import { useBalances, useCapabilities, useWalletConfig, useWalletLifecycle } from './useWalletData';
import { useBalances, usePermissions, useWalletConfig, useWalletLifecycle } from './useWalletData';
import { CreateWalletDialog } from './dialogs/CreateWalletDialog';
// Left panel of /wallet: the balance, the wallets, the sections, the lock state.
@@ -43,7 +43,7 @@ export const WalletNav = () => {
const section = useWalletSection();
const { config } = useWalletConfig();
const { wallet, wallets, walletId, isPinned, isLoading } = useSelectedWallet();
const { capabilities } = useCapabilities(walletId);
const { capabilities } = usePermissions(walletId);
const { balances } = useBalances(walletId);
const [createOpen, setCreateOpen] = useState(false);
@@ -12,7 +12,7 @@ import { LockBadge } from './LockBadge';
import { RescanCard } from './RescanCard';
import { useSelectedWallet } from './useSelectedWallet';
import { useLockCountdown } from './useLockCountdown';
import { useCapabilities, useWalletConfig, useWalletLifecycle } from './useWalletData';
import { usePermissions, useWalletConfig, useWalletLifecycle } from './useWalletData';
import { ChangePassphraseDialog } from './dialogs/ChangePassphraseDialog';
import { ExportSeedDialog } from './dialogs/ExportSeedDialog';
import { DeleteWalletDialog } from './dialogs/DeleteWalletDialog';
@@ -25,7 +25,7 @@ export const WalletSettingsView = () => {
const navigate = useNavigate();
const { wallet, walletId, isLoading } = useSelectedWallet();
const { config } = useWalletConfig();
const { capabilities, kind } = useCapabilities(walletId);
const { capabilities, kind } = usePermissions(walletId);
const { hasSeed } = useLockCountdown(walletId);
const { activate, rename } = useWalletLifecycle();
@@ -109,7 +109,7 @@ export function useLockState(walletId: number | null) {
return { lock: query.data ?? null, dataUpdatedAt: query.dataUpdatedAt, isLoading: query.isLoading };
}
export function useCapabilities(walletId: number | null) {
export function usePermissions(walletId: number | null) {
const { get } = useClient();
const query = useQuery({
+2 -2
View File
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { useCapabilities } from 'hooks/useCapabilities';
import { usePermissions } from 'hooks/usePermissions';
type AccessPolicy = {
allowedModels: string[];
@@ -14,7 +14,7 @@ export function useAccessPolicy() {
const client = useClient();
const { isAuthenticated } = useAuth();
// Under `/server-settings`, so `server-admin`: owner only. Authentication alone was never the right gate.
const { isOwner } = useCapabilities();
const { isOwner } = usePermissions();
const queryClient = useQueryClient();
const { data: policy = { allowedModels: [] } } = useQuery<AccessPolicy>({
+2 -2
View File
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth';
import { useCapabilities } from 'hooks/useCapabilities';
import { usePermissions } from 'hooks/usePermissions';
import { useAccessPolicy } from './useAccessPolicy';
import { useSettings } from './useSettings';
import type { ModelOption } from 'officerdev';
@@ -29,7 +29,7 @@ export function useModels() {
const { isAuthenticated } = useAuth();
// `/chat` is `kind: 'execution'` — the agent runs unsandboxed as the server owner, so it is owner-only and
// never grantable. The model list is no use to anyone who cannot open a chat.
const { can } = useCapabilities();
const { can } = usePermissions();
const { data: models = [] } = useQuery<ModelOption[]>({
queryKey: ['CHAT_MODELS'],
@@ -1,7 +1,7 @@
import { useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities';
import { usePermissions } from 'hooks/usePermissions';
type AIHarnesses = {
claudeCode: boolean;
@@ -22,7 +22,7 @@ export const useServerSettings = () => {
// `/server-settings` is the `server-admin` capability: owner only, and not grantable at any level. This
// hook is mounted by shell components that every account loads, so without the guard a member's first
// paint fired a 403 at it.
const { isOwner } = useCapabilities();
const { isOwner } = usePermissions();
const { data: settings, isLoading } = useQuery({
queryKey: SETTINGS_KEY,