hand over NPM's API instead of a curated subset of it

this plugin refused to create, edit or delete anything, and argued the refusal
was care: NPM's own UI does those well and each is a form with real
consequences. the owner's call on 2026-08-25 is that the argument ends up
somewhere else —

  "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"

and a curated surface cannot keep up anyway. every NPM feature we did not wrap
is one the owner cannot reach through Officer at all, so the plugin becomes a
worse NPM rather than a way into the real one.

so /api/proxy-manager/npm/* forwards method, path, query and body, attaches a
token, and returns NPM's own status and body unchanged — errors included. a
proxy that improves the answers is not a proxy. authentication is the only
thing it does not pass through, which is the entire job.

the permission gate needed nothing: isRequestAllowedAtLevel already returns
true for safe methods and false otherwise, so read level IS a read-only view of
NPM through here, with no code in this file deciding it twice.

the curated routes stay, for the panels. a screen wants sorted hosts and a
computed daysUntilExpiry; an agent wants NPM.

what replaces the refusal is confirmation, and it lives in the skill because
that is what it is — an instruction, not a boundary. saying so is better than a
boundary that only looks like one. the skill now carries NPM's API, names what
must be confirmed before it happens (deleting, editing a live upstream,
touching a certificate in use), says creating needs no ceremony, and forbids
reaching NPM any way but this route.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
2026-08-25 18:04:38 +00:00
co-authored by Claude Opus 5
parent 631443e5ae
commit 9be2ec7e6c
3 changed files with 203 additions and 70 deletions
+56
View File
@@ -152,3 +152,59 @@ export async function probe(url: string, timeoutMs = 5_000): Promise<{ version:
setup: body.setup ?? false,
};
}
/**
* Forward one request to NPM and hand back what it said, unchanged.
*
* ── Why this exists beside `npmRequest` ──
*
* `npmRequest` is for the platform's own curated routes: it parses, it throws a shaped `NpmError`, and it
* decides what a 404 means. All of that is right when a panel is the caller and wrong when an AGENT is.
* The agent is being handed NPM's API as the contract, so it must see NPM's own status and NPM's own body
* — including the failures. A proxy that improves the answers is not a proxy.
*
* What is NOT passed through is authentication. That is the whole job: the caller never holds the
* credential, and this mints, caches, and re-mints a token around a request it otherwise leaves alone.
*
* ── The retry is kept ──
*
* Same mint-once-and-retry as `npmRequest`, for the same reason: a token NPM has forgotten comes back as a
* 404, and passing that through faithfully would mean handing the agent a "not found" for a route that
* exists. Retrying is not improving the answer — it is making sure the answer is about the request rather
* than about our token.
*/
export type NpmProxyRequest = { method: string; path: string; search?: string; body?: string | null; timeoutMs?: number };
export type NpmProxyResponse = { status: number; body: string; contentType: string | null };
export async function npmProxy(credentials: NpmCredentials, request: NpmProxyRequest): Promise<NpmProxyResponse> {
const timeoutMs = request.timeoutMs ?? 15_000;
const base = normaliseUrl(credentials.url);
const target = `${base}/api${request.path}${request.search ?? ''}`;
const send = async (token: string): Promise<Response> =>
fetch(target, {
method: request.method,
headers: {
authorization: `Bearer ${token}`,
...(request.body == null ? {} : { 'content-type': 'application/json' }),
},
body: request.body ?? undefined,
signal: AbortSignal.timeout(timeoutMs),
}).catch((error: Error) => {
throw new NpmError(`Cannot reach Nginx Proxy Manager at ${base}: ${error.message}`, 0);
});
let response = await send(await tokenFor(credentials, timeoutMs));
if (response.status === 404 || response.status === 401) {
forgetToken(credentials);
response = await send(await mintToken(credentials, timeoutMs));
}
return {
status: response.status,
body: await response.text(),
contentType: response.headers.get('content-type'),
};
}
+67 -6
View File
@@ -1,7 +1,7 @@
import type { Context } from 'hono';
import { createRouter, type HonoVariables } from '@@/create-router';
import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection';
import { NpmError, npmRequest, probe, forgetToken } from './npm-client';
import { NpmError, npmRequest, npmProxy, probe, forgetToken } from './npm-client';
/**
* Mounted at `/api/proxy-manager`.
@@ -10,12 +10,32 @@ import { NpmError, npmRequest, probe, forgetToken } from './npm-client';
* 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.
*
* ── What this deliberately does not do ──
* ── Two surfaces, on purpose ──
*
* No creating, editing or deleting hosts, and no certificate issuance. NPM's own UI does those well, it
* is one click away, and each one is a form with real consequences — a wrong proxy host takes a domain
* off the internet. This surface answers "what is running, and is it healthy" and lets you take one out
* of service. Building the editor is a decision to make deliberately, not to arrive at by accretion.
* **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();
@@ -221,6 +241,47 @@ router.post('/hosts/:id/:action', async (ctx) => {
);
});
// ── The pass-through ────────────────────────────────────────────────────────────────────────────────
/**
* `ALL /api/proxy-manager/npm/*` → `<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()));