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:
@@ -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);
|
||||||
|
|||||||
+22
-22
@@ -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
@@ -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,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';
|
||||||
|
|||||||
+3
-3
@@ -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');
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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:
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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`.
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ──
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
+52
-41
@@ -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 });
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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!,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 machine’s display in the browser',
|
summary: 'Mirror this machine’s 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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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`;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
+57
-57
@@ -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 };
|
||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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`,
|
||||||
|
|||||||
+17
-17
@@ -14,20 +14,20 @@ import { useClient } from './useClient';
|
|||||||
// minute; an owner locked out of their own platform by a transient network error is an incident. The
|
// 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,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>({
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user