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:
+62
-11
@@ -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 { NpmError, npmRequest, probe, forgetToken } from './npm-client';
|
||||
|
||||
@@ -51,7 +52,10 @@ const asError = (error: unknown) => {
|
||||
if (error instanceof NpmError) {
|
||||
return {
|
||||
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;
|
||||
}
|
||||
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);
|
||||
|
||||
// ── 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. */
|
||||
router.get('/status', async (ctx) => {
|
||||
const connection = await readConnection();
|
||||
@@ -82,15 +128,18 @@ router.get('/status', async (ctx) => {
|
||||
const credentials = await readCredentials();
|
||||
if (!credentials) return ctx.json({ ...connection, reachable: false });
|
||||
|
||||
try {
|
||||
const info = await probe(credentials.url);
|
||||
await recordProbe(info.version, true);
|
||||
return ctx.json({ ...connection, version: info.version, reachable: true });
|
||||
} catch (error) {
|
||||
await recordProbe(connection.version, false);
|
||||
const shaped = asError(error);
|
||||
return ctx.json({ ...connection, reachable: false, error: shaped.body.error });
|
||||
const result = await probeCached(credentials.url);
|
||||
const version = result.version ?? connection.version;
|
||||
|
||||
// Only when something actually changed. `lastProbeOk` is null on a row that has never been probed, so
|
||||
// the first poll after configuring always writes.
|
||||
if (connection.lastProbeOk !== result.ok || connection.version !== version) {
|
||||
await recordProbe(version, result.ok);
|
||||
}
|
||||
|
||||
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 ────────────────────────────────────────────────────────────
|
||||
@@ -185,6 +234,7 @@ router.put('/_config', async (ctx) => {
|
||||
// The old credential's token must not outlive the credential itself.
|
||||
const credentials = await readCredentials();
|
||||
if (credentials) forgetToken(credentials);
|
||||
forgetProbe();
|
||||
return ctx.json(saved);
|
||||
} catch (error) {
|
||||
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) => {
|
||||
const credentials = await readCredentials();
|
||||
if (credentials) forgetToken(credentials);
|
||||
forgetProbe();
|
||||
await deleteConnection();
|
||||
return ctx.json({ configured: false });
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user