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:
+67
-6
@@ -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()));
|
||||
|
||||
Reference in New Issue
Block a user