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
+80 -64
View File
@@ -1,94 +1,110 @@
---
name: Proxy Manager
description: Use when asked which domains this machine serves, where a hostname forwards to, whether a site is up, or when a TLS certificate expires — and before taking a proxy host in or out of service. Carries the routes, the one 404 that means something else, and what this surface deliberately cannot do.
description: Use for anything about this machine's domains, proxying or TLS — which hostnames it serves, where one forwards, whether a site is down, when a certificate expires, and creating, editing, enabling or deleting any of it. Carries the pass-through route into Nginx Proxy Manager's own API, the one 404 that means something else, and what to confirm before doing.
version: 1
---
Officer proxies through **Nginx Proxy Manager**, and this plugin is how you reach it. Everything below is
`/api/proxy-manager/...`, authenticated as the signed-in account like any other platform route.
Officer proxies through **Nginx Proxy Manager**. This plugin holds NPM's admin credential and hands you
NPM's own API through it.
**Go through the platform, never at NPM directly.** The admin credential lives encrypted on the server and
is never handed out — not to you, not to the owner. A route here already holds it, already translates NPM's
odder answers, and is the only path that works for an account that is not the owner.
## What is running, and where it goes
## The route
```
GET /api/proxy-manager/hosts proxy hosts, sorted by first domain
GET /api/proxy-manager/hosts/:id one host, with its certificate and access list expanded
GET /api/proxy-manager/redirections redirection hosts
GET /api/proxy-manager/streams TCP/UDP streams
GET /api/proxy-manager/dead-hosts domains served deliberately as nothing (NPM calls them 404 hosts)
GET /api/proxy-manager/access-lists with their items and clients
GET /api/proxy-manager/status is a connection configured, and does it answer
/api/proxy-manager/npm/<anything> -> <npm>/api/<anything>
```
A host carries `domain_names`, `forward_scheme`, `forward_host`, `forward_port`, `enabled`, `ssl_forced`,
`certificate_id` (0 means none), `access_list_id`, and `meta.nginx_online` — which is `false` when nginx
itself reports the site down, and is the field to check when somebody says a site is broken.
Method, path, query string and body are forwarded unchanged, and **NPM's own status and body come back
unchanged** — including its errors. A token is attached for you.
**This is the honest answer to "what domains does this machine serve".** Better than any document: a
hand-maintained list in a repository is right on the day it is written, and this is what nginx is actually
configured with right now.
## Certificates
```
GET /api/proxy-manager/certificates
```bash
curl -s -H "Authorization: Bearer $OFFICER_AGENT_KEY" -H "Host: $OFFICER_API_HOST" \
"$OFFICER_API_URL/api/proxy-manager/npm/nginx/proxy-hosts?expand=certificate,access_list"
```
Sorted by expiry rather than name, with `daysUntilExpiry` computed server-side. That ordering is the point:
a list by name is a reference, a list by expiry answers the only question anyone has.
**The contract is NPM's API, not ours.** If NPM can do it, this can do it — `docs/npm-api.md` in the
plugin has the endpoints, and NPM's own docs cover the rest. Do not go looking for a curated Officer route
for each operation; there isn't one, and that is the design.
**Read the numbers the way Let's Encrypt behaves.** Renewal runs at 30 days, so under 30 is normal and not
worth mentioning. Under 14 means renewal has actually failed and the domain goes dark at zero — that is
worth raising unprompted. `expires_on` is null for some uploaded certificates, and `daysUntilExpiry` is
then null too; that is unknown, not urgent.
**Never reach NPM directly.** Not by its port, not by `docker exec`, not by finding the credential. The
credential is deliberately not yours, this route is the sanctioned path, and going around it is
unauthorised, unlogged and unbounded. If this route refuses you, that refusal is the answer.
**Nothing warns anybody.** No notification exists yet. If you notice a certificate inside 14 days, say so
in the conversation — there is no other mechanism that will.
### The panels use different routes
## The one write
`/api/proxy-manager/hosts`, `/certificates` and `/status` also exist. They sort and compute
`daysUntilExpiry` for the screen. Read them if you want the same shaped answer the panel shows; use
`/npm/*` for anything else.
```
POST /api/proxy-manager/hosts/:id/enable
POST /api/proxy-manager/hosts/:id/disable
```
## What you can do
Taking a host in or out of service. **It is the only write this surface has**, deliberately: it is
reversible in one click, and creating, editing and deleting hosts are not. Do not look for a create route —
there isn't one, and its absence is a decision rather than a gap.
Everything NPM can, which is roughly:
**Disabling a host takes a domain off the internet.** Confirm before you do it, name the domains, and do not
infer it from something adjacent. "Is `foo.example.com` up?" is a question about `meta.nginx_online`, not an
instruction to change anything.
| | |
| --- | --- |
| `GET/POST/PUT/DELETE /npm/nginx/proxy-hosts[/:id]` | the proxy hosts |
| `POST /npm/nginx/proxy-hosts/:id/enable` · `/disable` | in and out of service |
| `…/redirection-hosts`, `…/dead-hosts`, `…/streams` | the other three kinds of host |
| `…/certificates[/:id]`, `…/certificates/:id/renew` | Let's Encrypt and uploaded |
| `GET /npm/nginx/certificates/dns-providers` | which DNS providers this NPM can drive, with their credential field names |
| `…/access-lists[/:id]` | who may reach a host |
For anything this cannot do — a new proxy host, a certificate to issue, an access list to edit — say so and
point at NPM's own UI. Do not attempt it another way.
A proxy host carries `domain_names`, `forward_scheme`, `forward_host`, `forward_port`, `enabled`,
`ssl_forced`, `certificate_id` (0 means none), `access_list_id`, and `meta.nginx_online` — **which is the
field to read when somebody says a site is down**, not whether the row exists.
## Confirm before you change something
**Do what is asked.** The owner runs this machine; a request to create, edit, enable, disable or delete a
host is a legitimate request and refusing it is not caution, it is being unhelpful about somebody's own
server.
**But say what will happen first, and wait, when the change is not trivially reversible:**
- **Deleting** a proxy host, a redirection, a stream, a certificate or an access list. Name exactly what
goes and what it currently serves. A deleted host is not recoverable from here.
- **Editing** an existing host's domains or upstream. That is a live site changing where it points.
- **Disabling.** One click back, so a short confirmation is enough — but a domain goes dark meanwhile.
- **Anything touching a certificate** that a live host is using.
Creating something new needs no ceremony: report what you made.
**Never infer a change from a question.** "Is `foo.example.com` up?" asks about `meta.nginx_online`. It is
not an instruction to restart, disable or recreate anything.
## Certificates, read the way Let's Encrypt behaves
Renewal runs at **30 days**, so under 30 is normal and not worth mentioning. **Under 14 means renewal has
actually failed** and the domain goes dark at zero — raise that unprompted. `expires_on` is null on some
uploaded certificates; that is unknown, not urgent.
**Nothing warns anybody.** There is no notification for this yet. If you notice one inside 14 days, say so
in the conversation, because nothing else will.
## The 404 that is not a 404
**NPM answers an unauthenticated read with 404, not 401.** Its router only registers a resource once the
request carries a token, so "not signed in" and "no such endpoint" are the same response.
request carries a token, so "not signed in" and "no such endpoint" are identical responses.
The plugin already knows this: it re-mints its token and retries once, and if a 404 survives that it reports
the credential rather than the route. So a failure reaching you through these routes has already been
interpreted — **read the message, do not re-diagnose it as a missing endpoint.**
The pass-through already re-mints its token and retries once, so a 404 that reaches you is about your
request rather than about the token. Read it as "no such thing" — but if every route 404s, the stored
credential is wrong and the owner needs to fix it at `/proxy-manager?config=1`.
It is not a rule about the whole API, only about the routes that need it. `GET /nginx/certificates/dns-providers`,
for instance, answers a proper 403.
It is not a rule about the whole API: `GET /npm/nginx/certificates/dns-providers` answers a proper 403
unauthenticated.
## Two states that are not failures
## Two answers that are not failures
- **409 with `unconfigured`** — no NPM connection is stored. Nothing is broken; the owner has not connected
one. Only the owner can, at `/proxy-manager?config=1`.
- **403 on anything under `/_config`** — the connection settings are owner-only, including the test. It is
an NPM admin login and NPM has no read-only role, so anyone holding it can redirect any hostname this
machine serves. Reading the hosts and certificates is grantable; the credential is not.
- **409 `unconfigured`** — no NPM connection is stored. Nothing is broken; the owner has not connected one.
Only the owner can, at `/proxy-manager?config=1`.
- **403** — either the account lacks `proxy-manager`, or it has it at **read** level, which permits GET and
refuses every write. Do not look for another way round; report it.
## What is coming, so you do not promise it
## A private hostname needs a DNS credential, and that is not built
Handing DNS and certificates over properly — a domain registry, provider API keys in the secret store, and
one request that turns *"expose sub.example.com on the private network over HTTPS"* into a DNS record, a
certificate and a proxy host. **None of it is built.** `plugins/proxy-manager/README.md` has the design.
Until it exists, do not offer to create a host or issue a certificate.
TLS on a name that only resolves inside a VPN cannot use the usual challenge — Let's Encrypt cannot reach
it — so it needs a DNS-01 challenge, which needs that domain's provider API key.
**Officer does not store DNS provider credentials yet.** `GET /npm/nginx/certificates/dns-providers` lists
what this NPM can drive and the field names each one wants, and a certificate can be created through the
pass-through if the owner supplies the credential in the request. Do not promise the smoother version:
`plugins/proxy-manager/README.md` has the design and none of it exists.