Files
proxy-manager/api/connection.ts
T
pastilhasandClaude Fable 5 5914e96cf3 proxy-manager: nginx proxy manager in officer
reads hosts and certificates against a live NPM 2.15.1. one grantable
permission, with /_config owner-only — reading which hosts are healthy
is worth sharing, an NPM admin login is not, and NPM has no read-only
role.

the quirk that shapes the whole plugin: NPM answers an unauthenticated
read with 404, not 401, because its router only registers a resource
once a token is present. so 'not signed in' and 'no such endpoint' are
the same response, and a naive client tells you a feature is missing
when your password is wrong. the client mints a fresh token and retries
once, then reports a persistent 404 as likelyAuth.

certificates sort by expiry rather than name, which is the only ordering
that answers a question.

no create/edit/delete and no cert issuance: NPM's own UI does those and
a wrong proxy host takes a domain off the internet.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 08:38:15 +00:00

98 lines
3.6 KiB
TypeScript

import { db } from 'officerdb/db';
import { encryptSecret, decryptSecret } from 'officerdb/crypto';
import { eq } from 'drizzle-orm';
import { proxyManagerConnection, type ProxyManagerConnection } from '../db/schema';
import { normaliseUrl, type NpmCredentials } from './npm-client';
/**
* Reading and writing the one connection row.
*
* The secret is encrypted with this plugin's own purpose key — `encryptSecret('proxy-manager', …)` — so
* a leak of that key opens this column and nothing else. That is the per-purpose split the platform's
* secret store already decided on; this only follows it.
*
* **The secret never leaves this module in plaintext except into `npmRequest`.** There is no route that
* returns it, at any permission level, to anybody, including the owner. A settings form that wants to
* show "a secret is set" gets a boolean.
*/
const PURPOSE = 'proxy-manager';
export type ConnectionView = {
configured: boolean;
url: string | null;
identity: string | null;
/** Never the secret itself. Only whether one is stored. */
hasSecret: boolean;
version: string | null;
lastProbeAt: string | null;
lastProbeOk: boolean | null;
};
const row = async (): Promise<ProxyManagerConnection | null> => {
const rows = await db.select().from(proxyManagerConnection).limit(1);
return rows[0] ?? null;
};
export async function readConnection(): Promise<ConnectionView> {
const found = await row();
if (!found) {
return { configured: false, url: null, identity: null, hasSecret: false, version: null, lastProbeAt: null, lastProbeOk: null };
}
return {
configured: true,
url: found.url,
identity: found.identity,
hasSecret: found.secret.length > 0,
version: found.version,
lastProbeAt: found.lastProbeAt ? found.lastProbeAt.toISOString() : null,
lastProbeOk: found.lastProbeOk === null ? null : found.lastProbeOk === 1,
};
}
/** Decrypted, for the client only. Never serialise the result of this. */
export async function readCredentials(): Promise<NpmCredentials | null> {
const found = await row();
if (!found) return null;
return { url: found.url, identity: found.identity, secret: decryptSecret(PURPOSE, found.secret) };
}
export type SaveParams = { url: string; identity: string; secret?: string };
/**
* `secret` omitted means "keep the stored one" — so the owner can correct a typo in the URL without
* retyping a password, and so a settings form never has to round-trip the secret to send it back.
*/
export async function saveConnection(params: SaveParams): Promise<ConnectionView> {
const url = normaliseUrl(params.url);
const identity = params.identity.trim();
const existing = await row();
if (!existing && !params.secret) throw new Error('A secret is required the first time the connection is set.');
const secret = params.secret ? encryptSecret(PURPOSE, params.secret) : (existing?.secret as string);
if (existing) {
await db
.update(proxyManagerConnection)
.set({ url, identity, secret, updatedAt: new Date() })
.where(eq(proxyManagerConnection.id, existing.id));
} else {
await db.insert(proxyManagerConnection).values({ url, identity, secret });
}
return readConnection();
}
export async function deleteConnection(): Promise<void> {
await db.delete(proxyManagerConnection);
}
export async function recordProbe(version: string | null, ok: boolean): Promise<void> {
const existing = await row();
if (!existing) return;
await db
.update(proxyManagerConnection)
.set({ version, lastProbeAt: new Date(), lastProbeOk: ok ? 1 : 0 })
.where(eq(proxyManagerConnection.id, existing.id));
}