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 { Link, useLocation, useNavigate } from 'react-router';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { Play, Pause, SkipBack, SkipForward, X, Volume2, VolumeX, Loader2, MicVocal } from 'lucide-react';
|
||||
import { SeekBar } from 'officerdev';
|
||||
import { MusicHeart } from './MusicHeart';
|
||||
@@ -24,7 +24,7 @@ const MUSIC_API = '/api/music';
|
||||
|
||||
export const MusicPlayerHost = () => {
|
||||
const { token, get, put, delete: del } = useClient();
|
||||
const { can } = useCapabilities();
|
||||
const { can } = usePermissions();
|
||||
const canUseMusic = can('music');
|
||||
const navigate = useNavigate();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { useLocation } from 'react-router';
|
||||
import { useDock, usePanelFullscreen } from 'officerdev';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { ErrorBoundary } from '@/components/ErrorBoundary';
|
||||
import { ScreenErrorFallback } from './ScreenErrorFallback';
|
||||
import { Background } from './Background';
|
||||
@@ -15,7 +15,7 @@ type DashboardLayoutProps = {
|
||||
children?: React.ReactNode;
|
||||
};
|
||||
export function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
const { canVisit, plugins } = useCapabilities();
|
||||
const { canVisit, plugins } = usePermissions();
|
||||
// Filtered BEFORE useDock, so a member's saved dock order cannot resurrect an icon their role no longer
|
||||
// reaches, and so the pinned-item defaults fall back to something they can actually open. Cosmetic
|
||||
// either way — every one of these routes is refused server-side too — but an app that offers a door it
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useEffect, useRef, useState } from 'react';
|
||||
import { NavLink } from 'react-router';
|
||||
import type { LucideIcon } from 'lucide-react';
|
||||
import { resolveIcon } from 'officerdev';
|
||||
import type { PluginManifest } from 'hooks/useCapabilities';
|
||||
import type { PluginManifest } from 'hooks/usePermissions';
|
||||
|
||||
export type DockItem = {
|
||||
label: string;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useState, useEffect } from 'react';
|
||||
import { Link } from 'react-router';
|
||||
import { Loader2, ListOrdered } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
|
||||
type Counts = { running: number; runningJobId: string | null; queued: number };
|
||||
|
||||
@@ -14,7 +14,7 @@ type Counts = { running: number; runningJobId: string | null; queued: number };
|
||||
// links to a screen they cannot open. Neither is a security problem; both are the app lying about what it is.
|
||||
export const JobsIndicator = () => {
|
||||
const client = useClient();
|
||||
const { can } = useCapabilities();
|
||||
const { can } = usePermissions();
|
||||
const allowed = can('tasks');
|
||||
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { RotateCw } from 'lucide-react';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
|
||||
type RescanResponse = { ok: boolean; counts: Record<string, number> };
|
||||
|
||||
@@ -14,7 +14,7 @@ const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'process
|
||||
export function RescanButton() {
|
||||
const client = useClient();
|
||||
const qc = useQueryClient();
|
||||
const { can } = useCapabilities();
|
||||
const { can } = usePermissions();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Navigate, useLocation } from 'react-router';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
|
||||
// A screen exists only if this server has the thing behind it and this account may reach it. Otherwise the
|
||||
// path is treated exactly as an unknown one: redirect home, same as App.tsx's `path="*"`.
|
||||
@@ -27,7 +27,7 @@ import { useCapabilities } from 'hooks/useCapabilities';
|
||||
|
||||
export function RouteGate({ children }: { children?: React.ReactNode }) {
|
||||
const { pathname } = useLocation();
|
||||
const { denialReason } = useCapabilities();
|
||||
const { denialReason } = usePermissions();
|
||||
|
||||
// `replace`, so Back does not bounce between the denied path and home.
|
||||
if (denialReason(pathname)) return <Navigate to="/" replace />;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, useState, useCallback, type DragEvent } from 'react';
|
||||
import { X, Plus, RotateCcw } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useDock } from 'officerdev';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { CORE_DOCK_ITEMS, dockItemsFromPlugins, DEFAULT_DOCK_PATHS } from '@/Screens/Dashboard/Layout/Dock';
|
||||
|
||||
type DockPillProps = {
|
||||
@@ -106,7 +106,7 @@ const DropZone = ({ label, children, onDrop }: DropZoneProps) => {
|
||||
export const DockSettings = () => {
|
||||
// Same composition as the dock itself. Offering a pin for an uninstalled feature would let someone
|
||||
// pin a tile that cannot appear, which reads as the setting being broken.
|
||||
const { plugins } = useCapabilities();
|
||||
const { plugins } = usePermissions();
|
||||
const allDockItems = useMemo(() => [...CORE_DOCK_ITEMS, ...dockItemsFromPlugins(plugins)], [plugins]);
|
||||
const { items, allItems, setItems, reset } = useDock(allDockItems, DEFAULT_DOCK_PATHS);
|
||||
const [dropTarget, setDropTarget] = useState<{ path: string; side: 'left' | 'right' } | null>(null);
|
||||
|
||||
+22
-22
@@ -3,18 +3,18 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { CAPABILITIES_QUERY_KEY } from 'hooks/useCapabilities';
|
||||
import { PERMISSIONS_QUERY_KEY } from 'hooks/usePermissions';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
||||
|
||||
// What each ROLE may reach. Not each user — see the table comment in schema/capabilities.ts for why.
|
||||
// What each ROLE may reach. Not each user — see the table comment in schema/permissions.ts for why.
|
||||
//
|
||||
// The screen is one role at a time on purpose. A grid of every role against every capability is the
|
||||
// The screen is one role at a time on purpose. A grid of every role against every permission is the
|
||||
// obvious design and it is the wrong one: it invites reading across rows, which is not a question anyone
|
||||
// has, and it makes the destructive action ("uncheck Gitea for Members") a single click among fifty. One
|
||||
// role, an explicit Save, and a visible dirty state instead.
|
||||
|
||||
type CapabilityInfo = {
|
||||
type PermissionInfo = {
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
@@ -22,11 +22,11 @@ type CapabilityInfo = {
|
||||
hasPersonalWrites: boolean;
|
||||
};
|
||||
|
||||
type Grant = { role: string; capability: string; level: 'read' | 'write' };
|
||||
type Grant = { role: string; permission: string; level: 'read' | 'write' };
|
||||
|
||||
type CapabilitiesResponse = {
|
||||
type PermissionsResponse = {
|
||||
/** Grantable AND installed. What this server can currently do. */
|
||||
capabilities: CapabilityInfo[];
|
||||
permissions: PermissionInfo[];
|
||||
|
||||
roles: string[];
|
||||
grants: Grant[];
|
||||
@@ -43,9 +43,9 @@ export const PermissionsSection = () => {
|
||||
const [draft, setDraft] = useState<Record<string, Level>>({});
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const { data, isLoading, isError } = useQuery<CapabilitiesResponse>({
|
||||
const { data, isLoading, isError } = useQuery<PermissionsResponse>({
|
||||
queryKey: PERMISSIONS_KEY,
|
||||
queryFn: () => client.get<CapabilitiesResponse>('/users/capabilities'),
|
||||
queryFn: () => client.get<PermissionsResponse>('/users/permissions'),
|
||||
});
|
||||
|
||||
const activeRole = role ?? data?.roles[0] ?? null;
|
||||
@@ -53,9 +53,9 @@ export const PermissionsSection = () => {
|
||||
// What the server currently says, for this role. The comparison baseline for the dirty state below.
|
||||
const saved = useMemo(() => {
|
||||
const levels: Record<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 ?? []) {
|
||||
if (grant.role === activeRole) levels[grant.capability] = grant.level;
|
||||
if (grant.role === activeRole) levels[grant.permission] = grant.level;
|
||||
}
|
||||
return levels;
|
||||
}, [data, activeRole]);
|
||||
@@ -72,12 +72,12 @@ export const PermissionsSection = () => {
|
||||
try {
|
||||
const grants = Object.entries(draft)
|
||||
.filter(([, level]) => level !== 'none')
|
||||
.map(([capability, level]) => ({ capability, level }));
|
||||
await client.put(`/users/capabilities/${encodeURIComponent(activeRole)}`, { grants });
|
||||
.map(([permission, level]) => ({ permission, level }));
|
||||
await client.put(`/users/permissions/${encodeURIComponent(activeRole)}`, { grants });
|
||||
await queryClient.invalidateQueries({ queryKey: PERMISSIONS_KEY });
|
||||
// The owner may be editing their own view's inputs — and anyone already signed in needs the dock to
|
||||
// catch up without a reload.
|
||||
await queryClient.invalidateQueries({ queryKey: CAPABILITIES_QUERY_KEY });
|
||||
await queryClient.invalidateQueries({ queryKey: PERMISSIONS_QUERY_KEY });
|
||||
toast.success(`Saved what ${activeRole}s can reach`);
|
||||
} catch (ex) {
|
||||
toast.error(ex instanceof Error ? ex.message : 'Could not save');
|
||||
@@ -89,12 +89,12 @@ export const PermissionsSection = () => {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
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 (
|
||||
@@ -134,18 +134,18 @@ export const PermissionsSection = () => {
|
||||
</p>
|
||||
|
||||
<div className="divide-y rounded-lg border">
|
||||
{data.capabilities.map((capability) => {
|
||||
const level = draft[capability.key] ?? 'none';
|
||||
{data.permissions.map((permission) => {
|
||||
const level = draft[permission.key] ?? 'none';
|
||||
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
|
||||
they explained nothing anyone needed — and the "needs a Linux account" line went with them:
|
||||
every account gets one at creation, so warning about it on every row was noise about a state
|
||||
that no longer occurs on its own. */}
|
||||
<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
|
||||
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">
|
||||
<SelectValue />
|
||||
@@ -161,7 +161,7 @@ export const PermissionsSection = () => {
|
||||
})}
|
||||
</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
|
||||
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
|
||||
|
||||
+3
-3
@@ -3,7 +3,7 @@ import type { ServerWebSocket } from 'bun';
|
||||
import { serve } from 'bun';
|
||||
import { join } from 'node:path';
|
||||
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 { generatePluginsModule, rebuildFrontend, BUILD_DIR, SHELL_FILE } from './servers/plugins/generate';
|
||||
import { assertInstallLayout } from './servers/data-path';
|
||||
@@ -11,7 +11,7 @@ import { PORT } from './servers/officer-url.mjs';
|
||||
import { assertSecretsClosed } from './servers/os-user';
|
||||
import { resolveHomeDir } from './servers/user-home';
|
||||
import { resolveAuthToken } from './servers/auth-token';
|
||||
import { isWsProviderAllowed } from './servers/capabilities/authorize';
|
||||
import { isWsProviderAllowed } from './servers/permissions/authorize';
|
||||
import { isTokenBlacklisted, getUserById } from 'officerdb';
|
||||
import { terminalWebsocket } from './servers/api/terminal/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
|
||||
// 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.
|
||||
assertCapabilityTotality({
|
||||
assertPermissionTotality({
|
||||
apiPrefixes: [...PROTECTED_API_PREFIXES, ...UNPROTECTED_API_PREFIXES],
|
||||
wsProviders: Object.keys(handlers),
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
export * from './body-parser';
|
||||
export * from './user-middleware';
|
||||
export * from './origin-middleware';
|
||||
export * from './capability-gate';
|
||||
export * from './permission-gate';
|
||||
export * from './rate-limiter';
|
||||
export * from './known-users';
|
||||
export * from './auth-audit';
|
||||
|
||||
+3
-3
@@ -2,8 +2,8 @@ import type { MiddlewareHandler } from 'hono';
|
||||
import * as errors from '../custom-errors';
|
||||
import { resolveAuthToken } from '../auth-token';
|
||||
import { isSuperAdmin } from '../super-admin';
|
||||
import { isApiRequestAllowed } from '../capabilities/authorize';
|
||||
import { isExemptApiPath } from '../capabilities/totality';
|
||||
import { isApiRequestAllowed } from '../permissions/authorize';
|
||||
import { isExemptApiPath } from '../permissions/totality';
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
export const capabilityGateMiddleware: MiddlewareHandler = async function (ctx, next) {
|
||||
export const permissionGateMiddleware: MiddlewareHandler = async function (ctx, next) {
|
||||
const path = ctx.req.path;
|
||||
|
||||
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 —
|
||||
// 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 {
|
||||
// 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.
|
||||
//
|
||||
// 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:
|
||||
//
|
||||
// {"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
|
||||
// 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 = {
|
||||
job?: string;
|
||||
|
||||
@@ -7,7 +7,7 @@ import { logger } from '../chat/logger';
|
||||
* 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
|
||||
* `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
|
||||
* 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"
|
||||
// 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
|
||||
// would just replace an explanation with a silence.
|
||||
//
|
||||
|
||||
@@ -174,7 +174,7 @@ export async function startAgentRun(params: StartAgentRunParams): Promise<StartA
|
||||
titleRun(
|
||||
// `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
|
||||
// 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.
|
||||
{ email: params.user.email, home: homeDir, osUser: null, isOwner: true },
|
||||
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
|
||||
// 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
|
||||
// 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();
|
||||
|
||||
|
||||
@@ -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
|
||||
// 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
|
||||
// mistake here is not a leak of data, it is arbitrary process control.
|
||||
//
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { Handler } from 'hono';
|
||||
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 * as errors from '@@/custom-errors';
|
||||
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')) {
|
||||
await replaceRoleGrants(
|
||||
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) {
|
||||
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
|
||||
|
||||
@@ -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
|
||||
// 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.
|
||||
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* 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`.
|
||||
*/
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
// 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
|
||||
// `chat` is one capability covering both harnesses, and splitting it would strand the grants already issued.
|
||||
// identity the way `spawnClaudeAsMember` does. Restrict here rather than at the permission layer because
|
||||
// `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`.
|
||||
import { transcribeAudio } from '../stt/transcribe';
|
||||
import { registerAgentPanelRoutes } from './agent-panels-routes';
|
||||
|
||||
@@ -40,7 +40,7 @@ export function invalidateModelCache(): void {
|
||||
}
|
||||
|
||||
type OpenCodeModel = {
|
||||
capabilities?: { input?: { image?: boolean }; reasoning?: boolean };
|
||||
permissions?: { input?: { image?: boolean }; reasoning?: boolean };
|
||||
};
|
||||
|
||||
type ProvidersResponse = {
|
||||
@@ -69,13 +69,13 @@ async function listOpenCodeModels(): Promise<ModelInfo[]> {
|
||||
provider: providerId,
|
||||
contextWindow: 200000,
|
||||
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
|
||||
// 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
|
||||
// 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.
|
||||
images: model?.capabilities?.input?.image ?? false,
|
||||
// that does not declare the permission keeps the affordance hidden rather than offering it.
|
||||
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.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// ── This is not the app store ──
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { getUserSettings, setUserSettings, getUserState, patchUserState } from 'officerdb';
|
||||
import { selfCapabilitiesRouter } from '../users/capabilities-routes';
|
||||
import { selfPermissionsRouter } from '../users/permissions-routes';
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
chat: {
|
||||
@@ -17,9 +17,9 @@ const DEFAULT_SETTINGS = {
|
||||
|
||||
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.
|
||||
settingsRouter.route('/', selfCapabilitiesRouter);
|
||||
settingsRouter.route('/', selfPermissionsRouter);
|
||||
|
||||
// GET /settings — return user settings from DB, default if empty
|
||||
settingsRouter.get('/settings', async (ctx) => {
|
||||
|
||||
+52
-41
@@ -4,36 +4,36 @@ import * as errors from '@@/custom-errors';
|
||||
import { isSuperAdmin } from '../../super-admin';
|
||||
import { getAllRoleGrants, replaceRoleGrants, USER_ROLES } from 'officerdb';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import { CAPABILITIES, GRANTABLE_CAPABILITIES, CAPABILITY_BY_KEY } from '../../capabilities/registry';
|
||||
import { getEffectiveCapabilities, invalidateRoleGrants } from '../../capabilities/authorize';
|
||||
import { capabilityAvailability } from '../../app-store/availability';
|
||||
import { PERMISSIONS, GRANTABLE_PERMISSIONS, PERMISSION_BY_KEY } from '../../permissions/registry';
|
||||
import { getEffectivePermissions, invalidateRoleGrants } from '../../permissions/authorize';
|
||||
import { permissionAvailability } from '../../app-store/availability';
|
||||
import { pluginDockManifests } from '../../plugins/mount';
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
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();
|
||||
};
|
||||
|
||||
/** What the caller may reach. Mounted under /api/user, which is a `core` capability, so nobody is 403'd. */
|
||||
export const selfCapabilitiesRouter = createRouter();
|
||||
/** What the caller may reach. Mounted under /api/user, which is a `core` permission, so nobody is 403'd. */
|
||||
export const selfPermissionsRouter = createRouter();
|
||||
|
||||
selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
|
||||
selfPermissionsRouter.get('/permissions', async (ctx) => {
|
||||
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
|
||||
// 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.
|
||||
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
|
||||
// catalogue; when it is rebuilt on the plugin system this becomes one.
|
||||
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
|
||||
// remember to special-case. One shape for both audiences means one code path in the UI.
|
||||
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 }));
|
||||
|
||||
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
|
||||
// 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));
|
||||
|
||||
return ctx.json({
|
||||
isOwner,
|
||||
capabilities: held,
|
||||
/** Capabilities the account holds whose sidecar is not installed or is disabled. */
|
||||
permissions: held,
|
||||
/** Permissions the account holds whose sidecar is not installed or is disabled. */
|
||||
unavailable: [...unavailable].filter((key) => heldKeys.has(key)),
|
||||
/**
|
||||
* 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
|
||||
* privacy that lasts until someone opens the network tab.
|
||||
*/
|
||||
plugins: [...manifests, ...pluginManifests].filter((m) => !m.capability || heldKeys.has(m.capability)),
|
||||
// Flattened for the dock and the route guard, which care about paths rather than capability keys.
|
||||
routes: usable.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []),
|
||||
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 permission keys.
|
||||
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
|
||||
// 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.
|
||||
// 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
|
||||
// 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 ?? [],
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
/** 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.
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// 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,
|
||||
label: c.label,
|
||||
description: c.description,
|
||||
@@ -104,34 +104,44 @@ capabilityAdminRouter.get('/capabilities', ownerGate, async (ctx) => {
|
||||
return ctx.json({
|
||||
// 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.
|
||||
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
|
||||
// database refuses a row for that role.
|
||||
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;
|
||||
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');
|
||||
|
||||
const body = ctx.get('body') as { grants?: unknown } | undefined;
|
||||
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' }[] = [];
|
||||
for (const entry of raw) {
|
||||
const { capability, level } = (entry ?? {}) as { capability?: unknown; level?: unknown };
|
||||
if (typeof capability !== 'string') throw errors.BAD_REQUEST('Each grant needs a capability key');
|
||||
if (level !== 'read' && level !== 'write') throw errors.BAD_REQUEST(`Bad level for '${capability}'`);
|
||||
const { permission, level } = (entry ?? {}) as { permission?: unknown; level?: unknown };
|
||||
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 '${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
|
||||
// grant the resolver would drop on read anyway.
|
||||
const known = CAPABILITY_BY_KEY.get(capability);
|
||||
if (!known) throw errors.BAD_REQUEST(`Unknown capability '${capability}'`);
|
||||
const known = PERMISSION_BY_KEY.get(permission);
|
||||
if (!known) throw errors.BAD_REQUEST(`Unknown permission '${permission}'`);
|
||||
if (known.kind !== 'app' && known.kind !== 'confined') {
|
||||
throw errors.BAD_REQUEST(
|
||||
known.kind === 'execution'
|
||||
@@ -139,12 +149,13 @@ capabilityAdminRouter.put('/capabilities/:role', ownerGate, async (ctx) => {
|
||||
: `${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);
|
||||
// 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);
|
||||
|
||||
return ctx.json({ role, grants });
|
||||
@@ -8,7 +8,7 @@ import { listUsersHandler, updateUserRoleHandler, deleteUserHandler } from './ma
|
||||
import { createUserHandler } from './create-user';
|
||||
import { provisionLinuxHandler } from './provision-linux-route';
|
||||
import { resetUserPasswordHandler } from './reset-user-password';
|
||||
import { capabilityAdminRouter } from './capabilities-routes';
|
||||
import { permissionAdminRouter } from './permissions-routes';
|
||||
|
||||
export const usersRouter = createRouter();
|
||||
usersRouter.use(originMiddleware);
|
||||
@@ -16,7 +16,7 @@ usersRouter.use(originMiddleware);
|
||||
// Self-update. Any signed-in account may change its own name, username and avatar.
|
||||
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
|
||||
// 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
|
||||
@@ -37,5 +37,5 @@ usersRouter.post('/:id/provision-linux', ownerGate, provisionLinuxHandler);
|
||||
usersRouter.post('/:id/password', ownerGate, resetUserPasswordHandler);
|
||||
usersRouter.delete('/:id', ownerGate, deleteUserHandler);
|
||||
|
||||
// Which capabilities each role holds. Owner-gated inside its own router.
|
||||
usersRouter.route('/', capabilityAdminRouter);
|
||||
// Which permissions each role holds. Owner-gated inside its own router.
|
||||
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
|
||||
// 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
|
||||
// a Photos on this machine at all", and the owner is as subject to it as anyone — installing nothing
|
||||
// leaves nothing to use.
|
||||
@@ -17,20 +17,20 @@ import { CATALOGUE, type CatalogueEntry } from './catalogue';
|
||||
//
|
||||
// ── 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
|
||||
// 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
|
||||
* 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) =>
|
||||
[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]>,
|
||||
);
|
||||
|
||||
@@ -38,17 +38,17 @@ export type Availability = {
|
||||
/**
|
||||
* 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
|
||||
* uninstalled or nothing for something installed.
|
||||
*/
|
||||
manifests: Array<{ sidecarId: string; capability: string | null } & NonNullable<CatalogueEntry['ui']>>;
|
||||
/** Capability keys whose sidecar is not installed, or is installed but disabled. */
|
||||
manifests: Array<{ sidecarId: string; permission: string | null } & NonNullable<CatalogueEntry['ui']>>;
|
||||
/** Permission keys whose sidecar is not installed, or is installed but disabled. */
|
||||
unavailable: Set<string>;
|
||||
/**
|
||||
* 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
|
||||
* 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
|
||||
* process and its container, so the feature genuinely does not work — leaving its icon in place would
|
||||
* make disable look broken rather than effective.
|
||||
*/
|
||||
export async function capabilityAvailability(): Promise<Availability> {
|
||||
export async function permissionAvailability(): Promise<Availability> {
|
||||
const unavailable = new Set<string>();
|
||||
|
||||
let installs;
|
||||
@@ -70,7 +70,7 @@ export async function capabilityAvailability(): Promise<Availability> {
|
||||
installs = await listSidecarInstalls();
|
||||
} catch {
|
||||
// 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 };
|
||||
}
|
||||
|
||||
@@ -78,13 +78,13 @@ export async function capabilityAvailability(): Promise<Availability> {
|
||||
installs.filter((row) => row.status === 'installed' && row.enabled).map((row) => row.sidecarId),
|
||||
);
|
||||
|
||||
for (const [capability, sidecarId] of CAPABILITY_TO_SIDECAR) {
|
||||
if (!usable.has(sidecarId)) unavailable.add(capability);
|
||||
for (const [permission, sidecarId] of PERMISSION_TO_SIDECAR) {
|
||||
if (!usable.has(sidecarId)) unavailable.add(permission);
|
||||
}
|
||||
|
||||
const manifests = CATALOGUE.filter((e) => e.ui && usable.has(e.id)).map((e) => ({
|
||||
sidecarId: e.id,
|
||||
capability: e.capability,
|
||||
permission: e.permission,
|
||||
...e.ui!,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'bun:test';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
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
|
||||
// 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', () => {
|
||||
it('names a capability that exists, or explicitly none', () => {
|
||||
describe('permissions it claims to back', () => {
|
||||
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
|
||||
// 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) {
|
||||
if (entry.capability === null) continue;
|
||||
expect(keys).toContain(entry.capability);
|
||||
if (entry.permission === null) continue;
|
||||
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
|
||||
// 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) {
|
||||
if (!entry.ui || !entry.capability) continue;
|
||||
const declared = byKey.get(entry.capability)?.routes ?? [];
|
||||
if (!entry.ui || !entry.permission) continue;
|
||||
const declared = byKey.get(entry.permission)?.routes ?? [];
|
||||
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.
|
||||
*
|
||||
* 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
|
||||
* 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. */
|
||||
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).
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
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
|
||||
* `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
|
||||
* 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',
|
||||
members: 'accounts',
|
||||
modes: ['existing', 'provisioned'],
|
||||
capability: 'photos',
|
||||
permission: 'photos',
|
||||
composeTemplate: 'immich',
|
||||
existingFields: [
|
||||
{ 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',
|
||||
members: 'accounts',
|
||||
modes: ['existing', 'provisioned'],
|
||||
capability: 'jellyfin',
|
||||
permission: 'jellyfin',
|
||||
composeTemplate: 'jellyfin',
|
||||
existingFields: [
|
||||
{ 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',
|
||||
members: 'accounts',
|
||||
modes: ['existing', 'provisioned'],
|
||||
capability: 'memos',
|
||||
permission: 'memos',
|
||||
composeTemplate: 'memos',
|
||||
existingFields: [
|
||||
{ key: 'url', label: 'Memos URL', type: 'url', required: true },
|
||||
@@ -222,7 +222,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
||||
summary: 'InvoiceShelf — clients, estimates and invoices',
|
||||
members: 'accounts',
|
||||
modes: ['existing', 'provisioned'],
|
||||
capability: 'invoices',
|
||||
permission: 'invoices',
|
||||
composeTemplate: 'invoiceshelf',
|
||||
existingFields: [
|
||||
{ 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',
|
||||
members: 'invite',
|
||||
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
|
||||
// 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
|
||||
// `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.
|
||||
// The install still governs whether the sidecar runs at all.
|
||||
capability: null,
|
||||
permission: null,
|
||||
composeTemplate: 'vaultwarden',
|
||||
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',
|
||||
members: 'none',
|
||||
modes: ['existing', 'provisioned'],
|
||||
capability: 'transmission',
|
||||
permission: 'transmission',
|
||||
composeTemplate: 'transmission',
|
||||
existingFields: [
|
||||
{
|
||||
@@ -306,7 +306,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
||||
summary: 'slskd — search and download from the Soulseek network',
|
||||
members: 'none',
|
||||
modes: ['existing', 'provisioned'],
|
||||
capability: 'soulseek',
|
||||
permission: 'soulseek',
|
||||
composeTemplate: 'slskd',
|
||||
existingFields: [
|
||||
{ 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',
|
||||
members: 'accounts',
|
||||
modes: ['existing', 'provisioned'],
|
||||
capability: 'calendar',
|
||||
permission: 'calendar',
|
||||
composeTemplate: 'radicale',
|
||||
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,
|
||||
// backup and upgrade story for a service that is nobody's side feature.
|
||||
modes: ['existing'],
|
||||
capability: 'gitea',
|
||||
permission: 'gitea',
|
||||
existingFields: [
|
||||
{ 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',
|
||||
members: 'none',
|
||||
modes: ['config'],
|
||||
capability: 'email',
|
||||
permission: 'email',
|
||||
// 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.
|
||||
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
|
||||
// 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
|
||||
// `/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
|
||||
@@ -410,7 +410,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
||||
summary: 'Bitcoin and Lightning, with keys held by the sidecar alone',
|
||||
members: 'none',
|
||||
modes: ['config'],
|
||||
capability: 'wallet',
|
||||
permission: 'wallet',
|
||||
configFields: [],
|
||||
},
|
||||
{
|
||||
@@ -420,7 +420,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
||||
summary: 'Push to your phone when a job finishes or a turn needs you',
|
||||
members: 'none',
|
||||
modes: ['config'],
|
||||
capability: 'notify',
|
||||
permission: 'notify',
|
||||
configFields: [],
|
||||
},
|
||||
{
|
||||
@@ -431,7 +431,7 @@ export const CATALOGUE: CatalogueEntry[] = [
|
||||
summary: 'Mirror this machine’s display in the browser',
|
||||
members: 'none',
|
||||
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
|
||||
// store should say so rather than install something that starts and immediately fails.
|
||||
requires: 'linux-display',
|
||||
|
||||
@@ -11,7 +11,7 @@ import { OFFICER_ROOT } from '../data-path';
|
||||
// platform/ the app
|
||||
// data/ DATA_PATH — managed homes, attachments, job logs
|
||||
// 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
|
||||
// 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 ──
|
||||
//
|
||||
// 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
|
||||
// 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
|
||||
// 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`
|
||||
// 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.
|
||||
*
|
||||
* 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(), '..')`,
|
||||
* 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
|
||||
|
||||
+11
-11
@@ -48,7 +48,7 @@ import { integrationsRouter, googleCallbackHandler } from './api/integrations/in
|
||||
import { queueRouter } from './api/queue/queue';
|
||||
// import { emailRouter } from './api/email/router';
|
||||
// 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 { desktopRouter } from './api/desktop/rest';
|
||||
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 { pipelineJobsRouter } from './api/tasks/pipeline-jobs-routes';
|
||||
import { CustomError } from './custom-errors';
|
||||
import { userMiddleware, bodyParser, capabilityGateMiddleware } from './_middlewares';
|
||||
import { userMiddleware, bodyParser, permissionGateMiddleware } from './_middlewares';
|
||||
|
||||
export { Hono };
|
||||
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
|
||||
// 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
|
||||
// capability gate below.
|
||||
// permission gate below.
|
||||
const corsMiddleware = cors({
|
||||
origin: (origin) => origin ?? '*',
|
||||
allowMethods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
|
||||
@@ -110,8 +110,8 @@ const isDavPath = (path: string) =>
|
||||
|
||||
// The mount table, as DATA rather than forty statements.
|
||||
//
|
||||
// The reason is the capability registry: assertCapabilityTotality 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
|
||||
// The reason is the permission registry: assertPermissionTotality refuses to boot unless every mounted
|
||||
// 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
|
||||
// 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],
|
||||
['/upload', uploadRouter],
|
||||
['/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],
|
||||
['/file-browser', fileBrowserRouter],
|
||||
// ['/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
|
||||
];
|
||||
|
||||
/** 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);
|
||||
|
||||
/**
|
||||
* Mounted above the account gate, and so exempt from capability checks — see EXEMPT_API_PREFIXES in
|
||||
* capabilities/totality.ts, which has to justify each one.
|
||||
* Mounted above the account gate, and so exempt from permission checks — see EXEMPT_API_PREFIXES in
|
||||
* permissions/totality.ts, which has to justify each one.
|
||||
*/
|
||||
export const UNPROTECTED_API_PREFIXES: string[] = [
|
||||
'/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
|
||||
// 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.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
|
||||
// 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
|
||||
// peer on that panel's own dashboard. See servers/api/agent-handoff/router.ts.
|
||||
app.route('/api/agent-handoff', agentHandoffRouter);
|
||||
|
||||
@@ -31,7 +31,7 @@ import { runAs } from './os-user';
|
||||
// ── The costs, stated rather than discovered ──
|
||||
//
|
||||
// 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. */
|
||||
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
|
||||
// `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
|
||||
// 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.
|
||||
// - 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.
|
||||
|
||||
@@ -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
|
||||
* 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
|
||||
* 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.
|
||||
*
|
||||
* 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
|
||||
* 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
|
||||
* 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}`),
|
||||
'',
|
||||
'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}`),
|
||||
'',
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
import { getUserById, getRoleGrants } from 'officerdb';
|
||||
import type { UserRole } from 'officerdb';
|
||||
import {
|
||||
CAPABILITY_BY_KEY,
|
||||
PERMISSION_BY_KEY,
|
||||
CORE_CAPABILITIES,
|
||||
capabilityForApiPath,
|
||||
capabilityForWsProvider,
|
||||
permissionForApiPath,
|
||||
permissionForWsProvider,
|
||||
isRequestAllowedAtLevel,
|
||||
isSelfServiceRoute,
|
||||
type CapabilityLevel,
|
||||
type PermissionLevel,
|
||||
} from './registry';
|
||||
|
||||
// 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
|
||||
// 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.
|
||||
// An unrecognised capability, a missing row, a database error, a user who no longer exists: all deny.
|
||||
// 2. Everyone else gets core permissions plus whatever their ROLE has been granted, and nothing else.
|
||||
// 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
|
||||
// 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.
|
||||
|
||||
export type EffectiveCapabilities = {
|
||||
export type EffectivePermissions = {
|
||||
isOwner: boolean;
|
||||
/** Capability key → level. Empty for an account with nothing granted; the owner's is never consulted. */
|
||||
grants: Map<string, CapabilityLevel>;
|
||||
/** Permission key → level. Empty for an account with nothing granted; the owner's is never consulted. */
|
||||
grants: Map<string, PermissionLevel>;
|
||||
};
|
||||
|
||||
// ── 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
|
||||
// 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.
|
||||
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. */
|
||||
export function invalidateRoleGrants(role?: UserRole): void {
|
||||
@@ -46,10 +46,10 @@ export function invalidateRoleGrants(role?: UserRole): void {
|
||||
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);
|
||||
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);
|
||||
return grants;
|
||||
}
|
||||
@@ -57,15 +57,15 @@ async function grantsForRole(role: UserRole): Promise<Map<string, CapabilityLeve
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* `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
|
||||
* does not enforce is a constraint the reader must.
|
||||
*/
|
||||
export async function getEffectiveCapabilities(userId: number | undefined): Promise<EffectiveCapabilities> {
|
||||
const empty: EffectiveCapabilities = { isOwner: false, grants: new Map() };
|
||||
export async function getEffectivePermissions(userId: number | undefined): Promise<EffectivePermissions> {
|
||||
const empty: EffectivePermissions = { isOwner: false, grants: new Map() };
|
||||
if (!userId) return empty;
|
||||
|
||||
try {
|
||||
@@ -73,20 +73,20 @@ export async function getEffectiveCapabilities(userId: number | undefined): Prom
|
||||
if (!user) return empty;
|
||||
if (user.role === 'Super Admin') return { isOwner: true, grants: new Map() };
|
||||
|
||||
const grants = new Map<string, CapabilityLevel>();
|
||||
for (const capability of CORE_CAPABILITIES) grants.set(capability.key, 'write');
|
||||
const grants = new Map<string, PermissionLevel>();
|
||||
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.
|
||||
const hasOsAccount = !!user.osUser;
|
||||
|
||||
for (const [key, level] of await grantsForRole(user.role)) {
|
||||
const capability = CAPABILITY_BY_KEY.get(key);
|
||||
// Unknown key: a capability that was renamed or removed while a grant survived. Ignore it — the
|
||||
const permission = PERMISSION_BY_KEY.get(key);
|
||||
// Unknown key: a permission that was renamed or removed while a grant survived. Ignore it — the
|
||||
// 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
|
||||
// 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.
|
||||
@@ -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
|
||||
// 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.
|
||||
if (capability.kind === 'confined') {
|
||||
if (permission.kind === 'confined') {
|
||||
if (!hasOsAccount) continue;
|
||||
grants.set(key, level);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (capability.kind !== 'app') continue;
|
||||
if (permission.kind !== 'app') continue;
|
||||
grants.set(key, level);
|
||||
}
|
||||
|
||||
@@ -122,31 +122,31 @@ export async function isApiRequestAllowed(
|
||||
method: string,
|
||||
path: string,
|
||||
): Promise<{ allowed: boolean; reason?: string }> {
|
||||
const { isOwner, grants } = await getEffectiveCapabilities(userId);
|
||||
const { isOwner, grants } = await getEffectivePermissions(userId);
|
||||
if (isOwner) return { allowed: true };
|
||||
|
||||
const capability = capabilityForApiPath(path);
|
||||
// Totality guarantees every mounted, non-exempt prefix maps to a capability, so reaching this branch
|
||||
const permission = permissionForApiPath(path);
|
||||
// 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:
|
||||
// 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
|
||||
// the capability around it restricts. Exact method and path only — see the field's comment.
|
||||
if (isSelfServiceRoute(capability, method, path)) return { allowed: true };
|
||||
// the permission around it restricts. Exact method and path only — see the field's comment.
|
||||
if (isSelfServiceRoute(permission, method, path)) return { allowed: true };
|
||||
|
||||
if (capability.kind === 'execution') {
|
||||
return { allowed: false, reason: `${capability.label} runs as the server owner and cannot be shared` };
|
||||
if (permission.kind === 'execution') {
|
||||
return { allowed: false, reason: `${permission.label} runs as the server owner and cannot be shared` };
|
||||
}
|
||||
if (capability.kind === 'admin') {
|
||||
return { allowed: false, reason: `${capability.label} is restricted to the server owner` };
|
||||
if (permission.kind === 'admin') {
|
||||
return { allowed: false, reason: `${permission.label} is restricted to the server owner` };
|
||||
}
|
||||
|
||||
const level = grants.get(capability.key);
|
||||
if (!level) return { allowed: false, reason: `your role does not have access to ${capability.label}` };
|
||||
const level = grants.get(permission.key);
|
||||
if (!level) return { allowed: false, reason: `your role does not have access to ${permission.label}` };
|
||||
|
||||
if (!isRequestAllowedAtLevel(capability, level, method, path)) {
|
||||
return { allowed: false, reason: `you have read-only access to ${capability.label}` };
|
||||
if (!isRequestAllowedAtLevel(permission, level, method, path)) {
|
||||
return { allowed: false, reason: `you have read-only access to ${permission.label}` };
|
||||
}
|
||||
return { allowed: true };
|
||||
}
|
||||
@@ -154,21 +154,21 @@ export async function isApiRequestAllowed(
|
||||
/**
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
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;
|
||||
|
||||
const capability = capabilityForWsProvider(provider);
|
||||
// `confined` is admissible here as well as `app`: getEffectiveCapabilities has already dropped confined
|
||||
const permission = permissionForWsProvider(provider);
|
||||
// `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
|
||||
// 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;
|
||||
return grants.has(capability.key);
|
||||
if (!permission || (permission.kind !== 'app' && permission.kind !== 'confined')) return false;
|
||||
return grants.has(permission.key);
|
||||
}
|
||||
+57
-57
@@ -1,15 +1,15 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import type { Capability } from './registry';
|
||||
import type { Permission } from './registry';
|
||||
import {
|
||||
CAPABILITIES,
|
||||
CAPABILITY_BY_KEY,
|
||||
GRANTABLE_CAPABILITIES,
|
||||
capabilityForApiPath,
|
||||
capabilityForWsProvider,
|
||||
PERMISSIONS,
|
||||
PERMISSION_BY_KEY,
|
||||
GRANTABLE_PERMISSIONS,
|
||||
permissionForApiPath,
|
||||
permissionForWsProvider,
|
||||
isRequestAllowedAtLevel,
|
||||
isSelfServiceRoute,
|
||||
} 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
|
||||
// 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.
|
||||
// `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 realApi = () => [...new Set(CAPABILITIES.flatMap((c) => c.api))];
|
||||
const realApi = () => [...new Set(PERMISSIONS.flatMap((c) => c.api))];
|
||||
const surface = () => ({
|
||||
apiPrefixes: [...realApi(), '/auth', '/landing-page-data', '/waitlist', '/vault', '/sidecar'],
|
||||
wsProviders: REAL_WS,
|
||||
@@ -25,41 +25,41 @@ const surface = () => ({
|
||||
|
||||
describe('totality', () => {
|
||||
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
|
||||
// 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();
|
||||
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();
|
||||
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', () => {
|
||||
const s = surface();
|
||||
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', () => {
|
||||
const s = surface();
|
||||
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>();
|
||||
for (const capability of CAPABILITIES) {
|
||||
for (const prefix of capability.api) {
|
||||
for (const permission of PERMISSIONS) {
|
||||
for (const prefix of permission.api) {
|
||||
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', () => {
|
||||
expect(capabilityForApiPath('/api/gitea')?.key).toBe('gitea');
|
||||
expect(capabilityForApiPath('/api/gitea/repos/a/b')?.key).toBe('gitea');
|
||||
expect(permissionForApiPath('/api/gitea')?.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', () => {
|
||||
// '/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.)
|
||||
expect(capabilityForApiPath('/api/username')).toBeNull();
|
||||
expect(permissionForApiPath('/api/username')).toBeNull();
|
||||
});
|
||||
|
||||
test('longest prefix wins, so /dav and /caldav do not fight', () => {
|
||||
expect(capabilityForApiPath('/api/caldav/x')?.key).toBe('calendar');
|
||||
expect(capabilityForApiPath('/api/dav/x')?.key).toBe('calendar');
|
||||
expect(permissionForApiPath('/api/caldav/x')?.key).toBe('calendar');
|
||||
expect(permissionForApiPath('/api/dav/x')?.key).toBe('calendar');
|
||||
});
|
||||
|
||||
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', () => {
|
||||
expect(capabilityForWsProvider('terminal')?.key).toBe('terminal');
|
||||
expect(capabilityForWsProvider('chat')?.key).toBe('chat');
|
||||
expect(capabilityForWsProvider('nope')).toBeNull();
|
||||
test('sockets resolve to their permission', () => {
|
||||
expect(permissionForWsProvider('terminal')?.key).toBe('terminal');
|
||||
expect(permissionForWsProvider('chat')?.key).toBe('chat');
|
||||
expect(permissionForWsProvider('nope')).toBeNull();
|
||||
});
|
||||
|
||||
// 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
|
||||
// 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
|
||||
// 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', () => {
|
||||
expect(capabilityForWsProvider('cliamp')).toBeNull();
|
||||
expect(capabilityForWsProvider('cliamp-audio')).toBeNull();
|
||||
expect(permissionForWsProvider('cliamp')).toBeNull();
|
||||
expect(permissionForWsProvider('cliamp-audio')).toBeNull();
|
||||
expect(REAL_WS).not.toContain('cliamp');
|
||||
});
|
||||
});
|
||||
@@ -117,15 +117,15 @@ describe('path → capability', () => {
|
||||
describe('levels', () => {
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
const fixture: Capability = {
|
||||
const fixture: Permission = {
|
||||
key: 'fixture',
|
||||
label: 'Fixture',
|
||||
description: 'Not in the registry — a shape to exercise the level rules against',
|
||||
@@ -134,7 +134,7 @@ describe('levels', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
@@ -157,16 +157,16 @@ describe('levels', () => {
|
||||
|
||||
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
|
||||
// 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.
|
||||
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/scan')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
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', () => {
|
||||
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);
|
||||
});
|
||||
|
||||
test('capabilities without the field are unaffected', () => {
|
||||
expect(isSelfServiceRoute(CAPABILITY_BY_KEY.get('wallet')!, 'PUT', '/api/wallet')).toBe(false);
|
||||
test('permissions without the field are unaffected', () => {
|
||||
expect(isSelfServiceRoute(PERMISSION_BY_KEY.get('wallet')!, 'PUT', '/api/wallet')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('kinds', () => {
|
||||
test('execution capabilities are never grantable', () => {
|
||||
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key));
|
||||
test('execution permissions are never grantable', () => {
|
||||
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
|
||||
// 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.
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
// 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.
|
||||
test('confined capabilities are grantable', () => {
|
||||
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key));
|
||||
test('confined permissions are grantable', () => {
|
||||
const grantable = new Set(GRANTABLE_PERMISSIONS.map((c) => c.key));
|
||||
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);
|
||||
}
|
||||
});
|
||||
@@ -217,24 +217,24 @@ describe('kinds', () => {
|
||||
// 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.
|
||||
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', () => {
|
||||
const grantable = new Set(GRANTABLE_CAPABILITIES.map((c) => c.key));
|
||||
test('admin permissions are never grantable', () => {
|
||||
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
|
||||
// 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']) {
|
||||
expect(CAPABILITY_BY_KEY.get(key)?.kind).toBe('admin');
|
||||
expect(PERMISSION_BY_KEY.get(key)?.kind).toBe('admin');
|
||||
expect(grantable.has(key)).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
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', () => {
|
||||
for (const capability of CAPABILITIES) expect(capability.api.length).toBeGreaterThan(0);
|
||||
test('every permission declares at least one api prefix', () => {
|
||||
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.
|
||||
//
|
||||
// ── 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.
|
||||
// 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
|
||||
// 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
|
||||
// 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
|
||||
// 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.
|
||||
//
|
||||
// ── The four kinds, and why `execution` can never be granted ──
|
||||
@@ -27,17 +27,17 @@
|
||||
//
|
||||
// ── `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
|
||||
// the OWNER'S filesystem, because the account has its own Linux user, its own home, and the kernel refusing
|
||||
// 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
|
||||
// `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.
|
||||
//
|
||||
// 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.
|
||||
//
|
||||
// `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 ──
|
||||
//
|
||||
// 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
|
||||
// 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.
|
||||
@@ -59,14 +59,14 @@
|
||||
// the same thing through its manifest's `readOnlyWrites` — `isRequestAllowedAtLevel` merges the two lists,
|
||||
// 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. */
|
||||
key: string;
|
||||
label: string;
|
||||
description: string;
|
||||
kind: CapabilityKind;
|
||||
kind: PermissionKind;
|
||||
/**
|
||||
* 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
|
||||
@@ -79,37 +79,37 @@ export type Capability = {
|
||||
routes?: string[];
|
||||
/**
|
||||
* 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`
|
||||
* capability permits `POST /api/notify/devices/123`.
|
||||
* the caller's own data. Matched as a prefix after the permission's own: `/devices` on the `notify`
|
||||
* permission permits `POST /api/notify/devices/123`.
|
||||
*/
|
||||
personal?: string[];
|
||||
/**
|
||||
* 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[];
|
||||
/**
|
||||
* Routes any authenticated account may call even holding NO grant on this capability, because they act
|
||||
* on the caller themselves. `METHOD /exact/path`, relative to the capability's prefix — exact, not a
|
||||
* 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 permission's prefix — exact, not a
|
||||
* 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
|
||||
* 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
|
||||
* 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[];
|
||||
};
|
||||
|
||||
/**
|
||||
* 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
|
||||
* plugin is never in the fresh-install baseline.
|
||||
*/
|
||||
const CORE_REGISTRY: Capability[] = [
|
||||
const CORE_REGISTRY: Permission[] = [
|
||||
// ── core ────────────────────────────────────────────────────────────────────────────────────────
|
||||
{
|
||||
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
|
||||
// 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
|
||||
// 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'],
|
||||
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:
|
||||
// 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
|
||||
// commenting on their own issues — security theatre with a real cost and no benefit.
|
||||
personal: ['/'],
|
||||
@@ -315,7 +315,7 @@ const CORE_REGISTRY: Capability[] = [
|
||||
{
|
||||
key: 'tasks',
|
||||
label: 'Tasks and jobs',
|
||||
description: 'Running capabilities, pipelines and background jobs',
|
||||
description: 'Running permissions, pipelines and background jobs',
|
||||
kind: 'execution',
|
||||
api: ['/tasks', '/jobs', '/pipeline-jobs', '/queue'],
|
||||
ws: ['task-runner', 'pipeline'],
|
||||
@@ -323,7 +323,7 @@ const CORE_REGISTRY: Capability[] = [
|
||||
},
|
||||
{
|
||||
key: 'items',
|
||||
label: 'Capability authoring',
|
||||
label: 'Permission authoring',
|
||||
description: 'Skills, tools, agents and processes on disk',
|
||||
kind: 'execution',
|
||||
api: ['/skills', '/tools', '/agents', '/processes', '/rescan'],
|
||||
@@ -339,10 +339,10 @@ const CORE_REGISTRY: Capability[] = [
|
||||
routes: ['/desktop'],
|
||||
},
|
||||
// `/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.
|
||||
//
|
||||
// `/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.
|
||||
{
|
||||
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
|
||||
// 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
|
||||
// `offscale` at read without being able to install or remove anything.
|
||||
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
|
||||
// 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',
|
||||
label: 'Wallet',
|
||||
@@ -415,18 +415,18 @@ const CORE_REGISTRY: Capability[] = [
|
||||
|
||||
// ── Derived lookups ───────────────────────────────────────────────────────────────────────────────
|
||||
//
|
||||
// `let`, not `const`, because installing a plugin adds capabilities and uninstalling removes them. ESM
|
||||
// exports are live bindings, so an importer holding `CAPABILITY_BY_KEY` sees the reassignment — every
|
||||
// `let`, not `const`, because installing a plugin adds permissions and uninstalling removes them. ESM
|
||||
// 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.
|
||||
//
|
||||
// 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
|
||||
// stale one, and replacement has no half.
|
||||
|
||||
/** Core plus every installed plugin's. The list `capabilityForApiPath` and the totality check read. */
|
||||
export let CAPABILITIES: Capability[] = CORE_REGISTRY;
|
||||
/** Core plus every installed plugin's. The list `permissionForApiPath` and the totality check read. */
|
||||
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.
|
||||
@@ -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
|
||||
* 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
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
// 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.
|
||||
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.
|
||||
@@ -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.
|
||||
*
|
||||
* 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
|
||||
* 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.
|
||||
*/
|
||||
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 seen = new Set<string>();
|
||||
const accepted: Capability[] = [];
|
||||
const accepted: Permission[] = [];
|
||||
const rejected: string[] = [];
|
||||
|
||||
for (const capability of pluginCapabilities) {
|
||||
if (coreKeys.has(capability.key) || seen.has(capability.key)) {
|
||||
rejected.push(capability.key);
|
||||
for (const permission of pluginPermissions) {
|
||||
if (coreKeys.has(permission.key) || seen.has(permission.key)) {
|
||||
rejected.push(permission.key);
|
||||
continue;
|
||||
}
|
||||
seen.add(capability.key);
|
||||
accepted.push(capability);
|
||||
seen.add(permission.key);
|
||||
accepted.push(permission);
|
||||
}
|
||||
|
||||
CAPABILITIES = [...CORE_REGISTRY, ...accepted];
|
||||
CAPABILITY_BY_KEY = new Map(CAPABILITIES.map((c) => [c.key, c]));
|
||||
GRANTABLE_CAPABILITIES = CAPABILITIES.filter((c) => c.kind === 'app' || c.kind === 'confined');
|
||||
PERMISSIONS = [...CORE_REGISTRY, ...accepted];
|
||||
PERMISSION_BY_KEY = new Map(PERMISSIONS.map((c) => [c.key, c]));
|
||||
GRANTABLE_PERMISSIONS = PERMISSIONS.filter((c) => c.kind === 'app' || c.kind === 'confined');
|
||||
return { rejected };
|
||||
}
|
||||
|
||||
export type CapabilityLevel = 'read' | 'write';
|
||||
export type PermissionLevel = 'read' | 'write';
|
||||
|
||||
const isPrefixOf = (prefix: string, path: string): boolean =>
|
||||
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
|
||||
* order of the array mattering. Returns null for a path no capability claims — which the totality check
|
||||
* 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 permission claims — which the totality check
|
||||
* 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;
|
||||
let best: Capability | null = null;
|
||||
let best: Permission | null = null;
|
||||
let bestLength = -1;
|
||||
for (const capability of CAPABILITIES) {
|
||||
for (const prefix of capability.api) {
|
||||
for (const permission of PERMISSIONS) {
|
||||
for (const prefix of permission.api) {
|
||||
if (isPrefixOf(prefix, rest) && prefix.length > bestLength) {
|
||||
best = capability;
|
||||
best = permission;
|
||||
bestLength = prefix.length;
|
||||
}
|
||||
}
|
||||
@@ -519,36 +519,36 @@ export function capabilityForApiPath(path: string): Capability | null {
|
||||
return best;
|
||||
}
|
||||
|
||||
export function capabilityForWsProvider(provider: string): Capability | null {
|
||||
return CAPABILITIES.find((c) => c.ws?.includes(provider)) ?? null;
|
||||
export function permissionForWsProvider(provider: string): Permission | 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
|
||||
* 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 {
|
||||
if (!capability.selfService?.length) return false;
|
||||
export function isSelfServiceRoute(permission: Permission, method: string, path: string): boolean {
|
||||
if (!permission.selfService?.length) return false;
|
||||
const rest = path.startsWith('/api') ? path.slice('/api'.length) : path;
|
||||
const upper = method.toUpperCase();
|
||||
return capability.api.some((prefix) => {
|
||||
return permission.api.some((prefix) => {
|
||||
if (!isPrefixOf(prefix, rest)) return false;
|
||||
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']);
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
export function isRequestAllowedAtLevel(
|
||||
capability: Capability,
|
||||
level: CapabilityLevel,
|
||||
permission: Permission,
|
||||
level: PermissionLevel,
|
||||
method: string,
|
||||
path: string,
|
||||
): boolean {
|
||||
@@ -556,12 +556,12 @@ export function isRequestAllowedAtLevel(
|
||||
if (SAFE_METHODS.has(method.toUpperCase())) return true;
|
||||
|
||||
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
|
||||
// to the capability rather than repeated per prefix.
|
||||
const withinCapability = capability.api
|
||||
// Strip whichever of the permission's own prefixes matched, so `personal` entries are written relative
|
||||
// to the permission rather than repeated per prefix.
|
||||
const withinPermission = permission.api
|
||||
.filter((prefix) => isPrefixOf(prefix, rest))
|
||||
.map((prefix) => rest.slice(prefix.length) || '/');
|
||||
|
||||
const allowed = [...(capability.personal ?? []), ...(capability.readOnlyWrites ?? [])];
|
||||
return withinCapability.some((sub) => allowed.some((entry) => isPrefixOf(entry, sub)));
|
||||
const allowed = [...(permission.personal ?? []), ...(permission.readOnlyWrites ?? [])];
|
||||
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.
|
||||
//
|
||||
@@ -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
|
||||
// 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.
|
||||
//
|
||||
// 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.
|
||||
|
||||
/**
|
||||
* 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> = {
|
||||
// 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',
|
||||
'/waitlist': 'public signup form, no account involved',
|
||||
// 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',
|
||||
// Registration socket for sidecars. Process-to-process on loopback; there is no user on this path.
|
||||
'/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
|
||||
// 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
|
||||
// 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
|
||||
@@ -53,8 +53,8 @@ const EXEMPT_WS_PROVIDERS: Record<string, string> = {
|
||||
/**
|
||||
* 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
|
||||
* asked to prove a capability. Deliberately shares EXEMPT_API_PREFIXES with the boot check: an exemption
|
||||
* Read by the permission backstop, so that signin — which by definition has no account to check — is not
|
||||
* 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
|
||||
* hole.
|
||||
*/
|
||||
@@ -63,7 +63,7 @@ export function isExemptApiPath(path: string): boolean {
|
||||
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. */
|
||||
apiPrefixes: string[];
|
||||
/** 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.
|
||||
*
|
||||
* 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 prefix two capabilities claim → which grant applies is undefined
|
||||
* - a mounted prefix no permission claims → reachable by a rule nobody wrote
|
||||
* - 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 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[] = [];
|
||||
|
||||
// 1. Every mount is claimed, and claimed once.
|
||||
for (const prefix of surface.apiPrefixes) {
|
||||
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) {
|
||||
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) {
|
||||
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
|
||||
// registry kept describing it — which would leave a grant that silently means nothing.
|
||||
const mounted = new Set(surface.apiPrefixes);
|
||||
for (const capability of CAPABILITIES) {
|
||||
for (const prefix of capability.api) {
|
||||
for (const permission of PERMISSIONS) {
|
||||
for (const prefix of permission.api) {
|
||||
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.
|
||||
for (const provider of surface.wsProviders) {
|
||||
if (provider in EXEMPT_WS_PROVIDERS) continue;
|
||||
if (!capabilityForWsProvider(provider)) {
|
||||
problems.push(`websocket provider '${provider}' is served but no capability claims it`);
|
||||
if (!permissionForWsProvider(provider)) {
|
||||
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);
|
||||
for (const capability of CAPABILITIES) {
|
||||
for (const provider of capability.ws ?? []) {
|
||||
for (const permission of PERMISSIONS) {
|
||||
for (const provider of permission.ws ?? []) {
|
||||
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.
|
||||
const seen = new Set<string>();
|
||||
for (const capability of CAPABILITIES) {
|
||||
if (seen.has(capability.key)) problems.push(`duplicate capability key '${capability.key}'`);
|
||||
seen.add(capability.key);
|
||||
for (const permission of PERMISSIONS) {
|
||||
if (seen.has(permission.key)) problems.push(`duplicate permission key '${permission.key}'`);
|
||||
seen.add(permission.key);
|
||||
}
|
||||
|
||||
if (problems.length > 0) {
|
||||
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}`),
|
||||
'',
|
||||
'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',
|
||||
'websocket hole happened.',
|
||||
].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. */
|
||||
export function describeCapabilitySurface(): {
|
||||
/** Exposed for the settings UI, so an owner can see what a permission actually covers. */
|
||||
export function describePermissionSurface(): {
|
||||
exemptApi: typeof EXEMPT_API_PREFIXES;
|
||||
exemptWs: typeof 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
|
||||
* 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.
|
||||
*/
|
||||
export type PluginPermission = {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { listPluginInstalls, type PluginInstall } from 'officerdb';
|
||||
import type { MountedPlugin } 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 { mountPrefix, type DiscoveredPlugin } from './manifest';
|
||||
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
|
||||
* 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 prefix = mountPrefix(plugin);
|
||||
|
||||
@@ -170,7 +170,7 @@ export async function refreshPluginMounts(options: { skipFrontend?: boolean } =
|
||||
* (`dockItemsFromPlugins`).
|
||||
*
|
||||
* 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 —
|
||||
* 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<
|
||||
Array<{
|
||||
sidecarId: string;
|
||||
capability: string | null;
|
||||
permission: string | null;
|
||||
name: string;
|
||||
icon?: string;
|
||||
image?: string;
|
||||
@@ -197,7 +197,7 @@ export async function pluginDockManifests(): Promise<
|
||||
const prefix = mountPrefix(plugin);
|
||||
return {
|
||||
sidecarId: plugin.appName,
|
||||
capability: plugin.manifest.permissions[0]?.key ?? null,
|
||||
permission: plugin.manifest.permissions[0]?.key ?? null,
|
||||
name: plugin.manifest.label,
|
||||
// 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`,
|
||||
|
||||
+17
-17
@@ -14,20 +14,20 @@ import { useClient } from './useClient';
|
||||
// minute; an owner locked out of their own platform by a transient network error is an incident. The
|
||||
// server refuses what it should refuse either way.
|
||||
|
||||
export type CapabilityLevel = 'read' | 'write';
|
||||
export type PermissionLevel = 'read' | 'write';
|
||||
|
||||
export type SelfCapabilities = {
|
||||
export type SelfPermissions = {
|
||||
isOwner: boolean;
|
||||
capabilities: { key: string; level: CapabilityLevel }[];
|
||||
/** Frontend route prefixes the account holds, flattened across its capabilities. */
|
||||
permissions: { key: string; level: PermissionLevel }[];
|
||||
/** Frontend route prefixes the account holds, flattened across its permissions. */
|
||||
routes: string[];
|
||||
/**
|
||||
* Route prefixes claimed by capabilities the account does NOT hold. Both lists are needed: absence from
|
||||
* `routes` cannot tell a denied route from one no capability claims (`/`, the settings shell), and a
|
||||
* Route prefixes claimed by permissions the account does NOT hold. Both lists are needed: absence from
|
||||
* `routes` cannot tell a denied route from one no permission claims (`/`, the settings shell), and a
|
||||
* guard that cannot tell those apart either blanks the app or guards nothing.
|
||||
*/
|
||||
deniedRoutes: string[];
|
||||
/** Capabilities the account holds whose sidecar is not installed, or is installed but disabled. */
|
||||
/** Permissions the account holds whose sidecar is not installed, or is installed but disabled. */
|
||||
unavailable?: string[];
|
||||
/**
|
||||
* Dock tiles and routes of the sidecars actually installed on this server.
|
||||
@@ -54,20 +54,20 @@ export type PluginManifest = {
|
||||
extraTiles?: Array<{ name: string; icon?: string; image?: string; color: string; route: string }>;
|
||||
};
|
||||
|
||||
export const CAPABILITIES_QUERY_KEY = ['self-capabilities'];
|
||||
export const PERMISSIONS_QUERY_KEY = ['self-permissions'];
|
||||
|
||||
export function useCapabilities() {
|
||||
export function usePermissions() {
|
||||
const client = useClient();
|
||||
|
||||
const { data, isLoading, isError } = useQuery<SelfCapabilities>({
|
||||
queryKey: CAPABILITIES_QUERY_KEY,
|
||||
queryFn: () => client.get<SelfCapabilities>('/user/capabilities'),
|
||||
const { data, isLoading, isError } = useQuery<SelfPermissions>({
|
||||
queryKey: PERMISSIONS_QUERY_KEY,
|
||||
queryFn: () => client.get<SelfPermissions>('/user/permissions'),
|
||||
// Grants change rarely and only by an owner action, but they change the shape of the whole app when
|
||||
// they do. A minute matches the `tasks` / `task-categories` caches the rest of the app uses.
|
||||
staleTime: 60_000,
|
||||
});
|
||||
|
||||
const held = useMemo(() => new Map((data?.capabilities ?? []).map((c) => [c.key, c.level])), [data]);
|
||||
const held = useMemo(() => new Map((data?.permissions ?? []).map((c) => [c.key, c.level])), [data]);
|
||||
|
||||
// `can` and `canVisit` are memoised on `data` alone, and that matters more than it looks. A verb rebuilt
|
||||
// every render gets a new identity every render, so anything putting one in a useCallback or useMemo
|
||||
@@ -75,9 +75,9 @@ export function useCapabilities() {
|
||||
// Jellyfin player was disabled for days by an unmount cleanup re-running mid-playback. `data` is a React
|
||||
// Query value with a stable reference between fetches, so these change only when the answer does.
|
||||
|
||||
/** Whether the account holds a capability, optionally at write level. */
|
||||
/** Whether the account holds a permission, optionally at write level. */
|
||||
const can = useCallback(
|
||||
(key: string, level: CapabilityLevel = 'read'): boolean => {
|
||||
(key: string, level: PermissionLevel = 'read'): boolean => {
|
||||
if (!data) return true; // see the note above: fail open, the server does not
|
||||
if (data.isOwner) return true;
|
||||
const granted = held.get(key);
|
||||
@@ -118,7 +118,7 @@ export function useCapabilities() {
|
||||
// Held but unavailable → the sidecar is missing. Checked against the capability that claims the route,
|
||||
// which is why `unavailable` is returned as capability keys rather than routes.
|
||||
const unavailable = new Set(data.unavailable ?? []);
|
||||
const heldAndUnavailable = data.capabilities.some(({ key }) => unavailable.has(key));
|
||||
const heldAndUnavailable = data.permissions.some(({ key }) => unavailable.has(key));
|
||||
if (data.isOwner || heldAndUnavailable) return 'not-installed';
|
||||
return 'not-granted';
|
||||
},
|
||||
@@ -127,7 +127,7 @@ export function useCapabilities() {
|
||||
|
||||
return {
|
||||
isOwner: data?.isOwner ?? false,
|
||||
capabilities: held,
|
||||
permissions: held,
|
||||
routes: data?.routes ?? [],
|
||||
unavailable: data?.unavailable ?? [],
|
||||
denialReason,
|
||||
@@ -54,7 +54,7 @@ export function useAppStore() {
|
||||
// the same screen.
|
||||
const invalidate = () => {
|
||||
void queryClient.invalidateQueries({ queryKey: STORE_KEY });
|
||||
void queryClient.invalidateQueries({ queryKey: ['self-capabilities'] });
|
||||
void queryClient.invalidateQueries({ queryKey: ['self-permissions'] });
|
||||
};
|
||||
|
||||
const install = useMutation({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { type TriggerConfig, groupByCategory, matchesTrigger } from './useTasks';
|
||||
|
||||
export type AgentSummary = {
|
||||
@@ -20,7 +20,7 @@ export const useAgents = () => {
|
||||
const client = useClient();
|
||||
// Agents are the `items` capability — skills, tools and agents on the owner's disk — and running one
|
||||
// starts a chat session, which is `chat`. Both are execution-only, so a member gets no agent submenu.
|
||||
const { can } = useCapabilities();
|
||||
const { can } = usePermissions();
|
||||
const allowed = can('items');
|
||||
|
||||
const { data: agents = [] } = useQuery<AgentSummary[]>({
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
|
||||
export type TriggerConfig = { type: 'file'; extensions: string[] } | { type: 'directory' };
|
||||
|
||||
@@ -74,7 +74,7 @@ export const useTasks = () => {
|
||||
// `tasks` is `kind: 'execution'`: a task run executes a script as the server owner. A member browsing
|
||||
// their own files has a file browser, not a task runner — so the context menu simply has no Run Task
|
||||
// submenu, and these two requests are not made. Without the guard they 403'd on every Files render.
|
||||
const { can } = useCapabilities();
|
||||
const { can } = usePermissions();
|
||||
const allowed = can('tasks');
|
||||
|
||||
const { data: tasks = [] } = useQuery<TaskSummary[]>({
|
||||
|
||||
@@ -90,11 +90,11 @@ export function usePlugins() {
|
||||
queryFn: () => client.get<{ plugins: PluginItem[]; broken: BrokenPlugin[] }>('/plugins'),
|
||||
});
|
||||
|
||||
// Every verb invalidates the plugin list AND self-capabilities: installing a plugin can add a dock tile
|
||||
// Every verb invalidates the plugin list AND self-permissions: installing a plugin can add a dock tile
|
||||
// and a route the shell has to know about, so refreshing one without the other leaves the two disagreeing.
|
||||
const invalidate = useCallback(() => {
|
||||
queryClient.invalidateQueries({ queryKey: PLUGINS_KEY });
|
||||
queryClient.invalidateQueries({ queryKey: ['self-capabilities'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['self-permissions'] });
|
||||
}, [queryClient]);
|
||||
|
||||
const [steps, setSteps] = useState<string[]>([]);
|
||||
|
||||
@@ -8,7 +8,7 @@ import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
|
||||
import { SyncBadge } from './SyncBadge';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useCoinSelection } from './useCoinSelection';
|
||||
import { useCapabilities, useUtxos, useWalletOperations } from './useWalletData';
|
||||
import { usePermissions, useUtxos, useWalletOperations } from './useWalletData';
|
||||
|
||||
// Coin control. Works while locked: which coins exist and which are frozen is watch-only information, and
|
||||
// freezing is Officer's own flag rather than anything signed.
|
||||
@@ -17,7 +17,7 @@ import { useCapabilities, useUtxos, useWalletOperations } from './useWalletData'
|
||||
|
||||
export const CoinsView = () => {
|
||||
const { walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const { capabilities } = usePermissions(walletId);
|
||||
const supported = capabilities.includes('coinControl');
|
||||
|
||||
const { utxos, sync, isLoading: utxosLoading } = useUtxos(walletId, supported);
|
||||
|
||||
@@ -4,7 +4,7 @@ import { formatSats, formatTimestamp, truncateMiddle, PAYMENT_TONES } from './fo
|
||||
import { Amount } from './Amount';
|
||||
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useCapabilities, useChannels, usePayments, usePeers } from './useWalletData';
|
||||
import { usePermissions, useChannels, usePayments, usePeers } from './useWalletData';
|
||||
|
||||
// The node's own view of itself: payments it has made, channels it holds, peers it is connected to.
|
||||
//
|
||||
@@ -14,7 +14,7 @@ import { useCapabilities, useChannels, usePayments, usePeers } from './useWallet
|
||||
|
||||
export const LightningView = () => {
|
||||
const { walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const { capabilities } = usePermissions(walletId);
|
||||
const hasChannels = capabilities.includes('channels');
|
||||
const hasPeers = capabilities.includes('peers');
|
||||
const hasPayments = capabilities.includes('lightningSend');
|
||||
|
||||
@@ -8,7 +8,7 @@ import { Amount, UnitToggle } from './Amount';
|
||||
import { EmptyWallet } from './EmptyWallet';
|
||||
import { SyncBadge } from './SyncBadge';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useBalances, useCapabilities, useTransactions, useWalletInfo } from './useWalletData';
|
||||
import { useBalances, usePermissions, useTransactions, useWalletInfo } from './useWalletData';
|
||||
|
||||
// The at-a-glance section: what you hold, whether the node agrees with the chain, and the last few moves.
|
||||
//
|
||||
@@ -32,7 +32,7 @@ function onchainHint(balances: Balances | null | undefined): string | undefined
|
||||
|
||||
export const OverviewView = () => {
|
||||
const { wallet, walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const { capabilities } = usePermissions(walletId);
|
||||
const { balances, sync, isLoading: balancesLoading } = useBalances(walletId);
|
||||
const { info } = useWalletInfo(walletId);
|
||||
const { transactions } = useTransactions(walletId, 5);
|
||||
|
||||
@@ -7,7 +7,7 @@ import { Amount } from './Amount';
|
||||
import { CopyField } from './CopyField';
|
||||
import { EmptyWallet, UnsupportedSection } from './EmptyWallet';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { errorMessage, useCapabilities, useInvoices, useReceiveAddress } from './useWalletData';
|
||||
import { errorMessage, usePermissions, useInvoices, useReceiveAddress } from './useWalletData';
|
||||
import { CreateInvoiceDialog } from './dialogs/CreateInvoiceDialog';
|
||||
|
||||
// Receiving. Works while locked — deriving an address needs the account xpub, not the seed, which is why
|
||||
@@ -18,7 +18,7 @@ import { CreateInvoiceDialog } from './dialogs/CreateInvoiceDialog';
|
||||
|
||||
export const ReceiveView = () => {
|
||||
const { walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const { capabilities } = usePermissions(walletId);
|
||||
const canOnchain = capabilities.includes('onchainReceive');
|
||||
const canLightning = capabilities.includes('lightningReceive');
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import { UnlockPrompt } from './LockBadge';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useCoinSelection } from './useCoinSelection';
|
||||
import { useLockCountdown } from './useLockCountdown';
|
||||
import { useBalances, useCapabilities, useFees, useUtxos, useWalletOperations } from './useWalletData';
|
||||
import { useBalances, usePermissions, useFees, useUtxos, useWalletOperations } from './useWalletData';
|
||||
import { PayInvoiceDialog } from './dialogs/PayInvoiceDialog';
|
||||
|
||||
// Sending — the only part of the app that genuinely needs an unlocked wallet.
|
||||
@@ -38,7 +38,7 @@ const FEE_PRESETS: { key: keyof FeeEstimates; label: string; hint: string }[] =
|
||||
|
||||
export const SendView = () => {
|
||||
const { wallet, walletId, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const { capabilities } = usePermissions(walletId);
|
||||
const canOnchain = capabilities.includes('onchainSend');
|
||||
const canLightning = capabilities.includes('lightningSend');
|
||||
|
||||
@@ -81,7 +81,7 @@ type FormProps = { walletId: number; walletName: string };
|
||||
const OnchainSendForm = ({ walletId, walletName }: FormProps) => {
|
||||
const { balances } = useBalances(walletId);
|
||||
const { fees } = useFees(walletId);
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const { capabilities } = usePermissions(walletId);
|
||||
const { selected, clear } = useCoinSelection();
|
||||
const { utxos } = useUtxos(walletId, capabilities.includes('coinControl'));
|
||||
const { send } = useWalletOperations(walletId);
|
||||
|
||||
@@ -15,7 +15,7 @@ import { Amount, UnitToggle } from './Amount';
|
||||
import { LockBadge } from './LockBadge';
|
||||
import { useWalletSection } from './useWalletSection';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useBalances, useCapabilities, useWalletConfig, useWalletLifecycle } from './useWalletData';
|
||||
import { useBalances, usePermissions, useWalletConfig, useWalletLifecycle } from './useWalletData';
|
||||
import { CreateWalletDialog } from './dialogs/CreateWalletDialog';
|
||||
|
||||
// Left panel of /wallet: the balance, the wallets, the sections, the lock state.
|
||||
@@ -43,7 +43,7 @@ export const WalletNav = () => {
|
||||
const section = useWalletSection();
|
||||
const { config } = useWalletConfig();
|
||||
const { wallet, wallets, walletId, isPinned, isLoading } = useSelectedWallet();
|
||||
const { capabilities } = useCapabilities(walletId);
|
||||
const { capabilities } = usePermissions(walletId);
|
||||
const { balances } = useBalances(walletId);
|
||||
const [createOpen, setCreateOpen] = useState(false);
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import { LockBadge } from './LockBadge';
|
||||
import { RescanCard } from './RescanCard';
|
||||
import { useSelectedWallet } from './useSelectedWallet';
|
||||
import { useLockCountdown } from './useLockCountdown';
|
||||
import { useCapabilities, useWalletConfig, useWalletLifecycle } from './useWalletData';
|
||||
import { usePermissions, useWalletConfig, useWalletLifecycle } from './useWalletData';
|
||||
import { ChangePassphraseDialog } from './dialogs/ChangePassphraseDialog';
|
||||
import { ExportSeedDialog } from './dialogs/ExportSeedDialog';
|
||||
import { DeleteWalletDialog } from './dialogs/DeleteWalletDialog';
|
||||
@@ -25,7 +25,7 @@ export const WalletSettingsView = () => {
|
||||
const navigate = useNavigate();
|
||||
const { wallet, walletId, isLoading } = useSelectedWallet();
|
||||
const { config } = useWalletConfig();
|
||||
const { capabilities, kind } = useCapabilities(walletId);
|
||||
const { capabilities, kind } = usePermissions(walletId);
|
||||
const { hasSeed } = useLockCountdown(walletId);
|
||||
const { activate, rename } = useWalletLifecycle();
|
||||
|
||||
|
||||
@@ -109,7 +109,7 @@ export function useLockState(walletId: number | null) {
|
||||
return { lock: query.data ?? null, dataUpdatedAt: query.dataUpdatedAt, isLoading: query.isLoading };
|
||||
}
|
||||
|
||||
export function useCapabilities(walletId: number | null) {
|
||||
export function usePermissions(walletId: number | null) {
|
||||
const { get } = useClient();
|
||||
|
||||
const query = useQuery({
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
|
||||
type AccessPolicy = {
|
||||
allowedModels: string[];
|
||||
@@ -14,7 +14,7 @@ export function useAccessPolicy() {
|
||||
const client = useClient();
|
||||
const { isAuthenticated } = useAuth();
|
||||
// Under `/server-settings`, so `server-admin`: owner only. Authentication alone was never the right gate.
|
||||
const { isOwner } = useCapabilities();
|
||||
const { isOwner } = usePermissions();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data: policy = { allowedModels: [] } } = useQuery<AccessPolicy>({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useAuth } from 'hooks/useAuth';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
import { useAccessPolicy } from './useAccessPolicy';
|
||||
import { useSettings } from './useSettings';
|
||||
import type { ModelOption } from 'officerdev';
|
||||
@@ -29,7 +29,7 @@ export function useModels() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
// `/chat` is `kind: 'execution'` — the agent runs unsandboxed as the server owner, so it is owner-only and
|
||||
// never grantable. The model list is no use to anyone who cannot open a chat.
|
||||
const { can } = useCapabilities();
|
||||
const { can } = usePermissions();
|
||||
|
||||
const { data: models = [] } = useQuery<ModelOption[]>({
|
||||
queryKey: ['CHAT_MODELS'],
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useClient } from 'hooks/useClient';
|
||||
import { useCapabilities } from 'hooks/useCapabilities';
|
||||
import { usePermissions } from 'hooks/usePermissions';
|
||||
|
||||
type AIHarnesses = {
|
||||
claudeCode: boolean;
|
||||
@@ -22,7 +22,7 @@ export const useServerSettings = () => {
|
||||
// `/server-settings` is the `server-admin` capability: owner only, and not grantable at any level. This
|
||||
// hook is mounted by shell components that every account loads, so without the guard a member's first
|
||||
// paint fired a 403 at it.
|
||||
const { isOwner } = useCapabilities();
|
||||
const { isOwner } = usePermissions();
|
||||
|
||||
const { data: settings, isLoading } = useQuery({
|
||||
queryKey: SETTINGS_KEY,
|
||||
|
||||
Reference in New Issue
Block a user