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'),
};
}