import type { Context } from 'hono'; import { createRouter, type HonoVariables } from '@@/create-router'; import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection'; import { NpmError, npmRequest, npmProxy, probe, forgetToken } from './npm-client'; import { addDomain, dryCheck, listCredentials, listDomains, recheckDomain, removeCredential, removeDomain, saveCredential, issueWithDnsChallenge, DomainError, } from './domains'; /** * Mounted at `/api/proxy-manager`. * * Everything here is a read except the enable/disable pair and the settings under `/_config`, which the * manifest marks owner-only. The platform's permission gate enforces that — this file does not check it * again, because two places deciding the same thing is how they come to disagree. * * ── Two surfaces, on purpose ── * * **The curated routes** below — `/hosts`, `/certificates`, `/status` — exist for the PANELS. They sort, * they compute `daysUntilExpiry`, they shape errors into something a screen can render. A UI wants * answers, not NPM's raw output. * * **`/npm/*` is a pass-through**, and it exists for the AGENT. It forwards method, path, query and body to * NPM and returns NPM's own status and body unchanged. The contract there is NPM's API, not ours. * * ── Why the second one, after this file argued for the first ── * * This said, until 2026-08-25, that creating and deleting were deliberately absent because NPM's own UI * does them well and each is a form with real consequences. The owner's call, that day, was that this * reasoning ends up treating people as if they cannot be trusted with their own server: * * > "even though we are assuming users that don't know how to operate nginx, the last thing I want to * > do is treat users as stupid" * * A curated surface also cannot keep up. Every NPM feature we did not wrap is one the owner cannot reach * through Officer at all, and the list only grows — so the plugin becomes a worse NPM rather than a way * into the real one. Handing over the actual API and holding only the credential is the honest shape. * * **What replaces the refusal is confirmation, and it lives in the SKILL rather than here.** The agent * confirms before anything destructive and names what it is about to change. That is an instruction, not * a boundary, and saying so plainly is better than a boundary that only looks like one — the credential * sits behind a permission, and `read` level already refuses every unsafe method by itself. */ export const router = createRouter(); /** NPM's shape, narrowed to what the panels render. */ type ProxyHost = { id: number; domain_names: string[]; forward_scheme: string; forward_host: string; forward_port: number; enabled: number; ssl_forced: number; certificate_id: number; access_list_id: number; meta?: { nginx_online?: boolean; nginx_err?: string | null }; }; type Certificate = { id: number; provider: string; nice_name: string; domain_names: string[]; expires_on: string | null; }; /** * Every NPM failure becomes a shaped body rather than a stack trace. * * `likelyAuth` is the interesting field: NPM answers 404 when unauthenticated, so without saying this * out loud the UI would show "not found" for what is actually a wrong password. The panel turns it into * a sentence pointing at settings. */ const asError = (error: unknown) => { if (error instanceof NpmError) { return { body: { error: error.message, likelyAuth: error.likelyAuth, unreachable: error.status === 0 }, // 503 when NPM is not answering at all, 502 for everything it did answer — including a refused // credential, which is upstream's verdict rather than this request's. `likelyAuth` in the body is // what the panel branches on; it does not need a status of its own. status: error.status === 0 ? 503 : 502, } as const; } return { body: { error: error instanceof Error ? error.message : String(error) }, status: 500 } as const; }; /** Loads the credential, or answers 409 — "configured" is a state the UI has to distinguish. */ async function withCredentials(handler: (c: NonNullable>>) => Promise) { const credentials = await readCredentials(); if (!credentials) return { ok: false as const, status: 409, body: { error: 'No Nginx Proxy Manager connection is configured.', unconfigured: true } }; try { return { ok: true as const, data: await handler(credentials) }; } catch (error) { const shaped = asError(error); return { ok: false as const, status: shaped.status, body: shaped.body }; } } // `Context` is imported rather than derived from `router.get`. `Parameters` resolves // Hono's FIRST overload, `[path: string]`, so index 1 is `undefined` and every handler here typed as // `never` — twelve errors from one clever line, and `bunx tsgo` is shared with the platform. const respond = (ctx: Context<{ Variables: HonoVariables }>, result: Awaited>) => result.ok ? ctx.json(result.data) : ctx.json(result.body, result.status as 409 | 502 | 503 | 500); // ── Status ──────────────────────────────────────────────────────────────────────────────────────── /** * The last probe, in memory, so a polled endpoint does not write to Postgres on every call. * * `GET /status` is meant to be polled — two panels open is two callers — and it used to `recordProbe` * unconditionally, so a READ endpoint issued an UPDATE per poll per panel. The row exists to answer * "when was this last known good" across a restart, which needs a write when the answer CHANGES, not * when it is confirmed. * * So: the probe itself is cached for `PROBE_TTL_MS`, and the row is written only when the verdict or the * version differs from what is already stored. A flapping NPM still writes every transition, which is * exactly the case worth recording. */ const PROBE_TTL_MS = 30_000; type ProbeResult = { ok: boolean; version: string | null; error?: string }; let cachedProbe: { at: number; url: string; result: ProbeResult } | null = null; async function probeCached(url: string): Promise { if (cachedProbe && cachedProbe.url === url && Date.now() - cachedProbe.at < PROBE_TTL_MS) { return cachedProbe.result; } let result: ProbeResult; try { const info = await probe(url); result = { ok: true, version: info.version }; } catch (error) { result = { ok: false, version: null, error: asError(error).body.error }; } cachedProbe = { at: Date.now(), url, result }; return result; } /** Forget the cache when the credential changes — the next status must not answer for the old server. */ const forgetProbe = (): void => { cachedProbe = null; }; /** Cheap enough for a panel to poll: whether a connection exists, and whether it currently works. */ router.get('/status', async (ctx) => { const connection = await readConnection(); if (!connection.configured) return ctx.json({ ...connection, reachable: false }); const credentials = await readCredentials(); if (!credentials) return ctx.json({ ...connection, reachable: false }); const result = await probeCached(credentials.url); const version = result.version ?? connection.version; // Only when something actually changed. `lastProbeOk` is null on a row that has never been probed, so // the first poll after configuring always writes. if (connection.lastProbeOk !== result.ok || connection.version !== version) { await recordProbe(version, result.ok); } return result.ok ? ctx.json({ ...connection, version, reachable: true }) : ctx.json({ ...connection, version, reachable: false, error: result.error }); }); // ── The four kinds of host NPM manages ──────────────────────────────────────────────────────────── router.get('/hosts', async (ctx) => respond( ctx, await withCredentials(async (c) => { const hosts = await npmRequest(c, { path: '/nginx/proxy-hosts?expand=certificate,access_list' }); // Sorted by first domain rather than by NPM's creation order: a list read by a person should be // findable, and NPM's own UI sorts this way too. return hosts.sort((a, b) => (a.domain_names[0] ?? '').localeCompare(b.domain_names[0] ?? '')); }), ), ); router.get('/redirections', async (ctx) => respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/redirection-hosts' }))), ); router.get('/streams', async (ctx) => respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/streams' })))); /** NPM calls these "404 hosts" — a domain it serves deliberately as nothing. */ router.get('/dead-hosts', async (ctx) => respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/dead-hosts' }))), ); router.get('/hosts/:id', async (ctx) => respond( ctx, await withCredentials((c) => npmRequest(c, { path: `/nginx/proxy-hosts/${encodeURIComponent(ctx.req.param('id'))}?expand=certificate,access_list,owner` }), ), ), ); // ── Certificates and access lists ───────────────────────────────────────────────────────────────── router.get('/certificates', async (ctx) => respond( ctx, await withCredentials(async (c) => { const certificates = await npmRequest(c, { path: '/nginx/certificates' }); // The panel's whole job is "which of these is about to expire", so compute it once here rather // than parsing dates in the browser and getting the timezone wrong. const now = Date.now(); return certificates .map((certificate) => ({ ...certificate, daysUntilExpiry: certificate.expires_on ? Math.floor((Date.parse(certificate.expires_on) - now) / 86_400_000) : null, })) .sort((a, b) => (a.daysUntilExpiry ?? 1e9) - (b.daysUntilExpiry ?? 1e9)); }), ), ); router.get('/access-lists', async (ctx) => respond(ctx, await withCredentials((c) => npmRequest(c, { path: '/nginx/access-lists?expand=items,clients' }))), ); // ── The one mutation ────────────────────────────────────────────────────────────────────────────── /** * Taking a host in or out of service. The only write, and it is reversible in one click — which is why * it is here when creating and deleting are not. */ router.post('/hosts/:id/:action', async (ctx) => { const action = ctx.req.param('action'); if (action !== 'enable' && action !== 'disable') return ctx.json({ error: 'Expected enable or disable.' }, 400); return respond( ctx, await withCredentials(async (c) => { await npmRequest(c, { path: `/nginx/proxy-hosts/${encodeURIComponent(ctx.req.param('id'))}/${action}`, method: 'POST' }); return { id: Number(ctx.req.param('id')), enabled: action === 'enable' }; }), ); }); // ── Domains ───────────────────────────────────────────────────────────────────────────────────────── /** * Officer's own record of which domains this machine is responsible for, and what DNS says about them. * * **Not NPM's.** NPM knows about hosts it serves; it has no idea which domains you own, and cannot — a * name is only knowable if you already have it. So this is a list the owner curates, with a cached * observation attached to each entry. * * ── The credential half is under `/_config` on purpose ── * * A DNS provider API key can rewrite every record for a domain, which is a strictly larger power than the * NPM login already kept there. Putting those routes under the prefix the manifest marks `ownerOnly` means * the rule is declared once and enforced by the platform, with nothing in this file deciding it twice. * * Reading the domain LIST is not owner-only. Which names this machine serves is the same class of fact as * which proxy hosts exist, and `hasCredential` is a boolean rather than a secret. */ // `Context`, imported — NOT derived from `router.get`. That trick resolves hono's first overload and // yields `never`, which is the twelve-error bug this same file was fixed for earlier today. Twice is // enough to say it out loud. const domainFailure = (ctx: Context<{ Variables: HonoVariables }>, error: unknown) => { if (error instanceof DomainError) return ctx.json({ error: error.message }, error.status); return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 500); }; router.get('/domains', async (ctx) => ctx.json(await listDomains())); /** * Look a domain up without storing anything — the check button, before you commit to adding it. * * A GET with the domain in the query rather than a POST, because it changes nothing. That also means a * read-level account can use it, which is right: this asks public DNS a question anybody could ask. */ router.get('/domains/check', async (ctx) => { const domain = ctx.req.query('domain') ?? ''; try { return ctx.json(await dryCheck(domain)); } catch (error) { return domainFailure(ctx, error); } }); router.post('/domains', async (ctx) => { const body = (await ctx.req.json().catch(() => null)) as { domain?: string } | null; try { return ctx.json(await addDomain(body?.domain ?? '')); } catch (error) { return domainFailure(ctx, error); } }); router.post('/domains/:id/recheck', async (ctx) => { const id = Number(ctx.req.param('id')); if (!Number.isInteger(id)) return ctx.json({ error: 'Expected a domain id.' }, 400); try { return ctx.json(await recheckDomain(id)); } catch (error) { return domainFailure(ctx, error); } }); router.delete('/domains/:id', async (ctx) => { const id = Number(ctx.req.param('id')); if (!Number.isInteger(id)) return ctx.json({ error: 'Expected a domain id.' }, 400); // Removing a domain forgets Officer's note about it. It changes nothing at the registrar, nothing in // NPM, and nothing about whether the site works — worth saying, because "delete domain" reads much // larger than it is. await removeDomain(id); return ctx.json({ ok: true }); }); /** * `POST /domains/certificate` — a certificate for a name Let's Encrypt cannot visit. * * **The one route here that is deliberately not a pass-through.** Everything else hands NPM's API through; * this one cannot, because the request needs a DNS credential that is never returned to any caller. So the * platform decrypts it, builds the request, and returns NPM's own answer unchanged. * * Same arrangement as the NPM token itself, one level up: the thing that wants the work done is not the * thing holding the password. An agent asks for a certificate; it never sees the key that got it. * * **NOT under `/_config`.** The credential is owner-only to READ and WRITE, and that stays true — but * USING it on the owner's behalf is the feature, and gating this route to the owner would defeat it. A * `read`-level account still cannot reach it: this is a POST, and `isRequestAllowedAtLevel` refuses every * unsafe method below `write`. * * For a publicly-resolving name, do NOT use this. `POST /npm/nginx/certificates` needs no credential and * no propagation wait — see the skill. */ router.post('/domains/certificate', async (ctx) => { const body = (await ctx.req.json().catch(() => null)) as | { domain_names?: string[]; propagation_seconds?: number } | null; const credentials = await readCredentials(); if (!credentials) { return ctx.json({ error: 'No Nginx Proxy Manager connection is configured.', unconfigured: true }, 409); } let request: Awaited>; try { request = await issueWithDnsChallenge({ domainNames: body?.domain_names ?? [], propagationSeconds: body?.propagation_seconds, }); } catch (error) { return domainFailure(ctx, error); } try { // A long timeout on purpose: NPM waits for the challenge record to propagate before it asks Let's // Encrypt to look, so this legitimately takes minutes. The default 15s would abandon a request that // is going to succeed — and abandoning it does not stop it, which is the worst of both. const result = await npmProxy(credentials, { method: 'POST', path: '/nginx/certificates', body: JSON.stringify(request.body), timeoutMs: 10 * 60 * 1000, }); return new Response(result.body, { status: result.status, headers: { 'content-type': result.contentType ?? 'application/json' }, }); } catch (error) { const shaped = asError(error); return ctx.json(shaped.body, shaped.status); } }); // ── DNS credentials — owner only, by the manifest's `/_config` prefix ──────────────────────────────── router.get('/_config/dns-providers', async (ctx) => ctx.json(await listCredentials())); router.put('/_config/dns-providers/:provider', async (ctx) => { const body = (await ctx.req.json().catch(() => null)) as | { fields?: Record; scope?: string; label?: string } | null; const scope = body?.scope === 'broad' ? 'broad' : 'narrow'; try { return ctx.json( await saveCredential({ provider: ctx.req.param('provider'), fields: body?.fields ?? {}, scope, label: body?.label, }), ); } catch (error) { return domainFailure(ctx, error); } }); router.delete('/_config/dns-providers/:provider', async (ctx) => { await removeCredential(ctx.req.param('provider')); return ctx.json({ ok: true }); }); // ── The pass-through ──────────────────────────────────────────────────────────────────────────────── /** * `ALL /api/proxy-manager/npm/*` → `/api/*`, with a token attached and nothing else changed. * * **The permission gate does the authorisation and this route does not re-check it.** `proxy-manager` at * `read` level permits only safe methods — `isRequestAllowedAtLevel` returns true for GET/HEAD/OPTIONS and * false for everything else unless it is declared read-only — so a read-level account gets exactly a * read-only view of NPM through here, with no work in this file. `write` permits the rest. Two places * deciding the same thing is how they come to disagree. * * The query string is forwarded verbatim because NPM's `?expand=` is how half its API is useful, and the * body is forwarded as raw text rather than re-serialised JSON so that what NPM receives is what the * caller sent. */ router.all('/npm/*', async (ctx) => { const credentials = await readCredentials(); if (!credentials) { return ctx.json({ error: 'No Nginx Proxy Manager connection is configured.', unconfigured: true }, 409); } const url = new URL(ctx.req.url); // Everything after `/npm`, so `/npm/nginx/proxy-hosts` reaches NPM as `/api/nginx/proxy-hosts`. const path = url.pathname.slice(url.pathname.indexOf('/npm') + '/npm'.length) || '/'; const method = ctx.req.method; const body = method === 'GET' || method === 'HEAD' ? null : await ctx.req.text(); try { const result = await npmProxy(credentials, { method, path, search: url.search, body }); return new Response(result.body, { status: result.status, headers: { 'content-type': result.contentType ?? 'application/json' }, }); } catch (error) { // Only reachable when NPM could not be spoken to at all — `npmProxy` returns every answer it gets, // including the bad ones, because the caller is meant to see NPM's own verdict. const shaped = asError(error); return ctx.json(shaped.body, shaped.status); } }); // ── Settings — owner only, by the manifest ──────────────────────────────────────────────────────── router.get('/_config', async (ctx) => ctx.json(await readConnection())); router.put('/_config', async (ctx) => { const body = (await ctx.req.json().catch(() => null)) as { url?: string; identity?: string; secret?: string } | null; if (!body?.url || !body.identity) return ctx.json({ error: 'url and identity are required.' }, 400); try { const saved = await saveConnection({ url: body.url, identity: body.identity, secret: body.secret }); // The old credential's token must not outlive the credential itself. const credentials = await readCredentials(); if (credentials) forgetToken(credentials); forgetProbe(); return ctx.json(saved); } catch (error) { return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 400); } }); router.delete('/_config', async (ctx) => { const credentials = await readCredentials(); if (credentials) forgetToken(credentials); forgetProbe(); await deleteConnection(); return ctx.json({ configured: false }); }); /** * Test a credential WITHOUT storing it, so a wrong password never replaces a working one. * * Declared in the manifest as a `readOnlyWrites` path: it is a POST that changes nothing. */ router.post('/_config/test', async (ctx) => { const body = (await ctx.req.json().catch(() => null)) as { url?: string; identity?: string; secret?: string } | null; if (!body?.url) return ctx.json({ error: 'url is required.' }, 400); try { const info = await probe(body.url); if (!body.identity || !body.secret) { return ctx.json({ reachable: true, authenticated: false, version: info.version, setup: info.setup }); } await npmRequest({ url: body.url, identity: body.identity, secret: body.secret }, { path: '/nginx/proxy-hosts' }); return ctx.json({ reachable: true, authenticated: true, version: info.version, setup: info.setup }); } catch (error) { const shaped = asError(error); return ctx.json({ reachable: !(shaped.body as { unreachable?: boolean }).unreachable, authenticated: false, ...shaped.body }, 200); } });