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 (
- Loading capabilities… + Loading permissions…
); } if (isError || !data) { - return
Could not load capabilities.
; + return
Could not load permissions.
; } return ( @@ -134,18 +134,18 @@ export const PermissionsSection = () => {

- {data.capabilities.map((capability) => { - const level = draft[capability.key] ?? 'none'; + {data.permissions.map((permission) => { + const level = draft[permission.key] ?? 'none'; 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}