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:
@@ -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'),
|
||||
|
||||
@@ -21,8 +21,18 @@ import { CATALOGUE, type CatalogueEntry } from './catalogue';
|
||||
// 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. */
|
||||
const CAPABILITY_TO_SIDECAR = new Map(CATALOGUE.filter((e) => e.capability).map((e) => [e.capability as string, e.id]));
|
||||
/**
|
||||
* Capability 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
|
||||
* 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(
|
||||
CATALOGUE.flatMap((entry) =>
|
||||
[entry.capability, ...(entry.alsoServes ?? [])].filter((key): key is string => !!key).map((key) => [key, entry.id]),
|
||||
) as Array<[string, string]>,
|
||||
);
|
||||
|
||||
export type Availability = {
|
||||
/**
|
||||
|
||||
@@ -110,8 +110,20 @@ export type CatalogueEntry = {
|
||||
/**
|
||||
* The capability this sidecar backs, from `capabilities/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;
|
||||
/**
|
||||
* Other capabilities 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,
|
||||
* 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.
|
||||
*/
|
||||
alsoServes?: string[];
|
||||
/**
|
||||
* Asked when the user picks `existing`. Skipped entirely for `provisioned`, where we already know the
|
||||
* answers because we wrote the compose file.
|
||||
@@ -328,6 +340,8 @@ export const CATALOGUE: CatalogueEntry[] = [
|
||||
members: 'none',
|
||||
modes: ['existing'],
|
||||
capability: 'headscale',
|
||||
// A member enrolling their own device is the same sidecar. See `alsoServes`.
|
||||
alsoServes: ['vpn'],
|
||||
existingFields: [
|
||||
{ key: 'url', label: 'Headscale URL', type: 'url', required: true },
|
||||
{ key: 'secret', label: 'API key', type: 'secret', required: true },
|
||||
|
||||
@@ -235,11 +235,20 @@ export const CAPABILITIES: Capability[] = [
|
||||
// bound to the caller. Administering the tailnet is `headscale`, which is admin-only.
|
||||
personal: ['/'],
|
||||
},
|
||||
// Core, not app — and this was a real defect, not a preference. `/api/dashboards` is not a feature, it is
|
||||
// the per-user key-value store where EVERY workspace screen keeps its layout (`screens/files`,
|
||||
// `ws-layout-*`, panel config). `WorkspaceView` renders nothing until that store has loaded, so gating it
|
||||
// meant a member with `files` granted got a completely blank Files screen and no request to
|
||||
// /api/file-browser at all — the panel never mounted. Same for Terminal, Chat and every other screen.
|
||||
//
|
||||
// It is entirely `personal` and always was: every row is keyed to the caller. There is nothing here to
|
||||
// withhold, and withholding it does not restrict an account, it breaks it — which is exactly the
|
||||
// definition of `core` at the top of this file.
|
||||
{
|
||||
key: 'dashboards',
|
||||
label: 'Dashboards',
|
||||
description: 'Your own dashboards and saved layouts',
|
||||
kind: 'app',
|
||||
label: 'Screen layouts and dashboards',
|
||||
description: 'Where your own screen layouts and dashboards are saved',
|
||||
kind: 'core',
|
||||
api: ['/dashboards'],
|
||||
routes: ['/dashboards'],
|
||||
personal: ['/'],
|
||||
|
||||
Reference in New Issue
Block a user