import { pgTable, serial, text, timestamp, integer, uniqueIndex } from 'drizzle-orm/pg-core'; /** * The connection to Nginx Proxy Manager. * * ── Why its own table rather than core `service_connections` ── * * `service_connections` fits the shape exactly — it has `url`, `username` and an encrypted `secret`. But * its `ServiceName` is a closed TypeScript union, so using it means editing the platform to add a name, * and this plugin is meant to install into a platform it did not change. The table comment there already * says the column is text precisely so adding a service is not a schema change; the union is the part * that has not caught up. * * Revisit if that union opens. Until then the cost is one small table and one encryption purpose. * * ── One row, not one per user ── * * NPM has no read-only role. Its API is admin or nothing, so a per-user credential would be a per-user * copy of the same total authority over every hostname this machine serves. There is one connection, the * owner sets it, and members granted the permission read through it without ever seeing it. */ export const proxyManagerConnection = pgTable('proxy_manager_connection', { id: serial('id').primaryKey(), /** Where NPM's admin API is. Normalised without a trailing slash before write. */ url: text('url').notNull(), /** NPM calls it `identity`; it is an email address. */ identity: text('identity').notNull(), /** * Encrypted with `encryptSecret('proxy-manager', …)` — its own purpose key, so a leak of this one * opens this plugin's column and nothing else. Never returned across the API boundary, at any level, * to anybody, including the owner. */ secret: text('secret').notNull(), /** From `GET /api/`, recorded on a successful probe. Tells you which NPM you are talking to. */ version: text('version'), lastProbeAt: timestamp('last_probe_at', { withTimezone: true }), lastProbeOk: integer('last_probe_ok'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }); export type ProxyManagerConnection = typeof proxyManagerConnection.$inferSelect; /** * A domain this machine is responsible for. * * ── Why Officer stores this at all, when DNS already knows ── * * Because "which domains are mine" is not a question DNS can answer — you can only ask about a name you * already have. Today that list lives in a document somebody edits by hand, which is correct on the day it * is written. A row here is the owner saying "this one is mine", and everything else on it is a cached * observation that can be refreshed. * * ── Everything except `domain` is a CACHE ── * * `dnsProvider`, `pointsTo` and `resolvedIps` are what a lookup said at `lastCheckedAt`. They are stored so * a list renders without doing nine DNS queries, and they are never treated as authority: the check can be * re-run, and a stale answer is labelled with its age rather than presented as current. */ export const proxyManagerDomains = pgTable( 'proxy_manager_domains', { id: serial('id').primaryKey(), /** The apex, lowercased, no trailing dot — `officer.dev`. Subdomains are not rows; they belong to one. */ domain: text('domain').notNull(), /** * Who answers for it, derived from its NS records — `cloudflare`, `godaddy`, `route53`. * * **This is the DNS HOST, not the registrar**, and they are frequently different: a domain bought at * GoDaddy with nameservers pointed at Cloudflare answers `cloudflare` here. That is the useful one — * it is whose API can place an ACME challenge record — but it is worth the sentence, because somebody * who knows where they bought a domain will otherwise read this as wrong. */ dnsProvider: text('dns_provider'), /** What the last check made of where it points — see `PointsTo` in `api/dns.ts`. */ pointsTo: text('points_to'), /** The addresses it resolved to, so a person can see the actual answer rather than our verdict. */ resolvedIps: text('resolved_ips').array(), lastCheckedAt: timestamp('last_checked_at', { withTimezone: true }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [uniqueIndex('uq_proxy_manager_domains_domain').on(table.domain)], ); export type ProxyManagerDomain = typeof proxyManagerDomains.$inferSelect; /** * One DNS provider's API credential. * * ── Keyed on the PROVIDER, not the domain ── * * One Cloudflare token serves every domain whose DNS is on Cloudflare. Storing a copy per domain would * mean rotating it in six places and getting five of them. * * ── `scope` is narrow or broad, and it is NOT read or write ── * * A DNS-01 challenge works by CREATING a `_acme-challenge` TXT record, so issuing a certificate needs * write access. There is no read-only option that can do it, and offering one would produce a setup that * looks configured and fails at the first renewal — sixty days later, silently. * * What actually differs is blast radius. `narrow` is a token the provider scoped to DNS edits on the zones * that need it; `broad` is a full-account key that can do anything. Both issue certificates. Only the * second lets an agent manage records generally, which is a thing the owner may well want — but should * choose knowing which one they handed over. * * Whether `narrow` is even available is the provider's business: Cloudflare scopes tokens per zone, * GoDaddy's key and secret are account-wide with no scoping at all. */ export const proxyManagerDnsCredentials = pgTable( 'proxy_manager_dns_credentials', { id: serial('id').primaryKey(), /** Matches `proxyManagerDomains.dnsProvider` — `cloudflare`, `godaddy`. */ provider: text('provider').notNull(), /** * The credential itself, encrypted with `encryptSecret('proxy-manager', …)`. * * A JSON object rather than a string, because providers want different fields — Cloudflare one token, * GoDaddy a key AND a secret, Route53 three. NPM already describes those field names per provider * (`GET /nginx/certificates/dns-providers`), so the shape is theirs and this only carries it. * * **Never returned across the API boundary**, at any level, to anybody, including the owner. */ secret: text('secret').notNull(), /** `narrow` | `broad`. See the note above — this is blast radius, not read versus write. */ scope: text('scope').notNull().default('narrow'), /** What the owner called it, so a rotated key is recognisable. Optional. */ label: text('label'), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), }, (table) => [uniqueIndex('uq_proxy_manager_dns_credentials_provider').on(table.provider)], ); export type ProxyManagerDnsCredential = typeof proxyManagerDnsCredentials.$inferSelect;