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
+2 -2
View File
@@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'; import { useCallback, useEffect, useRef, useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router'; import { Link, useLocation, useNavigate } from 'react-router';
import { useClient } from 'hooks/useClient'; 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 { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
import { SeekBar } from 'officerdev'; import { SeekBar } from 'officerdev';
import { MusicHeart } from './MusicHeart'; import { MusicHeart } from './MusicHeart';
@@ -24,7 +24,7 @@ const MUSIC_API = '/api/music';
export const MusicPlayerHost = () => { export const MusicPlayerHost = () => {
const { token, get, put, delete: del } = useClient(); const { token, get, put, delete: del } = useClient();
const { can } = useCapabilities(); const { can } = usePermissions();
const canUseMusic = can('music'); const canUseMusic = can('music');
const navigate = useNavigate(); const navigate = useNavigate();
const { pathname } = useLocation(); const { pathname } = useLocation();
@@ -1,7 +1,7 @@
import { useMemo, useRef } from 'react'; import { useMemo, useRef } from 'react';
import { useLocation } from 'react-router'; import { useLocation } from 'react-router';
import { useDock, usePanelFullscreen } from 'officerdev'; import { useDock, usePanelFullscreen } from 'officerdev';
import { useCapabilities } from 'hooks/useCapabilities'; import { usePermissions } from 'hooks/usePermissions';
import { ErrorBoundary } from '@/components/ErrorBoundary'; import { ErrorBoundary } from '@/components/ErrorBoundary';
import { ScreenErrorFallback } from './ScreenErrorFallback'; import { ScreenErrorFallback } from './ScreenErrorFallback';
import { Background } from './Background'; import { Background } from './Background';
@@ -15,7 +15,7 @@ type DashboardLayoutProps = {
children?: React.ReactNode; children?: React.ReactNode;
}; };
export function DashboardLayout({ children }: DashboardLayoutProps) { 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 // 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 // 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 // either way — every one of these routes is refused server-side too — but an app that offers a door it
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
import { NavLink } from 'react-router'; import { NavLink } from 'react-router';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import { resolveIcon } from 'officerdev'; import { resolveIcon } from 'officerdev';
import type { PluginManifest } from 'hooks/useCapabilities'; import type { PluginManifest } from 'hooks/usePermissions';
export type DockItem = { export type DockItem = {
label: string; label: string;
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { Loader2, ListOrdered } from 'lucide-react'; import { Loader2, ListOrdered } from 'lucide-react';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities'; import { usePermissions } from 'hooks/usePermissions';
type Counts = { running: number; runningJobId: string | null; queued: number }; 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. // links to a screen they cannot open. Neither is a security problem; both are the app lying about what it is.
export const JobsIndicator = () => { export const JobsIndicator = () => {
const client = useClient(); const client = useClient();
const { can } = useCapabilities(); const { can } = usePermissions();
const allowed = can('tasks'); const allowed = can('tasks');
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 }); const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
@@ -3,7 +3,7 @@ import { RotateCw } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities'; import { usePermissions } from 'hooks/usePermissions';
type RescanResponse = { ok: boolean; counts: Record<string, number> }; type RescanResponse = { ok: boolean; counts: Record<string, number> };
@@ -14,7 +14,7 @@ const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'process
export function RescanButton() { export function RescanButton() {
const client = useClient(); const client = useClient();
const qc = useQueryClient(); const qc = useQueryClient();
const { can } = useCapabilities(); const { can } = usePermissions();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the // `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
@@ -1,5 +1,5 @@
import { Navigate, useLocation } from 'react-router'; 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 // 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="*"`. // 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 }) { export function RouteGate({ children }: { children?: React.ReactNode }) {
const { pathname } = useLocation(); const { pathname } = useLocation();
const { denialReason } = useCapabilities(); const { denialReason } = usePermissions();
// `replace`, so Back does not bounce between the denied path and home. // `replace`, so Back does not bounce between the denied path and home.
if (denialReason(pathname)) return <Navigate to="/" replace />; if (denialReason(pathname)) return <Navigate to="/" replace />;
@@ -2,7 +2,7 @@ import { useMemo, useState, useCallback, type DragEvent } from 'react';
import { X, Plus, RotateCcw } from 'lucide-react'; import { X, Plus, RotateCcw } from 'lucide-react';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useDock } from 'officerdev'; 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'; import { CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock';
type DockPillProps = { type DockPillProps = {
@@ -106,7 +106,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
export const DockSettings = () => { export const DockSettings = () => {
// Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone // 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. // 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 allDockItems = useMemo(() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)], [plugins]);
const { items, allItems, setItems, reset } = useDock(allDockItems, DEFAULT_DOCK_PATHS); const { items, allItems, setItems, reset } = useDock(allDockItems, DEFAULT_DOCK_PATHS);
const [dropTarget, setDropTarget] = useState<{ path: string; side: 'left' | 'right' } | null>(null); const [dropTarget, setDropTarget] = useState<{ path: string; side: 'left' | 'right' } | null>(null);
@@ -3,18 +3,18 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { useClient } from 'hooks/useClient'; 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 { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; 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 // 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 // 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. // role, an explicit Save, and a visible dirty state instead.
type CapabilityInfo = { type PermissionInfo = {
key: string; key: string;
label: string; label: string;
description: string; description: string;
@@ -22,11 +22,11 @@ type CapabilityInfo = {
hasPersonalWrites: boolean; 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. */ /** Grantable AND installed. What this server can currently do. */
capabilities: CapabilityInfo[]; permissions: PermissionInfo[];
roles: string[]; roles: string[];
grants: Grant[]; grants: Grant[];
@@ -43,9 +43,9 @@ export const PermissionsSection = () => {
const [draft, setDraft] = useState<Record<string, Level>>({}); const [draft, setDraft] = useState<Record<string, Level>>({});
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const { data, isLoading, isError } = useQuery<CapabilitiesResponse>({ const { data, isLoading, isError } = useQuery<PermissionsResponse>({
queryKey: PERMISSIONS_KEY, queryKey: PERMISSIONS_KEY,
queryFn: () => client.get<CapabilitiesResponse>('/users/capabilities'), queryFn: () => client.get<PermissionsResponse>('/users/permissions'),
}); });
const activeRole = role ?? data?.roles[0] ?? null; 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. // What the server currently says, for this role. The comparison baseline for the dirty state below.
const saved = useMemo(() => { const saved = useMemo(() => {
const levels: Record<string, Level> = {}; const levels: Record<string, Level> = {};
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 ?? []) { 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; return levels;
}, [data, activeRole]); }, [data, activeRole]);
@@ -72,12 +72,12 @@ export const PermissionsSection = () => {
try { try {
const grants = Object.entries(draft) const grants = Object.entries(draft)
.filter(([, level]) => level !== 'none') .filter(([, level]) => level !== 'none')
.map(([capability, level]) => ({ capability, level })); .map(([permission, level]) => ({ permission, level }));
await client.put(`/users/capabilities/${encodeURIComponent(activeRole)}`, { grants }); await client.put(`/users/permissions/${encodeURIComponent(activeRole)}`, { grants });
await queryClient.invalidateQueries({ queryKey: PERMISSIONS_KEY }); 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 // The owner may be editing their own view's inputs — and anyone already signed in needs the dock to
// catch up without a reload. // 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`); toast.success(`Saved what ${activeRole}s can reach`);
} catch (ex) { } catch (ex) {
toast.error(ex instanceof Error ? ex.message : 'Could not save'); toast.error(ex instanceof Error ? ex.message : 'Could not save');
@@ -89,12 +89,12 @@ export const PermissionsSection = () => {
if (isLoading) { if (isLoading) {
return ( return (
<div className="flex items-center gap-2 p-6 text-sm text-muted-foreground"> <div className="flex items-center gap-2 p-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" /> Loading capabilities <Loader2 className="h-4 w-4 animate-spin" /> Loading permissions
</div> </div>
); );
} }
if (isError || !data) { if (isError || !data) {
return <div className="p-6 text-sm text-destructive">Could not load capabilities.</div>; return <div className="p-6 text-sm text-destructive">Could not load permissions.</div>;
} }
return ( return (
@@ -134,18 +134,18 @@ export const PermissionsSection = () => {
</p> </p>
<div className="divide-y rounded-lg border"> <div className="divide-y rounded-lg border">
{data.capabilities.map((capability) => { {data.permissions.map((permission) => {
const level = draft[capability.key] ?? 'none'; const level = draft[permission.key] ?? 'none';
return ( return (
<div key={capability.key} className="flex items-center justify-between gap-4 p-3"> <div key={permission.key} className="flex items-center justify-between gap-4 p-3">
{/* Label only. The descriptions went because with three rows called Terminal, Chat and Files {/* 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: 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 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. */} that no longer occurs on its own. */}
<div className="min-w-0 text-sm font-medium">{capability.label}</div> <div className="min-w-0 text-sm font-medium">{permission.label}</div>
<Select <Select
value={level} value={level}
onValueChange={(value) => setDraft((prev) => ({ ...prev, [capability.key]: value as Level }))} onValueChange={(value) => setDraft((prev) => ({ ...prev, [permission.key]: value as Level }))}
> >
<SelectTrigger className="w-32 shrink-0"> <SelectTrigger className="w-32 shrink-0">
<SelectValue /> <SelectValue />
@@ -161,7 +161,7 @@ export const PermissionsSection = () => {
})} })}
</div> </div>
{/* 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 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 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 features presented as a permissions decision; the second described chat, tasks, the desktop and the
+3 -3
View File
@@ -3,7 +3,7 @@ import type { ServerWebSocket } from 'bun';
import { serve } from 'bun'; import { serve } from 'bun';
import { join } from 'node:path'; import { join } from 'node:path';
import { honoServer, PROTECTED_API_PREFIXES, UNPROTECTED_API_PREFIXES } from './servers/hono'; 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 { refreshPluginMounts, snapshotPlugins } from './servers/plugins/mount';
import { generatePluginsModule, rebuildFrontend, BUILD_DIR, SHELL_FILE } from './servers/plugins/generate'; import { generatePluginsModule, rebuildFrontend, BUILD_DIR, SHELL_FILE } from './servers/plugins/generate';
import { assertInstallLayout } from './servers/data-path'; import { assertInstallLayout } from './servers/data-path';
@@ -11,7 +11,7 @@ import { PORT } from './servers/officer-url.mjs';
import { assertSecretsClosed } from './servers/os-user'; import { assertSecretsClosed } from './servers/os-user';
import { resolveHomeDir } from './servers/user-home'; import { resolveHomeDir } from './servers/user-home';
import { resolveAuthToken } from './servers/auth-token'; import { resolveAuthToken } from './servers/auth-token';
import { isWsProviderAllowed } from './servers/capabilities/authorize'; import { isWsProviderAllowed } from './servers/permissions/authorize';
import { isTokenBlacklisted, getUserById } from 'officerdb'; import { isTokenBlacklisted, getUserById } from 'officerdb';
import { terminalWebsocket } from './servers/api/terminal/websocket'; import { terminalWebsocket } from './servers/api/terminal/websocket';
import { chatWebsocket } from './servers/api/chat/websocket'; import { chatWebsocket } from './servers/api/chat/websocket';
@@ -151,7 +151,7 @@ const handlers: Record<string, any> = {
// This throws rather than warns, and it throws HERE — before serve() — so a surface nobody has gated // 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 // 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. // 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], apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES],
wsProviders: Object.keys(handlers), wsProviders: Object.keys(handlers),
}); });
+1 -1
View File
@@ -1,7 +1,7 @@
export * from './body-parser'; export * from './body-parser';
export * from './user-middleware'; export * from './user-middleware';
export * from './origin-middleware'; export * from './origin-middleware';
export * from './capability-gate'; export * from './permission-gate';
export * from './rate-limiter'; export * from './rate-limiter';
export * from './known-users'; export * from './known-users';
export * from './auth-audit'; export * from './auth-audit';
@@ -2,8 +2,8 @@ import type { MiddlewareHandler } from 'hono';
import * as errors from '../custom-errors'; import * as errors from '../custom-errors';
import { resolveAuthToken } from '../auth-token'; import { resolveAuthToken } from '../auth-token';
import { isSuperAdmin } from '../super-admin'; import { isSuperAdmin } from '../super-admin';
import { isApiRequestAllowed } from '../capabilities/authorize'; import { isApiRequestAllowed } from '../permissions/authorize';
import { isExemptApiPath } from '../capabilities/totality'; import { isExemptApiPath } from '../permissions/totality';
// The global authorization gate: a valid NON-owner token may reach only what its ROLE has been granted. // 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. // 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 // 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. // 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 path = ctx.req.path;
const authorization = ctx.req.header('authorization'); const authorization = ctx.req.header('authorization');
+1 -1
View File
@@ -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 — // 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 // 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 { try {
// Both credentials resolve here — see auth-token.ts. Everything below applies to a session JWT only: // Both credentials resolve here — see auth-token.ts. Everything below applies to a session JWT only:
+2 -2
View File
@@ -1,6 +1,6 @@
// Shared structured-progress contract for the Activity feature. // 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: // 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} // {"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 // 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 // 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 = { export type ProgressLine = {
job?: string; job?: string;
+1 -1
View File
@@ -7,7 +7,7 @@ import { logger } from '../chat/logger';
* The door an AGENT knocks on — not a human, and not a browser. * 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 * 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 * 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: * token's authority is tiny by construction:
* *
+1 -1
View File
@@ -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" // 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. // 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 // 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. // would just replace an explanation with a silence.
// //
+1 -1
View File
@@ -174,7 +174,7 @@ export async function startAgentRun(params: StartAgentRunParams): Promise<StartA
titleRun( titleRun(
// `osUser: null` and `isOwner: true` both track `homeDir` above: it is `getOwnerHomeDir`, which // `osUser: null` and `isOwner: true` both track `homeDir` above: it is `getOwnerHomeDir`, which
// discards the email it is given, so an agent run is always the owner's — its transcript is theirs // discards the email it is given, so an agent run is always the owner's — its transcript is theirs
// and readable directly. `agents` is an `execution` capability, so no other account reaches this. // and readable directly. `agents` is an `execution` permission, so no other account reaches this.
// If agent runs ever reach members, this and line 134 have to move together. // If agent runs ever reach members, this and line 134 have to move together.
{ email: params.user.email, home: homeDir, osUser: null, isOwner: true }, { email: params.user.email, home: homeDir, osUser: null, isOwner: true },
cwd, cwd,
+1 -1
View File
@@ -14,7 +14,7 @@ import * as errors from '../../custom-errors';
// asks the same questions of that user it would have asked of a browser session. That is not an // asks the same questions of that user it would have asked of a browser session. That is not an
// escalation — it can do exactly what the password could already do — but it does mean a leaked key is a // escalation — it can do exactly what the password could already do — but it does mean a leaked key is a
// leaked account, which is why revocation is one call and last-used is recorded. Narrowing a key to a // leaked account, which is why revocation is one call and last-used is recorded. Narrowing a key to a
// subset of its holder's capabilities is the next step and wants a `scopes` column, not a change here. // subset of its holder's permissions is the next step and wants a `scopes` column, not a change here.
export const apiKeysRouter = createRouter(); export const apiKeysRouter = createRouter();
+1 -1
View File
@@ -10,7 +10,7 @@ import { byId, type InstallMode } from '../../app-store/catalogue';
// //
// Installing a sidecar starts a process on the machine, and provisioning one starts containers. That is // Installing a sidecar starts a process on the machine, and provisioning one starts containers. That is
// an administrative act however many members share the server, so this router gates on the owner in its // an administrative act however many members share the server, so this router gates on the owner in its
// own right rather than relying on the capability layer alone. `server-admin` already covers it, and // own right rather than relying on the permission layer alone. `server-admin` already covers it, and
// this is the belt to that braces — the same shape `/api/vault` uses, and for the same reason: a // this is the belt to that braces — the same shape `/api/vault` uses, and for the same reason: a
// mistake here is not a leak of data, it is arbitrary process control. // mistake here is not a leak of data, it is arbitrary process control.
// //
+4 -3
View File
@@ -1,6 +1,6 @@
import type { Handler } from 'hono'; import type { Handler } from 'hono';
import { getUserCount, createUser, replaceRoleGrants, USER_ROLES } from 'officerdb'; import { getUserCount, createUser, replaceRoleGrants, USER_ROLES } from 'officerdb';
import { DEFAULT_ROLE_CAPABILITIES } from '@@/capabilities/registry'; import { DEFAULT_ROLE_PERMISSIONS } from '@@/permissions/registry';
import argon2 from 'argon2'; import argon2 from 'argon2';
import * as errors from '@@/custom-errors'; import * as errors from '@@/custom-errors';
import { rememberUser } from '@@/_middlewares'; import { rememberUser } from '@@/_middlewares';
@@ -55,11 +55,12 @@ export const bootstrapHandler: Handler = async function (ctx) {
for (const role of USER_ROLES.filter((r) => r !== 'Super Admin')) { for (const role of USER_ROLES.filter((r) => r !== 'Super Admin')) {
await replaceRoleGrants( await replaceRoleGrants(
role, 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) { } 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 // The launch-time snapshot was taken while the user table was still empty. Without this the owner's
+1 -1
View File
@@ -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 // 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, // 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. // 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; const { id, name, username } = dbUser;
+2 -2
View File
@@ -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. * 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 * 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`. * The agent-facing door is separate and deliberately so: `servers/api/agent-handoff/router.ts`.
*/ */
+2 -2
View File
@@ -59,8 +59,8 @@ async function chatIdentity(user: { id: number; email: string }): Promise<ChatId
// by id. `handleOpenCodeChat` carried a comment calling itself owner-only; nothing enforced it. // by id. `handleOpenCodeChat` carried a comment calling itself owner-only; nothing enforced it.
// //
// So this is a stopgap, not a design: `who.isOwner` applied at every door below, until OpenCode carries an // So this is a stopgap, not a design: `who.isOwner` applied at every door below, until OpenCode carries an
// identity the way `spawnClaudeAsMember` does. Restrict here rather than at the capability layer because // identity the way `spawnClaudeAsMember` does. Restrict here rather than at the permission layer because
// `chat` is one capability covering both harnesses, and splitting it would strand the grants already issued. // `chat` is one permission covering both harnesses, and splitting it would strand the grants already issued.
// The matching refusal on the execution path is in `websocket.ts` → `handleChat`. // The matching refusal on the execution path is in `websocket.ts` → `handleChat`.
import { transcribeAudio } from '../stt/transcribe'; import { transcribeAudio } from '../stt/transcribe';
import { registerAgentPanelRoutes } from './agent-panels-routes'; import { registerAgentPanelRoutes } from './agent-panels-routes';
+5 -5
View File
@@ -40,7 +40,7 @@ export function invalidateModelCache(): void {
} }
type OpenCodeModel = { type OpenCodeModel = {
capabilities?: { input?: { image?: boolean }; reasoning?: boolean }; permissions?: { input?: { image?: boolean }; reasoning?: boolean };
}; };
type ProvidersResponse = { type ProvidersResponse = {
@@ -69,13 +69,13 @@ async function listOpenCodeModels(): Promise<ModelInfo[]> {
provider: providerId, provider: providerId,
contextWindow: 200000, contextWindow: 200000,
maxTokens: 8192, 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 // 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 // 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 // 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. // that does not declare the permission keeps the affordance hidden rather than offering it.
images: model?.capabilities?.input?.image ?? false, images: model?.permissions?.input?.image ?? false,
}); });
} }
} }
+1 -1
View File
@@ -16,7 +16,7 @@ import {
// /api/plugins — what is on this machine, what is installed, and the four verbs that change it. // /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 // 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. // is the belt to that braces, the same shape `/api/app-store` uses.
// //
// ── This is not the app store ── // ── This is not the app store ──
+3 -3
View File
@@ -1,6 +1,6 @@
import { createRouter } from '../../create-router'; import { createRouter } from '../../create-router';
import { getUserSettings, setUserSettings, getUserState, patchUserState } from 'officerdb'; import { getUserSettings, setUserSettings, getUserState, patchUserState } from 'officerdb';
import { selfCapabilitiesRouter } from '../users/capabilities-routes'; import { selfPermissionsRouter } from '../users/permissions-routes';
const DEFAULT_SETTINGS = { const DEFAULT_SETTINGS = {
chat: { chat: {
@@ -17,9 +17,9 @@ const DEFAULT_SETTINGS = {
export const settingsRouter = createRouter(); 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. // 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 // GET /settings — return user settings from DB, default if empty
settingsRouter.get('/settings', async (ctx) => { settingsRouter.get('/settings', async (ctx) => {
@@ -4,36 +4,36 @@ import * as errors from '@@/custom-errors';
import { isSuperAdmin } from '../../super-admin'; import { isSuperAdmin } from '../../super-admin';
import { getAllRoleGrants, replaceRoleGrants, USER_ROLES } from 'officerdb'; import { getAllRoleGrants, replaceRoleGrants, USER_ROLES } from 'officerdb';
import type { UserRole } from 'officerdb'; import type { UserRole } from 'officerdb';
import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry'; import { PERMISSIONS, GRANTABLE_PERMISSIONS, PERMISSION_BY_KEY } from '../../permissions/registry';
import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize'; import { getEffectivePermissions, invalidateRoleGrants } from '../../permissions/authorize';
import { capabilityAvailability } from '../../app-store/availability'; import { permissionAvailability } from '../../app-store/availability';
import { pluginDockManifests } from '../../plugins/mount'; import { pluginDockManifests } from '../../plugins/mount';
// Two audiences, deliberately split. // 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 // 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 // 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. // Everything else here is owner-only and edits the policy itself.
const ownerGate: MiddlewareHandler = async (ctx, next) => { 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(); return next();
}; };
/** What the caller may reach. Mounted under /api/user, which is a `core` capability, so nobody is 403'd. */ /** What the caller may reach. Mounted under /api/user, which is a `core` permission, so nobody is 403'd. */
export const selfCapabilitiesRouter = createRouter(); export const selfPermissionsRouter = createRouter();
selfCapabilitiesRouter.get('/capabilities', async (ctx) => { selfPermissionsRouter.get('/permissions', async (ctx) => {
const userId = ctx.get('user').id as number; 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 // 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. // 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 // 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. // catalogue; when it is rebuilt on the plugin system this becomes one.
const pluginManifests = await pluginDockManifests(); 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 // 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. // remember to special-case. One shape for both audiences means one code path in the UI.
const held = isOwner 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 })); : [...grants].map(([key, level]) => ({ key, level }));
const heldKeys = new Set(held.map((h) => h.key)); 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 // 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)); const usable = held.filter(({ key }) => !unavailable.has(key));
return ctx.json({ return ctx.json({
isOwner, isOwner,
capabilities: held, permissions: held,
/** Capabilities the account holds whose sidecar is not installed or is disabled. */ /** Permissions the account holds whose sidecar is not installed or is disabled. */
unavailable: [...unavailable].filter((key) => heldKeys.has(key)), unavailable: [...unavailable].filter((key) => heldKeys.has(key)),
/** /**
* Dock tiles and routes belonging to installed sidecars the account may reach. * 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 * 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. * privacy that lasts until someone opens the network tab.
*/ */
plugins: [...manifests, ...pluginManifests].filter((m) => !m.capability || heldKeys.has(m.capability)), 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 capability keys. // Flattened for the dock and the route guard, which care about paths rather than permission keys.
routes: usable.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []), 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 // 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. // 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 // 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. // 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 ?? [], (c) => c.routes ?? [],
), ),
}); });
}); });
/** Policy administration. Owner-only, mounted under /api/users. */ /** 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. // 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 // 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 // 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. // 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 // 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. // 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, key: c.key,
label: c.label, label: c.label,
description: c.description, description: c.description,
@@ -104,34 +104,44 @@ capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
return ctx.json({ return ctx.json({
// Only the grantable kinds are offered. `execution` and `admin` are deliberately absent: a UI that // 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. // 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 // Roles a grant may name. Super Admin is excluded: the owner bypasses this table entirely, and the
// database refuses a row for that role. // database refuses a row for that role.
roles: USER_ROLES.filter((r) => r !== 'Super Admin'), 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; const role = ctx.req.param('role') as UserRole;
if (!USER_ROLES.includes(role)) throw errors.BAD_REQUEST(`Unknown role '${role}'`); 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'); 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 body = ctx.get('body') as { grants?: unknown } | undefined;
const raw = body?.grants; 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' }[] = []; const grants: { capability: string; level: 'read' | 'write' }[] = [];
for (const entry of raw) { for (const entry of raw) {
const { capability, level } = (entry ?? {}) as { capability?: unknown; level?: unknown }; const { permission, level } = (entry ?? {}) as { permission?: unknown; level?: unknown };
if (typeof capability !== 'string') throw errors.BAD_REQUEST('Each grant needs a capability key'); 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 '${capability}'`); 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 // 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. // grant the resolver would drop on read anyway.
const known = CAPABILITY_BY_KEY.get(capability); const known = PERMISSION_BY_KEY.get(permission);
if (!known) throw errors.BAD_REQUEST(`Unknown capability '${capability}'`); if (!known) throw errors.BAD_REQUEST(`Unknown permission '${permission}'`);
if (known.kind !== 'app' && known.kind !== 'confined') { if (known.kind !== 'app' && known.kind !== 'confined') {
throw errors.BAD_REQUEST( throw errors.BAD_REQUEST(
known.kind === 'execution' known.kind === 'execution'
@@ -139,12 +149,13 @@ capabilityAdminRouter.put('/capabilities/:role', ownerGate, async (ctx) => {
: `${known.label} is not grantable`, : `${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); await replaceRoleGrants(role, grants);
// The cache's entire invalidation contract, discharged here. Adding a second writer means adding a // 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); invalidateRoleGrants(role);
return ctx.json({ role, grants }); return ctx.json({ role, grants });
+4 -4
View File
@@ -8,7 +8,7 @@ import { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './ma
import { createUserHandler } from './create-user'; import { createUserHandler } from './create-user';
import { provisionLinuxHandler } from './provision-linux-route'; import { provisionLinuxHandler } from './provision-linux-route';
import { resetUserPasswordHandler } from './reset-user-password'; import { resetUserPasswordHandler } from './reset-user-password';
import { capabilityAdminRouter } from './capabilities-routes'; import { permissionAdminRouter } from './permissions-routes';
export const usersRouter = createRouter(); export const usersRouter = createRouter();
usersRouter.use(originMiddleware); usersRouter.use(originMiddleware);
@@ -16,7 +16,7 @@ usersRouter.use(originMiddleware);
// Self-update. Any signed-in account may change its own name, username and avatar. // Self-update. Any signed-in account may change its own name, username and avatar.
usersRouter.put('/', updateUserHandler); 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 // 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 // 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 // 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.post('/:id/password', ownerGate, resetUserPasswordHandler);
usersRouter.delete('/:id', ownerGate, deleteUserHandler); usersRouter.delete('/:id', ownerGate, deleteUserHandler);
// Which capabilities each role holds. Owner-gated inside its own router. // Which permissions each role holds. Owner-gated inside its own router.
usersRouter.route('/', capabilityAdminRouter); usersRouter.route('/', permissionAdminRouter);
+17 -17
View File
@@ -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 // Which features actually EXIST on this server right now — as opposed to which the account is permitted
// to use. // 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 // 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 // a Photos on this machine at all", and the owner is as subject to it as anyone — installing nothing
// leaves nothing to use. // leaves nothing to use.
@@ -17,20 +17,20 @@ import { CATALOGUE, type CatalogueEntry } from './catalogue';
// //
// ── Why it is computed here and not in the client ── // ── 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 // 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. // 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 * 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. * 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) => 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]>, ) 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. * 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 * requests would mean two moments, and a dock rendered between them shows either a tile for something
* uninstalled or nothing for something installed. * uninstalled or nothing for something installed.
*/ */
manifests: Array<{ sidecarId: string; capability: string | null } & NonNullable<CatalogueEntry['ui']>>; manifests: Array<{ sidecarId: string; permission: string | null } & NonNullable<CatalogueEntry['ui']>>;
/** Capability keys whose sidecar is not installed, or is installed but disabled. */ /** Permission keys whose sidecar is not installed, or is installed but disabled. */
unavailable: Set<string>; unavailable: Set<string>;
/** /**
* True when install state could not be read. * 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 * 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. * 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 * 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 * process and its container, so the feature genuinely does not work — leaving its icon in place would
* make disable look broken rather than effective. * make disable look broken rather than effective.
*/ */
export async function capabilityAvailability(): Promise<Availability> { export async function permissionAvailability(): Promise<Availability> {
const unavailable = new Set<string>(); const unavailable = new Set<string>();
let installs; let installs;
@@ -70,7 +70,7 @@ export async function capabilityAvailability(): Promise<Availability> {
installs = await listSidecarInstalls(); installs = await listSidecarInstalls();
} catch { } catch {
// Degraded: subtract nothing, and offer no manifests. The dock keeps its baseline rather than // 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 }; return { unavailable, manifests: [], degraded: true };
} }
@@ -78,13 +78,13 @@ export async function capabilityAvailability(): Promise<Availability> {
installs.filter((row) => row.status === 'installed' && row.enabled).map((row) => row.sidecarId), installs.filter((row) => row.status === 'installed' && row.enabled).map((row) => row.sidecarId),
); );
for (const [capability, sidecarId] of CAPABILITY_TO_SIDECAR) { for (const [permission, sidecarId] of PERMISSION_TO_SIDECAR) {
if (!usable.has(sidecarId)) unavailable.add(capability); if (!usable.has(sidecarId)) unavailable.add(permission);
} }
const manifests = CATALOGUE.filter((e) => e.ui && usable.has(e.id)).map((e) => ({ const manifests = CATALOGUE.filter((e) => e.ui && usable.has(e.id)).map((e) => ({
sidecarId: e.id, sidecarId: e.id,
capability: e.capability, permission: e.permission,
...e.ui!, ...e.ui!,
})); }));
+10 -10
View File
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test';
import { existsSync } from 'node:fs'; import { existsSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { CATALOGUE, byId } from './catalogue'; 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 // 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. // 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', () => { describe('permissions it claims to back', () => {
it('names a capability that exists, or explicitly none', () => { 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 // `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. // 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) { for (const entry of CATALOGUE) {
if (entry.capability === null) continue; if (entry.permission === null) continue;
expect(keys).toContain(entry.capability); 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 // 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. // 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) { for (const entry of CATALOGUE) {
if (!entry.ui || !entry.capability) continue; if (!entry.ui || !entry.permission) continue;
const declared = byKey.get(entry.capability)?.routes ?? []; const declared = byKey.get(entry.permission)?.routes ?? [];
for (const route of entry.ui.routes) expect(declared).toContain(route); for (const route of entry.ui.routes) expect(declared).toContain(route);
} }
}); });
+21 -21
View File
@@ -80,7 +80,7 @@ export type UiManifest = {
/** /**
* Every frontend route this sidecar owns, `rootRoute` included. * 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 * already lists `/caldav` and `/dav` together — and the guard needs all of them while the dock needs
* exactly one. * 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. */ /** Which of the three shapes this sidecar supports, in the order the UI should offer them. */
modes: InstallMode[]; 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). * 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 * 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. * 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 * 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 * 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. * 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', summary: 'Your Immich library — browse, search, upload from the phone',
members: 'accounts', members: 'accounts',
modes: ['existing', 'provisioned'], modes: ['existing', 'provisioned'],
capability: 'photos', permission: 'photos',
composeTemplate: 'immich', composeTemplate: 'immich',
existingFields: [ existingFields: [
{ key: 'url', label: 'Immich URL', type: 'url', required: true, placeholder: 'https://photos.example.com' }, { 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', summary: 'Films and shows, with a player that handles direct, HLS and progressive',
members: 'accounts', members: 'accounts',
modes: ['existing', 'provisioned'], modes: ['existing', 'provisioned'],
capability: 'jellyfin', permission: 'jellyfin',
composeTemplate: 'jellyfin', composeTemplate: 'jellyfin',
existingFields: [ existingFields: [
{ key: 'url', label: 'Jellyfin URL', type: 'url', required: true, placeholder: 'https://jellyfin.example.com' }, { 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', summary: 'Quick notes, tagged and searchable',
members: 'accounts', members: 'accounts',
modes: ['existing', 'provisioned'], modes: ['existing', 'provisioned'],
capability: 'memos', permission: 'memos',
composeTemplate: 'memos', composeTemplate: 'memos',
existingFields: [ existingFields: [
{ key: 'url', label: 'Memos URL', type: 'url', required: true }, { key: 'url', label: 'Memos URL', type: 'url', required: true },
@@ -222,7 +222,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'InvoiceShelf — clients, estimates and invoices', summary: 'InvoiceShelf — clients, estimates and invoices',
members: 'accounts', members: 'accounts',
modes: ['existing', 'provisioned'], modes: ['existing', 'provisioned'],
capability: 'invoices', permission: 'invoices',
composeTemplate: 'invoiceshelf', composeTemplate: 'invoiceshelf',
existingFields: [ existingFields: [
{ key: 'url', label: 'InvoiceShelf URL', type: 'url', required: true }, { 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', summary: 'Vaultwarden — passwords, reachable by the Bitwarden apps',
members: 'invite', members: 'invite',
modes: ['existing', 'provisioned'], 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 // 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. // 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 // 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. // `/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. // The install still governs whether the sidecar runs at all.
capability: null, permission: null,
composeTemplate: 'vaultwarden', composeTemplate: 'vaultwarden',
existingFields: [{ key: 'url', label: 'Vaultwarden URL', type: 'url', required: true }], 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', summary: 'Torrents, with the daemon Officer talks to over RPC',
members: 'none', members: 'none',
modes: ['existing', 'provisioned'], modes: ['existing', 'provisioned'],
capability: 'transmission', permission: 'transmission',
composeTemplate: 'transmission', composeTemplate: 'transmission',
existingFields: [ existingFields: [
{ {
@@ -306,7 +306,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'slskd — search and download from the Soulseek network', summary: 'slskd — search and download from the Soulseek network',
members: 'none', members: 'none',
modes: ['existing', 'provisioned'], modes: ['existing', 'provisioned'],
capability: 'soulseek', permission: 'soulseek',
composeTemplate: 'slskd', composeTemplate: 'slskd',
existingFields: [ existingFields: [
{ key: 'url', label: 'slskd URL', type: 'url', required: true }, { 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', summary: 'Radicale — calendars and contacts over CalDAV/CardDAV',
members: 'accounts', members: 'accounts',
modes: ['existing', 'provisioned'], modes: ['existing', 'provisioned'],
capability: 'calendar', permission: 'calendar',
composeTemplate: 'radicale', composeTemplate: 'radicale',
existingFields: [{ key: 'url', label: 'CalDAV URL', type: 'url', required: true }], 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, // 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. // backup and upgrade story for a service that is nobody's side feature.
modes: ['existing'], modes: ['existing'],
capability: 'gitea', permission: 'gitea',
existingFields: [ existingFields: [
{ key: 'url', label: 'Gitea URL', type: 'url', required: true, placeholder: 'https://gitea.example.com' }, { 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', summary: 'Your IMAP accounts, synced and searchable',
members: 'none', members: 'none',
modes: ['config'], modes: ['config'],
capability: 'email', permission: 'email',
// Deliberately empty: accounts are added from /email, which already has a working multi-account // 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. // form. Duplicating it here would be a second place to maintain the same credentials.
configFields: [], 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 // 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. // 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 // 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 // `/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 // 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', summary: 'Bitcoin and Lightning, with keys held by the sidecar alone',
members: 'none', members: 'none',
modes: ['config'], modes: ['config'],
capability: 'wallet', permission: 'wallet',
configFields: [], configFields: [],
}, },
{ {
@@ -420,7 +420,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'Push to your phone when a job finishes or a turn needs you', summary: 'Push to your phone when a job finishes or a turn needs you',
members: 'none', members: 'none',
modes: ['config'], modes: ['config'],
capability: 'notify', permission: 'notify',
configFields: [], configFields: [],
}, },
{ {
@@ -431,7 +431,7 @@ export const CATALOGUE: CatalogueEntry[] = [
summary: 'Mirror this machines display in the browser', summary: 'Mirror this machines display in the browser',
members: 'none', members: 'none',
modes: ['config'], 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 // 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. // store should say so rather than install something that starts and immediately fails.
requires: 'linux-display', requires: 'linux-display',
+1 -1
View File
@@ -11,7 +11,7 @@ import { OFFICER_ROOT } from '../data-path';
// platform/ the app // platform/ the app
// data/ DATA_PATH — managed homes, attachments, job logs // data/ DATA_PATH — managed homes, attachments, job logs
// dockers/ services the app store provisioned <- this file // 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 // 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 // than configured separately, because a second environment variable that must agree with the first is a
+2 -2
View File
@@ -9,13 +9,13 @@ import { verify } from './jwt';
// ── One resolver, two doors ── // ── One resolver, two doors ──
// //
// Identity is decided in TWO independent middlewares: `userMiddleware`, which every protected router // 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 // 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 // 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 // other has never heard of — the second door would then see an unrecognisable token, resolve nobody, and
// wave the request through its account backstop. // 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` // 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. // is the only function that turns a bearer string into a caller, and both doors call it.
+1 -1
View File
@@ -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. * 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(), '..')`, * 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 * 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 * else and writes managed homes, capabilities and agent runs into it. The install looks empty and the
+11 -11
View File
@@ -48,7 +48,7 @@ import { integrationsRouter, googleCallbackHandler } from './api/integrations/in
import { queueRouter } from './api/queue/queue'; import { queueRouter } from './api/queue/queue';
// import { emailRouter } from './api/email/router'; // import { emailRouter } from './api/email/router';
// The browser relay is switched off — see server.tsx. Restoring this mount means restoring the // 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 { browserRouter } from './api/browser/router';
// import { desktopRouter } from './api/desktop/rest'; // import { desktopRouter } from './api/desktop/rest';
import { bugReportRouter } from './api/bug-report/bug-report'; 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 { chatRouter } from './api/chat/chat';
import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes'; import { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
import { CustomError } from './custom-errors'; import { CustomError } from './custom-errors';
import { userMiddleware, bodyParser, capabilityGateMiddleware } from './_middlewares'; import { userMiddleware, bodyParser, permissionGateMiddleware } from './_middlewares';
export { Hono }; export { Hono };
export { createRouter }; export { createRouter };
@@ -89,7 +89,7 @@ export type MountedPlugin = { prefix: string; router: ReturnType<typeof createRo
// Origin checking was removed on 2026-08-13, so CORS echoes back whatever Origin it is given. That is // Origin checking was removed on 2026-08-13, so CORS echoes back whatever Origin it is given. That is
// not a loosening: the check it replaced defaulted to off, so this is what every real install already // not a loosening: the check it replaced defaulted to off, so this is what every real install already
// did. The perimeter is the tailnet and the lock is a valid token on every protected route, plus the // did. The perimeter is the tailnet and the lock is a valid token on every protected route, plus the
// capability gate below. // permission gate below.
const corsMiddleware = cors({ const corsMiddleware = cors({
origin: (origin) => origin ?? '*', origin: (origin) => origin ?? '*',
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'], allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
@@ -110,8 +110,8 @@ const isDavPath = (path: string) =>
// The mount table, as DATA rather than forty statements. // The mount table, as DATA rather than forty statements.
// //
// The reason is the capability registry: assertCapabilityTotality refuses to boot unless every mounted // The reason is the permission registry: assertPermissionTotality 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 // 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 // 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. // prefix nobody had gated — which is precisely how the websocket hole happened.
// //
@@ -129,7 +129,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/scrape', scrapeRouter], ['/scrape', scrapeRouter],
['/upload', uploadRouter], ['/upload', uploadRouter],
['/user', settingsRouter], ['/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], ['/dashboards', dashboardsRouter],
['/file-browser', fileBrowserRouter], ['/file-browser', fileBrowserRouter],
// ['/slskd', slskdRouter], // plugin — switched off 2026-08-13 // ['/slskd', slskdRouter], // plugin — switched off 2026-08-13
@@ -161,12 +161,12 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
// ['/desktop', desktopRouter], // plugin — switched off 2026-08-13 // ['/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); 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 * Mounted above the account gate, and so exempt from permission checks — see EXEMPT_API_PREFIXES in
* capabilities/totality.ts, which has to justify each one. * permissions/totality.ts, which has to justify each one.
*/ */
export const UNPROTECTED_API_PREFIXES: string[] = [ export const UNPROTECTED_API_PREFIXES: string[] = [
'/auth', '/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 // 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. // 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.get('/api', (ctx) => ctx.json({ officerAPI: 'ok' }));
app.route('/api/auth', authRouter); 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 // 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, // 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 // 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. // peer on that panel's own dashboard. See servers/api/agent-handoff/router.ts.
app.route('/api/agent-handoff', agentHandoffRouter); app.route('/api/agent-handoff', agentHandoffRouter);
+1 -1
View File
@@ -31,7 +31,7 @@ import { runAs } from './os-user';
// ── The costs, stated rather than discovered ── // ── The costs, stated rather than discovered ──
// //
// Each account has its own image cache, so three members pulling postgres:16 store it three times. Ports // 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. */ /** Their own daemon's socket. The value `DOCKER_HOST` must point at. */
export const dockerSocketFor = (uid: number): string => `/run/user/${uid}/docker.sock`; export const dockerSocketFor = (uid: number): string => `/run/user/${uid}/docker.sock`;
+1 -1
View File
@@ -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 // 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. // `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 // - 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. // 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 // - 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. // PUBLIC grant is not overridden by a role-level revoke. Revoking from PUBLIC is the only lock.
+4 -4
View File
@@ -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 * `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 * 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 * 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. * gets skipped. Returns the offending paths; the caller decides whether that is fatal.
@@ -599,10 +599,10 @@ export async function findReadableSecrets(projectDir: string): Promise<string[]>
/** /**
* Refuse to boot while a secret in the project tree is readable by other accounts on this machine. * 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 * 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 * 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 * 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. * 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<void> {
...readable.map((p) => `${p}`), ...readable.map((p) => `${p}`),
'', '',
'A member with a shell can read them. JWT_SECRET alone is enough to mint an owner token, which', '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}`), ...readable.map((p) => ` chmod 600 ${p}`),
'', '',
@@ -1,13 +1,13 @@
import { getUserById, getRoleGrants } from 'officerdb'; import { getUserById, getRoleGrants } from 'officerdb';
import type { UserRole } from 'officerdb'; import type { UserRole } from 'officerdb';
import { import {
CAPABILITY_BY_KEY, PERMISSION_BY_KEY,
CORE_CAPABILITIES, CORE_CAPABILITIES,
capabilityForApiPath, permissionForApiPath,
capabilityForWsProvider, permissionForWsProvider,
isRequestAllowedAtLevel, isRequestAllowedAtLevel,
isSelfServiceRoute, isSelfServiceRoute,
type CapabilityLevel, type PermissionLevel,
} from './registry'; } from './registry';
// Resolving "may this account do this". Every deny path in the platform ends up here. // 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 // 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. // 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. // 2. Everyone else gets core permissions 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. // 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 // 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 // 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. // this file returns "no", and none of them log-and-continue.
export type EffectiveCapabilities = { export type EffectivePermissions = {
isOwner: boolean; isOwner: boolean;
/** Capability key → level. Empty for an account with nothing granted; the owner's is never consulted. */ /** Permission key → level. Empty for an account with nothing granted; the owner's is never consulted. */
grants: Map<string, CapabilityLevel>; grants: Map<string, PermissionLevel>;
}; };
// ── Grant cache ─────────────────────────────────────────────────────────────────────────────────── // ── 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 // 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 // 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. // cache and its invalidator live in the same file as the reader that depends on them.
const grantCache = new Map<UserRole, Map<string, CapabilityLevel>>(); const grantCache = new Map<UserRole, Map<string, PermissionLevel>>();
/** Called by every path that writes a grant. Clears one role, or all of them. */ /** Called by every path that writes a grant. Clears one role, or all of them. */
export function invalidateRoleGrants(role?: UserRole): void { export function invalidateRoleGrants(role?: UserRole): void {
@@ -46,10 +46,10 @@ export function invalidateRoleGrants(role?: UserRole): void {
else grantCache.clear(); else grantCache.clear();
} }
async function grantsForRole(role: UserRole): Promise<Map<string, CapabilityLevel>> { async function grantsForRole(role: UserRole): Promise<Map<string, PermissionLevel>> {
const cached = grantCache.get(role); const cached = grantCache.get(role);
if (cached) return cached; if (cached) return cached;
const grants = (await getRoleGrants(role)) as Map<string, CapabilityLevel>; const grants = (await getRoleGrants(role)) as Map<string, PermissionLevel>;
grantCache.set(role, grants); grantCache.set(role, grants);
return grants; return grants;
} }
@@ -57,15 +57,15 @@ async function grantsForRole(role: UserRole): Promise<Map<string, CapabilityLeve
/** /**
* What this account may reach, resolved from its role. * What this account may reach, resolved from its role.
* *
* Core capabilities come in at `write` unconditionally: they are the caller's own profile, dock and bug * Core permissions come in at `write` unconditionally: they are the caller's own profile, dock and bug
* reports, and a read-only version of "change your own password" is not a coherent thing to offer. * reports, and a read-only version of "change your own password" is not a coherent thing to offer.
* *
* `execution` and `admin` capabilities are dropped even if a row somehow grants them. The API refuses to * `execution` and `admin` permissions are dropped even if a row somehow grants them. The API refuses to
* write such a row, but this is the layer that has to hold if one ever exists a constraint the database * write such a row, but this is the layer that has to hold if one ever exists a constraint the database
* does not enforce is a constraint the reader must. * does not enforce is a constraint the reader must.
*/ */
export async function getEffectiveCapabilities(userId: number | undefined): Promise<EffectiveCapabilities> { export async function getEffectivePermissions(userId: number | undefined): Promise<EffectivePermissions> {
const empty: EffectiveCapabilities = { isOwner: false, grants: new Map() }; const empty: EffectivePermissions = { isOwner: false, grants: new Map() };
if (!userId) return empty; if (!userId) return empty;
try { try {
@@ -73,20 +73,20 @@ export async function getEffectiveCapabilities(userId: number | undefined): Prom
if (!user) return empty; if (!user) return empty;
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() }; if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
const grants = new Map<string, CapabilityLevel>(); const grants = new Map<string, PermissionLevel>();
for (const capability of CORE_CAPABILITIES) grants.set(capability.key, 'write'); for (const permission of CORE_CAPABILITIES) grants.set(permission.key, 'write');
// Whether the kernel can enforce a boundary for this account. `confined` capabilities are dropped // Whether the kernel can enforce a boundary for this account. `confined` permissions are dropped
// without it — see below. // without it — see below.
const hasOsAccount = !!user.osUser; const hasOsAccount = !!user.osUser;
for (const [key, level] of await grantsForRole(user.role)) { for (const [key, level] of await grantsForRole(user.role)) {
const capability = CAPABILITY_BY_KEY.get(key); const permission = PERMISSION_BY_KEY.get(key);
// Unknown key: a capability that was renamed or removed while a grant survived. Ignore it — the // Unknown key: a permission that was renamed or removed while a grant survived. Ignore it — the
// alternative is honouring a name nothing defines. // alternative is honouring a name nothing defines.
if (!capability) continue; if (!permission) continue;
// A confined capability touches the filesystem or runs a process, and is safe only because the // A confined permission touches the filesystem or runs a process, and is safe only because the
// account has its own Linux user to be confined to. Without one there is no boundary, so the grant // account has its own Linux user to be confined to. Without one there is no boundary, so the grant
// resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since // resolves to nothing rather than to the owner's home — which is what it WOULD resolve to, since
// `getOwnerHomeDir` ignores the email it is passed, always. // `getOwnerHomeDir` ignores the email it is passed, always.
@@ -94,13 +94,13 @@ export async function getEffectiveCapabilities(userId: number | undefined): Prom
// Dropped here rather than refused per-router so that one rule covers the HTTP routes, the // Dropped here rather than refused per-router so that one rule covers the HTTP routes, the
// websocket doors and the dock all at once. A member with `files` granted but no OS account sees no // websocket doors and the dock all at once. A member with `files` granted but no OS account sees no
// Files icon, gets a 403 from /api/file-browser, and cannot open the terminal socket — from this. // Files icon, gets a 403 from /api/file-browser, and cannot open the terminal socket — from this.
if (capability.kind === 'confined') { if (permission.kind === 'confined') {
if (!hasOsAccount) continue; if (!hasOsAccount) continue;
grants.set(key, level); grants.set(key, level);
continue; continue;
} }
if (capability.kind !== 'app') continue; if (permission.kind !== 'app') continue;
grants.set(key, level); grants.set(key, level);
} }
@@ -122,31 +122,31 @@ export async function isApiRequestAllowed(
method: string, method: string,
path: string, path: string,
): Promise<{ allowed: boolean; reason?: string }> { ): Promise<{ allowed: boolean; reason?: string }> {
const { isOwner, grants } = await getEffectiveCapabilities(userId); const { isOwner, grants } = await getEffectivePermissions(userId);
if (isOwner) return { allowed: true }; if (isOwner) return { allowed: true };
const capability = capabilityForApiPath(path); const permission = permissionForApiPath(path);
// Totality guarantees every mounted, non-exempt prefix maps to a capability, so reaching this branch // Totality guarantees every mounted, non-exempt prefix maps to a permission, so reaching this branch
// means either an exempt prefix (which the caller checks before us) or a path nothing serves. Deny: // means either an exempt prefix (which the caller checks before us) or a path nothing serves. Deny:
// a 403 on a route that does not exist is not a leak, and a permissive default here would be. // a 403 on a route that does not exist is not a leak, and a permissive default here would be.
if (!capability) return { allowed: false, reason: 'no capability covers this path' }; if (!permission) return { allowed: false, reason: 'no permission covers this path' };
// Checked before kind, because a self-service route acts on the caller and is therefore not the thing // Checked before kind, because a self-service route acts on the caller and is therefore not the thing
// the capability around it restricts. Exact method and path only — see the field's comment. // the permission around it restricts. Exact method and path only — see the field's comment.
if (isSelfServiceRoute(capability, method, path)) return { allowed: true }; if (isSelfServiceRoute(permission, method, path)) return { allowed: true };
if (capability.kind === 'execution') { if (permission.kind === 'execution') {
return { allowed: false, reason: `${capability.label} runs as the server owner and cannot be shared` }; return { allowed: false, reason: `${permission.label} runs as the server owner and cannot be shared` };
} }
if (capability.kind === 'admin') { if (permission.kind === 'admin') {
return { allowed: false, reason: `${capability.label} is restricted to the server owner` }; return { allowed: false, reason: `${permission.label} is restricted to the server owner` };
} }
const level = grants.get(capability.key); const level = grants.get(permission.key);
if (!level) return { allowed: false, reason: `your role does not have access to ${capability.label}` }; if (!level) return { allowed: false, reason: `your role does not have access to ${permission.label}` };
if (!isRequestAllowedAtLevel(capability, level, method, path)) { if (!isRequestAllowedAtLevel(permission, level, method, path)) {
return { allowed: false, reason: `you have read-only access to ${capability.label}` }; return { allowed: false, reason: `you have read-only access to ${permission.label}` };
} }
return { allowed: true }; return { allowed: true };
} }
@@ -154,21 +154,21 @@ export async function isApiRequestAllowed(
/** /**
* May this account open this WebSocket provider? * May this account open this WebSocket provider?
* *
* There is no method to reason about, so a socket needs the capability at any level. * There is no method to reason about, so a socket needs the permission at any level.
* *
* That rule was written for cliamp and cliamp-audio the music app's playback transport, and the only * That rule was written for cliamp and cliamp-audio the music app's playback transport, and the only
* grantable sockets there have ever been. Both left on 2026-08-15 with `plugins/music/cliamp/`, so every * grantable sockets there have ever been. Both left on 2026-08-15 with `plugins/music/cliamp/`, so every
* provider reaching here today belongs to an `execution` capability and is refused above, structurally, * provider reaching here today belongs to an `execution` permission and is refused above, structurally,
* rather than by being left off a list. The rule stays because the first plugin to own a socket needs it. * rather than by being left off a list. The rule stays because the first plugin to own a socket needs it.
*/ */
export async function isWsProviderAllowed(userId: number | undefined, provider: string): Promise<boolean> { export async function isWsProviderAllowed(userId: number | undefined, provider: string): Promise<boolean> {
const { isOwner, grants } = await getEffectiveCapabilities(userId); const { isOwner, grants } = await getEffectivePermissions(userId);
if (isOwner) return true; if (isOwner) return true;
const capability = capabilityForWsProvider(provider); const permission = permissionForWsProvider(provider);
// `confined` is admissible here as well as `app`: getEffectiveCapabilities has already dropped confined // `confined` is admissible here as well as `app`: getEffectivePermissions has already dropped confined
// grants for an account with no Linux user, so reaching this line with one in `grants` means the boundary // grants for an account with no Linux user, so reaching this line with one in `grants` means the boundary
// exists. Anything still `execution` is refused structurally, by not being in the map at all. // exists. Anything still `execution` is refused structurally, by not being in the map at all.
if (!capability || (capability.kind !== 'app' && capability.kind !== 'confined')) return false; if (!permission || (permission.kind !== 'app' && permission.kind !== 'confined')) return false;
return grants.has(capability.key); return grants.has(permission.key);
} }
@@ -1,15 +1,15 @@
import { describe, expect, test } from 'bun:test'; import { describe, expect, test } from 'bun:test';
import type { Capability } from './registry'; import type { Permission } from './registry';
import { import {
CAPABILITIES, PERMISSIONS,
CAPABILITY_BY_KEY, PERMISSION_BY_KEY,
GRANTABLE_CAPABILITIES, GRANTABLE_PERMISSIONS,
capabilityForApiPath, permissionForApiPath,
capabilityForWsProvider, permissionForWsProvider,
isRequestAllowedAtLevel, isRequestAllowedAtLevel,
isSelfServiceRoute, isSelfServiceRoute,
} from './registry'; } from './registry';
import { assertCapabilityTotality, isExemptApiPath } from './totality'; import { assertPermissionTotality, isExemptApiPath } from './totality';
// The database-backed half (authorize.ts) is exercised against the live schema; this file covers the pure // The database-backed half (authorize.ts) is exercised against the live schema; this file covers the pure
// half, which is where the rules actually live. Everything here runs without a database. // half, which is where the rules actually live. Everything here runs without a database.
@@ -17,7 +17,7 @@ import { assertCapabilityTotality, isExemptApiPath } from './totality';
// Mirrors the `handlers` map in server.tsx by hand, which is itself the drift this file keeps catching. // Mirrors the `handlers` map in server.tsx by hand, which is itself the drift this file keeps catching.
// `cliamp` and `cliamp-audio` left on 2026-08-15 with `plugins/music/cliamp/` — see below. // `cliamp` and `cliamp-audio` left on 2026-08-15 with `plugins/music/cliamp/` — see below.
const REAL_WS = ['terminal', 'chat', 'task-runner', 'pipeline', 'desktop', 'vault', 'sidecar']; const REAL_WS = ['terminal', 'chat', 'task-runner', 'pipeline', 'desktop', 'vault', 'sidecar'];
const realApi = () => [...new Set(CAPABILITIES.flatMap((c) => c.api))]; const realApi = () => [...new Set(PERMISSIONS.flatMap((c) => c.api))];
const surface = () => ({ const surface = () => ({
apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/vault', '/sidecar'], apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/vault', '/sidecar'],
wsProviders: REAL_WS, wsProviders: REAL_WS,
@@ -25,41 +25,41 @@ const surface = () => ({
describe('totality', () => { describe('totality', () => {
test('the registry covers its own declared surface', () => { test('the registry covers its own declared surface', () => {
expect(() => assertCapabilityTotality(surface())).not.toThrow(); expect(() => assertPermissionTotality(surface())).not.toThrow();
}); });
// The four ways this is allowed to fail. Each one is a real bug it exists to catch, and a check that // The four ways this is allowed to fail. Each one is a real bug it exists to catch, and a check that
// only ever passes is worth nothing — so assert that it refuses, not merely that it runs. // only ever passes is worth nothing — so assert that it refuses, not merely that it runs.
test('refuses a mounted router no capability claims', () => { test('refuses a mounted router no permission claims', () => {
const s = surface(); const s = surface();
s.apiPrefixes.push('/newthing'); s.apiPrefixes.push('/newthing');
expect(() => assertCapabilityTotality(s)).toThrow(/\/api\/newthing is mounted but no capability claims it/); expect(() => assertPermissionTotality(s)).toThrow(/\/api\/newthing is mounted but no permission claims it/);
}); });
test('refuses a served socket no capability claims — the 2026-08-06 hole', () => { test('refuses a served socket no permission claims — the 2026-08-06 hole', () => {
const s = surface(); const s = surface();
s.wsProviders = [...REAL_WS, 'newsocket']; s.wsProviders = [...REAL_WS, 'newsocket'];
expect(() => assertCapabilityTotality(s)).toThrow(/websocket provider 'newsocket' is served/); expect(() => assertPermissionTotality(s)).toThrow(/websocket provider 'newsocket' is served/);
}); });
test('refuses a claim on a router that no longer exists', () => { test('refuses a claim on a router that no longer exists', () => {
const s = surface(); const s = surface();
s.apiPrefixes = s.apiPrefixes.filter((p) => p !== '/gitea'); s.apiPrefixes = s.apiPrefixes.filter((p) => p !== '/gitea');
expect(() => assertCapabilityTotality(s)).toThrow(/claims \/api\/gitea, which nothing mounts/); expect(() => assertPermissionTotality(s)).toThrow(/claims \/api\/gitea, which nothing mounts/);
}); });
test('refuses a claim on a socket that is not served', () => { test('refuses a claim on a socket that is not served', () => {
const s = surface(); const s = surface();
s.wsProviders = REAL_WS.filter((p) => p !== 'terminal'); s.wsProviders = REAL_WS.filter((p) => p !== 'terminal');
expect(() => assertCapabilityTotality(s)).toThrow(/claims websocket 'terminal', which is not served/); expect(() => assertPermissionTotality(s)).toThrow(/claims websocket 'terminal', which is not served/);
}); });
test('no two capabilities claim the same prefix', () => { test('no two permissions claim the same prefix', () => {
const seen = new Map<string, string>(); const seen = new Map<string, string>();
for (const capability of CAPABILITIES) { for (const permission of PERMISSIONS) {
for (const prefix of capability.api) { for (const prefix of permission.api) {
expect(seen.has(prefix)).toBe(false); expect(seen.has(prefix)).toBe(false);
seen.set(prefix, capability.key); seen.set(prefix, permission.key);
} }
} }
}); });
@@ -71,35 +71,35 @@ describe('totality', () => {
}); });
}); });
describe('path → capability', () => { describe('path → permission', () => {
test('resolves a prefix and its descendants', () => { test('resolves a prefix and its descendants', () => {
expect(capabilityForApiPath('/api/gitea')?.key).toBe('gitea'); expect(permissionForApiPath('/api/gitea')?.key).toBe('gitea');
expect(capabilityForApiPath('/api/gitea/repos/a/b')?.key).toBe('gitea'); expect(permissionForApiPath('/api/gitea/repos/a/b')?.key).toBe('gitea');
}); });
test('does not match a prefix that is merely a string prefix', () => { test('does not match a prefix that is merely a string prefix', () => {
// '/api/username' must not resolve to the 'account' capability, which claims '/user'. A naive // '/api/username' must not resolve to the 'account' permission, which claims '/user'. A naive
// startsWith would. (This was '/api/musicbrainz' against 'music' until music became a plugin.) // startsWith would. (This was '/api/musicbrainz' against 'music' until music became a plugin.)
expect(capabilityForApiPath('/api/username')).toBeNull(); expect(permissionForApiPath('/api/username')).toBeNull();
}); });
test('longest prefix wins, so /dav and /caldav do not fight', () => { test('longest prefix wins, so /dav and /caldav do not fight', () => {
expect(capabilityForApiPath('/api/caldav/x')?.key).toBe('calendar'); expect(permissionForApiPath('/api/caldav/x')?.key).toBe('calendar');
expect(capabilityForApiPath('/api/dav/x')?.key).toBe('calendar'); expect(permissionForApiPath('/api/dav/x')?.key).toBe('calendar');
}); });
test('an unclaimed path resolves to nothing rather than to something permissive', () => { test('an unclaimed path resolves to nothing rather than to something permissive', () => {
expect(capabilityForApiPath('/api/not-a-thing')).toBeNull(); expect(permissionForApiPath('/api/not-a-thing')).toBeNull();
}); });
test('sockets resolve to their capability', () => { test('sockets resolve to their permission', () => {
expect(capabilityForWsProvider('terminal')?.key).toBe('terminal'); expect(permissionForWsProvider('terminal')?.key).toBe('terminal');
expect(capabilityForWsProvider('chat')?.key).toBe('chat'); expect(permissionForWsProvider('chat')?.key).toBe('chat');
expect(capabilityForWsProvider('nope')).toBeNull(); expect(permissionForWsProvider('nope')).toBeNull();
}); });
// This pinned a real hole for one day: `/api/cliamp/ws` and `/api/cliamp/audio/ws` were SERVED in // This pinned a real hole for one day: `/api/cliamp/ws` and `/api/cliamp/audio/ws` were SERVED in
// server.tsx's route table while no capability claimed them and their `handlers` entries were commented // server.tsx's route table while no permission claimed them and their `handlers` entries were commented
// out — so connecting crashed on a non-null assertion, and the boot check could not see any of it // out — so connecting crashed on a non-null assertion, and the boot check could not see any of it
// because it reads `Object.keys(handlers)` rather than the route table. // because it reads `Object.keys(handlers)` rather than the route table.
// //
@@ -108,8 +108,8 @@ describe('path → capability', () => {
// deleted, because "the route table serves nothing the handlers map lacks" is the invariant the // deleted, because "the route table serves nothing the handlers map lacks" is the invariant the
// original incident was about, and this is the cheapest place to notice it breaking again. // original incident was about, and this is the cheapest place to notice it breaking again.
test('no cliamp socket is served or claimed — the route table and handlers agree', () => { test('no cliamp socket is served or claimed — the route table and handlers agree', () => {
expect(capabilityForWsProvider('cliamp')).toBeNull(); expect(permissionForWsProvider('cliamp')).toBeNull();
expect(capabilityForWsProvider('cliamp-audio')).toBeNull(); expect(permissionForWsProvider('cliamp-audio')).toBeNull();
expect(REAL_WS).not.toContain('cliamp'); expect(REAL_WS).not.toContain('cliamp');
}); });
}); });
@@ -117,15 +117,15 @@ describe('path → capability', () => {
describe('levels', () => { describe('levels', () => {
// A FIXTURE, not a registry entry. // A FIXTURE, not a registry entry.
// //
// These tests ran against the real `music` capability until 2026-08-15, when music left with // These tests ran against the real `music` permission until 2026-08-15, when music left with
// `plugins/music/`. Re-anchoring them on whichever entry happens to have a `personal` list today only // `plugins/music/`. Re-anchoring them on whichever entry happens to have a `personal` list today only
// moves the problem to the next extraction — and it had already half-broken before that, because the // moves the problem to the next extraction — and it had already half-broken before that, because the
// moment music's `api` was commented out (2026-08-13) every path below stopped matching and three of // moment music's `api` was commented out (2026-08-13) every path below stopped matching and three of
// these four tests passed for the wrong reason: everything is refused when nothing is claimed. // these four tests passed for the wrong reason: everything is refused when nothing is claimed.
// //
// `isRequestAllowedAtLevel` is a pure function of a Capability. Handing it one states what is actually // `isRequestAllowedAtLevel` is a pure function of a Permission. Handing it one states what is actually
// under test — the RULE — rather than borrowing a feature that can leave. // under test — the RULE — rather than borrowing a feature that can leave.
const fixture: Capability = { const fixture: Permission = {
key: 'fixture', key: 'fixture',
label: 'Fixture', label: 'Fixture',
description: 'Not in the registry — a shape to exercise the level rules against', description: 'Not in the registry — a shape to exercise the level rules against',
@@ -134,7 +134,7 @@ describe('levels', () => {
personal: ['/favorites', '/now-playing'], personal: ['/favorites', '/now-playing'],
}; };
test('write permits anything within the capability', () => { test('write permits anything within the permission', () => {
expect(isRequestAllowedAtLevel(fixture, 'write', 'DELETE', '/api/fixture/track/9')).toBe(true); expect(isRequestAllowedAtLevel(fixture, 'write', 'DELETE', '/api/fixture/track/9')).toBe(true);
}); });
@@ -157,16 +157,16 @@ describe('levels', () => {
test('a plugin gets the same rule through readOnlyWrites', () => { test('a plugin gets the same rule through readOnlyWrites', () => {
// The two lists are concatenated, so a plugin declaring per-caller paths on the one field its // The two lists are concatenated, so a plugin declaring per-caller paths on the one field its
// manifest has behaves identically to a core capability declaring `personal`. This is what music // manifest has behaves identically to a core permission declaring `personal`. This is what music
// relies on now that it ships as one. // relies on now that it ships as one.
const plugin: Capability = { ...fixture, personal: undefined, readOnlyWrites: ['/favorites', '/now-playing'] }; const plugin: Permission = { ...fixture, personal: undefined, readOnlyWrites: ['/favorites', '/now-playing'] };
expect(isRequestAllowedAtLevel(plugin, 'read', 'POST', '/api/fixture/favorites/7')).toBe(true); expect(isRequestAllowedAtLevel(plugin, 'read', 'POST', '/api/fixture/favorites/7')).toBe(true);
expect(isRequestAllowedAtLevel(plugin, 'read', 'POST', '/api/fixture/scan')).toBe(false); expect(isRequestAllowedAtLevel(plugin, 'read', 'POST', '/api/fixture/scan')).toBe(false);
}); });
}); });
describe('self-service routes', () => { describe('self-service routes', () => {
const userAdmin = CAPABILITY_BY_KEY.get('user-admin')!; const userAdmin = PERMISSION_BY_KEY.get('user-admin')!;
test('PUT /api/users is self-profile update and stays reachable', () => { test('PUT /api/users is self-profile update and stays reachable', () => {
expect(isSelfServiceRoute(userAdmin, 'PUT', '/api/users')).toBe(true); expect(isSelfServiceRoute(userAdmin, 'PUT', '/api/users')).toBe(true);
@@ -181,29 +181,29 @@ describe('self-service routes', () => {
expect(isSelfServiceRoute(userAdmin, 'PUT', '/api/users/5')).toBe(false); expect(isSelfServiceRoute(userAdmin, 'PUT', '/api/users/5')).toBe(false);
}); });
test('capabilities without the field are unaffected', () => { test('permissions without the field are unaffected', () => {
expect(isSelfServiceRoute(CAPABILITY_BY_KEY.get('wallet')!, 'PUT', '/api/wallet')).toBe(false); expect(isSelfServiceRoute(PERMISSION_BY_KEY.get('wallet')!, 'PUT', '/api/wallet')).toBe(false);
}); });
}); });
describe('kinds', () => { describe('kinds', () => {
test('execution capabilities are never grantable', () => { test('execution permissions are never grantable', () => {
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key)); const grantable = new Set(GRANTABLE_PERMISSIONS.map((c) => c.key));
// `files` left this list on 2026-08-11, then `terminal` and `chat` the same day — see the tests below and // `files` left this list on 2026-08-11, then `terminal` and `chat` the same day — see the tests below and
// docs/per-user-linux-accounts.md. Everything still here runs as the OWNER in the owner's home with no // docs/per-user-linux-accounts.md. Everything still here runs as the OWNER in the owner's home with no
// per-caller resolution at all. // per-caller resolution at all.
for (const key of ['tasks', 'desktop', 'browser', 'items']) { for (const key of ['tasks', 'desktop', 'browser', 'items']) {
expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('execution'); expect(PERMISSION_BY_KEY.get(key)?.kind).toBe('execution');
expect(grantable.has(key)).toBe(false); expect(grantable.has(key)).toBe(false);
} }
}); });
// A confined capability is grantable, but the grant is inert without a Linux account — enforced in // A confined permission is grantable, but the grant is inert without a Linux account — enforced in
// authorize.ts, which is where the rule can cover routes, sockets and the dock at once. // authorize.ts, which is where the rule can cover routes, sockets and the dock at once.
test('confined capabilities are grantable', () => { test('confined permissions are grantable', () => {
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key)); const grantable = new Set(GRANTABLE_PERMISSIONS.map((c) => c.key));
for (const key of ['files', 'terminal', 'chat']) { for (const key of ['files', 'terminal', 'chat']) {
expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('confined'); expect(PERMISSION_BY_KEY.get(key)?.kind).toBe('confined');
expect(grantable.has(key)).toBe(true); expect(grantable.has(key)).toBe(true);
} }
}); });
@@ -217,24 +217,24 @@ describe('kinds', () => {
// on 2026-08-12, together, once the turn ran under `runAs` with the member's own home, credential, // on 2026-08-12, together, once the turn ran under `runAs` with the member's own home, credential,
// transcripts and session ownership. `chat` is now confined in fact and not only in the registry. // transcripts and session ownership. `chat` is now confined in fact and not only in the registry.
test('confined is a short, deliberate list', () => { test('confined is a short, deliberate list', () => {
expect(CAPABILITIES.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['terminal', 'chat', 'files']); expect(PERMISSIONS.filter((c) => c.kind === 'confined').map((c) => c.key)).toEqual(['terminal', 'chat', 'files']);
}); });
test('admin capabilities are never grantable', () => { test('admin permissions are never grantable', () => {
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key)); const grantable = new Set(GRANTABLE_PERMISSIONS.map((c) => c.key));
// `headscale` was here until 2026-08-15, when it left with offscale. A plugin's permissions are // `headscale` was here until 2026-08-15, when it left with offscale. A plugin's permissions are
// registered at install from its manifest, so they are not in this compile-time list by design. // registered at install from its manifest, so they are not in this compile-time list by design.
for (const key of ['user-admin', 'server-admin', 'wallet']) { for (const key of ['user-admin', 'server-admin', 'wallet']) {
expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('admin'); expect(PERMISSION_BY_KEY.get(key)?.kind).toBe('admin');
expect(grantable.has(key)).toBe(false); expect(grantable.has(key)).toBe(false);
} }
}); });
test('gitea is grantable — the case this was built for', () => { test('gitea is grantable — the case this was built for', () => {
expect(GRANTABLE_CAPABILITIES.some((c) => c.key === 'gitea')).toBe(true); expect(GRANTABLE_PERMISSIONS.some((c) => c.key === 'gitea')).toBe(true);
}); });
test('every capability declares at least one api prefix', () => { test('every permission declares at least one api prefix', () => {
for (const capability of CAPABILITIES) expect(capability.api.length).toBeGreaterThan(0); for (const permission of PERMISSIONS) expect(permission.api.length).toBeGreaterThan(0);
}); });
}); });
@@ -1,7 +1,7 @@
// The capability registry: the single enumeration of what this platform can do, and the unit the owner // The permission registry: the single enumeration of what this platform can do, and the unit the owner
// grants to a role. // grants to a role.
// //
// ── Why capabilities and not routes ── // ── Why permissions and not routes ──
// //
// The obvious model is "list the routes a role may call". It does not survive contact with this codebase. // The obvious model is "list the routes a role may call". It does not survive contact with this codebase.
// A sweep of all 100 mutating platform routes on 2026-08-06 found reads permanently stuck on POST for two // A sweep of all 100 mutating platform routes on 2026-08-06 found reads permanently stuck on POST for two
@@ -9,10 +9,10 @@
// `/transcribe`), and credentials that must not sit in a query string where access logs, shell history and // `/transcribe`), and credentials that must not sit in a query string where access logs, shell history and
// Referer headers capture them (`/tts/voices` apiKey, the four `/test` endpoints, `/local-providers/probe`). // Referer headers capture them (`/tts/voices` apiKey, the four `/test` endpoints, `/local-providers/probe`).
// Five genuinely free conversions were done in e54d71d; the rest are staying. So the METHOD alone cannot // Five genuinely free conversions were done in e54d71d; the rest are staying. So the METHOD alone cannot
// carry the read/write distinction — hence `readOnlyWrites` below, declared per capability. // carry the read/write distinction — hence `readOnlyWrites` below, declared per permission.
// //
// The deeper reason is that a route list is not what the owner is deciding. The owner decides "this person // The deeper reason is that a route list is not what the owner is deciding. The owner decides "this person
// gets Gitea". A capability is that decision; the prefixes, sockets and screens it expands to are an // gets Gitea". A permission is that decision; the prefixes, sockets and screens it expands to are an
// implementation detail that belongs next to the decision rather than in the granting UI. // implementation detail that belongs next to the decision rather than in the granting UI.
// //
// ── The four kinds, and why `execution` can never be granted ── // ── The four kinds, and why `execution` can never be granted ──
@@ -27,17 +27,17 @@
// //
// ── `confined`, and why it is not just `app` ── // ── `confined`, and why it is not just `app` ──
// //
// Added 2026-08-11 with per-user Linux accounts (docs/per-user-linux-accounts.md). A confined capability // Added 2026-08-11 with per-user Linux accounts (docs/per-user-linux-accounts.md). A confined permission
// touches the filesystem or runs a process, so calling it an `app` would be a lie — but it is no longer // touches the filesystem or runs a process, so calling it an `app` would be a lie — but it is no longer
// the OWNER'S filesystem, because the account has its own Linux user, its own home, and the kernel refusing // the OWNER'S filesystem, because the account has its own Linux user, its own home, and the kernel refusing
// everything above it. // everything above it.
// //
// The distinction earns its keep in one place: a grant on a confined capability means NOTHING unless the // The distinction earns its keep in one place: a grant on a confined permission means NOTHING unless the
// account actually has that Linux user. `authorize.ts` drops confined grants for an account with no // account actually has that Linux user. `authorize.ts` drops confined grants for an account with no
// `osUser`, so "granted but unconfined" resolves to no access rather than to the owner's home. That rule // `osUser`, so "granted but unconfined" resolves to no access rather than to the owner's home. That rule
// lives there, once, instead of in each router that would otherwise have to remember it. // lives there, once, instead of in each router that would otherwise have to remember it.
// //
// Moving a capability from `execution` to `confined` is therefore a claim with a test attached: every path // Moving a permission from `execution` to `confined` is therefore a claim with a test attached: every path
// it reaches must resolve its directory from the CALLER, not from HOME_DIR. // it reaches must resolve its directory from the CALLER, not from HOME_DIR.
// //
// `execution` is the important one. Everything under it runs as the OWNER'S OS user in the owner's home // `execution` is the important one. Everything under it runs as the OWNER'S OS user in the owner's home
@@ -50,7 +50,7 @@
// ── Read by default ── // ── Read by default ──
// //
// A grant carries a level, `read` or `write`. `read` permits safe methods (GET/HEAD/OPTIONS) anywhere in // A grant carries a level, `read` or `write`. `read` permits safe methods (GET/HEAD/OPTIONS) anywhere in
// the capability, plus mutations under `personal` — sub-paths that hold the CALLER'S own data and nothing // the permission, plus mutations under `personal` — sub-paths that hold the CALLER'S own data and nothing
// else. `dashboards` is the worked example: a dashboard belongs to the account that made it, so the whole // else. `dashboards` is the worked example: a dashboard belongs to the account that made it, so the whole
// surface is personal and a read grant is really "your own, fully". So "may a member write here" is a // surface is personal and a read grant is really "your own, fully". So "may a member write here" is a
// property of the endpoint, not a policy knob someone has to remember to set. // property of the endpoint, not a policy knob someone has to remember to set.
@@ -59,14 +59,14 @@
// the same thing through its manifest's `readOnlyWrites` — `isRequestAllowedAtLevel` merges the two lists, // the same thing through its manifest's `readOnlyWrites` — `isRequestAllowedAtLevel` merges the two lists,
// so they are one mechanism under two names. // so they are one mechanism under two names.
export type CapabilityKind = 'core' | 'app' | 'confined' | 'execution' | 'admin'; export type PermissionKind = 'core' | 'app' | 'confined' | 'execution' | 'admin';
export type Capability = { export type Permission = {
/** Stable identifier. Stored in the database as the grant's subject — renaming one is a data change. */ /** Stable identifier. Stored in the database as the grant's subject — renaming one is a data change. */
key: string; key: string;
label: string; label: string;
description: string; description: string;
kind: CapabilityKind; kind: PermissionKind;
/** /**
* Path prefixes under `/api`, written exactly as they are mounted on protectedRouter in hono.ts * Path prefixes under `/api`, written exactly as they are mounted on protectedRouter in hono.ts
* leading slash, no `/api`. The totality check pairs these against the real mount table, so a prefix * leading slash, no `/api`. The totality check pairs these against the real mount table, so a prefix
@@ -79,37 +79,37 @@ export type Capability = {
routes?: string[]; routes?: string[];
/** /**
* Sub-paths, relative to each `api` prefix, that a READ grant may still mutate because they hold only * Sub-paths, relative to each `api` prefix, that a READ grant may still mutate because they hold only
* the caller's own data. Matched as a prefix after the capability's own: `/devices` on the `notify` * the caller's own data. Matched as a prefix after the permission's own: `/devices` on the `notify`
* capability permits `POST /api/notify/devices/123`. * permission permits `POST /api/notify/devices/123`.
*/ */
personal?: string[]; personal?: string[];
/** /**
* Reads that must stay POST see the note at the top. A read grant permits these paths at any method. * Reads that must stay POST see the note at the top. A read grant permits these paths at any method.
* Written relative to the capability's `api` prefix, like `personal`. * Written relative to the permission's `api` prefix, like `personal`.
*/ */
readOnlyWrites?: string[]; readOnlyWrites?: string[];
/** /**
* Routes any authenticated account may call even holding NO grant on this capability, because they act * Routes any authenticated account may call even holding NO grant on this permission, because they act
* on the caller themselves. `METHOD /exact/path`, relative to the capability's prefix exact, not a * on the caller themselves. `METHOD /exact/path`, relative to the permission's prefix exact, not a
* prefix, so this cannot widen by accident. * prefix, so this cannot widen by accident.
* *
* One entry exists and it should stay that way. `PUT /api/users` is self-profile update (useAuth.ts * One entry exists and it should stay that way. `PUT /api/users` is self-profile update (useAuth.ts
* calls it to change your own name and avatar) and has always lived on the same router as the owner-only * calls it to change your own name and avatar) and has always lived on the same router as the owner-only
* account administration beside it. Moving it to `/api/user` would be tidier and would break every * account administration beside it. Moving it to `/api/user` would be tidier and would break every
* shipped mobile client, so the honest fix is to say out loud that this one route is not what the * shipped mobile client, so the honest fix is to say out loud that this one route is not what the
* capability around it is. * permission around it is.
*/ */
selfService?: string[]; selfService?: string[];
}; };
/** /**
* The PLATFORM's own capabilities. A plugin's are added on top at runtime see `setPluginPermissions`. * The PLATFORM's own permissions. A plugin's are added on top at runtime see `setPluginPermissions`.
* *
* Kept separate from the live `CAPABILITIES` below so two invariants hold by construction rather than by * Kept separate from the live `PERMISSIONS` below so two invariants hold by construction rather than by
* anyone remembering them: a plugin can never become `core` (granted to everyone, undeniable), and a * anyone remembering them: a plugin can never become `core` (granted to everyone, undeniable), and a
* plugin is never in the fresh-install baseline. * plugin is never in the fresh-install baseline.
*/ */
const CORE_REGISTRY: Capability[] = [ const CORE_REGISTRY: Permission[] = [
// ── core ──────────────────────────────────────────────────────────────────────────────────────── // ── core ────────────────────────────────────────────────────────────────────────────────────────
{ {
key: 'account', key: 'account',
@@ -119,7 +119,7 @@ const CORE_REGISTRY: Capability[] = [
// `/api-keys` is core rather than app or admin because a key is not new authority — it is a second way // `/api-keys` is core rather than app or admin because a key is not new authority — it is a second way
// to present the authority the account already has, so denying it would only force the holder to keep // to present the authority the account already has, so denying it would only force the holder to keep
// using a password in places a password should not go. What a key can then DO is decided by the same // using a password in places a password should not go. What a key can then DO is decided by the same
// capability checks as any other request from that user; nothing here widens them. // permission checks as any other request from that user; nothing here widens them.
api: ['/user', '/dock', '/api-keys'], api: ['/user', '/dock', '/api-keys'],
routes: ['/settings/profile'], routes: ['/settings/profile'],
}, },
@@ -145,7 +145,7 @@ const CORE_REGISTRY: Capability[] = [
// acts only as themselves upstream. Gitea's own permissions are the second gate and the real one: // acts only as themselves upstream. Gitea's own permissions are the second gate and the real one:
// a token cannot reach a repository its account cannot reach, whatever this platform thinks. // a token cannot reach a repository its account cannot reach, whatever this platform thinks.
// //
// Which is why the whole capability is `personal` rather than a list of sub-paths. Nothing under // Which is why the whole permission is `personal` rather than a list of sub-paths. Nothing under
// /api/gitea can affect another Officer user, so withholding write here would only stop someone // /api/gitea can affect another Officer user, so withholding write here would only stop someone
// commenting on their own issues — security theatre with a real cost and no benefit. // commenting on their own issues — security theatre with a real cost and no benefit.
personal: ['/'], personal: ['/'],
@@ -315,7 +315,7 @@ const CORE_REGISTRY: Capability[] = [
{ {
key: 'tasks', key: 'tasks',
label: 'Tasks and jobs', label: 'Tasks and jobs',
description: 'Running capabilities, pipelines and background jobs', description: 'Running permissions, pipelines and background jobs',
kind: 'execution', kind: 'execution',
api: ['/tasks', '/jobs', '/pipeline-jobs', '/queue'], api: ['/tasks', '/jobs', '/pipeline-jobs', '/queue'],
ws: ['task-runner', 'pipeline'], ws: ['task-runner', 'pipeline'],
@@ -323,7 +323,7 @@ const CORE_REGISTRY: Capability[] = [
}, },
{ {
key: 'items', key: 'items',
label: 'Capability authoring', label: 'Permission authoring',
description: 'Skills, tools, agents and processes on disk', description: 'Skills, tools, agents and processes on disk',
kind: 'execution', kind: 'execution',
api: ['/skills', '/tools', '/agents', '/processes', '/rescan'], api: ['/skills', '/tools', '/agents', '/processes', '/rescan'],
@@ -339,10 +339,10 @@ const CORE_REGISTRY: Capability[] = [
routes: ['/desktop'], routes: ['/desktop'],
}, },
// `/browser` — the Chrome-extension relay — is unmounted as of 2026-08-13 and dropped from this claim, // `/browser` — the Chrome-extension relay — is unmounted as of 2026-08-13 and dropped from this claim,
// because check 2 of assertCapabilityTotality refuses to boot on a capability claiming a prefix nothing // because check 2 of assertPermissionTotality refuses to boot on a permission claiming a prefix nothing
// serves. Put both back together or neither. // serves. Put both back together or neither.
// //
// `/scrape` is untouched and is what this capability still covers. It shares nothing with the relay: it // `/scrape` is untouched and is what this permission still covers. It shares nothing with the relay: it
// launches its own headless chromium through playwright and never speaks to the extension. // launches its own headless chromium through playwright and never speaks to the extension.
{ {
key: 'browser', key: 'browser',
@@ -373,7 +373,7 @@ const CORE_REGISTRY: Capability[] = [
// Admin for the same reason as the app store above: installing a plugin mounts routes and starts a // Admin for the same reason as the app store above: installing a plugin mounts routes and starts a
// process, which is process control rather than a feature to grant a read of. // process, which is process control rather than a feature to grant a read of.
// //
// Note this capability guards the MANAGEMENT surface, not the plugins themselves. A plugin declares // Note this permission guards the MANAGEMENT surface, not the plugins themselves. A plugin declares
// its own permissions in its manifest, and those are what gate its routes — so a member can hold // its own permissions in its manifest, and those are what gate its routes — so a member can hold
// `offscale` at read without being able to install or remove anything. // `offscale` at read without being able to install or remove anything.
kind: 'admin', kind: 'admin',
@@ -401,7 +401,7 @@ const CORE_REGISTRY: Capability[] = [
}, },
// `headscale` lived here until 2026-08-15, when it left with the rest of offscale. A plugin declares // `headscale` lived here until 2026-08-15, when it left with the rest of offscale. A plugin declares
// its own permissions in its manifest and they are registered at install — see plugins/mount.ts. The // its own permissions in its manifest and they are registered at install — see plugins/mount.ts. The
// platform no longer knows this capability exists, which is the entire point. // platform no longer knows this permission exists, which is the entire point.
{ {
key: 'wallet', key: 'wallet',
label: 'Wallet', label: 'Wallet',
@@ -415,18 +415,18 @@ const CORE_REGISTRY: Capability[] = [
// ── Derived lookups ─────────────────────────────────────────────────────────────────────────────── // ── Derived lookups ───────────────────────────────────────────────────────────────────────────────
// //
// `let`, not `const`, because installing a plugin adds capabilities and uninstalling removes them. ESM // `let`, not `const`, because installing a plugin adds permissions and uninstalling removes them. ESM
// exports are live bindings, so an importer holding `CAPABILITY_BY_KEY` sees the reassignment — every // exports are live bindings, so an importer holding `PERMISSION_BY_KEY` sees the reassignment — every
// consumer reads these inside a function, never at module scope, which is what makes that safe. // consumer reads these inside a function, never at module scope, which is what makes that safe.
// //
// Nothing mutates the arrays in place. `setPluginPermissions` replaces them, for the same reason // Nothing mutates the arrays in place. `setPluginPermissions` replaces them, for the same reason
// `buildHonoApp` replaces the app rather than adding routes to it: a half-updated lookup is worse than a // `buildHonoApp` replaces the app rather than adding routes to it: a half-updated lookup is worse than a
// stale one, and replacement has no half. // stale one, and replacement has no half.
/** Core plus every installed plugin's. The list `capabilityForApiPath` and the totality check read. */ /** Core plus every installed plugin's. The list `permissionForApiPath` and the totality check read. */
export let CAPABILITIES: Capability[] = CORE_REGISTRY; export let PERMISSIONS: Permission[] = CORE_REGISTRY;
export let CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c])); export let PERMISSION_BY_KEY = new Map(PERMISSIONS.map((c) => [c.key, c]));
/** /**
* The keys an owner may actually hand to a role. `core` is automatic; `execution` and `admin` are owner-only. * The keys an owner may actually hand to a role. `core` is automatic; `execution` and `admin` are owner-only.
@@ -435,10 +435,10 @@ export let CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c]));
* enforced in `authorize.ts`, not by withholding it from this list. Withholding it would mean the owner * enforced in `authorize.ts`, not by withholding it from this list. Withholding it would mean the owner
* could not pre-grant a role before provisioning the people in it, which is the normal order of operations. * could not pre-grant a role before provisioning the people in it, which is the normal order of operations.
*/ */
export let GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app' || c.kind === 'confined'); export let GRANTABLE_PERMISSIONS = PERMISSIONS.filter((c) => c.kind === 'app' || c.kind === 'confined');
/** /**
* What every role starts with on a fresh install: the three confined capabilities, at `write`. * What every role starts with on a fresh install: the three confined permissions, at `write`.
* *
* These are the baseline the platform is FOR a terminal, a file browser and chat. An account that can sign * These are the baseline the platform is FOR a terminal, a file browser and chat. An account that can sign
* in and reach none of them is not a restricted account, it is a useless one, and making the owner grant them * in and reach none of them is not a restricted account, it is a useless one, and making the owner grant them
@@ -448,12 +448,12 @@ export let GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app'
* means no access, always, with no exceptions to remember. So revoking one of these works exactly like * means no access, always, with no exceptions to remember. So revoking one of these works exactly like
* revoking anything else the row goes, and nothing puts it back. * revoking anything else the row goes, and nothing puts it back.
* *
* `app` capabilities are deliberately NOT here. Those reach data the owner may not intend to share, and each * `app` permissions are deliberately NOT here. Those reach data the owner may not intend to share, and each
* needs a sidecar installed before it means anything anyway. * needs a sidecar installed before it means anything anyway.
*/ */
// From CORE_REGISTRY, never from CAPABILITIES: a plugin must not be able to put itself in the baseline // From CORE_REGISTRY, never from PERMISSIONS: a plugin must not be able to put itself in the baseline
// every new role starts with. // every new role starts with.
export const DEFAULT_ROLE_CAPABILITIES: string[] = CORE_REGISTRY.filter((c) => c.kind === 'confined').map((c) => c.key); export const DEFAULT_ROLE_PERMISSIONS: string[] = CORE_REGISTRY.filter((c) => c.kind === 'confined').map((c) => c.key);
/** /**
* Available to every signed-in account without a grant. * Available to every signed-in account without a grant.
@@ -466,52 +466,52 @@ export const CORE_CAPABILITIES = CORE_REGISTRY.filter((c) => c.kind === 'core');
/** /**
* Replace the plugin half of the registry. Called after every install, uninstall, enable and disable. * Replace the plugin half of the registry. Called after every install, uninstall, enable and disable.
* *
* Plugin capabilities are dropped and re-added wholesale rather than diffed: the source of truth is the * Plugin permissions are dropped and re-added wholesale rather than diffed: the source of truth is the
* set of installed plugins, and computing a delta against it would be a second answer to the same * set of installed plugins, and computing a delta against it would be a second answer to the same
* question. A key colliding with a core one is REFUSED here rather than silently overriding a plugin * question. A key colliding with a core one is REFUSED here rather than silently overriding a plugin
* that could redefine `chat` or `terminal` could widen it. * that could redefine `chat` or `terminal` could widen it.
*/ */
export function setPluginPermissions(pluginCapabilities: Capability[]): { rejected: string[] } { export function setPluginPermissions(pluginPermissions: Permission[]): { rejected: string[] } {
const coreKeys = new Set(CORE_REGISTRY.map((c) => c.key)); const coreKeys = new Set(CORE_REGISTRY.map((c) => c.key));
const seen = new Set<string>(); const seen = new Set<string>();
const accepted: Capability[] = []; const accepted: Permission[] = [];
const rejected: string[] = []; const rejected: string[] = [];
for (const capability of pluginCapabilities) { for (const permission of pluginPermissions) {
if (coreKeys.has(capability.key) || seen.has(capability.key)) { if (coreKeys.has(permission.key) || seen.has(permission.key)) {
rejected.push(capability.key); rejected.push(permission.key);
continue; continue;
} }
seen.add(capability.key); seen.add(permission.key);
accepted.push(capability); accepted.push(permission);
} }
CAPABILITIES = [...CORE_REGISTRY, ...accepted]; PERMISSIONS = [...CORE_REGISTRY, ...accepted];
CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c])); PERMISSION_BY_KEY = new Map(PERMISSIONS.map((c) => [c.key, c]));
GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app' || c.kind === 'confined'); GRANTABLE_PERMISSIONS = PERMISSIONS.filter((c) => c.kind === 'app' || c.kind === 'confined');
return { rejected }; return { rejected };
} }
export type CapabilityLevel = 'read' | 'write'; export type PermissionLevel = 'read' | 'write';
const isPrefixOf = (prefix: string, path: string): boolean => const isPrefixOf = (prefix: string, path: string): boolean =>
prefix === '/' || path === prefix || path.startsWith(`${prefix}/`); prefix === '/' || path === prefix || path.startsWith(`${prefix}/`);
/** /**
* Which capability owns this path? `path` is the full request path (`/api/gitea/...`). * Which permission owns this path? `path` is the full request path (`/api/gitea/...`).
* *
* Longest prefix wins, so a capability may claim `/dav` while another claims `/dav/something` without the * Longest prefix wins, so a permission may claim `/dav` while another claims `/dav/something` without the
* order of the array mattering. Returns null for a path no capability claims which the totality check * order of the array mattering. Returns null for a path no permission claims which the totality check
* below is there to make impossible for anything mounted on protectedRouter. * below is there to make impossible for anything mounted on protectedRouter.
*/ */
export function capabilityForApiPath(path: string): Capability | null { export function permissionForApiPath(path: string): Permission | null {
const rest = path.startsWith('/api') ? path.slice('/api'.length) : path; const rest = path.startsWith('/api') ? path.slice('/api'.length) : path;
let best: Capability | null = null; let best: Permission | null = null;
let bestLength = -1; let bestLength = -1;
for (const capability of CAPABILITIES) { for (const permission of PERMISSIONS) {
for (const prefix of capability.api) { for (const prefix of permission.api) {
if (isPrefixOf(prefix, rest) && prefix.length > bestLength) { if (isPrefixOf(prefix, rest) && prefix.length > bestLength) {
best = capability; best = permission;
bestLength = prefix.length; bestLength = prefix.length;
} }
} }
@@ -519,36 +519,36 @@ export function capabilityForApiPath(path: string): Capability | null {
return best; return best;
} }
export function capabilityForWsProvider(provider: string): Capability | null { export function permissionForWsProvider(provider: string): Permission | null {
return CAPABILITIES.find((c) => c.ws?.includes(provider)) ?? null; return PERMISSIONS.find((c) => c.ws?.includes(provider)) ?? null;
} }
/** /**
* Is this an exact self-service route one any authenticated account may call without holding the * Is this an exact self-service route one any authenticated account may call without holding the
* capability at all? Matched exactly on method AND path, never as a prefix. * permission at all? Matched exactly on method AND path, never as a prefix.
*/ */
export function isSelfServiceRoute(capability: Capability, method: string, path: string): boolean { export function isSelfServiceRoute(permission: Permission, method: string, path: string): boolean {
if (!capability.selfService?.length) return false; if (!permission.selfService?.length) return false;
const rest = path.startsWith('/api') ? path.slice('/api'.length) : path; const rest = path.startsWith('/api') ? path.slice('/api'.length) : path;
const upper = method.toUpperCase(); const upper = method.toUpperCase();
return capability.api.some((prefix) => { return permission.api.some((prefix) => {
if (!isPrefixOf(prefix, rest)) return false; if (!isPrefixOf(prefix, rest)) return false;
const sub = rest.slice(prefix.length) || '/'; const sub = rest.slice(prefix.length) || '/';
return capability.selfService!.includes(`${upper} ${sub}`); return permission.selfService!.includes(`${upper} ${sub}`);
}); });
} }
const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']); const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
/** /**
* May a caller holding `level` on `capability` make this request? * May a caller holding `level` on `permission` make this request?
* *
* `write` is unconditional within the capability. `read` permits safe methods, anything the capability * `write` is unconditional within the permission. `read` permits safe methods, anything the permission
* declares as a `readOnlyWrites` read-in-POST-clothing, and mutations confined to `personal` sub-paths. * declares as a `readOnlyWrites` read-in-POST-clothing, and mutations confined to `personal` sub-paths.
*/ */
export function isRequestAllowedAtLevel( export function isRequestAllowedAtLevel(
capability: Capability, permission: Permission,
level: CapabilityLevel, level: PermissionLevel,
method: string, method: string,
path: string, path: string,
): boolean { ): boolean {
@@ -556,12 +556,12 @@ export function isRequestAllowedAtLevel(
if (SAFE_METHODS.has(method.toUpperCase())) return true; if (SAFE_METHODS.has(method.toUpperCase())) return true;
const rest = path.startsWith('/api') ? path.slice('/api'.length) : path; const rest = path.startsWith('/api') ? path.slice('/api'.length) : path;
// Strip whichever of the capability's own prefixes matched, so `personal` entries are written relative // Strip whichever of the permission's own prefixes matched, so `personal` entries are written relative
// to the capability rather than repeated per prefix. // to the permission rather than repeated per prefix.
const withinCapability = capability.api const withinPermission = permission.api
.filter((prefix) => isPrefixOf(prefix, rest)) .filter((prefix) => isPrefixOf(prefix, rest))
.map((prefix) => rest.slice(prefix.length) || '/'); .map((prefix) => rest.slice(prefix.length) || '/');
const allowed = [...(capability.personal ?? []), ...(capability.readOnlyWrites ?? [])]; const allowed = [...(permission.personal ?? []), ...(permission.readOnlyWrites ?? [])];
return withinCapability.some((sub) => allowed.some((entry) => isPrefixOf(entry, sub))); return withinPermission.some((sub) => allowed.some((entry) => isPrefixOf(entry, sub)));
} }
@@ -1,4 +1,4 @@
import { CAPABILITIES, capabilityForApiPath, capabilityForWsProvider } from './registry'; import { PERMISSIONS, permissionForApiPath, permissionForWsProvider } from './registry';
// The part that actually matters. // The part that actually matters.
// //
@@ -11,16 +11,16 @@ import { CAPABILITIES, capabilityForApiPath, capabilityForWsProvider } from './r
// //
// It was fixed in 2873948 by adding the check to the socket door too. That fix is a patch, and patches of // It was fixed in 2873948 by adding the check to the socket door too. That fix is a patch, and patches of
// that shape do not survive the next door. What survives is refusing to boot: every mounted API prefix and // that shape do not survive the next door. What survives is refusing to boot: every mounted API prefix and
// every user-facing WebSocket provider must map to exactly one capability, or the server does not start. // every user-facing WebSocket provider must map to exactly one permission, or the server does not start.
// Add a router and forget the registry and you find out during `pm2 restart`, not during an incident. // Add a router and forget the registry and you find out during `pm2 restart`, not during an incident.
// //
// The exemptions below are the honest cost of that: a short list of surfaces that genuinely are not // The exemptions below are the honest cost of that: a short list of surfaces that genuinely are not
// user-capability-gated, each of which has to say why. A list that grows silently is the failure mode, so // user-permission-gated, each of which has to say why. A list that grows silently is the failure mode, so
// keep it short and keep the reasons real. // keep it short and keep the reasons real.
/** /**
* Mounted under `honoServer` directly rather than `protectedRouter`, and gated by something other than a * Mounted under `honoServer` directly rather than `protectedRouter`, and gated by something other than a
* platform capability. Each entry is a claim that has to stay true. * platform permission. Each entry is a claim that has to stay true.
*/ */
const EXEMPT_API_PREFIXES: Record<string, string> = { const EXEMPT_API_PREFIXES: Record<string, string> = {
// Unauthenticated by necessity — this is where a caller goes to BECOME authenticated. // Unauthenticated by necessity — this is where a caller goes to BECOME authenticated.
@@ -29,12 +29,12 @@ const EXEMPT_API_PREFIXES: Record<string, string> = {
'/landing-page-data': 'public landing page content, no account involved', '/landing-page-data': 'public landing page content, no account involved',
'/waitlist': 'public signup form, no account involved', '/waitlist': 'public signup form, no account involved',
// The Bitwarden clients carry their own bearer token, not a platform JWT, so userMiddleware would 401 // The Bitwarden clients carry their own bearer token, not a platform JWT, so userMiddleware would 401
// them and a capability lookup has no account to resolve. Gated by origin scoping and Vaultwarden itself. // them and a permission lookup has no account to resolve. Gated by origin scoping and Vaultwarden itself.
'/vault': 'Bitwarden protocol clients authenticate to Vaultwarden, not to Officer', '/vault': 'Bitwarden protocol clients authenticate to Vaultwarden, not to Officer',
// Registration socket for sidecars. Process-to-process on loopback; there is no user on this path. // Registration socket for sidecars. Process-to-process on loopback; there is no user on this path.
'/sidecar': 'sidecar registration, loopback process-to-process', '/sidecar': 'sidecar registration, loopback process-to-process',
// The caller is a Claude session running curl, not a browser: it has no platform JWT to present, so // The caller is a Claude session running curl, not a browser: it has no platform JWT to present, so
// userMiddleware would 401 it and a capability lookup would have no account to resolve. What it does // userMiddleware would 401 it and a permission lookup would have no account to resolve. What it does
// present is a per-panel token minted by the platform, which identifies exactly one agent panel and // present is a per-panel token minted by the platform, which identifies exactly one agent panel and
// authorises exactly one action — deliver a prompt to a NAMED PEER ON THAT PANEL'S OWN DASHBOARD. // authorises exactly one action — deliver a prompt to a NAMED PEER ON THAT PANEL'S OWN DASHBOARD.
// It reads nothing else, writes nothing else, and cannot name a raw session key. The narrowness is // It reads nothing else, writes nothing else, and cannot name a raw session key. The narrowness is
@@ -53,8 +53,8 @@ const EXEMPT_WS_PROVIDERS: Record<string, string> = {
/** /**
* Is this path served above the account gate? * Is this path served above the account gate?
* *
* Read by the capability backstop, so that signin which by definition has no account to check is not * Read by the permission backstop, so that signin which by definition has no account to check is not
* asked to prove a capability. Deliberately shares EXEMPT_API_PREFIXES with the boot check: an exemption * asked to prove a permission. Deliberately shares EXEMPT_API_PREFIXES with the boot check: an exemption
* granted at boot and an exemption honoured at request time must be the same list, or one of them is a * granted at boot and an exemption honoured at request time must be the same list, or one of them is a
* hole. * hole.
*/ */
@@ -63,7 +63,7 @@ export function isExemptApiPath(path: string): boolean {
return Object.keys(EXEMPT_API_PREFIXES).some((prefix) => rest === prefix || rest.startsWith(`${prefix}/`)); return Object.keys(EXEMPT_API_PREFIXES).some((prefix) => rest === prefix || rest.startsWith(`${prefix}/`));
} }
export type CapabilitySurface = { export type PermissionSurface = {
/** Every prefix mounted on protectedRouter, as written in hono.ts. */ /** Every prefix mounted on protectedRouter, as written in hono.ts. */
apiPrefixes: string[]; apiPrefixes: string[];
/** Every key of the `handlers` map in server.tsx. */ /** Every key of the `handlers` map in server.tsx. */
@@ -74,20 +74,20 @@ export type CapabilitySurface = {
* Refuses to return if the registry and the real surface disagree. Called from the boot path. * Refuses to return if the registry and the real surface disagree. Called from the boot path.
* *
* Four ways to fail, and all four are real bugs rather than pedantry: * Four ways to fail, and all four are real bugs rather than pedantry:
* - a mounted prefix no capability claims reachable by a rule nobody wrote * - a mounted prefix no permission claims reachable by a rule nobody wrote
* - a prefix two capabilities claim which grant applies is undefined * - a prefix two permissions claim which grant applies is undefined
* - a declared prefix nothing mounts the registry is describing a router that no longer exists * - a declared prefix nothing mounts the registry is describing a router that no longer exists
* - a socket provider no capability claims exactly the 2026-08-06 hole, structurally * - a socket provider no permission claims exactly the 2026-08-06 hole, structurally
*/ */
export function assertCapabilityTotality(surface: CapabilitySurface): void { export function assertPermissionTotality(surface: PermissionSurface): void {
const problems: string[] = []; const problems: string[] = [];
// 1. Every mount is claimed, and claimed once. // 1. Every mount is claimed, and claimed once.
for (const prefix of surface.apiPrefixes) { for (const prefix of surface.apiPrefixes) {
if (prefix in EXEMPT_API_PREFIXES) continue; if (prefix in EXEMPT_API_PREFIXES) continue;
const claimants = CAPABILITIES.filter((c) => c.api.includes(prefix)); const claimants = PERMISSIONS.filter((c) => c.api.includes(prefix));
if (claimants.length === 0) { if (claimants.length === 0) {
problems.push(`/api${prefix} is mounted but no capability claims it — add it to the registry`); problems.push(`/api${prefix} is mounted but no permission claims it — add it to the registry`);
} else if (claimants.length > 1) { } else if (claimants.length > 1) {
problems.push(`/api${prefix} is claimed by ${claimants.map((c) => c.key).join(', ')} — it must be exactly one`); problems.push(`/api${prefix} is claimed by ${claimants.map((c) => c.key).join(', ')} — it must be exactly one`);
} }
@@ -96,10 +96,10 @@ export function assertCapabilityTotality(surface: CapabilitySurface): void {
// 2. Every claim corresponds to something real. Catches a router that was deleted or renamed while the // 2. Every claim corresponds to something real. Catches a router that was deleted or renamed while the
// registry kept describing it — which would leave a grant that silently means nothing. // registry kept describing it — which would leave a grant that silently means nothing.
const mounted = new Set(surface.apiPrefixes); const mounted = new Set(surface.apiPrefixes);
for (const capability of CAPABILITIES) { for (const permission of PERMISSIONS) {
for (const prefix of capability.api) { for (const prefix of permission.api) {
if (!mounted.has(prefix)) { if (!mounted.has(prefix)) {
problems.push(`capability '${capability.key}' claims /api${prefix}, which nothing mounts`); problems.push(`permission '${permission.key}' claims /api${prefix}, which nothing mounts`);
} }
} }
} }
@@ -107,36 +107,36 @@ export function assertCapabilityTotality(surface: CapabilitySurface): void {
// 3. Every socket door is claimed. This is the one the incident was about. // 3. Every socket door is claimed. This is the one the incident was about.
for (const provider of surface.wsProviders) { for (const provider of surface.wsProviders) {
if (provider in EXEMPT_WS_PROVIDERS) continue; if (provider in EXEMPT_WS_PROVIDERS) continue;
if (!capabilityForWsProvider(provider)) { if (!permissionForWsProvider(provider)) {
problems.push(`websocket provider '${provider}' is served but no capability claims it`); problems.push(`websocket provider '${provider}' is served but no permission claims it`);
} }
} }
// 4. And no capability claims a socket that does not exist. // 4. And no permission claims a socket that does not exist.
const providers = new Set(surface.wsProviders); const providers = new Set(surface.wsProviders);
for (const capability of CAPABILITIES) { for (const permission of PERMISSIONS) {
for (const provider of capability.ws ?? []) { for (const provider of permission.ws ?? []) {
if (!providers.has(provider)) { if (!providers.has(provider)) {
problems.push(`capability '${capability.key}' claims websocket '${provider}', which is not served`); problems.push(`permission '${permission.key}' claims websocket '${provider}', which is not served`);
} }
} }
} }
// 5. Keys are unique — a duplicate would make grants ambiguous in the database. // 5. Keys are unique — a duplicate would make grants ambiguous in the database.
const seen = new Set<string>(); const seen = new Set<string>();
for (const capability of CAPABILITIES) { for (const permission of PERMISSIONS) {
if (seen.has(capability.key)) problems.push(`duplicate capability key '${capability.key}'`); if (seen.has(permission.key)) problems.push(`duplicate permission key '${permission.key}'`);
seen.add(capability.key); seen.add(permission.key);
} }
if (problems.length > 0) { if (problems.length > 0) {
throw new Error( throw new Error(
[ [
'Capability registry does not cover the served surface. The server will not start.', 'Permission registry does not cover the served surface. The server will not start.',
'', '',
...problems.map((p) => `${p}`), ...problems.map((p) => `${p}`),
'', '',
'Fix src/servers/capabilities/registry.ts. If a surface genuinely is not capability-gated, add it', 'Fix src/servers/permissions/registry.ts. If a surface genuinely is not permission-gated, add it',
'to the exemption list in totality.ts WITH a reason — an unexplained exemption is how the', 'to the exemption list in totality.ts WITH a reason — an unexplained exemption is how the',
'websocket hole happened.', 'websocket hole happened.',
].join('\n'), ].join('\n'),
@@ -144,12 +144,12 @@ export function assertCapabilityTotality(surface: CapabilitySurface): void {
} }
} }
/** Exposed for the settings UI, so an owner can see what a capability actually covers. */ /** Exposed for the settings UI, so an owner can see what a permission actually covers. */
export function describeCapabilitySurface(): { export function describePermissionSurface(): {
exemptApi: typeof EXEMPT_API_PREFIXES; exemptApi: typeof EXEMPT_API_PREFIXES;
exemptWs: typeof EXEMPT_WS_PROVIDERS; exemptWs: typeof EXEMPT_WS_PROVIDERS;
} { } {
return { exemptApi: EXEMPT_API_PREFIXES, exemptWs: EXEMPT_WS_PROVIDERS }; return { exemptApi: EXEMPT_API_PREFIXES, exemptWs: EXEMPT_WS_PROVIDERS };
} }
export { capabilityForApiPath }; export { permissionForApiPath };
+1 -1
View File
@@ -18,7 +18,7 @@
* *
* Called `permissions`, and that word is used throughout the plugin system deliberately. The other one * Called `permissions`, and that word is used throughout the plugin system deliberately. The other one
* already means three different things here — the permission registry, the file-based item store under * already means three different things here — the permission registry, the file-based item store under
* `$OFFICER_ROOT/capabilities`, and the routing keys a sidecar registers with — and a fourth meaning * `$OFFICER_ROOT/permissions`, and the routing keys a sidecar registers with — and a fourth meaning
* would be one too many. Nothing in this system uses it. * would be one too many. Nothing in this system uses it.
*/ */
export type PluginPermission = { export type PluginPermission = {
+5 -5
View File
@@ -1,7 +1,7 @@
import { listPluginInstalls, type PluginInstall } from 'officerdb'; import { listPluginInstalls, type PluginInstall } from 'officerdb';
import type { MountedPlugin } from '../hono'; import type { MountedPlugin } from '../hono';
import { rebuildHonoApp } from '../hono'; import { rebuildHonoApp } from '../hono';
import { setPluginPermissions, type Capability } from '../capabilities/registry'; import { setPluginPermissions, type Permission } from '../permissions/registry';
import { discoverPlugins } from './discover'; import { discoverPlugins } from './discover';
import { mountPrefix, type DiscoveredPlugin } from './manifest'; import { mountPrefix, type DiscoveredPlugin } from './manifest';
import { generatePluginsModule, rebuildFrontend } from './generate'; import { generatePluginsModule, rebuildFrontend } from './generate';
@@ -99,7 +99,7 @@ export async function mountablePlugins(snapshot: PluginsSnapshot): Promise<Mount
* claim no path — a plugin wanting genuinely separate surfaces needs sub-path claims in the manifest, and * claim no path — a plugin wanting genuinely separate surfaces needs sub-path claims in the manifest, and
* that can be added when something needs it rather than guessed at now. * that can be added when something needs it rather than guessed at now.
*/ */
function pluginPermissions(state: PluginState): Capability[] { function pluginPermissions(state: PluginState): Permission[] {
const { plugin } = state; const { plugin } = state;
const prefix = mountPrefix(plugin); const prefix = mountPrefix(plugin);
@@ -170,7 +170,7 @@ export async function refreshPluginMounts(options: { skipFrontend?: boolean } =
* (`dockItemsFromPlugins`). * (`dockItemsFromPlugins`).
* *
* Presentation comes from the manifest and the route from `mountPrefix`, so there is one source for both * Presentation comes from the manifest and the route from `mountPrefix`, so there is one source for both
* and nothing to keep in step. `capability` is the plugin's first permission when it has one, which is * and nothing to keep in step. `permission` is the plugin's first permission when it has one, which is
* what lets the self endpoint filter the tile out for an account that cannot reach the screen — * what lets the self endpoint filter the tile out for an account that cannot reach the screen —
* a member must not be handed the manifest of a feature they may not use, even to hide it. * a member must not be handed the manifest of a feature they may not use, even to hide it.
* *
@@ -180,7 +180,7 @@ export async function refreshPluginMounts(options: { skipFrontend?: boolean } =
export async function pluginDockManifests(): Promise< export async function pluginDockManifests(): Promise<
Array<{ Array<{
sidecarId: string; sidecarId: string;
capability: string | null; permission: string | null;
name: string; name: string;
icon?: string; icon?: string;
image?: string; image?: string;
@@ -197,7 +197,7 @@ export async function pluginDockManifests(): Promise<
const prefix = mountPrefix(plugin); const prefix = mountPrefix(plugin);
return { return {
sidecarId: plugin.appName, sidecarId: plugin.appName,
capability: plugin.manifest.permissions[0]?.key ?? null, permission: plugin.manifest.permissions[0]?.key ?? null,
name: plugin.manifest.label, name: plugin.manifest.label,
// A shipped `assets/icon.png` wins; the lucide NAME is the fallback for a plugin with no // A shipped `assets/icon.png` wins; the lucide NAME is the fallback for a plugin with no
// artwork of its own. The dock renders `image` as an <img> and `icon` through `resolveIcon`, // artwork of its own. The dock renders `image` as an <img> and `icon` through `resolveIcon`,
@@ -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 // 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. // 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; isOwner: boolean;
capabilities: { key: string; level: CapabilityLevel }[]; permissions: { key: string; level: PermissionLevel }[];
/** Frontend route prefixes the account holds, flattened across its capabilities. */ /** Frontend route prefixes the account holds, flattened across its permissions. */
routes: string[]; routes: string[];
/** /**
* Route prefixes claimed by capabilities the account does NOT hold. Both lists are needed: absence from * 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 capability claims (`/`, the settings shell), and a * `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. * guard that cannot tell those apart either blanks the app or guards nothing.
*/ */
deniedRoutes: string[]; 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[]; unavailable?: string[];
/** /**
* Dock tiles and routes of the sidecars actually installed on this server. * 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 }>; 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 client = useClient();
const { data, isLoading, isError } = useQuery<SelfCapabilities>({ const { data, isLoading, isError } = useQuery<SelfPermissions>({
queryKey: CAPABILITIES_QUERY_KEY, queryKey: PERMISSIONS_QUERY_KEY,
queryFn: () => client.get<SelfCapabilities>('/user/capabilities'), 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 // 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. // they do. A minute matches the `tasks` / `task-categories` caches the rest of the app uses.
staleTime: 60_000, 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 // `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 // 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 // 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. // 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( 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) return true; // see the note above: fail open, the server does not
if (data.isOwner) return true; if (data.isOwner) return true;
const granted = held.get(key); 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, // 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. // which is why `unavailable` is returned as capability keys rather than routes.
const unavailable = new Set(data.unavailable ?? []); 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'; if (data.isOwner || heldAndUnavailable) return 'not-installed';
return 'not-granted'; return 'not-granted';
}, },
@@ -127,7 +127,7 @@ export function useCapabilities() {
return { return {
isOwner: data?.isOwner ?? false, isOwner: data?.isOwner ?? false,
capabilities: held, permissions: held,
routes: data?.routes ?? [], routes: data?.routes ?? [],
unavailable: data?.unavailable ?? [], unavailable: data?.unavailable ?? [],
denialReason, denialReason,
@@ -54,7 +54,7 @@ export function useAppStore() {
// the same screen. // the same screen.
const invalidate = () => { const invalidate = () => {
void queryClient.invalidateQueries({ queryKey: STORE_KEY }); void queryClient.invalidateQueries({ queryKey: STORE_KEY });
void queryClient.invalidateQueries({ queryKey: ['self-capabilities'] }); void queryClient.invalidateQueries({ queryKey: ['self-permissions'] });
}; };
const install = useMutation({ const install = useMutation({
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities'; import { usePermissions } from 'hooks/usePermissions';
import { type TriggerConfig, groupByCategory, matchesTrigger } from './useTasks'; import { type TriggerConfig, groupByCategory, matchesTrigger } from './useTasks';
export type AgentSummary = { export type AgentSummary = {
@@ -20,7 +20,7 @@ export const useAgents = () => {
const client = useClient(); const client = useClient();
// Agents are the `items` capability — skills, tools and agents on the owner's disk — and running one // 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. // 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 allowed = can('items');
const { data: agents = [] } = useQuery<AgentSummary[]>({ const { data: agents = [] } = useQuery<AgentSummary[]>({
@@ -1,6 +1,6 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities'; import { usePermissions } from 'hooks/usePermissions';
export type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' }; 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 // `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 // 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. // 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 allowed = can('tasks');
const { data: tasks = [] } = useQuery<TaskSummary[]>({ const { data: tasks = [] } = useQuery<TaskSummary[]>({
@@ -90,11 +90,11 @@ export function usePlugins() {
queryFn: () => client.get<{ plugins: PluginItem[]; broken: BrokenPlugin[] }>('/plugins'), 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. // and a route the shell has to know about, so refreshing one without the other leaves the two disagreeing.
const invalidate = useCallback(() => { const invalidate = useCallback(() => {
queryClient.invalidateQueries({ queryKey: PLUGINS_KEY }); queryClient.invalidateQueries({ queryKey: PLUGINS_KEY });
queryClient.invalidateQueries({ queryKey: ['self-capabilities'] }); queryClient.invalidateQueries({ queryKey: ['self-permissions'] });
}, [queryClient]); }, [queryClient]);
const [steps, setSteps] = useState<string[]>([]); const [steps, setSteps] = useState<string[]>([]);
@@ -8,7 +8,7 @@ import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
import { SyncBadge } from './SyncBadge'; import { SyncBadge } from './SyncBadge';
import { useSelectedWallet } from './useSelectedWallet'; import { useSelectedWallet } from './useSelectedWallet';
import { useCoinSelection } from './useCoinSelection'; 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 // 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. // freezing is Officer's own flag rather than anything signed.
@@ -17,7 +17,7 @@ import { useCapabilities, useUtxos, useWalletOperations } from './useWalletData'
export const CoinsView = () => { export const CoinsView = () => {
const { walletId, isLoading } = useSelectedWallet(); const { walletId, isLoading } = useSelectedWallet();
const { capabilities } = useCapabilities(walletId); const { capabilities } = usePermissions(walletId);
const supported = capabilities.includes('coinControl'); const supported = capabilities.includes('coinControl');
const { utxos, sync, isLoading: utxosLoading } = useUtxos(walletId, supported); 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 { Amount } from './Amount';
import { EmptyWallet, UnsupportedSection } from './EmptyWallet'; import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
import { useSelectedWallet } from './useSelectedWallet'; 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. // 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 = () => { export const LightningView = () => {
const { walletId, isLoading } = useSelectedWallet(); const { walletId, isLoading } = useSelectedWallet();
const { capabilities } = useCapabilities(walletId); const { capabilities } = usePermissions(walletId);
const hasChannels = capabilities.includes('channels'); const hasChannels = capabilities.includes('channels');
const hasPeers = capabilities.includes('peers'); const hasPeers = capabilities.includes('peers');
const hasPayments = capabilities.includes('lightningSend'); const hasPayments = capabilities.includes('lightningSend');
@@ -8,7 +8,7 @@ import { Amount, UnitToggle } from './Amount';
import { EmptyWallet } from './EmptyWallet'; import { EmptyWallet } from './EmptyWallet';
import { SyncBadge } from './SyncBadge'; import { SyncBadge } from './SyncBadge';
import { useSelectedWallet } from './useSelectedWallet'; 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. // 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 = () => { export const OverviewView = () => {
const { wallet, walletId, isLoading } = useSelectedWallet(); const { wallet, walletId, isLoading } = useSelectedWallet();
const { capabilities } = useCapabilities(walletId); const { capabilities } = usePermissions(walletId);
const { balances, sync, isLoading: balancesLoading } = useBalances(walletId); const { balances, sync, isLoading: balancesLoading } = useBalances(walletId);
const { info } = useWalletInfo(walletId); const { info } = useWalletInfo(walletId);
const { transactions } = useTransactions(walletId, 5); const { transactions } = useTransactions(walletId, 5);
@@ -7,7 +7,7 @@ import { Amount } from './Amount';
import { CopyField } from './CopyField'; import { CopyField } from './CopyField';
import { EmptyWallet, UnsupportedSection } from './EmptyWallet'; import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
import { useSelectedWallet } from './useSelectedWallet'; import { useSelectedWallet } from './useSelectedWallet';
import { errorMessage, useCapabilities, useInvoices, useReceiveAddress } from './useWalletData'; import { errorMessage, usePermissions, useInvoices, useReceiveAddress } from './useWalletData';
import { CreateInvoiceDialog } from './dialogs/CreateInvoiceDialog'; import { CreateInvoiceDialog } from './dialogs/CreateInvoiceDialog';
// Receiving. Works while locked — deriving an address needs the account xpub, not the seed, which is why // 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 = () => { export const ReceiveView = () => {
const { walletId, isLoading } = useSelectedWallet(); const { walletId, isLoading } = useSelectedWallet();
const { capabilities } = useCapabilities(walletId); const { capabilities } = usePermissions(walletId);
const canOnchain = capabilities.includes('onchainReceive'); const canOnchain = capabilities.includes('onchainReceive');
const canLightning = capabilities.includes('lightningReceive'); const canLightning = capabilities.includes('lightningReceive');
@@ -15,7 +15,7 @@ import { UnlockPrompt } from './LockBadge';
import { useSelectedWallet } from './useSelectedWallet'; import { useSelectedWallet } from './useSelectedWallet';
import { useCoinSelection } from './useCoinSelection'; import { useCoinSelection } from './useCoinSelection';
import { useLockCountdown } from './useLockCountdown'; 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'; import { PayInvoiceDialog } from './dialogs/PayInvoiceDialog';
// Sending — the only part of the app that genuinely needs an unlocked wallet. // 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 = () => { export const SendView = () => {
const { wallet, walletId, isLoading } = useSelectedWallet(); const { wallet, walletId, isLoading } = useSelectedWallet();
const { capabilities } = useCapabilities(walletId); const { capabilities } = usePermissions(walletId);
const canOnchain = capabilities.includes('onchainSend'); const canOnchain = capabilities.includes('onchainSend');
const canLightning = capabilities.includes('lightningSend'); const canLightning = capabilities.includes('lightningSend');
@@ -81,7 +81,7 @@ type FormProps = { walletId: number; walletName: string };
const OnchainSendForm = ({ walletId, walletName }: FormProps) => { const OnchainSendForm = ({ walletId, walletName }: FormProps) => {
const { balances } = useBalances(walletId); const { balances } = useBalances(walletId);
const { fees } = useFees(walletId); const { fees } = useFees(walletId);
const { capabilities } = useCapabilities(walletId); const { capabilities } = usePermissions(walletId);
const { selected, clear } = useCoinSelection(); const { selected, clear } = useCoinSelection();
const { utxos } = useUtxos(walletId, capabilities.includes('coinControl')); const { utxos } = useUtxos(walletId, capabilities.includes('coinControl'));
const { send } = useWalletOperations(walletId); const { send } = useWalletOperations(walletId);
@@ -15,7 +15,7 @@ import { Amount, UnitToggle } from './Amount';
import { LockBadge } from './LockBadge'; import { LockBadge } from './LockBadge';
import { useWalletSection } from './useWalletSection'; import { useWalletSection } from './useWalletSection';
import { useSelectedWallet } from './useSelectedWallet'; import { useSelectedWallet } from './useSelectedWallet';
import { useBalances, useCapabilities, useWalletConfig, useWalletLifecycle } from './useWalletData'; import { useBalances, usePermissions, useWalletConfig, useWalletLifecycle } from './useWalletData';
import { CreateWalletDialog } from './dialogs/CreateWalletDialog'; import { CreateWalletDialog } from './dialogs/CreateWalletDialog';
// Left panel of /wallet: the balance, the wallets, the sections, the lock state. // Left panel of /wallet: the balance, the wallets, the sections, the lock state.
@@ -43,7 +43,7 @@ export const WalletNav = () => {
const section = useWalletSection(); const section = useWalletSection();
const { config } = useWalletConfig(); const { config } = useWalletConfig();
const { wallet, wallets, walletId, isPinned, isLoading } = useSelectedWallet(); const { wallet, wallets, walletId, isPinned, isLoading } = useSelectedWallet();
const { capabilities } = useCapabilities(walletId); const { capabilities } = usePermissions(walletId);
const { balances } = useBalances(walletId); const { balances } = useBalances(walletId);
const [createOpen, setCreateOpen] = useState(false); const [createOpen, setCreateOpen] = useState(false);
@@ -12,7 +12,7 @@ import { LockBadge } from './LockBadge';
import { RescanCard } from './RescanCard'; import { RescanCard } from './RescanCard';
import { useSelectedWallet } from './useSelectedWallet'; import { useSelectedWallet } from './useSelectedWallet';
import { useLockCountdown } from './useLockCountdown'; import { useLockCountdown } from './useLockCountdown';
import { useCapabilities, useWalletConfig, useWalletLifecycle } from './useWalletData'; import { usePermissions, useWalletConfig, useWalletLifecycle } from './useWalletData';
import { ChangePassphraseDialog } from './dialogs/ChangePassphraseDialog'; import { ChangePassphraseDialog } from './dialogs/ChangePassphraseDialog';
import { ExportSeedDialog } from './dialogs/ExportSeedDialog'; import { ExportSeedDialog } from './dialogs/ExportSeedDialog';
import { DeleteWalletDialog } from './dialogs/DeleteWalletDialog'; import { DeleteWalletDialog } from './dialogs/DeleteWalletDialog';
@@ -25,7 +25,7 @@ export const WalletSettingsView = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const { wallet, walletId, isLoading } = useSelectedWallet(); const { wallet, walletId, isLoading } = useSelectedWallet();
const { config } = useWalletConfig(); const { config } = useWalletConfig();
const { capabilities, kind } = useCapabilities(walletId); const { capabilities, kind } = usePermissions(walletId);
const { hasSeed } = useLockCountdown(walletId); const { hasSeed } = useLockCountdown(walletId);
const { activate, rename } = useWalletLifecycle(); 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 }; 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 { get } = useClient();
const query = useQuery({ const query = useQuery({
+2 -2
View File
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useCapabilities } from 'hooks/useCapabilities'; import { usePermissions } from 'hooks/usePermissions';
type AccessPolicy = { type AccessPolicy = {
allowedModels: string[]; allowedModels: string[];
@@ -14,7 +14,7 @@ export function useAccessPolicy() {
const client = useClient(); const client = useClient();
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
// Under `/server-settings`, so `server-admin`: owner only. Authentication alone was never the right gate. // 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 queryClient = useQueryClient();
const { data: policy = { allowedModels: [] } } = useQuery<AccessPolicy>({ const { data: policy = { allowedModels: [] } } = useQuery<AccessPolicy>({
+2 -2
View File
@@ -1,7 +1,7 @@
import { useQuery } from '@tanstack/react-query'; import { useQuery } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useAuth } from 'hooks/useAuth'; import { useAuth } from 'hooks/useAuth';
import { useCapabilities } from 'hooks/useCapabilities'; import { usePermissions } from 'hooks/usePermissions';
import { useAccessPolicy } from './useAccessPolicy'; import { useAccessPolicy } from './useAccessPolicy';
import { useSettings } from './useSettings'; import { useSettings } from './useSettings';
import type { ModelOption } from 'officerdev'; import type { ModelOption } from 'officerdev';
@@ -29,7 +29,7 @@ export function useModels() {
const { isAuthenticated } = useAuth(); const { isAuthenticated } = useAuth();
// `/chat` is `kind: 'execution'` — the agent runs unsandboxed as the server owner, so it is owner-only and // `/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. // 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[]>({ const { data: models = [] } = useQuery<ModelOption[]>({
queryKey: ['CHAT_MODELS'], queryKey: ['CHAT_MODELS'],
@@ -1,7 +1,7 @@
import { useCallback } from 'react'; import { useCallback } from 'react';
import { useQuery, useQueryClient } from '@tanstack/react-query'; import { useQuery, useQueryClient } from '@tanstack/react-query';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
import { useCapabilities } from 'hooks/useCapabilities'; import { usePermissions } from 'hooks/usePermissions';
type AIHarnesses = { type AIHarnesses = {
claudeCode: boolean; 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 // `/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 // hook is mounted by shell components that every account loads, so without the guard a member's first
// paint fired a 403 at it. // paint fired a 403 at it.
const { isOwner } = useCapabilities(); const { isOwner } = usePermissions();
const { data: settings, isLoading } = useQuery({ const { data: settings, isLoading } = useQuery({
queryKey: SETTINGS_KEY, queryKey: SETTINGS_KEY,