import { db } from 'officerdb/db'; import { encryptSecret, decryptSecret } from 'officerdb/crypto'; import { eq } from 'drizzle-orm'; import { proxyManagerDomains, proxyManagerDnsCredentials, type ProxyManagerDomain, } from '../db/schema'; import { checkDomain, normaliseDomain, looksLikeDomain, type DomainCheck } from './dns'; /** * Domains, and the DNS credentials that go with them. * * The secret rule is the same one `connection.ts` states and for the same reason: it never leaves this * module in plaintext except into the code that uses it. There is no route that returns a DNS key, at any * level, to anybody. A screen that wants to know whether one exists gets a boolean. */ const PURPOSE = 'proxy-manager'; export type DomainView = { id: number; domain: string; dnsProvider: string | null; pointsTo: string | null; resolvedIps: string[]; lastCheckedAt: string | null; /** Whether a credential exists for THIS domain's provider. Never the credential. */ hasCredential: boolean; /** `narrow` | `broad` | null. What was handed over, not what it is. */ credentialScope: string | null; }; const view = (row: ProxyManagerDomain, credentials: Map): DomainView => ({ id: row.id, domain: row.domain, dnsProvider: row.dnsProvider, pointsTo: row.pointsTo, resolvedIps: row.resolvedIps ?? [], lastCheckedAt: row.lastCheckedAt ? row.lastCheckedAt.toISOString() : null, hasCredential: !!row.dnsProvider && credentials.has(row.dnsProvider), credentialScope: (row.dnsProvider && credentials.get(row.dnsProvider)) ?? null, }); /** Provider → scope, for every stored credential. The scope is safe to show; the secret is not. */ async function credentialScopes(): Promise> { const rows = await db .select({ provider: proxyManagerDnsCredentials.provider, scope: proxyManagerDnsCredentials.scope }) .from(proxyManagerDnsCredentials); return new Map(rows.map((row) => [row.provider, row.scope])); } export async function listDomains(): Promise { const [rows, scopes] = await Promise.all([ db.select().from(proxyManagerDomains).orderBy(proxyManagerDomains.domain), credentialScopes(), ]); return rows.map((row) => view(row, scopes)); } export class DomainError extends Error { constructor( message: string, readonly status: 400 | 404 | 409, ) { super(message); } } /** * Add a domain, checking it on the way in. * * The check is not optional and not deferred: a row with no provider and no verdict is a row somebody has * to remember to refresh, and the whole value of the list is that it says something. A domain that does * not resolve is still added — "nowhere" is a real and common state for a name bought ten minutes ago — * but it is added with that recorded rather than blank. */ export async function addDomain(input: string): Promise { const domain = normaliseDomain(input); if (!looksLikeDomain(domain)) throw new DomainError(`"${input}" does not look like a domain.`, 400); const existing = await db.select().from(proxyManagerDomains).where(eq(proxyManagerDomains.domain, domain)); if (existing.length > 0) throw new DomainError(`${domain} is already here.`, 409); const checked = await checkDomain(domain); const [row] = await db .insert(proxyManagerDomains) .values({ domain, dnsProvider: checked.dnsProvider, pointsTo: checked.pointsTo, resolvedIps: checked.resolvedIps, lastCheckedAt: new Date(), }) .returning(); return view(row!, await credentialScopes()); } /** Re-run the lookup for a domain already on the list, and keep what it says. */ export async function recheckDomain(id: number): Promise { const [row] = await db.select().from(proxyManagerDomains).where(eq(proxyManagerDomains.id, id)); if (!row) throw new DomainError('No such domain.', 404); const checked = await checkDomain(row.domain); const [updated] = await db .update(proxyManagerDomains) .set({ dnsProvider: checked.dnsProvider, pointsTo: checked.pointsTo, resolvedIps: checked.resolvedIps, lastCheckedAt: new Date(), updatedAt: new Date(), }) .where(eq(proxyManagerDomains.id, id)) .returning(); return view(updated!, await credentialScopes()); } export async function removeDomain(id: number): Promise { await db.delete(proxyManagerDomains).where(eq(proxyManagerDomains.id, id)); } /** A check with nothing stored — what the button does before you commit to adding it. */ export async function dryCheck(input: string): Promise { const domain = normaliseDomain(input); if (!looksLikeDomain(domain)) throw new DomainError(`"${input}" does not look like a domain.`, 400); return checkDomain(domain); } // ── Credentials ───────────────────────────────────────────────────────────────────────────────────── export type CredentialView = { provider: string; scope: string; label: string | null; updatedAt: string }; export async function listCredentials(): Promise { const rows = await db.select().from(proxyManagerDnsCredentials).orderBy(proxyManagerDnsCredentials.provider); return rows.map((row) => ({ provider: row.provider, scope: row.scope, label: row.label, updatedAt: row.updatedAt.toISOString(), // No secret. Deliberately not even a length. })); } export type SaveCredentialParams = { provider: string; /** The provider's own fields — one token, or a key and a secret. Shapes come from NPM's dns-providers list. */ fields: Record; scope: 'narrow' | 'broad'; label?: string; }; export async function saveCredential(params: SaveCredentialParams): Promise { const provider = params.provider.trim().toLowerCase(); if (!provider) throw new DomainError('A provider is required.', 400); const filled = Object.entries(params.fields ?? {}).filter(([, value]) => typeof value === 'string' && value.trim()); if (filled.length === 0) throw new DomainError('The credential is empty.', 400); const secret = encryptSecret(PURPOSE, JSON.stringify(Object.fromEntries(filled))); const existing = await db .select() .from(proxyManagerDnsCredentials) .where(eq(proxyManagerDnsCredentials.provider, provider)); if (existing.length > 0) { await db .update(proxyManagerDnsCredentials) .set({ secret, scope: params.scope, label: params.label ?? null, updatedAt: new Date() }) .where(eq(proxyManagerDnsCredentials.provider, provider)); } else { await db .insert(proxyManagerDnsCredentials) .values({ provider, secret, scope: params.scope, label: params.label ?? null }); } const [row] = await db .select() .from(proxyManagerDnsCredentials) .where(eq(proxyManagerDnsCredentials.provider, provider)); return { provider, scope: row!.scope, label: row!.label, updatedAt: row!.updatedAt.toISOString() }; } export async function removeCredential(provider: string): Promise { await db.delete(proxyManagerDnsCredentials).where(eq(proxyManagerDnsCredentials.provider, provider.toLowerCase())); } /** * Decrypted, for the code that places a challenge record. **Never serialise the result of this.** * * Nothing calls it yet — issuing a certificate through DNS-01 is the next piece, and it is the only thing * that should. It exists now so the storage above is provably usable rather than write-only. */ export async function readCredentialFields(provider: string): Promise | null> { const [row] = await db .select() .from(proxyManagerDnsCredentials) .where(eq(proxyManagerDnsCredentials.provider, provider.toLowerCase())); if (!row) return null; return JSON.parse(decryptSecret(PURPOSE, row.secret)) as Record; }