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:
+17
-17
@@ -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,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>({
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user