a member's screens render, and the shell stops asking for things it cannot have

Three findings from granting Files to a role and signing in as the member.

THE BLANK SCREEN. WorkspaceView returns null until workspace.isLoaded, and isLoaded
was the success flag of GET /api/dashboards — which the `dashboards` capability gated.
So a member with files granted got a completely blank Files screen and no request to
/api/file-browser at all: the panel never mounted. Terminal, Chat and every other
workspace screen were the same.

/api/dashboards is not a feature. It is the per-user key-value store where every
screen keeps its layout, entirely `personal`, every row keyed to the caller. Gating it
does not restrict an account, it breaks it — which is the definition of `core` at the
top of the registry. Moved there.

And the failure mode was wrong independently: `isLoaded` now covers a failed fetch as
well as a successful one, with `loadFailed` for the difference, so a screen that cannot
remember its layout still renders with defaults instead of showing nothing and
explaining nothing.

THE STRAY REQUESTS. Six shell-level queries gated on isAuthenticated but not on
capability, so a member's first paint fired 403s at /server-settings/settings,
/jobs/counts (every three seconds, forever), /chat/models, /plans, /music/now-playing
and the chat access policy. Each now checks the capability it needs. JobsIndicator and
RescanButton also render nothing without `tasks` and `items` — the header was offering
two links to a screen the member cannot open and a button that would 403.

THE PERMISSIONS SCREEN. It listed all fourteen app capabilities on a server where none
of their sidecars are installed. Offering to grant Photos on a machine with no Immich
is not a permission decision. It now shows only what is installed, lists the rest as
"nothing installed for these yet" so their absence reads as a fact rather than a bug,
and marks confined rows as needing a Linux account. Fails open on a degraded read.

Found while checking that: the headscale catalogue entry claimed only the `headscale`
capability, but the same sidecar also serves `vpn` — a member enrolling their own
device — so vpn was never subtracted. Hence `alsoServes`.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-11 18:13:03 +00:00
co-authored by Claude Opus 5
parent 2c9d4e55aa
commit e393d0f5c2
13 changed files with 177 additions and 29 deletions
@@ -2,21 +2,41 @@ 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';
type Counts = { running: number; runningJobId: string | null; queued: number };
// Always-present header badges: how many jobs are running (→ the running job) and queued (→ the queue).
// Header badges: how many jobs are running (→ the running job) and queued (→ the queue).
//
// Shown only to an account that holds `tasks`, which today means the owner — the queue runs scripts as the
// server owner and is `kind: 'execution'`. It used to render for everyone and poll `/jobs/counts` every
// three seconds regardless, so a member's console filled with 403s at 20 a minute and the header offered two
// 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 allowed = can('tasks');
const [counts, setCounts] = useState<Counts>({ running: 0, runningJobId: null, queued: 0 });
useEffect(() => {
// Guarded inside the effect as well as at the render below, because the timer is the expensive half:
// an early return in the body would still leave an interval running from a previous render.
if (!allowed) return;
let alive = true;
const load = () => client.get<Counts>('/jobs/counts').then((c) => alive && setCounts(c)).catch(() => {});
const load = () =>
client
.get<Counts>('/jobs/counts')
.then((c) => alive && setCounts(c))
.catch(() => {});
load();
const timer = setInterval(load, 3000);
return () => { alive = false; clearInterval(timer); };
}, []);
return () => {
alive = false;
clearInterval(timer);
};
}, [allowed]);
if (!allowed) return null;
const pill = 'flex items-center gap-1 h-8 px-2.5 rounded-full text-xs font-semibold tabular-nums transition-colors';
@@ -3,6 +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';
type RescanResponse = { ok: boolean; counts: Record<string, number> };
@@ -13,8 +14,14 @@ const ITEM_QUERY_KEYS = ['tasks', 'task-categories', 'skills', 'tools', 'process
export function RescanButton() {
const client = useClient();
const qc = useQueryClient();
const { can } = useCapabilities();
const [loading, setLoading] = useState(false);
// `POST /api/rescan` belongs to the `items` capability — skills, tools, agents and processes on the
// owner's disk, `kind: 'execution'`. A member pressing this got a 403 and a red toast about a feature
// whose existence is not their business.
if (!can('items')) return null;
const rescan = async () => {
if (loading) return;
setLoading(true);