Files
platform/src/servers/app-store/effects.ts
T
pastilhasandClaude Opus 5 dd2be8c8b3 app store: never assume a service is local, or on its usual port
An instance may be on another host, behind a reverse proxy on 443 under a path prefix, on a tailnet
address, or on an arbitrary port because the usual one was taken. All ordinary self-hosted setups, and
each one a case where assuming otherwise produces a connection that fails later with no clue why.

Two places were sloppy about it. Jellyfin's placeholder read `http://localhost:8096` and Transmission's
`http://localhost:9091`, which quietly teach that a service must be local and on its project's default
port; both now show remote examples, and the field type says why. And nothing validated what was typed,
so a bare hostname or a URL with a token in the query string was stored as-is.

The rule: reject only what cannot work, normalise what is merely untidy, have no opinion about the rest.
No check that the host is local, that the port matches a default, or that the scheme is https — a
tailnet HTTP service is completely normal.

Trailing slashes go, because `${url}/api/x` otherwise doubles the separator: accepted by some servers
and 404 by others, which is the kind of difference that reproduces on one machine and not another.
Query strings go, because that is where a token hides, and it would sit in a column meant for a
location. Credentials in the URL are refused for the same reason — outside the encrypted secret, and in
every log line that ever prints it.

A missing scheme is named rather than called invalid: it is the commonest mistake, because it is what
people type into a browser.

Verified through the API: `memos.example.com` is blocked with the fix quoted back, and
`https://memos.example.com:8443/memos/` installs and stores normalised — remote host, non-standard port,
path prefix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 19:12:06 +00:00

152 lines
7.2 KiB
TypeScript

import { join } from 'node:path';
import { getOwnerUser, saveServiceConnection, getServiceConnection } 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';
import { publishAssets } from './assets';
import { checkServiceUrl } from './url';
// 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) {
// Validated and normalised before anything is stored. What the user typed may be a bare hostname,
// may carry a trailing slash that doubles a separator later, or may hide a token in a query
// string — and none of those are visible again once written.
const raw = ctx.values.url;
if (raw) {
const checked = checkServiceUrl(raw);
if (!checked.ok) {
return { status: 'blocked', reason: `${ctx.entry.label}: ${checked.error}` };
}
ctx.values.url = checked.url;
}
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');
// Never overwrite a connection the owner already has, unless they said so.
//
// Found the hard way: installing a sidecar that was already configured replaced its connection
// row with the new install's, silently repointing a working service at something else. On a fresh
// machine that is harmless; on one with an existing setup it breaks a feature that was fine, and
// the only symptom is that it stops working.
//
// Blocking rather than failing, because there is a sensible answer and the user is the only one
// who has it: keep what is there, or replace it deliberately.
const current = await getServiceConnection(owner.id, ctx.entry.id as never);
if (current && current.url && current.url !== url && ctx.values.replaceConnection !== 'true') {
return {
status: 'blocked',
reason:
`${ctx.entry.label} is already connected to ${current.url}. Installing would repoint it to ` +
`${url}. Keep the existing connection, or reinstall with "replace connection" to change it.`,
completeAt: current.url,
};
}
// 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 publishAssets(ctx: StepContext) {
// Every install, not just provisioned ones: an `existing` Photos still needs its icon in the dock.
// The template directory is where a sidecar ships its assets, and a sidecar with none is the
// common case rather than an error.
const template = ctx.entry.composeTemplate ?? ctx.entry.id;
const published = await publishAssets(ctx.entry.id, join(TEMPLATES_DIR, template));
ctx.log(published ? `assets published to /plugins/${ctx.entry.id}/` : 'assets: none shipped');
},
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);