app store: a sidecar carries its own dock tile and routes, and an uninstalled one has neither
Two gaps, both from the same root: the app knew what an account MAY use and not what this server actually HAS. Availability is now subtracted server-side in the /capabilities answer. "Installed" is orthogonal to "permitted" and the owner is subject to it — the owner bypasses every permission check, but a capability they hold unconditionally still means nothing if its sidecar was never installed. Without this the dock on a fresh machine lists Photos, Jellyfin, Transmission and the rest, each leading to a screen that reports itself unavailable. Computed on the server rather than intersected in the client, so the rule lives in one place: the dock already reads `/capabilities`, and making it read a second list and combine them is how a member's dock and an owner's dock drift apart. `unavailable` is returned alongside `deniedRoutes` because the two mean different things to a UI — "not yours" versus "not here yet, install it". A disabled sidecar counts as unavailable: disable stops the process and its container, so the feature genuinely does not work, and leaving its icon would make disable look broken rather than effective. Reading install state failing subtracts NOTHING, matching useCapabilities' deliberate fail-open. Each entry now also carries a UI manifest — name, icon, colour, rootRoute, routes — because a sidecar shipping from its own repository has to be able to say what it looks like. The icon is a NAME rather than an imported component: a manifest has to survive being JSON from marketplace.officer.dev, which a lucide import cannot make. Tests pin the manifests against the capability registry, so a tile cannot appear for a route the server guards differently, and against each other, so two sidecars cannot claim one root route. No backfill, by decision: this is proven on a blank machine first and applied to alpha from scratch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@ 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';
|
||||
|
||||
// Two audiences, deliberately split.
|
||||
//
|
||||
@@ -28,6 +29,11 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
|
||||
const userId = ctx.get('user').id as number;
|
||||
const { isOwner, grants } = await getEffectiveCapabilities(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,
|
||||
// and the owner is as subject to that as a member — see app-store/availability.ts.
|
||||
const { unavailable } = await capabilityAvailability();
|
||||
|
||||
// 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
|
||||
@@ -35,16 +41,26 @@ selfCapabilitiesRouter.get('/capabilities', async (ctx) => {
|
||||
: [...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".
|
||||
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. */
|
||||
unavailable: [...unavailable].filter((key) => heldKeys.has(key)),
|
||||
// Flattened for the dock and the route guard, which care about paths rather than capability keys.
|
||||
routes: held.flatMap(({ key }) => CAPABILITY_BY_KEY.get(key)?.routes ?? []),
|
||||
routes: usable.flatMap(({ key }) => CAPABILITY_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
|
||||
// screens — and a guard that cannot tell those apart either blanks the app or guards nothing.
|
||||
deniedRoutes: CAPABILITIES.filter((c) => !heldKeys.has(c.key)).flatMap((c) => c.routes ?? []),
|
||||
// Routes of capabilities 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(
|
||||
(c) => c.routes ?? [],
|
||||
),
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user