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:
@@ -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'),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user