diff --git a/src/databases/officer_db/src/index.ts b/src/databases/officer_db/src/index.ts index f99104b0..8c8a9a58 100644 --- a/src/databases/officer_db/src/index.ts +++ b/src/databases/officer_db/src/index.ts @@ -280,3 +280,17 @@ export { markPushDeviceSeen, } from './queries/notify'; export type { PushDeviceSelect, PushDeviceInsert } from './types'; + +// App store — what the owner has installed, and whether it should be running. +export { + listSidecarInstalls, + getSidecarInstall, + beginInstall, + recordSteps, + markInstalled, + markFailed, + markBlocked, + setEnabled, + removeInstall, + type SidecarInstall, +} from './queries/sidecar-installs'; diff --git a/src/databases/officer_db/src/queries/sidecar-installs.ts b/src/databases/officer_db/src/queries/sidecar-installs.ts new file mode 100644 index 00000000..b08b74b1 --- /dev/null +++ b/src/databases/officer_db/src/queries/sidecar-installs.ts @@ -0,0 +1,109 @@ +import { eq } from 'drizzle-orm'; +import { db } from '../db'; +import { sidecarInstalls } from '../schema'; + +// What the owner has installed from the app store. See ../schema/app-store.ts for why there is no +// userId and why `installed` and `enabled` are separate. + +export type SidecarInstall = typeof sidecarInstalls.$inferSelect; + +export async function listSidecarInstalls(): Promise { + return db.select().from(sidecarInstalls); +} + +export async function getSidecarInstall(sidecarId: string): Promise { + const [row] = await db.select().from(sidecarInstalls).where(eq(sidecarInstalls.sidecarId, sidecarId)); + return row ?? null; +} + +/** + * Create the row for an install that is about to start, or pick up the one a previous attempt left. + * + * Returning the existing row rather than replacing it is what makes a retry a RESUME: `completedSteps` + * is how the installer knows not to provision a second container, and starting fresh would throw that + * away every time someone pressed the button again after a failure. + */ +export async function beginInstall(sidecarId: string, mode: string): Promise { + const existing = await getSidecarInstall(sidecarId); + if (existing) { + const [row] = await db + .update(sidecarInstalls) + // `lastError` cleared on the way in: it describes the PREVIOUS attempt, and leaving it visible + // while a new one runs is how a UI ends up showing a stale failure next to a working service. + .set({ status: 'installing', mode, lastError: null, updatedAt: new Date() }) + .where(eq(sidecarInstalls.sidecarId, sidecarId)) + .returning(); + return row!; + } + + const [row] = await db.insert(sidecarInstalls).values({ sidecarId, mode, status: 'installing' }).returning(); + return row!; +} + +/** Record progress mid-install, so an interrupted run can be resumed rather than restarted. */ +export async function recordSteps(sidecarId: string, completedSteps: string[], composeDir?: string): Promise { + await db + .update(sidecarInstalls) + .set({ completedSteps, ...(composeDir ? { composeDir } : {}), updatedAt: new Date() }) + .where(eq(sidecarInstalls.sidecarId, sidecarId)); +} + +/** Install finished. `enabled` goes true here because installing a thing is asking for it to run. */ +export async function markInstalled(sidecarId: string, completedSteps: string[]): Promise { + await db + .update(sidecarInstalls) + .set({ + status: 'installed', + enabled: true, + completedSteps, + lastError: null, + installedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(sidecarInstalls.sidecarId, sidecarId)); +} + +/** + * Install stopped and cannot continue on its own. + * + * `completedSteps` is still written: what worked stays recorded, so resuming picks up rather than + * repeating. A failure that forgot its progress would re-provision on every retry. + */ +export async function markFailed(sidecarId: string, completedSteps: string[], error: string): Promise { + await db + .update(sidecarInstalls) + .set({ status: 'failed', completedSteps, lastError: error, updatedAt: new Date() }) + .where(eq(sidecarInstalls.sidecarId, sidecarId)); +} + +/** + * Waiting for a human — a token only the service's own UI can mint. + * + * Deliberately NOT `failed`. The container is up and healthy and everything so far worked; calling it a + * failure would make a normal install look broken and invite the user to tear down a working service. + * `lastError` carries the instruction instead of an error. + */ +export async function markBlocked(sidecarId: string, completedSteps: string[], reason: string): Promise { + await db + .update(sidecarInstalls) + .set({ status: 'blocked', completedSteps, lastError: reason, updatedAt: new Date() }) + .where(eq(sidecarInstalls.sidecarId, sidecarId)); +} + +/** Enable or disable — the process and its container, without touching anything installed. */ +export async function setEnabled(sidecarId: string, enabled: boolean): Promise { + await db + .update(sidecarInstalls) + .set({ enabled, updatedAt: new Date() }) + .where(eq(sidecarInstalls.sidecarId, sidecarId)); +} + +/** + * Forget this install. + * + * Only this row. The sidecar's tables, the service directory and every byte of data under it survive — + * see the schema comment. Uninstalling is "stop running this", not "delete my library". + */ +export async function removeInstall(sidecarId: string): Promise { + await db.delete(sidecarInstalls).where(eq(sidecarInstalls.sidecarId, sidecarId)); +} diff --git a/src/databases/officer_db/src/schema/index.ts b/src/databases/officer_db/src/schema/index.ts index 24c46926..c0936bdd 100644 --- a/src/databases/officer_db/src/schema/index.ts +++ b/src/databases/officer_db/src/schema/index.ts @@ -1,3 +1,4 @@ +export * from './app-store'; export * from './agent-panels'; export * from './api-keys'; export * from './auth'; diff --git a/src/servers/api/app-store/router.ts b/src/servers/api/app-store/router.ts new file mode 100644 index 00000000..a9a765b3 --- /dev/null +++ b/src/servers/api/app-store/router.ts @@ -0,0 +1,85 @@ +import { createRouter } from '../../create-router'; +import * as errors from '../../custom-errors'; +import { isSuperAdmin } from '../../super-admin'; +import { listStore, install, setEnabled, uninstall } from '../../app-store/service'; +import { byId, type InstallMode } from '../../app-store/catalogue'; + +// /api/app-store — what can be installed, what is installed, and the four verbs that change it. +// +// ── Owner only, explicitly ── +// +// Installing a sidecar starts a process on the machine, and provisioning one starts containers. That is +// an administrative act however many members share the server, so this router gates on the owner in its +// own right rather than relying on the capability layer alone. `server-admin` already covers it, and +// this is the belt to that braces — the same shape `/api/vault` uses, and for the same reason: a +// mistake here is not a leak of data, it is arbitrary process control. +// +// The per-user half lives elsewhere: `service_connections` is where a member's own credential goes, and +// members.ts is how they get one. + +export const appStoreRouter = createRouter(); + +appStoreRouter.use(async (ctx, next) => { + if (!(await isSuperAdmin(ctx.get('user')))) throw errors.FORBIDDEN('The app store is owner-only'); + return next(); +}); + +/** GET /api/app-store — the catalogue joined to what has happened to each entry. */ +appStoreRouter.get('/', async (ctx) => { + return ctx.json({ items: await listStore() }); +}); + +/** + * POST /api/app-store/:id/install — install, or resume one that stopped. + * + * Resume is the same call deliberately: pressing the button after a failure and pressing it after + * supplying the API key it was waiting for are one action to the user. What re-runs is decided by the + * steps already recorded, not by which endpoint was hit. + * + * Answers with the outcome rather than a bare 200 — `blocked` is a normal result that the UI has to + * render differently from success, and flattening it to "ok" would lose the reason and the link. + */ +appStoreRouter.post('/:id/install', async (ctx) => { + const id = ctx.req.param('id'); + const entry = byId(id); + if (!entry) throw errors.NOT_FOUND(`Unknown sidecar: ${id}`); + + const body = (ctx.get('body') ?? {}) as { mode?: string; values?: Record }; + const mode = body.mode as InstallMode | undefined; + if (!mode) throw errors.BAD_REQUEST('mode is required'); + if (!entry.modes.includes(mode)) { + throw errors.BAD_REQUEST(`${entry.label} cannot be installed as '${mode}'`); + } + + // The install log is collected rather than streamed for now. Streaming it into a terminal panel is + // the intended shape — the lines are already produced one at a time — and needs a channel this route + // does not have yet. + const lines: string[] = []; + const outcome = await install({ sidecarId: id, mode, values: body.values ?? {}, log: (l) => lines.push(l) }); + + return ctx.json({ outcome, log: lines }); +}); + +/** POST /api/app-store/:id/enable — start the sidecar (and, later, its container). */ +appStoreRouter.post('/:id/enable', async (ctx) => { + await setEnabled(ctx.req.param('id'), true); + return ctx.json({ ok: true }); +}); + +/** POST /api/app-store/:id/disable — stop it, keeping everything installed. */ +appStoreRouter.post('/:id/disable', async (ctx) => { + await setEnabled(ctx.req.param('id'), false); + return ctx.json({ ok: true }); +}); + +/** + * POST /api/app-store/:id/uninstall — stop running this. + * + * A POST rather than a DELETE, because it is not a deletion: the sidecar's tables and the service + * directory survive it. Calling it DELETE would suggest otherwise to the next person reading the route + * table. + */ +appStoreRouter.post('/:id/uninstall', async (ctx) => { + await uninstall(ctx.req.param('id')); + return ctx.json({ ok: true }); +}); diff --git a/src/servers/app-store/effects.ts b/src/servers/app-store/effects.ts new file mode 100644 index 00000000..0d3bcb17 --- /dev/null +++ b/src/servers/app-store/effects.ts @@ -0,0 +1,109 @@ +import { join } from 'node:path'; +import { getOwnerUser, saveServiceConnection } from 'officerdb'; +import type { InstallEffects, StepContext } from './installer'; +import { preflight } from './preflight'; +import { runSetupScript } from './run-script'; +import { startProcess } from './pm2'; +import { serviceDir } from './paths'; + +// The installer's steps, wired to the actual world. +// +// Kept apart from `installer.ts` on purpose: that file is the machine — ordering, resume, blocking — +// and is tested with these replaced by spies. This file is the thin part that genuinely touches PM2, +// Postgres and the filesystem, and is deliberately boring enough to read in one sitting. + +/** Where the platform lives, for `pm2 start ecosystem.config.cjs`. */ +const PLATFORM_DIR = process.cwd(); + +const TEMPLATES_DIR = join(import.meta.dir, 'templates'); + +export function createEffects(): InstallEffects { + return { + preflight: (entry, mode) => preflight(entry, mode), + + async provision(ctx: StepContext) { + const template = ctx.entry.composeTemplate; + // planSteps only emits `provision` for a provisioned install, and the catalogue test asserts + // every such entry has a template — so reaching here without one is a broken catalogue, not a + // user error, and it should say so rather than fail obscurely inside the runner. + if (!template) throw new Error(`${ctx.entry.id} has no compose template but was asked to provision`); + + const result = await runSetupScript({ + sidecarId: ctx.entry.id, + templateDir: join(TEMPLATES_DIR, template), + env: ctx.values, + log: ctx.log, + }); + if (!result.ok) throw new Error(result.error); + + return { results: result.results, composeDir: result.serviceDir }; + }, + + async connect(ctx: StepContext) { + const url = ctx.values.url; + // A provisioned install knows its own URL because the setup script printed it. An `existing` one + // was given it in the form. Neither having produced one means we cannot reach the service, and + // the honest answer is to stop rather than write a row that points nowhere. + if (!url) { + return { + status: 'blocked', + reason: `${ctx.entry.label} is running, but no connection URL was provided.`, + }; + } + + // Shape 3 from templates/README.md: Immich, Jellyfin and Memos mint their API key in their own + // UI, so a provisioned install is up and healthy but not yet connectable. That is a pause, not a + // failure — see markBlocked. + const needsSecret = ctx.entry.existingFields?.some((f) => f.key === 'secret' && f.required); + if (needsSecret && !ctx.values.secret) { + return { + status: 'blocked', + reason: `${ctx.entry.label} needs an API key, which only its own interface can create.`, + completeAt: url, + }; + } + + const owner = await getOwnerUser(); + if (!owner) throw new Error('no owner account'); + + // The OWNER's row: it carries the URL and IS the instance. Members get their own rows later, with + // a null url that inherits this one — see members.ts and the service_connections schema. + await saveServiceConnection({ + userId: owner.id, + service: ctx.entry.id as never, + url, + username: ctx.values.username || null, + secret: ctx.values.secret || null, + path: ctx.values.path || null, + }); + + ctx.log(`connection saved for ${ctx.entry.label}`); + return { status: 'done' }; + }, + + async applySchema(ctx: StepContext) { + // A no-op today, and honestly so. Every sidecar's tables still ship in the platform's single + // Drizzle schema and arrive together via `bun db:push`, so by the time an install runs they + // already exist. The step is in the plan because per-sidecar schema is the direction — a + // marketplace plugin cannot ship a table into a schema it does not own — and adding the step + // later would mean revisiting every install that had already recorded its progress without it. + ctx.log('schema: already present (platform-wide schema; per-sidecar schema is a later phase)'); + }, + + async startProcess(ctx: StepContext) { + const result = await startProcess(ctx.entry.process, PLATFORM_DIR); + if (!result.ok) throw new Error(`could not start ${ctx.entry.process}: ${result.error}`); + ctx.log(`${ctx.entry.process} started`); + }, + + async provisionMembers(ctx: StepContext) { + // Nothing implements MemberProvisioner yet — each sidecar will, beside its own code, rather than + // in a switch here. Logged rather than silently skipped so that a member who cannot see a service + // has a trail explaining why. + ctx.log(`members: no provisioner registered for ${ctx.entry.id} yet — members will need manual access`); + }, + }; +} + +/** Where a provisioned service's compose file lives, for the caller to record. */ +export const composeDirFor = (sidecarId: string): string => serviceDir(sidecarId); diff --git a/src/servers/app-store/pm2.ts b/src/servers/app-store/pm2.ts new file mode 100644 index 00000000..282fd1f4 --- /dev/null +++ b/src/servers/app-store/pm2.ts @@ -0,0 +1,98 @@ +// Starting and stopping a sidecar's PM2 process. +// +// ── Why shell out rather than use the pm2 API ── +// +// PM2 is already the supervisor for every process here and the ecosystem file is already the definition +// of how each one runs. Importing pm2 as a library would put a second thing in charge of that, and the +// failure mode is two supervisors disagreeing about what should be running. The CLI is the same +// interface a human uses, which also means an owner can undo anything the app store did with a command +// they already know. +// +// ── The one PM2 fact that matters here ── +// +// `pm2 start ` only works for a process PM2 has already seen. A sidecar that has never run is not +// in PM2's list, and starting it by name fails with "process or namespace not found". So a first +// install has to start it FROM THE ECOSYSTEM FILE, and every start after that can go by name. +// +// `startProcess` handles both without the caller having to know which case it is in, because the caller +// genuinely cannot know: a resumed install, a re-enable after a restart, and a first install all arrive +// at the same line. + +export type Pm2Result = { ok: true } | { ok: false; error: string }; + +const ECOSYSTEM = 'ecosystem.config.cjs'; + +async function pm2(args: string[], cwd: string): Promise<{ code: number; out: string }> { + const proc = Bun.spawn(['pm2', ...args], { cwd, stdout: 'pipe', stderr: 'pipe' }); + const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + const code = await proc.exited; + return { code, out: `${out}${err}`.trim() }; +} + +/** Is this process known to PM2 at all — running, stopped or errored? */ +export async function isKnownToPm2(name: string, cwd: string): Promise { + const { code, out } = await pm2(['jlist'], cwd); + if (code !== 0) return false; + try { + const list = JSON.parse(out) as Array<{ name?: string }>; + return list.some((p) => p.name === name); + } catch { + // jlist printing something unparseable means PM2 is in a state we should not guess about. Saying + // "not known" makes the caller start from the ecosystem file, which is the safe direction — it + // works whether or not the process exists. + return false; + } +} + +/** + * Start a sidecar, whether or not PM2 has seen it before. + * + * `--only` is what keeps this surgical: starting the ecosystem file without it would bring up every + * process in the estate, which on a light install is precisely the thing the user opted out of. + */ +export async function startProcess(name: string, cwd: string): Promise { + const known = await isKnownToPm2(name, cwd); + const args = known ? ['start', name] : ['start', ECOSYSTEM, '--only', name]; + + const { code, out } = await pm2(args, cwd); + if (code !== 0) return { ok: false, error: out || `pm2 ${args.join(' ')} exited ${code}` }; + return { ok: true }; +} + +/** + * Stop it, leaving it in PM2's list. + * + * Stop rather than delete, deliberately. A stopped process still appears in `pm2 list` as stopped, + * which is the honest picture for a disabled sidecar — deleting it would make a disabled service + * indistinguishable from one that was never installed, both to PM2 and to anyone looking. + */ +export async function stopProcess(name: string, cwd: string): Promise { + const { code, out } = await pm2(['stop', name], cwd); + // Stopping something that is not there is the desired end state, not an error. This happens on a + // resumed uninstall, and treating it as a failure would leave the row un-uninstallable. + if (code !== 0 && !/not found|doesn't exist/i.test(out)) { + return { ok: false, error: out || `pm2 stop ${name} exited ${code}` }; + } + return { ok: true }; +} + +/** Remove it from PM2 entirely. Uninstall only — see `stopProcess` for why disable does not do this. */ +export async function deleteProcess(name: string, cwd: string): Promise { + const { code, out } = await pm2(['delete', name], cwd); + if (code !== 0 && !/not found|doesn't exist/i.test(out)) { + return { ok: false, error: out || `pm2 delete ${name} exited ${code}` }; + } + return { ok: true }; +} + +/** 'online' | 'stopped' | 'errored' | … , or null when PM2 has never heard of it. */ +export async function processStatus(name: string, cwd: string): Promise { + const { code, out } = await pm2(['jlist'], cwd); + if (code !== 0) return null; + try { + const list = JSON.parse(out) as Array<{ name?: string; pm2_env?: { status?: string } }>; + return list.find((p) => p.name === name)?.pm2_env?.status ?? null; + } catch { + return null; + } +} diff --git a/src/servers/app-store/run-script.test.ts b/src/servers/app-store/run-script.test.ts index f1ddcfe9..2915980c 100644 --- a/src/servers/app-store/run-script.test.ts +++ b/src/servers/app-store/run-script.test.ts @@ -8,7 +8,12 @@ import { parseResults } from './run-script'; describe('parseResults', () => { it('reads the values a script hands back', () => { const out = parseResults( - ['==> Starting', 'OFFICER_RESULT_URL=http://127.0.0.1:18091', 'OFFICER_RESULT_PATH=/transmission/rpc', '==> Done'].join('\n'), + [ + '==> Starting', + 'OFFICER_RESULT_URL=http://127.0.0.1:18091', + 'OFFICER_RESULT_PATH=/transmission/rpc', + '==> Done', + ].join('\n'), ); expect(out).toEqual({ url: 'http://127.0.0.1:18091', path: '/transmission/rpc' }); }); diff --git a/src/servers/app-store/service.ts b/src/servers/app-store/service.ts new file mode 100644 index 00000000..e1bb1093 --- /dev/null +++ b/src/servers/app-store/service.ts @@ -0,0 +1,160 @@ +import { + listSidecarInstalls, + getSidecarInstall, + beginInstall, + markInstalled, + markFailed, + markBlocked, + setEnabled as setEnabledRow, + removeInstall, + type SidecarInstall, +} from 'officerdb'; +import { CATALOGUE, byId, type CatalogueEntry, type InstallMode } from './catalogue'; +import { runInstall, type StepName } from './installer'; +import { createEffects } from './effects'; +import { startProcess, stopProcess, deleteProcess, processStatus } from './pm2'; +import { plannedOutcome } from './members'; + +// The app store's operations, between the HTTP routes and the machinery. Routes stay about HTTP; this +// stays about what installing, enabling and uninstalling actually mean. + +const PLATFORM_DIR = process.cwd(); + +export type StoreItem = { + id: string; + label: string; + summary: string; + modes: InstallMode[]; + members: CatalogueEntry['members']; + /** What adding a member would do for this service, so the UI can say so before anyone commits. */ + memberOutcome: ReturnType; + existingFields: CatalogueEntry['existingFields']; + configFields: CatalogueEntry['configFields']; + install: { + status: 'not-installed' | SidecarInstall['status']; + enabled: boolean; + mode: string | null; + lastError: string | null; + completedSteps: string[]; + }; + /** PM2's own view. Included because the row saying `enabled` and the process being dead is the + * interesting case, and hiding it would make the store lie about what is running. */ + processStatus: string | null; +}; + +/** The catalogue joined to what has actually happened to each entry. */ +export async function listStore(): Promise { + const installs = new Map((await listSidecarInstalls()).map((row) => [row.sidecarId, row])); + + return Promise.all( + CATALOGUE.map(async (entry) => { + const row = installs.get(entry.id); + return { + id: entry.id, + label: entry.label, + summary: entry.summary, + modes: entry.modes, + members: entry.members, + memberOutcome: plannedOutcome(entry), + existingFields: entry.existingFields, + configFields: entry.configFields, + install: { + status: row?.status ?? 'not-installed', + enabled: row?.enabled ?? false, + mode: row?.mode ?? null, + lastError: row?.lastError ?? null, + completedSteps: (row?.completedSteps as string[]) ?? [], + }, + // Only asked for things that claim to be installed: `pm2 jlist` per catalogue entry would be + // fourteen subprocesses to render a page. + processStatus: row ? await processStatus(entry.process, PLATFORM_DIR) : null, + }; + }), + ); +} + +export type InstallRequest = { + sidecarId: string; + mode: InstallMode; + /** Answers from the form. Passed to the setup script as environment and to `connect` as values. */ + values: Record; + log?: (line: string) => void; +}; + +/** + * Install, or resume an install that stopped. + * + * Resume is not a separate entry point on purpose: pressing the button again after a failure, and + * pressing it again after supplying the API key it was waiting for, are the same action from the user's + * side. The recorded steps decide what actually re-runs. + */ +export async function install(req: InstallRequest) { + const entry = byId(req.sidecarId); + if (!entry) throw new Error(`unknown sidecar: ${req.sidecarId}`); + if (!entry.modes.includes(req.mode)) { + throw new Error(`${entry.label} cannot be installed as '${req.mode}'`); + } + + const row = await beginInstall(entry.id, req.mode); + const completed = (row.completedSteps as StepName[]) ?? []; + + const outcome = await runInstall({ + entry, + mode: req.mode, + values: req.values, + completed, + effects: createEffects(), + log: req.log, + }); + + if (outcome.status === 'installed') { + await markInstalled(entry.id, outcome.completed); + } else if (outcome.status === 'blocked') { + await markBlocked(entry.id, outcome.completed, outcome.reason); + } else { + await markFailed(entry.id, outcome.completed, outcome.error); + } + + return outcome; +} + +/** + * Start or stop a sidecar and its container, without changing what is installed. + * + * The container half is not implemented yet — `docker compose stop` against the recorded compose + * directory — and is deliberately absent rather than silently skipped: a disable that leaves Immich + * running is a different thing from one that stops it, and the difference is memory on the user's box. + */ +export async function setEnabled(sidecarId: string, enabled: boolean) { + const entry = byId(sidecarId); + if (!entry) throw new Error(`unknown sidecar: ${sidecarId}`); + const row = await getSidecarInstall(sidecarId); + if (!row) throw new Error(`${entry.label} is not installed`); + + const result = enabled + ? await startProcess(entry.process, PLATFORM_DIR) + : await stopProcess(entry.process, PLATFORM_DIR); + if (!result.ok) throw new Error(result.error); + + await setEnabledRow(sidecarId, enabled); + return { ok: true as const }; +} + +/** + * Stop running this. Never "delete my data". + * + * The process is stopped and removed from PM2. The sidecar's tables, the service directory and + * everything under it survive — see the schema comment and the design doc. Removing the containers is + * the missing half, for the same reason as above. + */ +export async function uninstall(sidecarId: string) { + const entry = byId(sidecarId); + if (!entry) throw new Error(`unknown sidecar: ${sidecarId}`); + + const stopped = await stopProcess(entry.process, PLATFORM_DIR); + if (!stopped.ok) throw new Error(stopped.error); + await deleteProcess(entry.process, PLATFORM_DIR); + + await removeInstall(sidecarId); + return { ok: true as const }; +} diff --git a/src/servers/capabilities/registry.ts b/src/servers/capabilities/registry.ts index 3ef80692..3208e200 100644 --- a/src/servers/capabilities/registry.ts +++ b/src/servers/capabilities/registry.ts @@ -298,6 +298,18 @@ export const CAPABILITIES: Capability[] = [ }, // ── admin: the platform administering itself ──────────────────────────────────────────────────── + { + key: 'app-store', + label: 'App store', + description: 'Install, enable and remove the sidecars this server runs', + // Admin, not app. Installing a sidecar starts a process on the machine and provisioning one starts + // containers — that is process control, not a feature a member can be granted a read of. The router + // gates on the owner in its own right as well; this entry is what makes the boot check pass and what + // keeps the surface visible in one enumeration. + kind: 'admin', + api: ['/app-store'], + routes: ['/app-store'], + }, { key: 'server-admin', label: 'Server settings', diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 99a8e447..2a83e517 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -36,6 +36,7 @@ import { terminalRouter } from './api/terminal/sidecar-server'; import { caldavRouter } from './api/dav/sidecar-server'; import { memosRouter } from './api/memos/router'; import { giteaRouter } from './api/gitea/router'; +import { appStoreRouter } from './api/app-store/router'; import { davSyncRouter } from './api/dav/sync-router'; import { davRouter } from './api/dav/router'; import { claimIosProfile } from './api/dav/ios-profile'; @@ -186,6 +187,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType ['/terminal', terminalRouter], ['/memos', memosRouter], ['/gitea', giteaRouter], + ['/app-store', appStoreRouter], ['/caldav', caldavRouter], // the JSON door for Officer's own calendar/contacts UI ['/dav', davRouter], // app-password management (the sync door is /dav, top-level) ['/notify', notifyRouter],