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
+32 -10
View File
@@ -76,17 +76,39 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
export const capabilityAdminRouter = createRouter();
capabilityAdminRouter.get('/capabilities', 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
// 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
// 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 describe = (c: (typeof GRANTABLE_CAPABILITIES)[number]) => ({
key: c.key,
label: c.label,
description: c.description,
// What the owner is actually deciding about, shown so the grant is legible rather than a name.
routes: c.routes ?? [],
hasPersonalWrites: !!c.personal?.length,
/** `confined` needs a Linux account per member to mean anything — the UI says so next to the row. */
needsOsAccount: c.kind === 'confined',
});
return ctx.json({
// Only the grantable kind is offered. `execution` and `admin` are deliberately not in this list:
// a UI that shows a checkbox it will refuse to honour is worse than one that never offered it.
capabilities: GRANTABLE_CAPABILITIES.map((c) => ({
key: c.key,
label: c.label,
description: c.description,
// What the owner is actually deciding about, shown so the grant is legible rather than a name.
routes: c.routes ?? [],
hasPersonalWrites: !!c.personal?.length,
})),
// 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),
/**
* Grantable, but their sidecar is not installed. Returned rather than dropped so the screen can say
* "these appear once you install them" — otherwise an owner who remembers seeing Photos here concludes
* the list is broken, and the honest answer is one sentence.
*/
notInstalled: GRANTABLE_CAPABILITIES.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'),