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
This commit is contained in:
2026-08-25 15:26:18 +00:00
co-authored by Claude Opus 5
parent 5914e96cf3
commit 3380a482ed
4 changed files with 378 additions and 23 deletions
+20 -7
View File
@@ -3,8 +3,8 @@
Nginx Proxy Manager inside Officer — which hosts are running, whether they are healthy, and which Nginx Proxy Manager inside Officer — which hosts are running, whether they are healthy, and which
certificates are about to expire. certificates are about to expire.
**Status: first round. Reads work against a live NPM 2.15.1; the settings form is not built yet** — **Status: first round. Reads work against a live NPM 2.15.1; the settings form is not built and the
see "What is not built" below. plugin is not installed** — see "What is not built" below.
--- ---
@@ -57,6 +57,12 @@ 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. **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. 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 ## The connection
@@ -89,7 +95,10 @@ endpoint"* are the same response. Left alone that produces the worst kind of bug
reads as missing when the credential is simply wrong. 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 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. It also mints a fresh `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 token and retries once before concluding anything — otherwise a restarted NPM makes every panel look
broken until a reload. broken until a reload.
@@ -105,17 +114,21 @@ Verified against the live instance: a wrong credential comes back `likelyAuth =
renders it yet, so the connection has to be inserted by hand for now. 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. - **`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. - **Redirections, streams, 404 hosts and access lists** have routes and no panels.
- **Nothing is polled.** Both panels load once and have a refresh button. - **Nothing is polled.** Both panels load once and have a refresh button. `GET /status` is built to be
- **No tests.** The client's behaviour was verified by hand against the live NPM; that should be a test polled when something does: the probe is cached for 30 seconds and the row is written only when the
with a stubbed fetch. 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 ## Files
``` ```
manifest.ts permission, ownerOnly, readOnlyWrites manifest.ts permission, ownerOnly
api/npm-client.ts tokens, the 404 quirk, retry 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/connection.ts the one row; encryption; the secret never leaves
api/router.ts mounted at /api/proxy-manager api/router.ts mounted at /api/proxy-manager
db/schema.ts proxy_manager_connection db/schema.ts proxy_manager_connection
+285
View File
@@ -0,0 +1,285 @@
import { describe, expect, test, beforeEach, afterEach } from 'bun:test';
import { NpmError, forgetToken, normaliseUrl, npmRequest, probe } from './npm-client';
/**
* The client, against a stubbed `fetch`.
*
* Two behaviours here are worth a test more than anything else in the plugin, because both are invisible
* when they break: **a 404 means the credential, not a missing route**, and **a stale token is re-minted
* once before anything is concluded**. Neither throws, neither logs, and a plausible refactor of either
* turns a working panel into one that says "not found" forever.
*
* The token cache is module state keyed on `url + identity`, so every test uses its own credential and
* clears it afterwards. Sharing one would let an earlier test's cached token satisfy a later one, and the
* test that then passes for the wrong reason is the retry test.
*/
type StubbedCall = { url: string; init: RequestInit | undefined };
const realFetch = globalThis.fetch;
let calls: StubbedCall[] = [];
/** Queue one Response per expected call, in order. A call past the end of the queue fails loudly. */
const stubFetch = (responses: (() => Response | Promise<Response>)[]): void => {
let index = 0;
calls = [];
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(input), init });
const next = responses[index++];
if (!next) throw new Error(`unexpected fetch #${index} to ${String(input)}`);
return next();
}) as typeof fetch;
};
const json = (body: unknown, status = 200) =>
new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
const token = (value: string, expires?: string) => () => json({ token: value, expires });
const npmNotFound = (path: string) => () => json({ error: { code: 404, message: `Not Found - ${path}` } }, 404);
let counter = 0;
const freshCredentials = () => ({ url: 'http://npm.test', identity: `admin${counter++}@test`, secret: 'pw' });
let used: ReturnType<typeof freshCredentials>[] = [];
const credentials = () => {
const c = freshCredentials();
used.push(c);
return c;
};
beforeEach(() => {
used = [];
});
afterEach(() => {
globalThis.fetch = realFetch;
for (const c of used) forgetToken(c);
});
describe('normaliseUrl', () => {
test('strips trailing slashes and surrounding space', () => {
expect(normaliseUrl(' http://npm.test/ ')).toBe('http://npm.test');
expect(normaliseUrl('http://npm.test///')).toBe('http://npm.test');
expect(normaliseUrl('http://npm.test')).toBe('http://npm.test');
});
// The base is concatenated with `/api${path}`, so a surviving slash produces `//api/...`, which NPM
// answers with — of course — a 404, and then the 404 rule blames the credential.
test('a normalised url produces exactly one slash before /api', async () => {
const c = { ...credentials(), url: 'http://npm.test/' };
stubFetch([token('t'), () => json([])]);
await npmRequest(c, { path: '/nginx/proxy-hosts' });
expect(calls[1]?.url).toBe('http://npm.test/api/nginx/proxy-hosts');
});
});
describe('tokens', () => {
test('mints once and reuses the cached token', async () => {
const c = credentials();
stubFetch([token('t1'), () => json([]), () => json([])]);
await npmRequest(c, { path: '/nginx/proxy-hosts' });
await npmRequest(c, { path: '/nginx/certificates' });
expect(calls).toHaveLength(3);
expect(calls[0]?.url).toBe('http://npm.test/api/tokens');
expect(calls[1]?.init?.headers).toMatchObject({ authorization: 'Bearer t1' });
expect(calls[2]?.init?.headers).toMatchObject({ authorization: 'Bearer t1' });
});
test('the cache is keyed on identity, so a changed credential cannot reuse a token', async () => {
const first = credentials();
const second = { ...credentials(), url: first.url };
stubFetch([token('t1'), () => json([]), token('t2'), () => json([])]);
await npmRequest(first, { path: '/nginx/proxy-hosts' });
await npmRequest(second, { path: '/nginx/proxy-hosts' });
expect(calls[1]?.init?.headers).toMatchObject({ authorization: 'Bearer t1' });
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer t2' });
});
test('forgetToken forces a fresh mint', async () => {
const c = credentials();
stubFetch([token('t1'), () => json([]), token('t2'), () => json([])]);
await npmRequest(c, { path: '/nginx/proxy-hosts' });
forgetToken(c);
await npmRequest(c, { path: '/nginx/proxy-hosts' });
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer t2' });
});
// A token cached right up to its stated expiry fails mid-flight as a 404, which reads as a missing
// route. The client keeps a minute of headroom; an already-expired one must never be served.
test('an expiry inside the headroom window is not cached', async () => {
const c = credentials();
const soon = new Date(Date.now() + 30_000).toISOString();
stubFetch([token('t1', soon), () => json([]), token('t2', soon), () => json([])]);
await npmRequest(c, { path: '/nginx/proxy-hosts' });
await npmRequest(c, { path: '/nginx/proxy-hosts' });
expect(calls[2]?.url).toBe('http://npm.test/api/tokens');
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer t2' });
});
test('a bad credential is reported as auth, not as a 400', async () => {
const c = credentials();
stubFetch([() => json({ error: { message: 'Invalid email or password' } }, 400)]);
const error = (await npmRequest(c, { path: '/nginx/proxy-hosts' }).catch((e) => e)) as NpmError;
expect(error).toBeInstanceOf(NpmError);
expect(error.status).toBe(400);
expect(error.likelyAuth).toBe(true);
});
test('a token response with no token is an error rather than a bearer of undefined', async () => {
const c = credentials();
stubFetch([() => json({ expires: '2030-01-01T00:00:00Z' })]);
const error = (await npmRequest(c, { path: '/nginx/proxy-hosts' }).catch((e) => e)) as NpmError;
expect(error).toBeInstanceOf(NpmError);
expect(error.message).toMatch(/no token/i);
});
});
describe('the 404 quirk', () => {
// The whole reason this client exists in the shape it does. NPM registers a resource only once the
// request carries a token, so unauthenticated and non-existent are the same response.
test('a stale token is re-minted once and the request retried', async () => {
const c = credentials();
stubFetch([token('stale'), npmNotFound('/nginx/proxy-hosts'), token('fresh'), () => json([{ id: 1 }])]);
const hosts = await npmRequest<{ id: number }[]>(c, { path: '/nginx/proxy-hosts' });
expect(hosts).toEqual([{ id: 1 }]);
expect(calls).toHaveLength(4);
expect(calls[1]?.init?.headers).toMatchObject({ authorization: 'Bearer stale' });
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer fresh' });
});
test('401 gets the same retry as 404', async () => {
const c = credentials();
stubFetch([token('stale'), () => json({ error: { message: 'nope' } }, 401), token('fresh'), () => json([])]);
await npmRequest(c, { path: '/nginx/proxy-hosts' });
expect(calls[3]?.init?.headers).toMatchObject({ authorization: 'Bearer fresh' });
});
test('a 404 that survives the retry blames the credential, not the route', async () => {
const c = credentials();
stubFetch([token('t1'), npmNotFound('/nginx/proxy-hosts'), token('t2'), npmNotFound('/nginx/proxy-hosts')]);
const error = (await npmRequest(c, { path: '/nginx/proxy-hosts' }).catch((e) => e)) as NpmError;
expect(error.status).toBe(404);
expect(error.likelyAuth).toBe(true);
expect(error.message).toMatch(/credential/i);
});
test('retries exactly once — a second 404 does not mint a third token', async () => {
const c = credentials();
stubFetch([token('t1'), npmNotFound('/x'), token('t2'), npmNotFound('/x')]);
await npmRequest(c, { path: '/x' }).catch(() => undefined);
expect(calls.filter((call) => call.url.endsWith('/api/tokens'))).toHaveLength(2);
});
});
describe('requests', () => {
test('a body is sent as JSON with a content-type; a bodyless request has neither', async () => {
const c = credentials();
stubFetch([token('t'), () => json({ ok: true }), () => json({ ok: true })]);
await npmRequest(c, { path: '/x', method: 'POST', body: { a: 1 } });
await npmRequest(c, { path: '/y' });
expect(calls[1]?.init?.method).toBe('POST');
expect(calls[1]?.init?.headers).toMatchObject({ 'content-type': 'application/json' });
expect(calls[1]?.init?.body).toBe('{"a":1}');
expect(calls[2]?.init?.body).toBeUndefined();
expect(calls[2]?.init?.headers).not.toMatchObject({ 'content-type': 'application/json' });
});
// NPM answers 204 to some writes. `response.json()` on an empty body throws, and that throw would
// surface as an unshaped 500 rather than a success.
test('204 resolves to undefined instead of failing to parse', async () => {
const c = credentials();
stubFetch([token('t'), () => new Response(null, { status: 204 })]);
expect(await npmRequest(c, { path: '/x', method: 'POST' })).toBeUndefined();
});
test('an unreachable server is status 0, not a raw fetch error', async () => {
const c = credentials();
stubFetch([
() => {
throw new Error('connect ECONNREFUSED');
},
]);
const error = (await npmRequest(c, { path: '/x' }).catch((e) => e)) as NpmError;
expect(error).toBeInstanceOf(NpmError);
expect(error.status).toBe(0);
expect(error.message).toMatch(/Cannot reach/);
});
test("an error body's message is preferred over the status text", async () => {
const c = credentials();
stubFetch([token('t'), () => json({ error: { message: 'Access list not found' } }, 400)]);
const error = (await npmRequest(c, { path: '/x' }).catch((e) => e)) as NpmError;
expect(error.message).toBe('Access list not found');
expect(error.likelyAuth).toBe(false);
});
test('a non-JSON error body falls back to the status rather than throwing', async () => {
const c = credentials();
stubFetch([token('t'), () => new Response('<html>502</html>', { status: 502 })]);
const error = (await npmRequest(c, { path: '/x' }).catch((e) => e)) as NpmError;
expect(error).toBeInstanceOf(NpmError);
expect(error.status).toBe(502);
});
});
describe('probe', () => {
test('reads the version from GET /api/ and sends no token', async () => {
stubFetch([() => json({ status: 'OK', setup: true, version: { major: 2, minor: 15, revision: 1 } })]);
expect(await probe('http://npm.test/')).toEqual({ version: '2.15.1', setup: true });
expect(calls[0]?.url).toBe('http://npm.test/api/');
expect(calls[0]?.init?.headers).toBeUndefined();
});
test('setup defaults to false when NPM omits it', async () => {
stubFetch([() => json({ version: { major: 2, minor: 15, revision: 1 } })]);
expect((await probe('http://npm.test')).setup).toBe(false);
});
test('something that answers but is not an NPM is rejected', async () => {
stubFetch([() => json({ hello: 'world' })]);
const error = (await probe('http://npm.test').catch((e) => e)) as NpmError;
expect(error).toBeInstanceOf(NpmError);
expect(error.message).toMatch(/not like an Nginx Proxy Manager/);
});
test('a non-200 names the url rather than reporting a parse failure', async () => {
stubFetch([() => new Response('nope', { status: 500 })]);
const error = (await probe('http://npm.test').catch((e) => e)) as NpmError;
expect(error.status).toBe(500);
expect(error.message).toMatch(/is that an Nginx Proxy Manager/);
});
test('unreachable is status 0', async () => {
stubFetch([
() => {
throw new Error('ETIMEDOUT');
},
]);
const error = (await probe('http://npm.test').catch((e) => e)) as NpmError;
expect(error.status).toBe(0);
});
});
+62 -11
View File
@@ -1,4 +1,5 @@
import { createRouter } from '@@/create-router'; import type { Context } from 'hono';
import { createRouter, type HonoVariables } from '@@/create-router';
import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection'; import { deleteConnection, readConnection, readCredentials, recordProbe, saveConnection } from './connection';
import { NpmError, npmRequest, probe, forgetToken } from './npm-client'; import { NpmError, npmRequest, probe, forgetToken } from './npm-client';
@@ -51,7 +52,10 @@ const asError = (error: unknown) => {
if (error instanceof NpmError) { if (error instanceof NpmError) {
return { return {
body: { error: error.message, likelyAuth: error.likelyAuth, unreachable: error.status === 0 }, body: { error: error.message, likelyAuth: error.likelyAuth, unreachable: error.status === 0 },
status: error.status === 0 ? 503 : error.likelyAuth ? 502 : 502, // 503 when NPM is not answering at all, 502 for everything it did answer — including a refused
// credential, which is upstream's verdict rather than this request's. `likelyAuth` in the body is
// what the panel branches on; it does not need a status of its own.
status: error.status === 0 ? 503 : 502,
} as const; } as const;
} }
return { body: { error: error instanceof Error ? error.message : String(error) }, status: 500 } as const; return { body: { error: error instanceof Error ? error.message : String(error) }, status: 500 } as const;
@@ -69,11 +73,53 @@ async function withCredentials<T>(handler: (c: NonNullable<Awaited<ReturnType<ty
} }
} }
const respond = (ctx: Parameters<Parameters<typeof router.get>[1]>[0], result: Awaited<ReturnType<typeof withCredentials>>) => // `Context` is imported rather than derived from `router.get`. `Parameters<typeof router.get>` resolves
// Hono's FIRST overload, `[path: string]`, so index 1 is `undefined` and every handler here typed as
// `never` — twelve errors from one clever line, and `bunx tsgo` is shared with the platform.
const respond = (ctx: Context<{ Variables: HonoVariables }>, result: Awaited<ReturnType<typeof withCredentials>>) =>
result.ok ? ctx.json(result.data) : ctx.json(result.body, result.status as 409 | 502 | 503 | 500); result.ok ? ctx.json(result.data) : ctx.json(result.body, result.status as 409 | 502 | 503 | 500);
// ── Status ──────────────────────────────────────────────────────────────────────────────────────── // ── Status ────────────────────────────────────────────────────────────────────────────────────────
/**
* The last probe, in memory, so a polled endpoint does not write to Postgres on every call.
*
* `GET /status` is meant to be polled — two panels open is two callers — and it used to `recordProbe`
* unconditionally, so a READ endpoint issued an UPDATE per poll per panel. The row exists to answer
* "when was this last known good" across a restart, which needs a write when the answer CHANGES, not
* when it is confirmed.
*
* So: the probe itself is cached for `PROBE_TTL_MS`, and the row is written only when the verdict or the
* version differs from what is already stored. A flapping NPM still writes every transition, which is
* exactly the case worth recording.
*/
const PROBE_TTL_MS = 30_000;
type ProbeResult = { ok: boolean; version: string | null; error?: string };
let cachedProbe: { at: number; url: string; result: ProbeResult } | null = null;
async function probeCached(url: string): Promise<ProbeResult> {
if (cachedProbe && cachedProbe.url === url && Date.now() - cachedProbe.at < PROBE_TTL_MS) {
return cachedProbe.result;
}
let result: ProbeResult;
try {
const info = await probe(url);
result = { ok: true, version: info.version };
} catch (error) {
result = { ok: false, version: null, error: asError(error).body.error };
}
cachedProbe = { at: Date.now(), url, result };
return result;
}
/** Forget the cache when the credential changes — the next status must not answer for the old server. */
const forgetProbe = (): void => {
cachedProbe = null;
};
/** Cheap enough for a panel to poll: whether a connection exists, and whether it currently works. */ /** Cheap enough for a panel to poll: whether a connection exists, and whether it currently works. */
router.get('/status', async (ctx) => { router.get('/status', async (ctx) => {
const connection = await readConnection(); const connection = await readConnection();
@@ -82,15 +128,18 @@ router.get('/status', async (ctx) => {
const credentials = await readCredentials(); const credentials = await readCredentials();
if (!credentials) return ctx.json({ ...connection, reachable: false }); if (!credentials) return ctx.json({ ...connection, reachable: false });
try { const result = await probeCached(credentials.url);
const info = await probe(credentials.url); const version = result.version ?? connection.version;
await recordProbe(info.version, true);
return ctx.json({ ...connection, version: info.version, reachable: true }); // Only when something actually changed. `lastProbeOk` is null on a row that has never been probed, so
} catch (error) { // the first poll after configuring always writes.
await recordProbe(connection.version, false); if (connection.lastProbeOk !== result.ok || connection.version !== version) {
const shaped = asError(error); await recordProbe(version, result.ok);
return ctx.json({ ...connection, reachable: false, error: shaped.body.error });
} }
return result.ok
? ctx.json({ ...connection, version, reachable: true })
: ctx.json({ ...connection, version, reachable: false, error: result.error });
}); });
// ── The four kinds of host NPM manages ──────────────────────────────────────────────────────────── // ── The four kinds of host NPM manages ────────────────────────────────────────────────────────────
@@ -185,6 +234,7 @@ router.put('/_config', async (ctx) => {
// The old credential's token must not outlive the credential itself. // The old credential's token must not outlive the credential itself.
const credentials = await readCredentials(); const credentials = await readCredentials();
if (credentials) forgetToken(credentials); if (credentials) forgetToken(credentials);
forgetProbe();
return ctx.json(saved); return ctx.json(saved);
} catch (error) { } catch (error) {
return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 400); return ctx.json({ error: error instanceof Error ? error.message : String(error) }, 400);
@@ -194,6 +244,7 @@ router.put('/_config', async (ctx) => {
router.delete('/_config', async (ctx) => { router.delete('/_config', async (ctx) => {
const credentials = await readCredentials(); const credentials = await readCredentials();
if (credentials) forgetToken(credentials); if (credentials) forgetToken(credentials);
forgetProbe();
await deleteConnection(); await deleteConnection();
return ctx.json({ configured: false }); return ctx.json({ configured: false });
}); });
+11 -5
View File
@@ -34,12 +34,18 @@ export const manifest: PluginManifest = {
label: 'Proxy Manager', label: 'Proxy Manager',
description: 'Nginx Proxy Manager — proxy hosts, redirections, streams and certificates', description: 'Nginx Proxy Manager — proxy hosts, redirections, streams and certificates',
// The connection form and the probe. Everything under here reads or writes the admin credential. // The connection form and the probe. Everything under here reads or writes the admin credential,
// and the match is by PREFIX — so `/_config/test` is covered too, deliberately.
//
// There is no `readOnlyWrites` here, and the omission is the decision rather than an oversight.
// `POST /_config/test` is a genuine read-only write: it validates a credential and stores nothing.
// Declaring it would still change nothing, because `authorize.ts` asks `isOwnerOnlyRoute` before
// `isRequestAllowedAtLevel` — ownerOnly wins, so the entry would be inert.
//
// And it should be inert. The test request carries the admin secret in its BODY. A member who can
// call it can offer this server a credential and be told whether it works, which is an oracle
// pointed at somebody else's password. Testing a connection belongs to whoever owns it.
ownerOnly: ['/_config'], ownerOnly: ['/_config'],
// Probes that look like writes and are not. Without declaring these a read-level account meets
// what reads as a broken feature: the "test connection" button 403s.
readOnlyWrites: ['/_config/test'],
}, },
], ],
}; };