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',