app store: make it work — pm2, install state, and the routes

Email installs end to end now, which was the point of picking it as tier one: no container, no external
wiring, so the machinery is exercised without the provisioning half.

Verified against the running system, not asserted:

  POST /api/app-store/email/install  -> {"status":"installed","completed":["preflight","schema","process"]}
  row                                -> email mode=config status=installed enabled=true
  pm2                                -> officer-email online
  second install                     -> all three steps skipped, process not restarted
  disable                            -> stopped

The server boots with the new router, which is the real test of the capability entry: totality.ts throws
before serve() if a mounted router has none, so booting IS the check passing.

pm2.ts shells out rather than importing pm2 as a library. PM2 is already the supervisor and the
ecosystem file is already the definition of how each process runs; a second thing in charge of that
means two supervisors disagreeing. It also means an owner can undo anything the app store did with a
command they already know. The one fact that matters: `pm2 start <name>` fails for a process PM2 has
never seen, so a first install starts from the ecosystem file with --only, and everything after goes by
name. Callers cannot know which case they are in, so startProcess decides.

Disable stops rather than deletes: a stopped process still shows in `pm2 list`, which is the honest
picture. Deleting would make a disabled sidecar indistinguishable from one never installed.

beginInstall returns the existing row instead of replacing it — that is what makes a retry a resume
rather than a re-provision — and clears lastError on the way in, so a UI never shows a stale failure
beside a working service.

The container half of enable/disable/uninstall is deliberately absent rather than stubbed silently: a
disable that leaves Immich running is a different thing from one that stops it, and the difference is
memory on the user's machine.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-10 13:55:11 +00:00
co-authored by Claude Opus 5
parent 4b9b98efda
commit ba71dc1957
10 changed files with 596 additions and 1 deletions
+14
View File
@@ -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';
@@ -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<SidecarInstall[]> {
return db.select().from(sidecarInstalls);
}
export async function getSidecarInstall(sidecarId: string): Promise<SidecarInstall | null> {
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<SidecarInstall> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
await db.delete(sidecarInstalls).where(eq(sidecarInstalls.sidecarId, sidecarId));
}
@@ -1,3 +1,4 @@
export * from './app-store';
export * from './agent-panels';
export * from './api-keys';
export * from './auth';
+85
View File
@@ -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<string, string> };
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 });
});
+109
View File
@@ -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);
+98
View File
@@ -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 <name>` 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<boolean> {
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<Pm2Result> {
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<Pm2Result> {
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<Pm2Result> {
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<string | null> {
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;
}
}
+6 -1
View File
@@ -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' });
});
+160
View File
@@ -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<typeof plannedOutcome>;
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<StoreItem[]> {
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<string, string>;
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 };
}
+12
View File
@@ -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',
+2
View File
@@ -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<typeof createRouter>
['/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],