From d273dc6a14d1d64fe673bb1df36936b22f993f7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 28 Aug 2026 23:12:54 +0100 Subject: [PATCH] install a plugin from a git URL, with no registry involved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit the catalogue was the only way a remote plugin could arrive, so "build one and give it to a friend" had no route. the first real case is a plugin that must never be listed: fofos/fofos-admin is one website's admin panel, useful to exactly one person. MUCH smaller than the todo assumed. fetchPlugin already cloned, validated the URL, refused to overwrite an existing directory, checked for a manifest and removed a failed clone. exactly one line tied it to the marketplace — where the string came from. so this is a source parameter threaded through, and a route. THE NAME COMES FROM THE REPOSITORY, because appName is the DIRECTORY name and is never declared in a manifest — the id cannot disagree with where the code sits. that decision is inherited here rather than remade: the repo name becomes the directory, the route segment, the table prefix and the sidecar suffix. so it is checked against ^[a-z0-9]+(-[a-z0-9]+)*$ BEFORE anything is cloned; a half-clone under an unusable name reads as a broken plugin rather than an absent one. the URL is validated identically whether it came from the catalogue or from a person, because the catalogue is not a trust boundary — it is just where a string came from, and the same string reaches the same argv. what differs is who may ask: this route is owner-only, and the owner is choosing what code runs rather than choosing from a list somebody curated. validation is not review, and nothing here reads what it cloned. caught by testing: the source reached installPlugin and stopped there — the fetchPlugin call still had the old two arguments, so a URL install fell back to the catalogue and failed with "no catalogue entry". the guard tests are what surfaced it, because the error named the wrong cause. verified end to end against fofos-admin — cloned, schema applied, mounted, dock pinned — then purged, since officer-dev is not that website's machine. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- TODO.md | 4 +- src/servers/api/plugins/router.ts | 52 +++++++++++++++++++ src/servers/plugins/fetch.ts | 24 ++++++--- src/servers/plugins/install.ts | 10 +++- .../no-install-from-git-url.md | 17 +++++- 5 files changed, 97 insertions(+), 10 deletions(-) diff --git a/TODO.md b/TODO.md index baff0324..410089b7 100644 --- a/TODO.md +++ b/TODO.md @@ -22,7 +22,7 @@ the owner's OS user and can never be granted. Indirection there really is accide ## Themes -**107 open, 32 closed**, across 34 themes. +**106 open, 33 closed**, across 34 themes. | Theme | Open | Closed | | --- | --- | --- | @@ -45,7 +45,7 @@ the owner's OS user and can never be granted. Indirection there really is accide | [Mobile apps](todo/mobile-apps/) | 5 | 0 | | [Multi-user](todo/multi-user/) | 17 | 5 | | [Notifications](todo/notifications/) | 1 | 0 | -| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 5 | 0 | +| [Plugin ecosystem — before it opens to third parties (2026-08-18)](todo/plugin-ecosystem/) | 4 | 1 | | [Plugin infrastructure](todo/plugin-infrastructure/) | 2 | 1 | | [Plugin repos — what writing their READMEs turned up](todo/plugin-repos/) | 10 | 9 | | [Plugin system — gaps the email extraction found (2026-08-18)](todo/plugin-system/) | 3 | 0 | diff --git a/src/servers/api/plugins/router.ts b/src/servers/api/plugins/router.ts index c01660f3..37f89e08 100644 --- a/src/servers/api/plugins/router.ts +++ b/src/servers/api/plugins/router.ts @@ -224,6 +224,58 @@ pluginsRouter.post('/update-all', async (ctx) => { }); }); +/* + POST /plugins/install-from-url — a plugin that is in no registry. + + The catalogue was the only way a remote plugin could arrive, so "build one and give it to a friend" + had no route at all. The first real case is a plugin that must never be listed: fofos-admin is one + website's admin panel, useful to exactly one person, and putting it in a public registry to install it + would be the wrong shape. + + ── The name comes from the URL, because the name IS the directory ── + + `appName` is taken from the directory a plugin sits in and never declared in its manifest, so the id + cannot disagree with where the code is. That decision is made for us here: the repository name becomes + the directory, and therefore the id, the route segment, the table prefix and the sidecar suffix. A + repository called something unusable as an id is refused before anything is cloned rather than after — + a half-clone under a bad name is a plugin the platform reports as broken instead of absent. + + ── Owner-only, and it is not the same owner-only as the rest of this router ── + + Every route here is owner-gated because installing a plugin mounts routes and starts a process. This + one also takes an ADDRESS from the caller, so the owner is choosing what code runs, not choosing from + a catalogue somebody curated. The URL is validated in `fetch.ts` — https, no credentials, no shell — + but validation is not review: nothing here reads what it cloned. +*/ +pluginsRouter.post('/install-from-url', async (ctx) => { + type FromUrl = { url?: string; appName?: string }; + const body = await ctx.req.json().catch(() => ({}) as FromUrl); + const url = body.url?.trim(); + if (!url) throw errors.BAD_REQUEST('url is required'); + + // `https://host/owner/repo` or `…/repo.git` → `repo`. Derived rather than asked for, so the name and + // the address cannot disagree; `appName` in the body only OVERRIDES it, for a repository whose name is + // not the id you want. + const derived = url + .replace(/\.git$/, '') + .replace(/\/+$/, '') + .split('/') + .pop(); + const appName = body.appName?.trim() || derived; + + // The same shape every other plugin id has. Checked here because everything downstream — the route + // segment, the table prefix, the pm2 process name — inherits it, and each of those fails differently. + if (!appName || !/^[a-z0-9]+(-[a-z0-9]+)*$/.test(appName)) { + throw errors.BAD_REQUEST( + `"${appName ?? ''}" is not a usable plugin name. Lowercase letters, digits and single hyphens — ` + + 'it becomes the directory, the route and the table prefix.', + ); + } + + const result = await installPlugin(appName, undefined, ctx.get('user').id, undefined, url); + return ctx.json(result, result.ok ? 200 : 400); +}); + pluginsRouter.post('/:appName/install', async (ctx) => { const result = await installPlugin(ctx.req.param('appName'), undefined, ctx.get('user').id); return ctx.json(result, result.ok ? 200 : 400); diff --git a/src/servers/plugins/fetch.ts b/src/servers/plugins/fetch.ts index e91306a9..a337125e 100644 --- a/src/servers/plugins/fetch.ts +++ b/src/servers/plugins/fetch.ts @@ -40,7 +40,7 @@ const gitAvailable = (): boolean => !!Bun.which('git'); * verb with different questions — what happens to uncommitted changes, does the version go backwards — * and it does not exist yet. */ -export async function fetchPlugin(appName: string, onLog?: OnLog): Promise { +export async function fetchPlugin(appName: string, onLog?: OnLog, source?: string): Promise { const dir = join(PLUGINS_DIR, appName); if (existsSync(dir)) { @@ -50,8 +50,20 @@ export async function fetchPlugin(appName: string, onLog?: OnLog): Promise { const steps: string[] = []; @@ -160,7 +168,7 @@ export async function installPlugin( // as it was. let plugin = await findPlugin(appName); if (!plugin) { - const fetched = await fetchPlugin(appName, onLog); + const fetched = await fetchPlugin(appName, onLog, source); if (!fetched.ok) { return { ok: false, appName, steps, error: fetched.error ?? `cannot fetch "${appName}"` }; } diff --git a/todo/plugin-ecosystem/no-install-from-git-url.md b/todo/plugin-ecosystem/no-install-from-git-url.md index 4fe9dad4..44117e4e 100644 --- a/todo/plugin-ecosystem/no-install-from-git-url.md +++ b/todo/plugin-ecosystem/no-install-from-git-url.md @@ -2,7 +2,7 @@ id: plugin-ecosystem/no-install-from-git-url title: "No install-from-git-URL" theme: plugin-ecosystem -status: open +status: done created: 2026-08-18 date_source: section order: 3 @@ -11,3 +11,18 @@ order: 3 **No install-from-git-URL.** The catalogue is the only route for a remote plugin, so the "build your own, give it to a friend" path has no affordance. Small, and needed before the unofficial channel is real. + +**Done 2026-08-28.** `POST /api/plugins/install-from-url`, owner-only, taking `{ url }` and optionally +`{ appName }`. + +Smaller than this note assumed: `fetchPlugin` already cloned, validated the URL, refused to overwrite an +existing directory, checked for a `manifest.ts` and cleaned up a failed clone. One line tied it to the +catalogue — where the URL came from. So the change is a source parameter threaded through, plus a route. + +The name comes from the repository, because `appName` IS the directory name and is never declared in a +manifest. That is checked against `^[a-z0-9]+(-[a-z0-9]+)*$` BEFORE cloning: it becomes the route +segment, the table prefix and the pm2 process name, and a half-clone under an unusable name reads as a +broken plugin rather than an absent one. + +The first real user is `fofos/fofos-admin` — one website's admin panel, which should never be in a +registry. Verified end to end against it and then purged, since this is not that website's machine.