Files
proxy-manager/README.md
T
pastilhasandClaude Opus 5 3380a482ed the clever type cost twelve errors; the untested rule was the load-bearing one
four fixes from a review, and the first one was leaking out of the plugin.

TYPING. `respond` derived its context from `Parameters<typeof router.get>[1]`.
hono's .get is overloaded, `Parameters<>` resolves the FIRST overload — `[path:
string]` — so index 1 was `undefined`, ctx was `never`, and all nine handlers
errored. twelve errors from one line, in a tree where `bunx tsgo` is shared with
the platform and expected clean. imports hono's `Context` instead. tsgo now
exits 0 tree-wide, which it has not done since this plugin landed.

READONLYWRITES. `/_config/test` was declared as a read-only write so a member
would not meet a 403 on the test button. they always would: authorize.ts asks
isOwnerOnlyRoute before isRequestAllowedAtLevel, and ownerOnly is prefix
matched, so `/_config` already covered it and the entry was inert. removed, and
the reason it SHOULD be inert written down — that request carries the admin
secret in its body, so a member who could call it would hold an oracle pointed
at somebody else's password.

STATUS WROTE ON EVERY POLL. recordProbe ran unconditionally on a GET whose own
comment invites polling, so a read endpoint issued an UPDATE per poll per open
panel. the probe is cached 30s and the row is written only when the verdict or
the version changes — a flapping NPM still records every transition, which is
the case the column exists for. the cache is dropped when the credential does.

DEAD TERNARY. `likelyAuth ? 502 : 502`.

and the tests. 22 over a stubbed fetch, 99% of npm-client.ts. the two that
matter are the 404-means-credential rule and the mint-once-and-retry: both are
silent when broken, and a refactor of either turns a working panel into one
that says "not found" forever. every test uses its own credential because the
token cache is module state — sharing one would let an earlier test's token
satisfy the retry test, which would then pass for the wrong reason.

still not installed, still no settings form, still no db:push. README says so.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-25 15:26:18 +00:00

138 lines
6.5 KiB
Markdown

# Proxy Manager
Nginx Proxy Manager inside Officer — which hosts are running, whether they are healthy, and which
certificates are about to expire.
**Status: first round. Reads work against a live NPM 2.15.1; the settings form is not built and the
plugin is not installed** — see "What is not built" below.
---
## Why it is called `proxy-manager` and not `npm`
The container is called `npm` and everybody says NPM. But in a repository where every other sentence is
about bun, lockfiles and `npm install`, a plugin directory called `npm` collides with the package
manager in every grep and every conversation. The label says the real name; the id says what it does.
The same reasoning renamed `officer-claude` to `officer-anthropic-proxy`. Trivial to change while
nothing depends on it.
---
## What it does
**Hosts** — every proxy host, sorted by domain, showing where it forwards, whether TLS is on and
forced, whether an access list guards it, and whether nginx currently reports it offline. Selecting one
lets you take it out of service or put it back.
**Certificates** — sorted by how soon they expire, not by name. That ordering is the whole design: a
list sorted by name is a reference, a list sorted by expiry answers the only question anyone has.
Under 30 days is amber, under 14 is red, because Let's Encrypt renews at 30 and anything inside 14 means
renewal has actually failed.
Redirection hosts, streams, 404 hosts and access lists are exposed by the API and not yet drawn.
---
## What it deliberately does not do
**No creating, editing or deleting hosts. No certificate issuance.** NPM's own UI does those well and is
one click away, and each 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 the one write it allows is reversible in
a click. Building the editor is a decision to make deliberately, not to arrive at by accretion.
---
## Permissions
One permission, `proxy-manager`, and it is **grantable**. Reading which hosts exist and whether their
certificates are healthy is genuinely useful to somebody who is not the owner.
The credential is not. It is an NPM admin login, NPM has no read-only role, and anyone holding it can
redirect any hostname this machine serves. So `/_config` is `ownerOnly` — the split that field exists
for, and the same shape Jellyfin uses for its admin token.
**The secret is never returned across the API boundary**, at any level, to anybody, including the owner.
A settings form asking "is one set" gets a boolean.
`POST /_config/test` is owner-only too, and that is on purpose rather than by accident of the prefix
match. It is a genuine read-only write — it validates a credential and stores nothing — but the request
carries the admin secret in its BODY, so a member who could call it would have an oracle pointed at
somebody else's password. Testing a connection belongs to whoever owns it. (Declaring it in
`readOnlyWrites` would also have done nothing: `authorize.ts` asks `isOwnerOnlyRoute` first.)
---
## The connection
One row in `proxy_manager_connection` — URL, identity, and a secret encrypted under this plugin's own
purpose key (`encryptSecret('proxy-manager', …)`), so a leak of that key opens this column and nothing
else.
**One row, not one per user.** NPM's API is admin or nothing, so a per-user credential would be a
per-user copy of total authority over every hostname on the machine. The owner sets it; members granted
the permission read through it without ever seeing it.
It is a plugin-owned table rather than core `service_connections` — which fits the shape exactly, with
`url`, `username` and an encrypted `secret` — only because `ServiceName` there is a closed TypeScript
union, and this plugin should install into a platform it did not have to edit. Worth revisiting if that
union opens; `db/schema.ts` says so.
---
## The API quirk that shapes this plugin
**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 `npm-client.ts` treats a 404 on a path it knows exists as an authentication failure, carries that as
`NpmError.likelyAuth`, and the panel turns it into a sentence pointing at settings. Both halves of that
— the rule and the single retry — are the plugin's most load-bearing behaviour and its only tested one:
`api/npm-client.test.ts` covers them against a stubbed fetch, because a refactor of either fails silently
and turns a working panel into one that says "not found" forever. It also mints a fresh
token and retries once before concluding anything — otherwise a restarted NPM makes every panel look
broken until a reload.
Verified against the live instance: a wrong credential comes back `likelyAuth = true`, and
`POST /api/tokens` answers **400 "Invalid email or password"** rather than 401.
---
## What is not built
- **The settings form.** `/_config` works — `GET`, `PUT`, `DELETE`, and `POST /_config/test` which
checks a credential *without storing it*, so a wrong password never replaces a working one. Nothing
renders it yet, so the connection has to be inserted by hand for now.
- **`db:push` has not been run**, so `proxy_manager_connection` does not exist in Postgres yet.
- **Redirections, streams, 404 hosts and access lists** have routes and no panels.
- **Nothing is polled.** Both panels load once and have a refresh button. `GET /status` is built to be
polled when something does: the probe is cached for 30 seconds and the row is written only when the
verdict or the version changes, so a poll is not an UPDATE.
- **It is not installed.** `plugin_installs` has no row for it and `proxy_manager_connection` does not
exist in Postgres, so nothing here has been through the permission gate, `assertPermissionTotality` or
a real request. The reads were verified by direct invocation against a live NPM 2.15.1.
---
## Files
```
manifest.ts permission, ownerOnly
api/npm-client.ts tokens, the 404 quirk, retry
api/npm-client.test.ts 22 tests over a stubbed fetch
api/connection.ts the one row; encryption; the secret never leaves
api/router.ts mounted at /api/proxy-manager
db/schema.ts proxy_manager_connection
web/panels.ts two panels
web/layout.ts how they are arranged
```