Files
pastilhasandClaude Opus 5 9be2ec7e6c 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
2026-08-25 18:04:38 +00:00

211 lines
8.7 KiB
TypeScript

/**
* A client for Nginx Proxy Manager's admin API (verified against 2.15.1).
*
* ── The quirk worth knowing before you debug anything here ──
*
* NPM answers an unauthenticated read with **404, not 401**:
*
* GET /api/nginx/proxy-hosts -> {"error":{"code":404,"message":"Not Found - /nginx/proxy-hosts"}}
*
* Its router only registers a resource once the request carries a token, so "not signed in" and "no such
* endpoint" are the same response. Left alone that produces the worst kind of bug report — a feature that
* reads as missing when the credential is simply wrong. So a 404 on a path we know exists is reported as
* an authentication failure, and `NpmError.likelyAuth` carries that judgement rather than hiding it.
*
* ── Tokens ──
*
* `POST /api/tokens {identity, secret}` returns `{token, expires}`. Tokens are cached in memory only,
* never persisted: the credential is already stored encrypted, and a second copy of a derived token on
* disk would be a second thing to protect for no benefit.
*/
export type NpmCredentials = { url: string; identity: string; secret: string };
export class NpmError extends Error {
constructor(
message: string,
readonly status: number,
/** NPM cannot distinguish "unauthenticated" from "no such route" — see the note above. */
readonly likelyAuth: boolean = false,
) {
super(message);
this.name = 'NpmError';
}
}
type TokenCacheEntry = { token: string; expiresAt: number };
/** Keyed by url+identity, so changing the credential cannot serve a token minted for the old one. */
const tokenCache = new Map<string, TokenCacheEntry>();
const cacheKey = (c: NpmCredentials) => `${c.url} ${c.identity}`;
export const forgetToken = (c: NpmCredentials): void => {
tokenCache.delete(cacheKey(c));
};
export const normaliseUrl = (url: string): string => url.trim().replace(/\/+$/, '');
async function readError(response: Response): Promise<string> {
try {
const body = (await response.json()) as { error?: { message?: string } };
return body.error?.message ?? `${response.status} ${response.statusText}`;
} catch {
return `${response.status} ${response.statusText}`;
}
}
async function mintToken(credentials: NpmCredentials, timeoutMs: number): Promise<string> {
const response = await fetch(`${normaliseUrl(credentials.url)}/api/tokens`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ identity: credentials.identity, secret: credentials.secret }),
signal: AbortSignal.timeout(timeoutMs),
}).catch((error: Error) => {
throw new NpmError(`Cannot reach Nginx Proxy Manager at ${credentials.url}: ${error.message}`, 0);
});
if (!response.ok) {
// NPM returns 400 "Invalid email or password" for a bad credential, not 401.
const message = await readError(response);
throw new NpmError(message, response.status, /invalid email or password/i.test(message));
}
const body = (await response.json()) as { token?: string; expires?: string };
if (!body.token) throw new NpmError('Nginx Proxy Manager returned no token', 500);
const expiresAt = body.expires ? Date.parse(body.expires) : Date.now() + 60 * 60 * 1000;
// A minute of headroom: a token that expires mid-flight fails as a 404, which reads as a missing route.
tokenCache.set(cacheKey(credentials), { token: body.token, expiresAt: expiresAt - 60_000 });
return body.token;
}
async function tokenFor(credentials: NpmCredentials, timeoutMs: number): Promise<string> {
const cached = tokenCache.get(cacheKey(credentials));
if (cached && cached.expiresAt > Date.now()) return cached.token;
return mintToken(credentials, timeoutMs);
}
export type NpmRequest = { path: string; method?: string; body?: unknown; timeoutMs?: number };
export async function npmRequest<T>(credentials: NpmCredentials, request: NpmRequest): Promise<T> {
const timeoutMs = request.timeoutMs ?? 8_000;
const base = normaliseUrl(credentials.url);
const send = async (token: string): Promise<Response> =>
fetch(`${base}/api${request.path}`, {
method: request.method ?? 'GET',
headers: {
authorization: `Bearer ${token}`,
...(request.body === undefined ? {} : { 'content-type': 'application/json' }),
},
body: request.body === undefined ? undefined : JSON.stringify(request.body),
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));
// A cached token NPM has already forgotten looks like a missing route. Mint once and retry before
// concluding anything — without this, a restarted NPM makes every panel look broken until a reload.
if (response.status === 404 || response.status === 401) {
forgetToken(credentials);
response = await send(await mintToken(credentials, timeoutMs));
}
if (!response.ok) {
const message = await readError(response);
throw new NpmError(
response.status === 404
? `Nginx Proxy Manager refused "${request.path}". Its API answers 404 when unauthenticated, so this is most likely the credential rather than a missing endpoint.`
: message,
response.status,
response.status === 404 || response.status === 401,
);
}
if (response.status === 204) return undefined as T;
return (await response.json()) as T;
}
/** `GET /api/` needs no token — the cheapest proof that a URL is an NPM at all. */
export async function probe(url: string, timeoutMs = 5_000): Promise<{ version: string; setup: boolean }> {
const response = await fetch(`${normaliseUrl(url)}/api/`, { signal: AbortSignal.timeout(timeoutMs) }).catch(
(error: Error) => {
throw new NpmError(`Cannot reach ${url}: ${error.message}`, 0);
},
);
if (!response.ok) {
throw new NpmError(`${url} answered ${response.status} — is that an Nginx Proxy Manager?`, response.status);
}
const body = (await response.json()) as {
status?: string;
setup?: boolean;
version?: { major: number; minor: number; revision: number };
};
if (!body.version) throw new NpmError(`${url} answered, but not like an Nginx Proxy Manager`, response.status);
return {
version: `${body.version.major}.${body.version.minor}.${body.version.revision}`,
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'),
};
}