From 27c1331b3c139c6cddac2fb0d2e487b987c44d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Mon, 10 Aug 2026 18:15:40 +0000 Subject: [PATCH] app store: a sidecar carries its own dock tile and routes, and an uninstalled one has neither MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/servers/api/users/capabilities-routes.ts | 20 +++++- src/servers/app-store/availability.ts | 66 ++++++++++++++++++++ src/servers/app-store/catalogue.test.ts | 46 ++++++++++++++ src/servers/app-store/catalogue.ts | 60 ++++++++++++++++++ 4 files changed, 190 insertions(+), 2 deletions(-) create mode 100644 src/servers/app-store/availability.ts diff --git a/src/servers/api/users/capabilities-routes.ts b/src/servers/api/users/capabilities-routes.ts index 250d06ec..9d16eaf9 100644 --- a/src/servers/api/users/capabilities-routes.ts +++ b/src/servers/api/users/capabilities-routes.ts @@ -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 ?? [], + ), }); }); diff --git a/src/servers/app-store/availability.ts b/src/servers/app-store/availability.ts new file mode 100644 index 00000000..23ecc176 --- /dev/null +++ b/src/servers/app-store/availability.ts @@ -0,0 +1,66 @@ +import { listSidecarInstalls } from 'officerdb'; +import { CATALOGUE } 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 ── +// +// They answer different questions and combining them would get the owner wrong. A capability 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. +// +// Without this the dock on a fresh install lists Photos, Jellyfin, Transmission and the rest for the +// owner, each leading to a screen that reports itself unavailable. The features are meant to arrive when +// they are installed, not sit there greyed out from the start. +// +// ── 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 +// 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])); + +export type Availability = { + /** Capability keys whose sidecar is not installed, or is installed but disabled. */ + unavailable: Set; + /** + * True when install state could not be read. + * + * The caller then subtracts NOTHING. Same reasoning as `useCapabilities` 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. + */ + degraded: boolean; +}; + +/** + * What is missing on this server, by capability 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 { + const unavailable = new Set(); + + let installs; + try { + installs = await listSidecarInstalls(); + } catch { + return { unavailable, degraded: true }; + } + + const usable = new Set( + 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); + } + + return { unavailable, degraded: false }; +} diff --git a/src/servers/app-store/catalogue.test.ts b/src/servers/app-store/catalogue.test.ts index 239dd6ee..e6dffbf8 100644 --- a/src/servers/app-store/catalogue.test.ts +++ b/src/servers/app-store/catalogue.test.ts @@ -106,3 +106,49 @@ describe('member provisioning is declared for every entry', () => { expect(byId('vault')!.members).toBe('invite'); }); }); + +describe('the UI manifest each sidecar carries', () => { + it('declares one, unless it has no interface of its own', () => { + // notify is the only exception: it produces notifications FOR other features and has no screen. + for (const entry of CATALOGUE) { + if (entry.id === 'notify') expect(entry.ui).toBeUndefined(); + else expect(entry.ui).toBeDefined(); + } + }); + + it('names an icon rather than importing one', () => { + // A manifest has to survive being JSON from marketplace.officer.dev. A lucide component cannot make + // that trip; a name can, and resolveIcon already maps names to glyphs. + for (const entry of CATALOGUE) { + if (!entry.ui) continue; + const hasGlyph = typeof entry.ui.icon === 'string'; + const hasImage = typeof entry.ui.image === 'string'; + expect(hasGlyph || hasImage).toBe(true); + } + }); + + it('includes its rootRoute among its routes', () => { + // The dock links to rootRoute and the guard matches `routes`. If the root is missing from the list, + // the tile appears and leads somewhere the guard refuses. + for (const entry of CATALOGUE) { + if (!entry.ui) continue; + expect(entry.ui.routes).toContain(entry.ui.rootRoute); + } + }); + + it('claims routes that the capability 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])); + for (const entry of CATALOGUE) { + if (!entry.ui || !entry.capability) continue; + const declared = byKey.get(entry.capability)?.routes ?? []; + for (const route of entry.ui.routes) expect(declared).toContain(route); + } + }); + + it('does not have two sidecars claiming the same root route', () => { + const roots = CATALOGUE.filter((e) => e.ui).map((e) => e.ui!.rootRoute); + expect(new Set(roots).size).toBe(roots.length); + }); +}); diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts index 30da888e..ed2c6511 100644 --- a/src/servers/app-store/catalogue.ts +++ b/src/servers/app-store/catalogue.ts @@ -36,9 +36,44 @@ export type ConfigField = { help?: string; }; +/** + * How a sidecar presents itself in the app: the dock tile and the routes it owns. + * + * Carried by the sidecar rather than hardcoded in the shell, because a sidecar that ships from its own + * repository has to be able to say what it looks like. `ALL_DOCK_ITEMS` in Dock.tsx is the current + * hardcoded list; the intent is that it becomes derived from installed manifests, so a feature appears + * in the dock when it is installed and leaves when it is removed, with nothing in the shell to update. + * + * The icon is a NAME, not an imported component. A manifest has to survive being JSON from + * marketplace.officer.dev, and a lucide import cannot. `resolveIcon` already maps names to glyphs for + * exactly this reason. + */ +export type UiManifest = { + /** Dock label. Often shorter than the catalogue `label` — "Video" for Jellyfin. */ + name: string; + /** A lucide icon name, resolved at render. */ + icon?: string; + /** An image asset instead of a glyph, for a service with its own mark. */ + image?: string; + /** Tile colour. */ + color: string; + /** Where the dock tile goes, and the prefix the route guard matches. */ + rootRoute: string; + /** + * Every frontend route this sidecar owns, `rootRoute` included. + * + * Separate from `rootRoute` because a feature can own more than one path — the capability registry + * already lists `/caldav` and `/dav` together — and the guard needs all of them while the dock needs + * exactly one. + */ + routes: string[]; +}; + export type CatalogueEntry = { /** Stable id. Matches `sidecar_installs.sidecar_id` and `service_connections.service` where both exist. */ id: string; + /** How it appears in the app. Absent for a sidecar with no UI of its own, like notify. */ + ui?: UiManifest; /** The PM2 process to start and stop. Must exist in ecosystem.config.cjs. */ process: string; label: string; @@ -92,6 +127,7 @@ export const CATALOGUE: CatalogueEntry[] = [ // ── Point at something you already run, or let us start one ──────────────────────────────────────── { id: 'photos', + ui: { name: 'Photos', icon: 'Images', color: '#10b981', rootRoute: '/photos', routes: ['/photos'] }, process: 'officer-photos', label: 'Photos', summary: 'Your Immich library — browse, search, upload from the phone', @@ -112,6 +148,7 @@ export const CATALOGUE: CatalogueEntry[] = [ }, { id: 'jellyfin', + ui: { name: 'Video', icon: 'Clapperboard', color: '#a855f7', rootRoute: '/jellyfin', routes: ['/jellyfin'] }, process: 'officer-jellyfin', label: 'Jellyfin', summary: 'Films and shows, with a player that handles direct, HLS and progressive', @@ -126,6 +163,7 @@ export const CATALOGUE: CatalogueEntry[] = [ }, { id: 'memos', + ui: { name: 'Memos', icon: 'NotebookPen', color: '#eab308', rootRoute: '/memos', routes: ['/memos'] }, process: 'officer-memos', label: 'Memos', summary: 'Quick notes, tagged and searchable', @@ -140,6 +178,7 @@ export const CATALOGUE: CatalogueEntry[] = [ }, { id: 'invoiceshelf', + ui: { name: 'Invoices', icon: 'Receipt', color: '#0891b2', rootRoute: '/invoices', routes: ['/invoices'] }, process: 'officer-invoiceshelf', label: 'Invoices', summary: 'InvoiceShelf — clients, estimates and invoices', @@ -154,6 +193,7 @@ export const CATALOGUE: CatalogueEntry[] = [ }, { id: 'vault', + ui: { name: 'Vault', icon: 'KeyRound', color: '#175ddc', rootRoute: '/vault', routes: ['/vault'] }, process: 'officer-vault', label: 'Vault', summary: 'Vaultwarden — passwords, reachable by the Bitwarden apps', @@ -174,6 +214,13 @@ export const CATALOGUE: CatalogueEntry[] = [ }, { id: 'transmission', + ui: { + name: 'Transmission', + icon: 'ArrowDownUp', + color: '#e11d48', + rootRoute: '/transmission', + routes: ['/transmission'], + }, process: 'officer-transmission', label: 'Transmission', summary: 'Torrents, with the daemon Officer talks to over RPC', @@ -203,6 +250,7 @@ export const CATALOGUE: CatalogueEntry[] = [ }, { id: 'slskd', + ui: { name: 'Soulseek', image: '/slskd.png', color: '#ffffff', rootRoute: '/soulseek', routes: ['/soulseek'] }, process: 'officer-slskd', label: 'Soulseek', summary: 'slskd — search and download from the Soulseek network', @@ -217,6 +265,13 @@ export const CATALOGUE: CatalogueEntry[] = [ }, { id: 'caldav', + ui: { + name: 'Calendar', + icon: 'CalendarDays', + color: '#3b82f6', + rootRoute: '/calendar', + routes: ['/calendar', '/contacts'], + }, process: 'officer-caldav', label: 'Calendar', summary: 'Radicale — calendars and contacts over CalDAV/CardDAV', @@ -228,6 +283,7 @@ export const CATALOGUE: CatalogueEntry[] = [ }, { id: 'headscale', + ui: { name: 'Headscale', icon: 'Network', color: '#818cf8', rootRoute: '/headscale', routes: ['/headscale'] }, process: 'officer-headscale', label: 'Headscale', summary: 'Your own tailnet control plane', @@ -243,6 +299,7 @@ export const CATALOGUE: CatalogueEntry[] = [ // ── Nothing to reach: configuration only ─────────────────────────────────────────────────────────── { id: 'email', + ui: { name: 'Email', icon: 'Mail', color: '#ef4444', rootRoute: '/email', routes: ['/email'] }, process: 'officer-email', label: 'Email', summary: 'Your IMAP accounts, synced and searchable', @@ -255,6 +312,7 @@ export const CATALOGUE: CatalogueEntry[] = [ }, { id: 'music', + ui: { name: 'Music', icon: 'Music', color: '#22c55e', rootRoute: '/music', routes: ['/music'] }, process: 'officer-music', label: 'Music', summary: 'Index and play the library on this machine', @@ -265,6 +323,7 @@ export const CATALOGUE: CatalogueEntry[] = [ }, { id: 'wallet', + ui: { name: 'Wallet', icon: 'Bitcoin', color: '#f7931a', rootRoute: '/wallet', routes: ['/wallet'] }, process: 'officer-wallet', label: 'Wallet', summary: 'Bitcoin and Lightning, with keys held by the sidecar alone', @@ -285,6 +344,7 @@ export const CATALOGUE: CatalogueEntry[] = [ }, { id: 'vnc', + ui: { name: 'Desktop', icon: 'MonitorSmartphone', color: '#ec4899', rootRoute: '/desktop', routes: ['/desktop'] }, process: 'officer-vnc', label: 'Desktop', summary: 'Mirror this machine’s display in the browser',