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>
98 lines
3.6 KiB
TypeScript
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));
|
|
}
|