openvibers/openvibe.domains Commits
The control panel connects Porkbun: records, nameservers and DNSSEC DS records as proposals, with the stale check and rollback; the key pair stays a Network project secret granted to api.porkbun.com only (#5)
Changes
@@@ -30,13 +30,18 @@ theme loader, navbar mounted into `#navbar-mount`, the shared server-rendered fo`/panel` is a signed-in person's own (sign-in with OpenVibe.Network, client `domains`).--- **Connect:** paste a Cloudflare API token (Edit zone DNS + Zone Read). Domains verifies it, then stores it as a-- secret in your own Network project (`OpenVibe.Domains`, ADR-056), granted to `domains` for calls to-- `https://api.cloudflare.com` only. It is revealed per call (`network.secret.use`) and never kept here.--- **Zones and records:** zones are synced into `domains_zones`; records are read live.++- **Connect:** paste a Cloudflare API token (Edit zone DNS + Zone Read) or a Porkbun API key pair (create keys at++ porkbun.com/account/api, then switch on API access for each domain). Domains verifies it, then stores it as a++ secret in your own Network project (`OpenVibe.Domains`, ADR-056), granted to `domains` for calls to that provider++ only (`https://api.cloudflare.com` or `https://api.porkbun.com`). It is revealed per call (`network.secret.use`)++ and never kept here. Porkbun's pair is one JSON secret, parsed only inside its client.++- **Zones and records:** zones are synced into `domains_zones` (Porkbun's domains are its zones); records are read++ live.- **Proposals:** every change is a `domains.proposal@1`: create, update or delete records, why, what could break, and-- 7 days to decide. Approve on `/panel/p/:id` or in the Action Center. Changes apply in order; a record that moved-- since the proposal stops the run (stale). An applied (or part-applied) proposal rolls back in one step.++ 7 days to decide. A registrar change is a nameserver list update (`NS`) or a DNSSEC DS create/delete (`DS`), applied++ through the registrar API. Approve on `/panel/p/:id` or in the Action Center. Changes apply in order; a record or++ nameserver list that moved since the proposal stops the run (stale). An applied (or part-applied) proposal rolls++ back in one step.- **API:** `GET /api/v1/panel/connections`, `/api/v1/panel/zones`, `/api/v1/panel/zones/:conn/:zone/records`,`GET|POST /api/v1/proposals`, `POST /api/v1/proposals/:id/decide|rollback` with your Network token; Network's ActionCenter reads `GET /api/v1/actions?subject=` (`domains.actions.read`) and decides (`domains.proposal.decide`).@@@ -39,6 +39,7 @@ const { createSso } = require('./auth/sso');const { createPrincipal } = require('./http/principal');const { createVault } = require('./panel/vault');const { createCloudflare } = require('./panel/cloudflare');++const { createPorkbun } = require('./panel/porkbun');const { createPanel } = require('./panel/panel');const { createPanelRoutes } = require('./http/panel');const accountDataLib = require('./identity/account-data');@@@ -63,7 +64,7 @@ async function createApp(opts = {}) {const sso = createSso({ config, keys, fetchImpl, now: store.now, log });const principal = createPrincipal({ config, keys });const vault = opts.vault || createVault({ config, fetchImpl });-- const providers = opts.providers || { cloudflare: createCloudflare({ fetchImpl }) };++ const providers = opts.providers || { cloudflare: createCloudflare({ fetchImpl }), porkbun: createPorkbun({ fetchImpl }) };const panel = createPanel({ store, vault, providers, log });const accountData = accountDataLib.create({ db: store.db, log });const accountSend = opts.accountSend || (config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }) : null);@@@ -6,10 +6,11 @@* with a service token (domains.actions.read, domains.proposal.decide) for the person X-OV-Subject names.** GET /panel connections, zones, waiting proposals, recent changes-- * GET /panel/connect, POST connect Cloudflare (verified, then stored in the person's Network project)++ * GET /panel/connect, POST connect Cloudflare or Porkbun (verified, then stored in the person's Network project)* POST /panel/c/:id/sync | /remove* GET /panel/z/:conn/:zone a zone's records, live; POST …/propose (create | update | delete)* GET /panel/p/:id a proposal; POST …/decide (approve | decline), POST …/rollback++ * (a proposal also carries NS and DS registrar changes from the API)** GET /api/v1/panel/connections | /api/v1/panel/zones | /api/v1/panel/zones/:conn/:zone/records (person)* GET /api/v1/proposals, /api/v1/proposals/:id, POST /api/v1/proposals (person)@@@ -25,15 +26,15 @@ const { sameOrigin } = require('./principal');const NOTICES = {connected: 'Connected. Your zones are below.',-- synced: 'Synced with Cloudflare.',-- removed: 'Removed. Its token was deleted from your OpenVibe project.',++ synced: 'Synced with the provider.',++ removed: 'Removed. Its keys were deleted from your OpenVibe project.',declined: 'Declined. Nothing changed.',rolled_back: 'Rolled back.',};const PROBLEMS = {-- 'vault.secret_missing': 'That connection\'s token is gone or no longer granted. Remove it and connect again.',-- 'provider.forbidden': 'Cloudflare refused the token. Check it in Cloudflare, then connect again.',-- 'provider.unreachable': 'Cloudflare did not answer. Try again in a moment.',++ 'vault.secret_missing': 'That connection\'s keys are gone or no longer granted. Remove it and connect again.',++ 'provider.forbidden': 'The provider refused the keys. Check them, then connect again.',++ 'provider.unreachable': 'The provider did not answer. Try again in a moment.','vault.unavailable': 'OpenVibe.Network did not answer. Try again in a moment.','vault.signed_out': 'Your sign-in ended. Sign in again.',};@@@ -66,12 +67,17 @@ function createPanelRoutes({ config, panel, principal, log = console }) {pages.get('/panel/connect', (req, res) => (person(req) ? privateHtml(res, 200, views.connect()) : toLogin(req, res)));post('/panel/connect', async (req, res, p) => {++ const provider = req.body.provider || 'cloudflare';++ // Porkbun's two keys travel to Network as the one JSON secret its client parses; Cloudflare's token as itself.++ const token = provider === 'porkbun'++ ? JSON.stringify({ apikey: String(req.body.apikey || '').trim(), secretapikey: String(req.body.secretapikey || '').trim() })++ : req.body.token;try {-- await panel.connect(p, { provider: req.body.provider || 'cloudflare', label: req.body.label, token: req.body.token });++ await panel.connect(p, { provider, label: req.body.label, token });return res.redirect(303, '/panel?done=connected');} catch (err) {if (!err.status || err.status >= 500) log.warn('[OpenVibe.Domains] connect failed:', err.code || err.message);-- return privateHtml(res, err.status && err.status < 500 ? 422 : 503, views.connect({ error: err.status ? err.message : 'That did not work. Try again in a moment.', values: { label: req.body.label } }));++ return privateHtml(res, err.status && err.status < 500 ? 422 : 503, views.connect({ error: err.status ? err.message : 'That did not work. Try again in a moment.', values: { label: req.body.label, provider } }));}});post('/panel/c/:id/sync', async (req, res, p) => {@@@ -115,7 +121,8 @@ function createPanelRoutes({ config, panel, principal, log = console }) {const row = await panel.get(p.subject, req.params.id);const pub = { ...panel.toPublic(row), can_roll_back: Array.isArray(row.rollback) ? row.rollback.length > 0 : JSON.parse(row.rollback || '[]').length > 0 };const done = String(req.query.done || '');-- const words = done === 'approved' ? (pub.status === 'applied' ? 'Applied. Cloudflare has the change; caches catch up within the TTL.' : 'Approved, but it did not apply. See why below.') : notice(req);++ const providerName = pub.provider === 'porkbun' ? 'Porkbun' : 'Cloudflare';++ const words = done === 'approved' ? (pub.status === 'applied' ? `Applied. ${providerName} has the change; caches catch up within the TTL.` : 'Approved, but it did not apply. See why below.') : notice(req);return privateHtml(res, 200, views.proposal({ p: pub, notice: words }));} catch (err) {if (err.status === 404) return res.redirect(303, '/panel');@@@ -7,9 +7,11 @@* connect / remove / sync a provider account: verified, its token stored in the person's Network project* (vault.js), its zones cached in domains_zones; a refused token marks it broken* records a zone's records, read live from the provider (the provider stays the truth)-- * propose a checked diff (create, update, delete) with why, the risk in words, 7 days to decide-- * decide approve → applied in order; a record that moved since the proposal is refused as-- * stale; a failure part-way keeps the rollback of what was applied++ * propose a checked diff (create, update, delete) with why, the risk in words, 7 days to decide;++ * a registrar change is an NS list update or a DS create/delete (same proposal, same++ * approval, same rollback)++ * decide approve → applied in order; a record or nameserver set that moved since the proposal++ * is refused as stale; a failure part-way keeps the rollback of what was applied* rollBack the inverse of what was applied, newest first** Every step is in domains_audit. The provider token is revealed for one call and dropped; it is never stored, logged@@@ -18,10 +20,11 @@const net = require('net');const { normalize } = require('./cloudflare');--const PROVIDERS = ['cloudflare'];++const PROVIDERS = ['cloudflare', 'porkbun'];const TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'NS', 'CAA', 'SRV'];const PROXIABLE = new Set(['A', 'AAAA', 'CNAME']);const MAX_CHANGES = 50;++const MAX_NAMESERVERS = 8;const DECIDE_DAYS = 7;const PUBLIC_ORIGIN = 'https://openvibe.domains';const HOST_RE = /^(?=.{1,253}$)(\*\.)?([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.?$/i;@@@ -75,9 +78,86 @@ function sameRecord(a, b) {&& Number(a.ttl) === Number(b.ttl) && (a.proxied ?? null) === (b.proxied ?? null) && (a.priority ?? null) === (b.priority ?? null);}++/** Two nameserver lists say the same thing (Porkbun's registry may return a different order). */++function sameNameservers(a, b) {++ const x = Array.isArray(a) ? a.map(lowerName).filter(Boolean).sort() : [];++ const y = Array.isArray(b) ? b.map(lowerName).filter(Boolean).sort() : [];++ return x.length === y.length && x.every((n, i) => n === y[i]);++}++++/** A registrar change: the domain's nameservers, whose before and after are the lists. */++function isNameserverChange(c) {++ return Boolean(c) && (String(c.type || '').toUpperCase() === 'NS' || Array.isArray(c.after) || Array.isArray(c.before) || Array.isArray(c.nameservers));++}++++/** A DS change is a record-shaped DS, applied at the registrar rather than in the DNS zone. */++function isDsChange(c) {++ if (!c) return false;++ if (String(c.type || '').toUpperCase() === 'DS') return true;++ const r = c.after || c.before;++ if (!r || Array.isArray(r)) return false;++ return String(r.type || '').toUpperCase() === 'DS' || r.keyTag != null || r.key_tag != null || r.keytag != null;++}++++function sameDs(a, b) {++ if (sameRecord(a, b)) return true;++ return Boolean(a && b && a.type === 'DS' && b.type === 'DS' && lowerName(a.name) === lowerName(b.name)++ && String(a.content).toLowerCase() === String(b.content).toLowerCase());++}++++/** The nameservers a person asked for, checked and de-duplicated. */++function cleanNameservers(list, where) {++ const wanted = Array.isArray(list) ? list : (list && Array.isArray(list.ns) ? list.ns : (list && Array.isArray(list.nameservers) ? list.nameservers : null));++ if (!wanted || !wanted.length) fail(422, 'proposal.invalid', `${where}: the new name servers are missing.`);++ const out = [];++ for (const raw of wanted) {++ const ns = lowerName(raw);++ if (!ns || !HOST_RE.test(ns)) fail(422, 'proposal.invalid', `${where}: ${ns || 'that'} is not a name server host name.`);++ if (!out.includes(ns)) out.push(ns);++ }++ if (out.length > MAX_NAMESERVERS) fail(422, 'proposal.invalid', `${where}: at most ${MAX_NAMESERVERS} name servers.`);++ return out;++}++++/** A DS record's four registry fields, as domains.proposal@1's record shape. */++function cleanDs(rec, zone, where) {++ if (!rec || typeof rec !== 'object') fail(422, 'proposal.invalid', `${where}: the DS record is missing.`);++ const parts = typeof rec.content === 'string' ? rec.content.trim().split(/\s+/).filter(Boolean) : [];++ const keyTag = String(rec.keyTag ?? rec.key_tag ?? rec.keytag ?? parts[0] ?? '').trim();++ const alg = String(rec.alg ?? rec.algorithm ?? parts[1] ?? '').trim();++ const digestType = String(rec.digestType ?? rec.digest_type ?? rec.digesttype ?? parts[2] ?? '').trim();++ const digest = String(rec.digest ?? parts.slice(3).join('') ?? '').trim();++ if (!/^\d{1,5}$/.test(keyTag) || Number(keyTag) > 65535) fail(422, 'proposal.invalid', `${where}: a DS key tag is 0 to 65 535.`);++ if (!/^\d{1,3}$/.test(alg) || Number(alg) > 255) fail(422, 'proposal.invalid', `${where}: a DS algorithm is 0 to 255.`);++ if (!/^\d{1,3}$/.test(digestType) || Number(digestType) > 255) fail(422, 'proposal.invalid', `${where}: a DS digest type is 0 to 255.`);++ if (!/^[0-9a-f]{4,128}$/i.test(digest)) fail(422, 'proposal.invalid', `${where}: the DS digest is hex, 4 to 128 characters.`);++ let name = lowerName(rec.name);++ if (!name || name === '@') name = lowerName(zone);++ else if (!inZone(name, zone)) name = `${name}.${lowerName(zone)}`;++ if (!inZone(name, zone)) fail(422, 'proposal.invalid', `${where}: ${name} is not a name in ${zone}.`);++ return { type: 'DS', name, content: `${keyTag} ${alg} ${digestType} ${digest}`, ttl: 1, proxied: null, priority: null, comment: null };++}++++/** The key tag of a DS record, from its id or the first field of its content. */++function dsKey(record) {++ if (!record) return null;++ if (record.id != null && record.id !== '') return String(record.id);++ const first = String(record.content || '').trim().split(/\s+/)[0];++ return first || null;++}++/** One change in plain words: "Add TXT example.com", "Change A www.example.com: 192.0.2.1 → 192.0.2.7". */function describe(c) {++ if (isNameserverChange(c)) {++ const list = (v) => (Array.isArray(v) ? v.join(', ') : '?');++ return `Change name servers: ${list(c.before)} → ${list(c.after)}`;++ }const r = c.after || c.before || {};++ if (isDsChange(c) || r.type === 'DS') {++ if (c.op === 'delete') return `Remove DS ${r.name} (${r.content})`;++ if (c.op === 'update') return `Change DS ${r.name}: ${c.before ? c.before.content : '?'} → ${c.after ? c.after.content : '?'}`;++ return `Add DS ${r.name} (${r.content})`;++ }if (c.op === 'create') return `Add ${r.type} ${r.name} → ${r.content}`;if (c.op === 'delete') return `Remove ${r.type} ${r.name} (${r.content})`;return `Change ${r.type} ${r.name}: ${c.before ? c.before.content : '?'} → ${c.after ? c.after.content : '?'}`;@@@ -88,7 +168,9 @@ function riskOf(changes) {const words = new Set();for (const c of changes) {const r = c.after || c.before || {};-- if (r.type === 'TXT' || r.type === 'CAA') words.add(c.op === 'delete' && r.type === 'CAA' ? 'Removing a CAA record lets any certificate authority issue for the name.' : 'Low: a TXT record changes no traffic, though a service that checks it (mail, verification) reads the new value.');++ if (isNameserverChange(c)) words.add('Name servers decide which DNS host answers for the domain: a wrong list makes the domain stop resolving.');++ else if (isDsChange(c) || r.type === 'DS') words.add('A DS record tells validating resolvers which DNSKEY to trust: if it does not match the DNS host\'s DNSKEY, validating resolvers refuse the domain.');++ else if (r.type === 'TXT' || r.type === 'CAA') words.add(c.op === 'delete' && r.type === 'CAA' ? 'Removing a CAA record lets any certificate authority issue for the name.' : 'Low: a TXT record changes no traffic, though a service that checks it (mail, verification) reads the new value.');else if (r.type === 'MX') words.add('Mail for the domain goes where the MX records say; a mistake here bounces or loses mail.');else if (r.type === 'NS') words.add('NS records delegate the name: a wrong one makes it unreachable.');else words.add(c.op === 'delete' ? `${r.name} stops answering at ${r.content}: visitors and services using it lose it.` : `Visitors to ${r.name} reach the new address once caches expire (the record's TTL).`);@@@ -100,7 +182,7 @@ function toPublic(row) {return {id: row.id, domain: row.domain, provider: row.provider || 'cloudflare', connection_id: row.connection_id,title: row.title, why: row.why || null, risk: row.risk || null, status: row.status,-- changes: parse(row.changes).map((c) => ({ op: c.op, record_id: c.record_id || null, before: c.before || null, after: c.after || null })),++ changes: parse(row.changes).map((c) => ({ ...(c.type ? { type: c.type } : {}), op: c.op, record_id: c.record_id || null, before: c.before || null, after: c.after || null })),created_by: row.created_by, created_at: iso(row.created_at), decided_at: iso(row.decided_at), applied_at: iso(row.applied_at),rolled_back_at: iso(row.rolled_back_at), expires_at: iso(row.expires_at), error: row.error || null,href: `${PUBLIC_ORIGIN}/panel/p/${row.id}`,@@@ -176,13 +258,18 @@ function createPanel({ store, vault, providers, log = console }) {}async function connect(person, { provider, label, token }) {-- if (!PROVIDERS.includes(provider)) fail(422, 'connection.provider', 'Cloudflare is the provider you can connect today.');-- const name = String(label || '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 60) || 'Cloudflare';++ if (!PROVIDERS.includes(provider)) fail(422, 'connection.provider', 'Cloudflare and Porkbun are the providers you can connect today.');++ const providerName = provider === 'porkbun' ? 'Porkbun' : 'Cloudflare';++ const name = String(label || '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 60) || providerName;const secret = String(token || '').trim();-- if (secret.length < 20 || secret.length > 400 || /\s/.test(secret)) fail(422, 'connection.token', 'Paste the API token exactly as Cloudflare showed it.');++ if (provider === 'porkbun') {++ if (secret.length < 20 || secret.length > 800 || /\s/.test(secret)) fail(422, 'connection.token', 'Paste the API key and secret key exactly as Porkbun showed them.');++ } else if (secret.length < 20 || secret.length > 400 || /\s/.test(secret)) {++ fail(422, 'connection.token', 'Paste the API token exactly as Cloudflare showed it.');++ }const p = providerOf(provider);const verified = await p.verify(secret);-- if (!verified.ok) fail(422, 'connection.token', verified.reason || 'Cloudflare did not accept this token.');++ if (!verified.ok) fail(422, 'connection.token', verified.reason || `${providerName} did not accept these keys.`);const count = await db.maybe("SELECT count(*)::int AS n FROM domains_connections WHERE subject = $1 AND status <> 'removed'", [person.subject]);if (count && count.n >= 20) fail(409, 'connection.limit', 'You can keep 20 connections. Remove one first.');const id = store.newId('dcn');@@@ -229,16 +316,43 @@ function createPanel({ store, vault, providers, log = console }) {return list.sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type));}-- /** A checked proposal from the asker's changes; update and delete read the record's current value as `before`. */++ /** A checked proposal from the asker's changes; update and delete read the current value as `before`. */async function propose(subject, createdBy, { connection_id: connectionId, zone_id: zoneId, title, why, risk, changes }) {const { conn, zone: z } = await zone(subject, connectionId, zoneId);if (!Array.isArray(changes) || !changes.length) fail(422, 'proposal.invalid', 'A proposal holds at least one change.');if (changes.length > MAX_CHANGES) fail(422, 'proposal.invalid', `A proposal holds at most ${MAX_CHANGES} changes.`);-- const current = await withToken(conn, (p, token) => p.records(token, zoneId));-- const byId = new Map(current.map((r) => [r.id, r]));++ const kinds = changes.map((c) => (isNameserverChange(c) ? 'ns' : isDsChange(c) ? 'ds' : 'record'));++ const current = await withToken(conn, async (p, token) => {++ const out = { records: [], ns: null, ds: [] };++ if (kinds.includes('record')) out.records = await p.records(token, zoneId);++ if (kinds.includes('ns')) {++ if (typeof p.nameservers !== 'function') throw new PanelError(422, 'proposal.unsupported', `${conn.provider} cannot change name servers here.`);++ out.ns = await p.nameservers(token, z.name);++ }++ if (kinds.includes('ds')) {++ if (typeof p.dsRecords !== 'function') throw new PanelError(422, 'proposal.unsupported', `${conn.provider} cannot change DS records here.`);++ out.ds = await p.dsRecords(token, z.name);++ }++ return out;++ });++ const byId = new Map(current.records.map((r) => [r.id, r]));const clean = changes.map((c, i) => {const where = `Change ${i + 1}`;const op = String(c && c.op || '');++ if (isNameserverChange(c)) {++ if (op !== 'update') fail(422, 'proposal.invalid', `${where}: name servers are changed, not created or removed.`);++ return { type: 'NS', op: 'update', record_id: null, before: current.ns || [], after: cleanNameservers(c.after == null ? c.nameservers : c.after, where) };++ }++ if (isDsChange(c)) {++ if (op === 'create') return { op, record_id: null, before: null, after: cleanDs(c.after, z.name, where) };++ if (op !== 'delete') fail(422, 'proposal.invalid', `${where}: a DS record is created or removed, not changed.`);++ const rawKey = c.record_id == null ? (c.keyTag == null ? (c.key_tag == null ? c.keytag : c.key_tag) : c.keyTag) : c.record_id;++ const wanted = rawKey == null || rawKey === '' ? null : String(rawKey);++ const before = (current.ds || []).find((r) => (wanted && String(r.id) === wanted)++ || (c.before && sameDs(r, c.before)) || (c.after && sameDs(r, c.after)));++ if (!before) fail(409, 'proposal.stale', `${where}: that DS record is not there any more. Reload the domain.`);++ return { op, record_id: before.id, before, after: null };++ }if (op === 'create') return { op, record_id: null, before: null, after: cleanRecord(c.after, z.name, where) };if (op !== 'update' && op !== 'delete') fail(422, 'proposal.invalid', `${where}: create, update or delete.`);const before = byId.get(String(c.record_id || ''));@@@ -246,7 +360,7 @@ function createPanel({ store, vault, providers, log = console }) {const { id: _id, ...plain } = before;return { op, record_id: before.id, before: plain, after: op === 'update' ? cleanRecord({ ...plain, ...c.after }, z.name, where) : null };});-- const noop = clean.find((c) => c.op === 'update' && sameRecord(c.before, c.after));++ const noop = clean.find((c) => (c.type === 'NS' ? sameNameservers(c.before, c.after) : c.op === 'update' && sameRecord(c.before, c.after)));if (noop) fail(422, 'proposal.invalid', `${describe(noop)} changes nothing.`);const id = store.newId('dpr');const name = String(title || '').trim().slice(0, 160) || (clean.length === 1 ? describe(clean[0]).slice(0, 160) : `${clean.length} DNS changes to ${z.name}`);@@@ -284,7 +398,26 @@ function createPanel({ store, vault, providers, log = console }) {let done = 0;try {for (const c of ops) {-- if (c.op === 'create') {++ if (isNameserverChange(c)) {++ if (typeof p.nameservers !== 'function' || typeof p.setNameservers !== 'function') throw new PanelError(422, 'proposal.unsupported', `${conn.provider} cannot change name servers here.`);++ const now = await p.nameservers(token, zoneId);++ if (!sameNameservers(now, c.before)) throw new PanelError(409, 'proposal.stale', `${describe(c)}: the name servers changed since this was proposed, so nothing more was applied.`);++ await p.setNameservers(token, zoneId, c.after);++ rollback.unshift({ type: 'NS', op: 'update', record_id: null, before: c.after, after: c.before });++ } else if (isDsChange(c)) {++ if (typeof p.dsRecords !== 'function' || typeof p.createDs !== 'function' || typeof p.deleteDs !== 'function') throw new PanelError(422, 'proposal.unsupported', `${conn.provider} cannot change DS records here.`);++ if (c.op === 'create') {++ const made = await p.createDs(token, zoneId, c.after);++ const record = made && made.type === 'DS' ? made : c.after;++ rollback.unshift({ op: 'delete', record_id: dsKey(record), before: record, after: null });++ } else {++ const list = await p.dsRecords(token, zoneId);++ const now = list.find((r) => (c.record_id != null && String(r.id) === String(c.record_id)) || sameDs(r, c.before));++ if (!now || !sameDs(now, c.before)) throw new PanelError(409, 'proposal.stale', `${describe(c)}: the DS record changed since this was proposed, so nothing more was applied.`);++ await p.deleteDs(token, zoneId, now.id || c.record_id);++ rollback.unshift({ op: 'create', record_id: null, before: null, after: now });++ }++ } else if (c.op === 'create') {const made = await p.create(token, zoneId, c.after);const { id: madeId, ...plain } = made;rollback.unshift({ op: 'delete', record_id: madeId, before: plain, after: null });@@@ -358,4 +491,4 @@ function createPanel({ store, vault, providers, log = console }) {return { connect, remove, sync, connections, zones, zone, records, propose, get, list, decide, rollBack, recover, toPublic, toActionItem, describe };}--module.exports = { createPanel, PanelError, cleanRecord, sameRecord, describe, riskOf, toPublic, toActionItem, inZone, PROVIDERS, TYPES, normalize };++module.exports = { createPanel, PanelError, cleanRecord, cleanNameservers, cleanDs, sameRecord, sameNameservers, sameDs, describe, riskOf, toPublic, toActionItem, inZone, PROVIDERS, TYPES, normalize };nnew file mode 100644@@@ -0,0 +1,265 @@++'use strict';++++/**++ * Porkbun's official API (v3) for the control panel: one person's API key pair, passed to each call and never kept,++ * logged or returned. Only https://api.porkbun.com is ever called (ORIGIN), the same origin the key pair's Network++ * grant names, so a revealed key pair cannot be sent anywhere else.++ *++ * Porkbun takes every call as a POST whose JSON body carries apikey and secretapikey. The Network secret is that pair++ * as one JSON string; it is parsed here and nowhere else.++ *++ * const pb = createPorkbun({ fetchImpl });++ * await pb.verify(token) → { ok: true } | { ok: false, reason }++ * await pb.zones(token) → [{ id, name, status, name_servers, plan }] (id is the domain)++ * await pb.records(token, domain) → [record]++ * await pb.record(token, domain, id) → record | null++ * await pb.create(token, domain, rec) / update(token, domain, id, rec) / remove(token, domain, id)++ * await pb.nameservers(token, domain) / setNameservers(token, domain, list)++ * await pb.dsRecords(token, domain) / createDs(token, domain, rec) / deleteDs(token, domain, id)++ *++ * A record is domains.proposal@1's shape: { id, type, name, content, ttl, proxied, priority, comment }.++ * Porkbun's create and edit calls take a name relative to the domain ('' for the apex); its reads are fully qualified.++ * Both are accepted and normalized to the full name, and a full name is written back as relative.++ *++ * A DS record is the same shape with type DS and content "keyTag alg digestType digest"; its id is the key tag, which++ * is what Porkbun's registry API deletes by.++ */++const ORIGIN = 'https://api.porkbun.com';++const BASE = `${ORIGIN}/api/json/v3`;++const PRIORITY = new Set(['MX', 'SRV']);++const MAX_DOMAIN_PAGES = 20; // 20 000 domains++const PAGE_SIZE = 1000; // Porkbun's listAll page size++++class ProviderError extends Error {++ constructor(status, message, code = 'provider.error') {++ super(message);++ this.status = status;++ this.code = code;++ }++}++++const lowerName = (n) => String(n || '').trim().toLowerCase().replace(/\.$/, '');++++/** The pair in the Network secret, or a ProviderError that never contains it. */++function keysOf(token) {++ if (typeof token !== 'string' || !token.trim()) throw new ProviderError(401, 'The connection has no Porkbun keys.', 'provider.no_token');++ let data;++ try {++ data = JSON.parse(token);++ } catch {++ throw new ProviderError(401, 'The connection is not a Porkbun key pair. Connect it again.', 'provider.no_token');++ }++ const apikey = data && typeof data.apikey === 'string' ? data.apikey.trim() : '';++ const secretapikey = data && typeof data.secretapikey === 'string' ? data.secretapikey.trim() : '';++ if (!apikey || !secretapikey) throw new ProviderError(401, 'The connection is missing one of its Porkbun keys. Connect it again.', 'provider.no_token');++ return { apikey, secretapikey };++}++++function redact(words, keys) {++ let out = String(words);++ for (const key of [keys.apikey, keys.secretapikey]) {++ if (key) out = out.split(key).join('[redacted]');++ }++ return out;++}++++/** The provider's record as domains.proposal@1 keeps it, with a relative name made fully qualified. */++function normalize(r, domain = '') {++ if (!r || typeof r !== 'object') return null;++ const zone = lowerName(domain);++ const type = String(r.type || '').toUpperCase();++ let name = lowerName(r.name);++ if (!name || name === '@') name = zone;++ else if (zone && name !== zone && !name.endsWith(`.${zone}`)) name = `${name}.${zone}`;++ const ttl = Number(r.ttl);++ const rawPrio = r.prio == null ? r.priority : r.prio;++ const prio = rawPrio == null || rawPrio === '' ? NaN : Number(rawPrio);++ const notes = typeof r.notes === 'string' ? r.notes : r.comment;++ return {++ id: r.id == null || r.id === '' ? null : String(r.id),++ type,++ name,++ content: String(r.content == null ? '' : r.content),++ ttl: Number.isInteger(ttl) && ttl > 0 ? ttl : 1,++ proxied: null,++ priority: PRIORITY.has(type) && Number.isInteger(prio) ? prio : null,++ comment: typeof notes === 'string' && notes ? notes.slice(0, 100) : null,++ };++}++++/** The body Porkbun takes for a create or edit, with a full name written as a subdomain ('' for the apex). */++function bodyOf(rec, domain = '') {++ const zone = lowerName(domain);++ const type = String(rec.type || '').toUpperCase();++ let name = lowerName(rec.name);++ if (!name || name === '@' || (zone && name === zone)) name = '';++ else if (zone && name.endsWith(`.${zone}`)) name = name.slice(0, -(zone.length + 1));++ else if (name.endsWith('.')) name = name.slice(0, -1);++ const ttl = Number(rec.ttl);++ const out = { type, name, content: String(rec.content == null ? '' : rec.content), ttl: Number.isInteger(ttl) && ttl > 1 ? ttl : 0 };++ const prio = Number(rec.priority);++ if (PRIORITY.has(type) && Number.isInteger(prio)) out.prio = prio;++ if (typeof rec.comment === 'string' && rec.comment) out.notes = rec.comment.slice(0, 100);++ return out;++}++++/** A DS record's four registry fields, from a record (content) or the registry's own object. */++function dsOf(rec) {++ if (!rec || typeof rec !== 'object') throw new ProviderError(422, 'A DS record needs keyTag, alg, digestType and digest.', 'provider.invalid');++ const parts = typeof rec.content === 'string' ? rec.content.trim().split(/\s+/).filter(Boolean) : [];++ const keyTag = String(rec.keyTag == null ? (rec.key_tag == null ? (rec.keytag == null ? (parts[0] || '') : rec.keytag) : rec.key_tag) : rec.keyTag).trim();++ const alg = String(rec.alg == null ? (rec.algorithm == null ? (parts[1] || '') : rec.algorithm) : rec.alg).trim();++ const digestType = String(rec.digestType == null ? (rec.digest_type == null ? (rec.digesttype == null ? (parts[2] || '') : rec.digesttype) : rec.digest_type) : rec.digestType).trim();++ const digest = String(rec.digest == null ? parts.slice(3).join('') : rec.digest).trim();++ if (!keyTag || !alg || !digestType || !digest) throw new ProviderError(422, 'A DS record needs keyTag, alg, digestType and digest.', 'provider.invalid');++ return { keyTag, alg, digestType, digest };++}++++/** A DS record in the same shape as every other record. */++function normalizeDs(record, key, domain) {++ const keyTag = String((record && record.keyTag) == null ? key : record.keyTag).trim();++ const alg = String((record && record.alg) == null ? '' : record.alg).trim();++ const digestType = String((record && record.digestType) == null ? '' : record.digestType).trim();++ const digest = String((record && record.digest) == null ? '' : record.digest).trim();++ return {++ id: keyTag,++ type: 'DS',++ name: lowerName(domain),++ content: `${keyTag} ${alg} ${digestType} ${digest}`.trim(),++ ttl: 1,++ proxied: null,++ priority: null,++ comment: null,++ };++}++++function createPorkbun({ fetchImpl = globalThis.fetch, timeoutMs = 10_000 } = {}) {++ async function call(token, path, body = {}) {++ const keys = keysOf(token);++ let res;++ try {++ res = await fetchImpl(`${BASE}${path}`, {++ method: 'POST',++ headers: { accept: 'application/json', 'content-type': 'application/json' },++ body: JSON.stringify({ ...body, apikey: keys.apikey, secretapikey: keys.secretapikey }),++ redirect: 'error',++ signal: AbortSignal.timeout(timeoutMs),++ });++ } catch (err) {++ const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError');++ throw new ProviderError(502, timedOut ? 'Porkbun did not answer in time.' : 'Porkbun could not be reached.', 'provider.unreachable');++ }++ const data = await res.json().catch(() => null);++ const status = data && typeof data.status === 'string' ? data.status.toUpperCase() : '';++ if (res.ok && data && status !== 'ERROR') return data;++ // Porkbun's own words for what went wrong, with any key it might echo removed.++ const words = data && data.message ? redact(data.message, keys).slice(0, 300) : `Porkbun answered ${res.status}.`;++ const code = data && data.code ? String(data.code) : '';++ if (res.status === 401 || res.status === 403 || /INVALID_API_KEY|INVALID_CREDENTIALS|AUTHENTICATION|API_KEY_MISSING/.test(code)++ || /invalid (api )?key|invalid credentials|authentication/i.test(words)) {++ throw new ProviderError(res.status === 403 ? 403 : 401, `Porkbun refused the keys: ${words}`, 'provider.forbidden');++ }++ if (res.status === 404 || /NOT_FOUND|DOMAIN_NOT_FOUND|RECORD_NOT_FOUND/.test(code)) throw new ProviderError(404, words, 'provider.not_found');++ throw new ProviderError(res.status >= 500 ? 502 : 422, words, 'provider.refused');++ }++++ async function record(token, domain, id) {++ try {++ const data = await call(token, `/dns/retrieve/${encodeURIComponent(domain)}/${encodeURIComponent(id)}`);++ const list = Array.isArray(data.records) ? data.records : [];++ const one = list.find((r) => String(r.id) === String(id)) || (list.length === 1 && list[0].id == null ? list[0] : null);++ if (one) return normalize(one, domain);++ } catch (err) {++ if (err.code !== 'provider.not_found' && !/not found|does not exist/i.test(err.message)) throw err;++ }++ // Some accounts and stand-ins answer retrieve-by-id with the whole zone: find it there.++ try {++ const data = await call(token, `/dns/retrieve/${encodeURIComponent(domain)}`);++ const found = (Array.isArray(data.records) ? data.records : []).find((r) => String(r.id) === String(id));++ return found ? normalize(found, domain) : null;++ } catch (err) {++ if (err.code === 'provider.not_found' || /not found|does not exist/i.test(err.message)) return null;++ throw err;++ }++ }++++ return {++ ORIGIN,++ /** /ping with the pair: Porkbun says whether the keys are valid and for which account. */++ async verify(token) {++ try {++ const data = await call(token, '/ping');++ const valid = data.credentialsValid == null ? true : data.credentialsValid === true;++ return valid ? { ok: true } : { ok: false, reason: 'Porkbun did not confirm these keys.' };++ } catch (err) {++ return { ok: false, reason: err.message };++ }++ },++ /** Every domain in the account, paged. Porkbun has no separate zone id, so the domain is the id. */++ async zones(token) {++ const out = [];++ for (let page = 0, start = 0; page < MAX_DOMAIN_PAGES; page++) {++ const data = await call(token, '/domain/listAll', { start });++ const list = Array.isArray(data.domains) ? data.domains : [];++ out.push(...list);++ if (list.length < PAGE_SIZE) break;++ start += list.length;++ }++ return out.map((d) => ({++ id: lowerName(d.domain), name: lowerName(d.domain), status: d.status ? String(d.status) : null,++ name_servers: [], plan: null,++ })).filter((z) => z.id);++ },++ async records(token, domain) {++ const data = await call(token, `/dns/retrieve/${encodeURIComponent(domain)}`);++ return (Array.isArray(data.records) ? data.records : []).map((r) => normalize(r, domain));++ },++ record,++ async create(token, domain, rec) {++ const body = bodyOf(rec, domain);++ const data = await call(token, `/dns/create/${encodeURIComponent(domain)}`, body);++ const returned = data.record || (Array.isArray(data.records) ? data.records[0] : null);++ const id = data.id == null ? (returned && returned.id) : data.id;++ if (id == null || id === '') throw new ProviderError(502, 'Porkbun did not return the new record id.', 'provider.refused');++ return (await record(token, domain, id)) || normalize(returned || { ...body, id }, domain);++ },++ async update(token, domain, id, rec) {++ const body = bodyOf(rec, domain);++ await call(token, `/dns/edit/${encodeURIComponent(domain)}/${encodeURIComponent(id)}`, body);++ return (await record(token, domain, id)) || normalize({ ...body, id }, domain);++ },++ async remove(token, domain, id) {++ await call(token, `/dns/delete/${encodeURIComponent(domain)}/${encodeURIComponent(id)}`);++ return true;++ },++ async nameservers(token, domain) {++ const data = await call(token, `/domain/getNs/${encodeURIComponent(domain)}`);++ return (Array.isArray(data.ns) ? data.ns : []).map(lowerName).filter(Boolean);++ },++ async setNameservers(token, domain, list) {++ const wanted = Array.isArray(list) ? list : (list && Array.isArray(list.ns) ? list.ns : []);++ const ns = wanted.map(lowerName).filter(Boolean);++ if (!ns.length) throw new ProviderError(422, 'A domain needs at least one name server.', 'provider.invalid');++ await call(token, `/domain/updateNs/${encodeURIComponent(domain)}`, { ns });++ return true;++ },++ async dsRecords(token, domain) {++ const data = await call(token, `/dns/getDnssecRecords/${encodeURIComponent(domain)}`);++ const records = data.records;++ if (Array.isArray(records)) return records.map((r) => normalizeDs(r, r && (r.keyTag || r.id), domain));++ return Object.entries(records && typeof records === 'object' ? records : {}).map(([key, r]) => normalizeDs(r, key, domain));++ },++ async createDs(token, domain, rec) {++ const ds = dsOf(rec);++ await call(token, `/dns/createDnssecRecord/${encodeURIComponent(domain)}`, ds);++ return normalizeDs(ds, ds.keyTag, domain);++ },++ async deleteDs(token, domain, id) {++ const key = id && typeof id === 'object' ? (id.id == null ? dsOf(id).keyTag : id.id) : id;++ await call(token, `/dns/deleteDnssecRecord/${encodeURIComponent(domain)}/${encodeURIComponent(key)}`);++ return true;++ },++ };++}++++module.exports = { createPorkbun, normalize, bodyOf, normalizeDs, dsOf, ProviderError, ORIGIN, PRIORITY };@@@ -37,20 +37,21 @@ function head(kicker, h1, lede = '') {function signedOut() {const steps = [-- ['link', 'Connect', 'Paste a Cloudflare API token. It is kept as a secret in your own OpenVibe project and only ever sent to Cloudflare.'],++ ['link', 'Connect', 'Paste a Cloudflare API token or Porkbun API keys. They are kept as a secret in your own OpenVibe project and only ever sent to that provider.'],['spark', 'Propose', 'Every DNS change, yours or an agent\'s, becomes a proposal: the exact records, why, and what could break.'],['check', 'Approve', 'Nothing changes until you approve it, here or in the Action Center. Applied changes roll back in one tap.'],];return page('Control panel', `${head('Control panel', 'Run every domain you own from one place', 'Connect your DNS once and see every zone together. Changes wait for your approval, and each one can be undone.')}<ol class="dmp-steps">${steps.map(([icon, t, d], i) => `<li><span class="dmp-step-ic">${kit.icon(icon)}</span><b>${i + 1}. ${esc(t)}</b><span>${esc(d)}</span></li>`).join('')}</ol><p class="dmp-actions"><a class="dm-btn" href="/auth/login?next=%2Fpanel">Sign in with OpenVibe</a></p>--<p class="dm-fine">Cloudflare today; Porkbun, Namecheap and Spaceship follow. OpenVibe never stores your token in its own database and never shows it again.</p>`);++<p class="dm-fine">Cloudflare and Porkbun today; Namecheap and Spaceship follow. OpenVibe never stores your keys in its own database and never shows them again.</p>`);}function connectionRow(c) {const broken = c.status === 'broken';++ const provider = c.provider === 'porkbun' ? 'Porkbun' : 'Cloudflare';return `<li class="dmp-conn${broken ? ' is-broken' : ''}" id="dmp-conn-${esc(c.id)}">--<div class="dmp-conn-main"><b>${esc(c.label)}</b><span class="dmp-sub">Cloudflare · ${c.zone_count} zone${c.zone_count === 1 ? '' : 's'}${c.checked_at ? ` · checked ${when(c.checked_at)}` : ''}</span>++<div class="dmp-conn-main"><b>${esc(c.label)}</b><span class="dmp-sub">${provider} · ${c.zone_count} zone${c.zone_count === 1 ? '' : 's'}${c.checked_at ? ` · checked ${when(c.checked_at)}` : ''}</span>${broken ? `<span class="dmp-err">${kit.icon('alert')}${esc(c.last_error || 'The token stopped working.')}</span>` : ''}</div><div class="dmp-conn-act"><form method="post" action="/panel/c/${esc(c.id)}/sync"><button class="dmp-btn" type="submit">Sync</button></form>@@@ -69,7 +70,7 @@ function proposalCard(p) {function home({ connections = [], zones = [], waiting = [], recent = [], notice = null } = {}) {if (!connections.length) {return page('Control panel', `${head('Control panel', 'Connect your first account', 'Domains reads your zones through the provider\'s own API. Every change is proposed to you first.')}--<p class="dmp-actions"><a class="dm-btn" href="/panel/connect">${kit.icon('plus')}Connect Cloudflare</a></p>`, { notice });++<p class="dmp-actions"><a class="dm-btn" href="/panel/connect">${kit.icon('plus')}Connect Cloudflare or Porkbun</a></p>`, { notice });}const facts = `<dl class="dm-facts"><div><dt>Zones</dt><dd><b>${zones.length}</b></dd></div><div><dt>Waiting for you</dt><dd><b>${waiting.length}</b></dd></div><div><dt>Connections</dt><dd><b>${connections.length}</b>${connections.some((c) => c.status === 'broken') ? '<small>one needs attention</small>' : ''}</dd></div></dl>`;const zoneRows = zones.map((z) => `<a class="dm-tr dmp-zone" id="dmp-zone-${esc(z.zone_id)}" href="/panel/z/${esc(z.connection_id)}/${esc(z.zone_id)}">@@@ -84,21 +85,34 @@ ${recent.length ? `<section aria-labelledby="dmp-recent-h"><h2 id="dmp-recent-h"}function connect({ error = null, values = {} } = {}) {-- return page('Connect Cloudflare', `${head('<a href="/panel">Control panel</a>', 'Connect Cloudflare')}++ const provider = values.provider === 'porkbun' ? 'porkbun' : 'cloudflare';++ const label = values.label || (provider === 'porkbun' ? 'Porkbun' : 'Cloudflare');++ return page('Connect a provider', `${head('<a href="/panel">Control panel</a>', 'Connect a provider')}<div class="dmp-split"><form class="dmp-form" method="post" action="/panel/connect" id="dmp-connect" autocomplete="off">${error ? `<p class="dm-warn" role="alert">${kit.icon('alert')}<span>${esc(error)}</span></p>` : ''}--<input type="hidden" name="provider" value="cloudflare">--<label class="dmp-field"><span>A name for it</span><input name="label" maxlength="60" value="${esc(values.label || 'Cloudflare')}" required></label>--<label class="dmp-field"><span>API token</span><input name="token" type="password" minlength="20" maxlength="400" required spellcheck="false" autocomplete="off" placeholder="Paste the token Cloudflare showed you"></label>++<label class="dmp-field"><span>Provider</span><select name="provider">++<option value="cloudflare"${provider === 'cloudflare' ? ' selected' : ''}>Cloudflare</option>++<option value="porkbun"${provider === 'porkbun' ? ' selected' : ''}>Porkbun</option>++</select></label>++<label class="dmp-field"><span>A name for it</span><input name="label" maxlength="60" value="${esc(label)}" required></label>++<label class="dmp-field"><span>Cloudflare API token</span><input name="token" type="password" maxlength="400" spellcheck="false" autocomplete="off" placeholder="Paste the token Cloudflare showed you"></label>++<label class="dmp-field"><span>Porkbun API key</span><input name="apikey" type="password" maxlength="400" spellcheck="false" autocomplete="off" placeholder="pk1_… from porkbun.com/account/api"></label>++<label class="dmp-field"><span>Porkbun secret API key</span><input name="secretapikey" type="password" maxlength="400" spellcheck="false" autocomplete="off" placeholder="sk1_… from the same page"></label>++<p class="dm-fine">Use the token for Cloudflare or the two keys for Porkbun.</p><p class="dmp-actions"><button class="dm-btn" type="submit">${kit.icon('lock')}Connect</button><a class="dmp-btn dmp-btn--quiet" href="/panel">Cancel</a></p></form>--<aside class="dmp-help" aria-labelledby="dmp-help-h"><h2 id="dmp-help-h">Make a token that can do only this</h2>++<aside class="dmp-help" aria-labelledby="dmp-help-h"><h2 id="dmp-help-h">Make keys that can do only this</h2>++<h3>Cloudflare</h3><ol><li>Open <a href="https://dash.cloudflare.com/profile/api-tokens" rel="noopener noreferrer" target="_blank">API tokens</a> in Cloudflare and choose <b>Create token</b>.</li><li>Start from <b>Edit zone DNS</b>. Add <b>Zone · Zone · Read</b> so Domains can list your zones.</li><li>Under <b>Zone resources</b>, pick the zones you want here (or all of them).</li><li>Create it and paste it on the left. Cloudflare shows it once.</li></ol>--<p class="dmp-where">${kit.icon('shield')}<span>The token is stored as a secret in your own OpenVibe project, granted to OpenVibe.Domains only for calls to <b>api.cloudflare.com</b>. Domains keeps its name, never its value. Remove the connection and the secret goes with it.</span></p>++<h3>Porkbun</h3>++<ol><li>Create API keys at <a href="https://porkbun.com/account/api" rel="noopener noreferrer" target="_blank">porkbun.com/account/api</a>.</li>++<li>Then switch on <b>API access</b> for each domain you want here: Porkbun enables it per domain.</li>++<li>Paste the API key and secret API key on the left.</li></ol>++<p class="dmp-where">${kit.icon('shield')}<span>The keys are stored as a secret in your own OpenVibe project, granted to OpenVibe.Domains only for calls to the provider's API (<b>api.cloudflare.com</b> or <b>api.porkbun.com</b>). Domains keeps their name, never their value. Remove the connection and the secret goes with it.</span></p></aside></div>`);}@@@ -133,20 +147,27 @@ ${error ? `<p class="dm-warn" role="alert">${kit.icon('alert')}<span>${esc(error${recordFields(values)}<label class="dmp-field dmp-field--wide"><span>Why (optional)</span><input name="why" maxlength="300" value="${esc(values.why || '')}"></label><p class="dmp-actions"><button class="dm-btn" type="submit">${kit.icon('plus')}Propose</button></p></form></section><section aria-labelledby="dmp-recs-h"><h2 id="dmp-recs-h">Records</h2>--${records.length ? `<ul class="dmp-recs">${rows}</ul>` : '<p class="dm-fine">This zone has no records Cloudflare showed us.</p>'}</section>`);++${records.length ? `<ul class="dmp-recs">${rows}</ul>` : `<p class="dm-fine">This zone has no records ${conn.provider === 'porkbun' ? 'Porkbun' : 'Cloudflare'} showed us.</p>`}</section>`);}function changeCard(c) {const b = c.before;const a = c.after;++ if (String(c.type || '').toUpperCase() === 'NS' || Array.isArray(b) || Array.isArray(a)) {++ const list = (x, side) => `<div class="${side}"><span class="dmp-sub">${side === 'dmp-before' ? 'Now' : 'After'}</span>${Array.isArray(x) && x.length ? `<div class="dmp-ns">${x.map((n) => `<code>${esc(n)}</code>`).join('<br>')}</div>` : '<em>—</em>'}</div>`;++ return `<li class="dmp-change dmp-change--${esc(c.op)}"><span class="dmp-op">${esc(OP_WORDS[c.op] || c.op)}</span>++<div class="dmp-change-main"><p class="dmp-change-rec"><span class="dmp-type">NS</span><b>Name servers</b></p>++<div class="dmp-diff">${list(b, 'dmp-before')}${list(a, 'dmp-after')}</div></div></li>`;++ }const r = a || b || {};-- const val = (x) => (x ? `<code>${esc(x.content)}</code><span class="dmp-sub">${x.ttl === 1 ? 'Auto TTL' : `TTL ${esc(x.ttl)}`}${x.priority != null ? ` · priority ${esc(x.priority)}` : ''}${x.proxied ? ' · proxied' : ''}</span>` : '');++ const val = (x) => (x ? `<code>${esc(x.content)}</code>${x.type === 'DS' ? '' : `<span class="dmp-sub">${x.ttl === 1 ? 'Auto TTL' : `TTL ${esc(x.ttl)}`}${x.priority != null ? ` · priority ${esc(x.priority)}` : ''}${x.proxied ? ' · proxied' : ''}</span>`}` : '');return `<li class="dmp-change dmp-change--${esc(c.op)}"><span class="dmp-op">${esc(OP_WORDS[c.op] || c.op)}</span><div class="dmp-change-main"><p class="dmp-change-rec"><span class="dmp-type">${esc(r.type)}</span><b>${esc(r.name)}</b></p><div class="dmp-diff">${b ? `<div class="dmp-before"><span class="dmp-sub">Now</span>${val(b)}</div>` : ''}${a ? `<div class="dmp-after"><span class="dmp-sub">${b ? 'After' : 'New'}</span>${val(a)}</div>` : '<div class="dmp-after"><span class="dmp-sub">After</span><em>Removed</em></div>'}</div></div></li>`;}function proposal({ p, notice = null } = {}) {++ const providerName = p.provider === 'porkbun' ? 'Porkbun' : 'Cloudflare';const rolledBackable = (p.status === 'applied' || p.status === 'failed') && p.can_roll_back;const actions = p.status === 'pending'? `<form method="post" action="/panel/p/${esc(p.id)}/decide"><input type="hidden" name="decision" value="approve"><button class="dm-btn" type="submit">${kit.icon('check')}Approve and apply</button></form>@@@ -160,7 +181,7 @@ ${p.error ? `<p class="dm-warn" role="alert">${kit.icon('alert')}<span>${esc(p.e${p.why ? `<section aria-labelledby="dmp-why-h"><h2 id="dmp-why-h">Why</h2><p class="dmp-text">${esc(p.why)}</p></section>` : ''}${p.risk ? `<section aria-labelledby="dmp-risk-h"><h2 id="dmp-risk-h">What could break</h2><p class="dmp-text">${esc(p.risk)}</p></section>` : ''}${actions ? `<div class="dmp-decide">${actions}</div>` : ''}--<p class="dm-fine">${p.status === 'pending' ? 'Approving applies the changes through Cloudflare\'s API, one by one. If a record changed since this was proposed, nothing more is applied and you can roll back what was.' : 'Every step is kept in your panel\'s audit.'}</p>`, { notice });++<p class="dm-fine">${p.status === 'pending' ? `Approving applies the changes through ${providerName}'s API, one by one. If a record or nameserver set changed since this was proposed, nothing more is applied and you can roll back what was.` : 'Every step is kept in your panel\'s audit.'}</p>`, { notice });}module.exports = { signedOut, home, connect, zone, proposal, describe };nnew file mode 100644@@@ -0,0 +1,195 @@++'use strict';++/**++ * A stand-in for Porkbun's API v3 behind createPorkbun(): an in-memory account answering the same paths, so the real++ * client (server/panel/porkbun.js) is what the tests drive. Only https://api.porkbun.com is answered; any other origin++ * is recorded as a leak.++ *++ * const pb = fakePorkbun({ apikey: 'pk1_…', secretapikey: 'sk1_…', domains: [{ domain: 'example.com' }] });++ * createPorkbun({ fetchImpl: pb.fetch })++ * pb.records('example.com') / pb.setRecord(…) / pb.nameservers(…) / pb.setNameservers(…) / pb.dsRecords(…)++ * pb.revoke() / pb.calls / pb.leaks++ *++ * The fake keeps records fully qualified, as Porkbun's reads return them, and expands the relative names its create and++ * edit calls take.++ */++function fakePorkbun({ apikey, secretapikey, domains = [] } = {}) {++ const st = {++ apikey, secretapikey, revoked: false, domains: [], records: new Map(), ns: new Map(), ds: new Map(),++ nextId: 1, calls: [], leaks: [],++ };++ for (const input of domains) {++ const d = typeof input === 'string' ? { domain: input } : input;++ const domain = String(d.domain).toLowerCase();++ st.domains.push({ domain, status: d.status || 'ACTIVE', apiAccess: d.apiAccess !== false });++ st.records.set(domain, []);++ st.ns.set(domain, (d.nameservers || ['ns1.porkbun.com', 'ns2.porkbun.com']).map((n) => String(n).toLowerCase()));++ st.ds.set(domain, new Map());++ }++++ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });++ const ok = (body = {}) => json(200, { status: 'SUCCESS', ...body });++ const err = (status, message, code) => json(status, { status: 'ERROR', message, ...(code ? { code } : {}) });++ const page = (domain, name) => {++ const zone = String(domain).toLowerCase();++ const n = String(name || '').trim().toLowerCase().replace(/\.$/, '');++ if (!n || n === zone || n === '@') return zone;++ return n.endsWith(`.${zone}`) ? n : `${n}.${zone}`;++ };++ const domainOf = (name) => st.domains.find((d) => d.domain === String(name).toLowerCase());++++ async function fetchImpl(input, init = {}) {++ const u = new URL(String(input));++ if (u.origin !== 'https://api.porkbun.com') { st.leaks.push(String(input)); return err(400, 'wrong origin'); }++ const method = init.method || 'GET';++ let body = {};++ try { body = init.body ? JSON.parse(init.body) : {}; } catch { return err(400, 'Invalid JSON.'); }++ st.calls.push({ method, path: u.pathname, keys: body.apikey === st.apikey && body.secretapikey === st.secretapikey });++ if (method !== 'POST') return err(405, 'Use POST.');++ if (st.revoked || body.apikey !== st.apikey || body.secretapikey !== st.secretapikey) return err(400, 'Invalid API key', 'INVALID_API_KEY');++ const p = u.pathname.replace('/api/json/v3', '');++++ if (p === '/ping') return ok({ credentialsValid: true, account: 'ada' });++ if (p === '/domain/listAll') {++ const start = Number.isInteger(body.start) && body.start > 0 ? body.start : 0;++ const slice = st.domains.slice(start, start + 1000);++ return ok({++ count: slice.length,++ domains: slice.map((d) => ({ domain: d.domain, status: d.status, tld: d.domain.split('.').pop(), apiAccess: d.apiAccess ? 1 : 0 })),++ });++ }++++ let m = p.match(/^\/domain\/getNs\/([^/]+)$/);++ if (m) {++ const d = domainOf(decodeURIComponent(m[1]));++ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');++ if (!d.apiAccess) return err(400, `API access is not enabled for ${d.domain}.`, 'API_ACCESS_DISABLED');++ return ok({ ns: [...st.ns.get(d.domain)] });++ }++ m = p.match(/^\/domain\/updateNs\/([^/]+)$/);++ if (m) {++ const d = domainOf(decodeURIComponent(m[1]));++ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');++ if (!Array.isArray(body.ns) || !body.ns.length) return err(400, 'At least one name server is required.');++ st.ns.set(d.domain, body.ns.map((n) => String(n).toLowerCase()));++ return ok();++ }++++ m = p.match(/^\/dns\/retrieve\/([^/]+)$/);++ if (m) {++ const d = domainOf(decodeURIComponent(m[1]));++ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');++ if (!d.apiAccess) return err(400, `API access is not enabled for ${d.domain}.`, 'API_ACCESS_DISABLED');++ return ok({ records: st.records.get(d.domain).map((r) => ({ ...r })) });++ }++ m = p.match(/^\/dns\/retrieve\/([^/]+)\/([^/]+)$/);++ if (m) {++ const d = domainOf(decodeURIComponent(m[1]));++ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');++ const rec = st.records.get(d.domain).find((r) => r.id === decodeURIComponent(m[2]));++ if (!rec) return err(404, 'Record not found', 'RECORD_NOT_FOUND');++ return ok({ records: [{ ...rec }] });++ }++ m = p.match(/^\/dns\/create\/([^/]+)$/);++ if (m) {++ const d = domainOf(decodeURIComponent(m[1]));++ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');++ if (!body.type || body.content == null) return err(400, 'A record needs a type and content.');++ const rec = {++ id: String(st.nextId++), name: page(d.domain, body.name), type: String(body.type).toUpperCase(),++ content: String(body.content), ttl: String(body.ttl == null ? 0 : body.ttl),++ prio: body.prio == null ? null : String(body.prio), notes: body.notes == null ? null : String(body.notes),++ };++ st.records.get(d.domain).push(rec);++ return ok({ id: rec.id });++ }++ m = p.match(/^\/dns\/edit\/([^/]+)\/([^/]+)$/);++ if (m) {++ const d = domainOf(decodeURIComponent(m[1]));++ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');++ const rec = st.records.get(d.domain).find((r) => r.id === decodeURIComponent(m[2]));++ if (!rec) return err(404, 'Record not found', 'RECORD_NOT_FOUND');++ if (body.name !== undefined) rec.name = page(d.domain, body.name);++ if (body.type !== undefined) rec.type = String(body.type).toUpperCase();++ if (body.content !== undefined) rec.content = String(body.content);++ if (body.ttl !== undefined) rec.ttl = String(body.ttl);++ if (body.prio !== undefined) rec.prio = body.prio == null ? null : String(body.prio);++ if (body.notes !== undefined) rec.notes = body.notes == null ? null : String(body.notes);++ return ok();++ }++ m = p.match(/^\/dns\/delete\/([^/]+)\/([^/]+)$/);++ if (m) {++ const d = domainOf(decodeURIComponent(m[1]));++ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');++ const list = st.records.get(d.domain);++ const at = list.findIndex((r) => r.id === decodeURIComponent(m[2]));++ if (at < 0) return err(404, 'Record not found', 'RECORD_NOT_FOUND');++ list.splice(at, 1);++ return ok();++ }++++ m = p.match(/^\/dns\/getDnssecRecords\/([^/]+)$/);++ if (m) {++ const d = domainOf(decodeURIComponent(m[1]));++ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');++ return ok({ records: Object.fromEntries([...st.ds.get(d.domain)].map(([key, value]) => [key, { ...value }])) });++ }++ m = p.match(/^\/dns\/createDnssecRecord\/([^/]+)$/);++ if (m) {++ const d = domainOf(decodeURIComponent(m[1]));++ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');++ for (const field of ['keyTag', 'alg', 'digestType', 'digest']) {++ if (body[field] == null || body[field] === '') return err(400, `A DNSSEC record needs ${field}.`);++ }++ const rec = {++ keyTag: String(body.keyTag), alg: String(body.alg), digestType: String(body.digestType), digest: String(body.digest),++ };++ st.ds.get(d.domain).set(rec.keyTag, rec);++ return ok();++ }++ m = p.match(/^\/dns\/deleteDnssecRecord\/([^/]+)\/([^/]+)$/);++ if (m) {++ const d = domainOf(decodeURIComponent(m[1]));++ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');++ st.ds.get(d.domain).delete(decodeURIComponent(m[2]));++ return ok();++ }++ return err(404, 'not found');++ }++++ return {++ fetch: fetchImpl,++ state: st,++ records: (domain) => st.records.get(String(domain).toLowerCase()).map((r) => ({ ...r })),++ addRecord: (domain, r) => {++ const zone = String(domain).toLowerCase();++ const rec = {++ id: String(st.nextId++), ttl: '600', prio: null, notes: null,++ ...r, name: page(zone, r.name), type: String(r.type).toUpperCase(), content: String(r.content),++ };++ st.records.get(zone).push(rec);++ return { ...rec };++ },++ setRecord: (domain, id, patch) => {++ const zone = String(domain).toLowerCase();++ const list = st.records.get(zone);++ const at = list.findIndex((r) => r.id === id);++ if (at < 0) return null;++ list[at] = { ...list[at], ...patch };++ if (patch.name !== undefined) list[at].name = page(zone, patch.name);++ return { ...list[at] };++ },++ nameservers: (domain) => [...st.ns.get(String(domain).toLowerCase())],++ setNameservers: (domain, list) => { st.ns.set(String(domain).toLowerCase(), list.map((n) => String(n).toLowerCase())); },++ dsRecords: (domain) => [...st.ds.get(String(domain).toLowerCase()).values()].map((r) => ({ ...r })),++ addDs: (domain, r) => {++ const rec = { keyTag: String(r.keyTag), alg: String(r.alg), digestType: String(r.digestType), digest: String(r.digest) };++ st.ds.get(String(domain).toLowerCase()).set(rec.keyTag, rec);++ return { ...rec };++ },++ revoke: () => { st.revoked = true; },++ get calls() { return st.calls; },++ get leaks() { return st.leaks; },++ };++}++++module.exports = { fakePorkbun };nnew file mode 100644@@@ -0,0 +1,239 @@++'use strict';++/**++ * Porkbun in the control panel (DOM-2) end to end, on a migrated test database with a Network stand-in and a++ * stand-in Porkbun account behind the real client:++ *++ * connect stores the two keys as one JSON secret granted for reveal to api.porkbun.com only, with no key anywhere in++ * this database; a DNS record is proposed, applied and rolled back; a nameserver list is changed through the registrar++ * and rolled back; a DS record is created through the registrar and rolled back; a nameserver list that moved is++ * refused as stale; the Action Center reads the proposal.++ */++const assert = require('assert');++const contracts = require('openvibe-contracts');++const { boot, check, done } = require('./helpers');++const { startNetwork } = require('./helpers/network');++const { fakePorkbun } = require('./helpers/fake-porkbun');++const { createPorkbun, normalize, bodyOf } = require('../server/panel/porkbun');++++const API_KEY = 'pk1_example-not-a-real-key-aaaaaaaa';++const SECRET_KEY = 'sk1_example-not-a-real-key-bbbbbbbb';++const STUB = { tlds: async () => ({ tlds: [] }), tld: async () => null, prices: async () => null, lists: async () => ({}), history: async () => null, check: async () => null };++const sorted = (list) => [...list].sort();++++(async () => {++ const network = await startNetwork();++ const pb = fakePorkbun({ apikey: API_KEY, secretapikey: SECRET_KEY, domains: [{ domain: 'example.com' }] });++ pb.addRecord('example.com', { type: 'A', name: 'www', content: '192.0.2.1', ttl: 600 });++ const t = await boot({ host: STUB, tools: STUB, network, providers: { porkbun: createPorkbun({ fetchImpl: pb.fetch }) } });++ const ada = network.addUser('ada');++ const cookie = t.signIn(ada);++ const db = t.store.db;++ const locationOf = (r) => r.headers.get('location');++ let conn = null;++++ await check('a relative record name is made fully qualified and a full name is written back relative', async () => {++ const rec = normalize({ id: '1', name: 'www', type: 'A', content: '192.0.2.1', ttl: '600', prio: null }, 'example.com');++ assert.strictEqual(rec.name, 'www.example.com');++ assert.strictEqual(rec.ttl, 600);++ assert.strictEqual(bodyOf({ type: 'A', name: 'www.example.com', content: '192.0.2.1', ttl: 600 }, 'example.com').name, 'www');++ assert.strictEqual(bodyOf({ type: 'A', name: 'example.com', content: '192.0.2.1', ttl: 1 }, 'example.com').name, '');++ });++++ await check('bad keys are refused in Porkbun\'s words, and nothing is stored', async () => {++ const r = await t.get('/panel/connect', { cookie, form: { provider: 'porkbun', label: 'Main', apikey: 'pk1_wrong', secretapikey: 'sk1_wrong' } });++ assert.strictEqual(r.status, 422, r.text);++ assert.match(r.text, /Porkbun refused the keys: Invalid API key/);++ assert.strictEqual(network.state.secrets.size, 0);++ assert.strictEqual(await db.value('SELECT count(*)::int FROM domains_connections'), 0);++ assert.deepStrictEqual(pb.leaks, []);++ });++++ await check('connecting stores the pair as one JSON secret granted to api.porkbun.com only, and syncs the domains', async () => {++ const r = await t.get('/panel/connect', { cookie, form: { provider: 'porkbun', label: 'Main account', apikey: API_KEY, secretapikey: SECRET_KEY } });++ assert.strictEqual(r.status, 303, r.text);++ assert.strictEqual(locationOf(r), '/panel?done=connected');++ conn = await db.maybe('SELECT * FROM domains_connections WHERE subject = $1', [ada.subject]);++ assert.ok(conn && conn.status === 'active' && conn.provider === 'porkbun');++ const [key, secret] = [...network.state.secrets.entries()][0];++ assert.strictEqual(key, `${conn.project_id}/production/${conn.secret_name}`);++ assert.strictEqual(secret.value, JSON.stringify({ apikey: API_KEY, secretapikey: SECRET_KEY }));++ assert.deepStrictEqual(secret.grants.domains, { purposes: ['reveal'], origins: ['https://api.porkbun.com'] });++ // Neither key is anywhere in this service's database.++ for (const table of ['domains_connections', 'domains_zones', 'domains_proposals', 'domains_audit']) {++ const dump = JSON.stringify(await db.many(`SELECT * FROM ${table}`));++ assert.ok(!dump.includes(API_KEY), `${table} holds the API key`);++ assert.ok(!dump.includes(SECRET_KEY), `${table} holds the secret key`);++ }++ const zones = await db.many('SELECT * FROM domains_zones WHERE connection_id = $1', [conn.id]);++ assert.deepStrictEqual(zones.map((z) => z.name), ['example.com']);++ const page = await t.get('/panel?done=connected', { cookie });++ assert.match(page.text, /id="dmp-zone-example\.com"/);++ assert.match(page.text, /Porkbun · 1 zone/);++ assert.ok(pb.calls.some((c) => c.path === '/api/json/v3/ping'), 'verify used /ping');++ assert.deepStrictEqual(pb.leaks, []);++ });++++ await check('a record proposal changes nothing until approved; approving applies and rolling back undoes it', async () => {++ const r = await t.get('/api/v1/proposals', {++ bearer: network.userToken(ada),++ json: { connection_id: conn.id, zone_id: 'example.com', why: 'Prove the zone is ours', changes: [{ op: 'create', after: { type: 'TXT', name: '@', content: 'openvibe-proof=1' } }] },++ });++ assert.strictEqual(r.status, 201, r.text);++ const pub = r.json();++ assert.ok(contracts.validate('domains.proposal', pub).valid);++ assert.strictEqual(pb.records('example.com').some((x) => x.type === 'TXT'), false, 'nothing applied yet');++ const decided = await t.get(`/api/v1/proposals/${pub.id}/decide`, { bearer: network.userToken(ada), json: { decision: 'approve' } });++ assert.strictEqual(decided.status, 200, decided.text);++ assert.strictEqual(decided.json().status, 'applied');++ const added = pb.records('example.com').find((x) => x.type === 'TXT');++ assert.ok(added && added.name === 'example.com' && added.content === 'openvibe-proof=1');++ const row = await db.maybe('SELECT * FROM domains_proposals WHERE id = $1', [pub.id]);++ assert.strictEqual(row.rollback[0].op, 'delete');++ const back = await t.get(`/api/v1/proposals/${pub.id}/rollback`, { bearer: network.userToken(ada), json: {} });++ assert.strictEqual(back.status, 200, back.text);++ assert.strictEqual(back.json().status, 'rolled_back');++ assert.strictEqual(pb.records('example.com').some((x) => x.type === 'TXT'), false, 'the record is gone again');++ });++++ await check('a nameserver change is applied through the registrar and rolls back; its risk is in plain words', async () => {++ const before = pb.nameservers('example.com');++ const after = ['ns1.new.example', 'ns2.new.example'];++ const r = await t.get('/api/v1/proposals', {++ bearer: network.userToken(ada),++ json: { connection_id: conn.id, zone_id: 'example.com', changes: [{ type: 'NS', op: 'update', after }] },++ });++ assert.strictEqual(r.status, 201, r.text);++ const pub = r.json();++ assert.deepStrictEqual(pub.changes[0].before, before);++ assert.deepStrictEqual(pub.changes[0].after, after);++ assert.match(pub.title, /Change name servers/);++ assert.match(pub.risk, /wrong list makes the domain stop resolving/);++ assert.deepStrictEqual(pb.nameservers('example.com'), before, 'nothing applied yet');++ const decided = await t.get(`/api/v1/proposals/${pub.id}/decide`, { bearer: network.userToken(ada), json: { decision: 'approve' } });++ assert.strictEqual(decided.status, 200, decided.text);++ assert.strictEqual(decided.json().status, 'applied');++ assert.deepStrictEqual(sorted(pb.nameservers('example.com')), sorted(after));++ const row = await db.maybe('SELECT * FROM domains_proposals WHERE id = $1', [pub.id]);++ assert.strictEqual(row.rollback[0].type, 'NS');++ const back = await t.get(`/api/v1/proposals/${pub.id}/rollback`, { bearer: network.userToken(ada), json: {} });++ assert.strictEqual(back.status, 200, back.text);++ assert.strictEqual(back.json().status, 'rolled_back');++ assert.deepStrictEqual(sorted(pb.nameservers('example.com')), sorted(before));++ });++++ await check('a DS record is created through the registrar and rolls back', async () => {++ const digest = 'ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789';++ const after = { type: 'DS', name: 'example.com', content: `12345 13 2 ${digest}` };++ const r = await t.get('/api/v1/proposals', {++ bearer: network.userToken(ada),++ json: { connection_id: conn.id, zone_id: 'example.com', changes: [{ op: 'create', after }] },++ });++ assert.strictEqual(r.status, 201, r.text);++ const pub = r.json();++ const valid = contracts.validate('domains.proposal', pub);++ assert.ok(valid.valid, JSON.stringify(valid.errors));++ assert.strictEqual(pb.dsRecords('example.com').length, 0, 'nothing applied yet');++ assert.ok(pb.calls.some((c) => c.path === '/api/json/v3/dns/getDnssecRecords/example.com'));++ assert.match(pub.risk, /does not match the DNS host's DNSKEY/);++ const decided = await t.get(`/api/v1/proposals/${pub.id}/decide`, { bearer: network.userToken(ada), json: { decision: 'approve' } });++ assert.strictEqual(decided.status, 200, decided.text);++ assert.strictEqual(decided.json().status, 'applied');++ const ds = pb.dsRecords('example.com');++ assert.strictEqual(ds.length, 1);++ assert.strictEqual(ds[0].keyTag, '12345');++ assert.strictEqual(ds[0].alg, '13');++ assert.strictEqual(ds[0].digestType, '2');++ assert.ok(pb.calls.some((c) => c.path === '/api/json/v3/dns/createDnssecRecord/example.com'), 'created at the registrar, not in the zone');++ const row = await db.maybe('SELECT * FROM domains_proposals WHERE id = $1', [pub.id]);++ assert.strictEqual(row.rollback[0].op, 'delete');++ assert.strictEqual(row.rollback[0].record_id, '12345');++ const back = await t.get(`/api/v1/proposals/${pub.id}/rollback`, { bearer: network.userToken(ada), json: {} });++ assert.strictEqual(back.status, 200, back.text);++ assert.strictEqual(back.json().status, 'rolled_back');++ assert.strictEqual(pb.dsRecords('example.com').length, 0);++ });++++ await check('a DS record is removed through the registrar and rolls back', async () => {++ pb.addDs('example.com', { keyTag: '777', alg: '13', digestType: '2', digest: 'ABCDEF0123456789' });++ const r = await t.get('/api/v1/proposals', {++ bearer: network.userToken(ada),++ json: { connection_id: conn.id, zone_id: 'example.com', changes: [{ type: 'DS', op: 'delete', record_id: '777' }] },++ });++ assert.strictEqual(r.status, 201, r.text);++ const pub = r.json();++ assert.strictEqual(pub.changes[0].before.content, '777 13 2 ABCDEF0123456789');++ assert.strictEqual(pub.changes[0].after, null);++ const decided = await t.get(`/api/v1/proposals/${pub.id}/decide`, { bearer: network.userToken(ada), json: { decision: 'approve' } });++ assert.strictEqual(decided.status, 200, decided.text);++ assert.strictEqual(decided.json().status, 'applied');++ assert.strictEqual(pb.dsRecords('example.com').length, 0);++ const row = await db.maybe('SELECT * FROM domains_proposals WHERE id = $1', [pub.id]);++ assert.strictEqual(row.rollback[0].op, 'create');++ const back = await t.get(`/api/v1/proposals/${pub.id}/rollback`, { bearer: network.userToken(ada), json: {} });++ assert.strictEqual(back.status, 200, back.text);++ assert.strictEqual(back.json().status, 'rolled_back');++ assert.deepStrictEqual(pb.dsRecords('example.com').map((x) => x.keyTag), ['777']);++ });++++ await check('a nameserver list that moved since the proposal is refused as stale', async () => {++ const r = await t.get('/api/v1/proposals', {++ bearer: network.userToken(ada),++ json: { connection_id: conn.id, zone_id: 'example.com', changes: [{ type: 'NS', op: 'update', after: ['ns3.new.example', 'ns4.new.example'] }] },++ });++ assert.strictEqual(r.status, 201, r.text);++ const id = r.json().id;++ const moved = ['ns9.other.example', 'ns10.other.example'];++ pb.setNameservers('example.com', moved);++ const decided = await t.get(`/api/v1/proposals/${id}/decide`, { bearer: network.userToken(ada), json: { decision: 'approve' } });++ assert.strictEqual(decided.status, 200, decided.text);++ assert.strictEqual(decided.json().status, 'failed');++ assert.match(decided.json().error, /name servers changed since this was proposed/);++ assert.deepStrictEqual(sorted(pb.nameservers('example.com')), sorted(moved), 'left as someone set it');++ const row = await db.maybe('SELECT rollback FROM domains_proposals WHERE id = $1', [id]);++ assert.deepStrictEqual(row.rollback, []);++ });++++ await check('the Action Center reads the item with domains.actions.read and declines with domains.proposal.decide', async () => {++ const fresh = await t.get('/api/v1/proposals', {++ bearer: network.userToken(ada),++ json: { connection_id: conn.id, zone_id: 'example.com', title: 'Add a TXT proof', changes: [{ op: 'create', after: { type: 'TXT', name: '@', content: 'action-center=1' } }] },++ });++ assert.strictEqual(fresh.status, 201, fresh.text);++ const id = fresh.json().id;++ const noCap = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: network.serviceToken(['domains.proposal.decide']) });++ assert.strictEqual(noCap.status, 403);++ const list = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: network.serviceToken(['domains.actions.read']) });++ assert.strictEqual(list.status, 200, list.text);++ const item = list.json().items.find((x) => x.id === id);++ assert.ok(item, 'the proposal is in the Action Center');++ assert.ok(contracts.validate('common.action-item', item).valid);++ assert.deepStrictEqual([item.bucket, item.actions], ['waiting', ['approve', 'decline', 'open']]);++ assert.match(item.summary, /Add TXT example\.com/);++ const decided = await t.get(`/api/v1/proposals/${id}/decide`, {++ bearer: network.serviceToken(['domains.proposal.decide']), headers: { 'x-ov-subject': ada.subject }, json: { decision: 'decline' }, origin: '',++ });++ assert.strictEqual(decided.status, 200, decided.text);++ assert.strictEqual(decided.json().state, 'declined');++ assert.ok(contracts.validate('common.action-item', decided.json()).valid);++ assert.ok(!pb.records('example.com').some((x) => x.type === 'TXT' && x.content === 'action-center=1'));++ });++++ await check('a revoked key pair marks the connection broken, and removing it deletes the secret', async () => {++ pb.revoke();++ const synced = await t.get(`/panel/c/${conn.id}/sync`, { cookie, form: {} });++ assert.strictEqual(synced.status, 303);++ assert.match(locationOf(synced), /problem=provider\.forbidden/);++ let row = await db.maybe('SELECT status, last_error FROM domains_connections WHERE id = $1', [conn.id]);++ assert.strictEqual(row.status, 'broken');++ assert.match(row.last_error, /Invalid API key/);++ const removed = await t.get(`/panel/c/${conn.id}/remove`, { cookie, form: {} });++ assert.strictEqual(removed.status, 303);++ assert.strictEqual(network.state.secrets.size, 0);++ row = await db.maybe('SELECT status FROM domains_connections WHERE id = $1', [conn.id]);++ assert.strictEqual(row.status, 'removed');++ });++++ await t.close();++ await network.close();++ done();++})();
@@ -30,13 +30,18 @@ theme loader, navbar mounted into `#navbar-mount`, the shared server-rendered fo
@@ -30,13 +30,18 @@ theme loader, navbar mounted into `#navbar-mount`, the shared server-rendered fo
`/panel` is a signed-in person's own (sign-in with OpenVibe.Network, client `domains`).
`/panel` is a signed-in person's own (sign-in with OpenVibe.Network, client `domains`).
-- **Connect:** paste a Cloudflare API token (Edit zone DNS + Zone Read). Domains verifies it, then stores it as a
- secret in your own Network project (`OpenVibe.Domains`, ADR-056), granted to `domains` for calls to
- `https://api.cloudflare.com` only. It is revealed per call (`network.secret.use`) and never kept here.
-- **Zones and records:** zones are synced into `domains_zones`; records are read live.
+- **Connect:** paste a Cloudflare API token (Edit zone DNS + Zone Read) or a Porkbun API key pair (create keys at
+ porkbun.com/account/api, then switch on API access for each domain). Domains verifies it, then stores it as a
+ secret in your own Network project (`OpenVibe.Domains`, ADR-056), granted to `domains` for calls to that provider
+ only (`https://api.cloudflare.com` or `https://api.porkbun.com`). It is revealed per call (`network.secret.use`)
+ and never kept here. Porkbun's pair is one JSON secret, parsed only inside its client.
+- **Zones and records:** zones are synced into `domains_zones` (Porkbun's domains are its zones); records are read
+ live.
- **Proposals:** every change is a `domains.proposal@1`: create, update or delete records, why, what could break, and
- **Proposals:** every change is a `domains.proposal@1`: create, update or delete records, why, what could break, and
- 7 days to decide. Approve on `/panel/p/:id` or in the Action Center. Changes apply in order; a record that moved
- since the proposal stops the run (stale). An applied (or part-applied) proposal rolls back in one step.
+ 7 days to decide. A registrar change is a nameserver list update (`NS`) or a DNSSEC DS create/delete (`DS`), applied
+ through the registrar API. Approve on `/panel/p/:id` or in the Action Center. Changes apply in order; a record or
+ nameserver list that moved since the proposal stops the run (stale). An applied (or part-applied) proposal rolls
+ back in one step.
- **API:** `GET /api/v1/panel/connections`, `/api/v1/panel/zones`, `/api/v1/panel/zones/:conn/:zone/records`,
- **API:** `GET /api/v1/panel/connections`, `/api/v1/panel/zones`, `/api/v1/panel/zones/:conn/:zone/records`,
`GET|POST /api/v1/proposals`, `POST /api/v1/proposals/:id/decide|rollback` with your Network token; Network's Action
`GET|POST /api/v1/proposals`, `POST /api/v1/proposals/:id/decide|rollback` with your Network token; Network's Action
Center reads `GET /api/v1/actions?subject=` (`domains.actions.read`) and decides (`domains.proposal.decide`).
Center reads `GET /api/v1/actions?subject=` (`domains.actions.read`) and decides (`domains.proposal.decide`).
@@ -39,6 +39,7 @@ const { createSso } = require('./auth/sso');
@@ -39,6 +39,7 @@ const { createSso } = require('./auth/sso');
const { createPrincipal } = require('./http/principal');
const { createPrincipal } = require('./http/principal');
const { createVault } = require('./panel/vault');
const { createVault } = require('./panel/vault');
const { createCloudflare } = require('./panel/cloudflare');
const { createCloudflare } = require('./panel/cloudflare');
+const { createPorkbun } = require('./panel/porkbun');
const { createPanel } = require('./panel/panel');
const { createPanel } = require('./panel/panel');
const { createPanelRoutes } = require('./http/panel');
const { createPanelRoutes } = require('./http/panel');
const accountDataLib = require('./identity/account-data');
const accountDataLib = require('./identity/account-data');
@@ -63,7 +64,7 @@ async function createApp(opts = {}) {
@@ -63,7 +64,7 @@ async function createApp(opts = {}) {
const sso = createSso({ config, keys, fetchImpl, now: store.now, log });
const sso = createSso({ config, keys, fetchImpl, now: store.now, log });
const principal = createPrincipal({ config, keys });
const principal = createPrincipal({ config, keys });
const vault = opts.vault || createVault({ config, fetchImpl });
const vault = opts.vault || createVault({ config, fetchImpl });
- const providers = opts.providers || { cloudflare: createCloudflare({ fetchImpl }) };
+ const providers = opts.providers || { cloudflare: createCloudflare({ fetchImpl }), porkbun: createPorkbun({ fetchImpl }) };
const panel = createPanel({ store, vault, providers, log });
const panel = createPanel({ store, vault, providers, log });
const accountData = accountDataLib.create({ db: store.db, log });
const accountData = accountDataLib.create({ db: store.db, log });
const accountSend = opts.accountSend || (config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }) : null);
const accountSend = opts.accountSend || (config.oauth.clientSecret ? createNetworkSender({ networkInternalUrl: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }) : null);
@@ -6,10 +6,11 @@
@@ -6,10 +6,11 @@
* with a service token (domains.actions.read, domains.proposal.decide) for the person X-OV-Subject names.
* with a service token (domains.actions.read, domains.proposal.decide) for the person X-OV-Subject names.
*
*
* GET /panel connections, zones, waiting proposals, recent changes
* GET /panel connections, zones, waiting proposals, recent changes
- * GET /panel/connect, POST connect Cloudflare (verified, then stored in the person's Network project)
+ * GET /panel/connect, POST connect Cloudflare or Porkbun (verified, then stored in the person's Network project)
* POST /panel/c/:id/sync | /remove
* POST /panel/c/:id/sync | /remove
* GET /panel/z/:conn/:zone a zone's records, live; POST …/propose (create | update | delete)
* GET /panel/z/:conn/:zone a zone's records, live; POST …/propose (create | update | delete)
* GET /panel/p/:id a proposal; POST …/decide (approve | decline), POST …/rollback
* GET /panel/p/:id a proposal; POST …/decide (approve | decline), POST …/rollback
+ * (a proposal also carries NS and DS registrar changes from the API)
*
*
* GET /api/v1/panel/connections | /api/v1/panel/zones | /api/v1/panel/zones/:conn/:zone/records (person)
* GET /api/v1/panel/connections | /api/v1/panel/zones | /api/v1/panel/zones/:conn/:zone/records (person)
* GET /api/v1/proposals, /api/v1/proposals/:id, POST /api/v1/proposals (person)
* GET /api/v1/proposals, /api/v1/proposals/:id, POST /api/v1/proposals (person)
@@ -25,15 +26,15 @@ const { sameOrigin } = require('./principal');
@@ -25,15 +26,15 @@ const { sameOrigin } = require('./principal');
const NOTICES = {
const NOTICES = {
connected: 'Connected. Your zones are below.',
connected: 'Connected. Your zones are below.',
- synced: 'Synced with Cloudflare.',
- removed: 'Removed. Its token was deleted from your OpenVibe project.',
+ synced: 'Synced with the provider.',
+ removed: 'Removed. Its keys were deleted from your OpenVibe project.',
declined: 'Declined. Nothing changed.',
declined: 'Declined. Nothing changed.',
rolled_back: 'Rolled back.',
rolled_back: 'Rolled back.',
};
};
const PROBLEMS = {
const PROBLEMS = {
- 'vault.secret_missing': 'That connection\'s token is gone or no longer granted. Remove it and connect again.',
- 'provider.forbidden': 'Cloudflare refused the token. Check it in Cloudflare, then connect again.',
- 'provider.unreachable': 'Cloudflare did not answer. Try again in a moment.',
+ 'vault.secret_missing': 'That connection\'s keys are gone or no longer granted. Remove it and connect again.',
+ 'provider.forbidden': 'The provider refused the keys. Check them, then connect again.',
+ 'provider.unreachable': 'The provider did not answer. Try again in a moment.',
'vault.unavailable': 'OpenVibe.Network did not answer. Try again in a moment.',
'vault.unavailable': 'OpenVibe.Network did not answer. Try again in a moment.',
'vault.signed_out': 'Your sign-in ended. Sign in again.',
'vault.signed_out': 'Your sign-in ended. Sign in again.',
};
};
@@ -66,12 +67,17 @@ function createPanelRoutes({ config, panel, principal, log = console }) {
@@ -66,12 +67,17 @@ function createPanelRoutes({ config, panel, principal, log = console }) {
pages.get('/panel/connect', (req, res) => (person(req) ? privateHtml(res, 200, views.connect()) : toLogin(req, res)));
pages.get('/panel/connect', (req, res) => (person(req) ? privateHtml(res, 200, views.connect()) : toLogin(req, res)));
post('/panel/connect', async (req, res, p) => {
post('/panel/connect', async (req, res, p) => {
+ const provider = req.body.provider || 'cloudflare';
+ // Porkbun's two keys travel to Network as the one JSON secret its client parses; Cloudflare's token as itself.
+ const token = provider === 'porkbun'
+ ? JSON.stringify({ apikey: String(req.body.apikey || '').trim(), secretapikey: String(req.body.secretapikey || '').trim() })
+ : req.body.token;
try {
try {
- await panel.connect(p, { provider: req.body.provider || 'cloudflare', label: req.body.label, token: req.body.token });
+ await panel.connect(p, { provider, label: req.body.label, token });
return res.redirect(303, '/panel?done=connected');
return res.redirect(303, '/panel?done=connected');
} catch (err) {
} catch (err) {
if (!err.status || err.status >= 500) log.warn('[OpenVibe.Domains] connect failed:', err.code || err.message);
if (!err.status || err.status >= 500) log.warn('[OpenVibe.Domains] connect failed:', err.code || err.message);
- return privateHtml(res, err.status && err.status < 500 ? 422 : 503, views.connect({ error: err.status ? err.message : 'That did not work. Try again in a moment.', values: { label: req.body.label } }));
+ return privateHtml(res, err.status && err.status < 500 ? 422 : 503, views.connect({ error: err.status ? err.message : 'That did not work. Try again in a moment.', values: { label: req.body.label, provider } }));
}
}
});
});
post('/panel/c/:id/sync', async (req, res, p) => {
post('/panel/c/:id/sync', async (req, res, p) => {
@@ -115,7 +121,8 @@ function createPanelRoutes({ config, panel, principal, log = console }) {
@@ -115,7 +121,8 @@ function createPanelRoutes({ config, panel, principal, log = console }) {
const row = await panel.get(p.subject, req.params.id);
const row = await panel.get(p.subject, req.params.id);
const pub = { ...panel.toPublic(row), can_roll_back: Array.isArray(row.rollback) ? row.rollback.length > 0 : JSON.parse(row.rollback || '[]').length > 0 };
const pub = { ...panel.toPublic(row), can_roll_back: Array.isArray(row.rollback) ? row.rollback.length > 0 : JSON.parse(row.rollback || '[]').length > 0 };
const done = String(req.query.done || '');
const done = String(req.query.done || '');
- const words = done === 'approved' ? (pub.status === 'applied' ? 'Applied. Cloudflare has the change; caches catch up within the TTL.' : 'Approved, but it did not apply. See why below.') : notice(req);
+ const providerName = pub.provider === 'porkbun' ? 'Porkbun' : 'Cloudflare';
+ const words = done === 'approved' ? (pub.status === 'applied' ? `Applied. ${providerName} has the change; caches catch up within the TTL.` : 'Approved, but it did not apply. See why below.') : notice(req);
return privateHtml(res, 200, views.proposal({ p: pub, notice: words }));
return privateHtml(res, 200, views.proposal({ p: pub, notice: words }));
} catch (err) {
} catch (err) {
if (err.status === 404) return res.redirect(303, '/panel');
if (err.status === 404) return res.redirect(303, '/panel');
@@ -7,9 +7,11 @@
@@ -7,9 +7,11 @@
* connect / remove / sync a provider account: verified, its token stored in the person's Network project
* connect / remove / sync a provider account: verified, its token stored in the person's Network project
* (vault.js), its zones cached in domains_zones; a refused token marks it broken
* (vault.js), its zones cached in domains_zones; a refused token marks it broken
* records a zone's records, read live from the provider (the provider stays the truth)
* records a zone's records, read live from the provider (the provider stays the truth)
- * propose a checked diff (create, update, delete) with why, the risk in words, 7 days to decide
- * decide approve → applied in order; a record that moved since the proposal is refused as
- * stale; a failure part-way keeps the rollback of what was applied
+ * propose a checked diff (create, update, delete) with why, the risk in words, 7 days to decide;
+ * a registrar change is an NS list update or a DS create/delete (same proposal, same
+ * approval, same rollback)
+ * decide approve → applied in order; a record or nameserver set that moved since the proposal
+ * is refused as stale; a failure part-way keeps the rollback of what was applied
* rollBack the inverse of what was applied, newest first
* rollBack the inverse of what was applied, newest first
*
*
* Every step is in domains_audit. The provider token is revealed for one call and dropped; it is never stored, logged
* Every step is in domains_audit. The provider token is revealed for one call and dropped; it is never stored, logged
@@ -18,10 +20,11 @@
@@ -18,10 +20,11 @@
const net = require('net');
const net = require('net');
const { normalize } = require('./cloudflare');
const { normalize } = require('./cloudflare');
-const PROVIDERS = ['cloudflare'];
+const PROVIDERS = ['cloudflare', 'porkbun'];
const TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'NS', 'CAA', 'SRV'];
const TYPES = ['A', 'AAAA', 'CNAME', 'TXT', 'MX', 'NS', 'CAA', 'SRV'];
const PROXIABLE = new Set(['A', 'AAAA', 'CNAME']);
const PROXIABLE = new Set(['A', 'AAAA', 'CNAME']);
const MAX_CHANGES = 50;
const MAX_CHANGES = 50;
+const MAX_NAMESERVERS = 8;
const DECIDE_DAYS = 7;
const DECIDE_DAYS = 7;
const PUBLIC_ORIGIN = 'https://openvibe.domains';
const PUBLIC_ORIGIN = 'https://openvibe.domains';
const HOST_RE = /^(?=.{1,253}$)(\*\.)?([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.?$/i;
const HOST_RE = /^(?=.{1,253}$)(\*\.)?([a-z0-9_](?:[a-z0-9_-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.?$/i;
@@ -75,9 +78,86 @@ function sameRecord(a, b) {
@@ -75,9 +78,86 @@ function sameRecord(a, b) {
&& Number(a.ttl) === Number(b.ttl) && (a.proxied ?? null) === (b.proxied ?? null) && (a.priority ?? null) === (b.priority ?? null);
&& Number(a.ttl) === Number(b.ttl) && (a.proxied ?? null) === (b.proxied ?? null) && (a.priority ?? null) === (b.priority ?? null);
}
}
+/** Two nameserver lists say the same thing (Porkbun's registry may return a different order). */
+function sameNameservers(a, b) {
+ const x = Array.isArray(a) ? a.map(lowerName).filter(Boolean).sort() : [];
+ const y = Array.isArray(b) ? b.map(lowerName).filter(Boolean).sort() : [];
+ return x.length === y.length && x.every((n, i) => n === y[i]);
+}
+
+/** A registrar change: the domain's nameservers, whose before and after are the lists. */
+function isNameserverChange(c) {
+ return Boolean(c) && (String(c.type || '').toUpperCase() === 'NS' || Array.isArray(c.after) || Array.isArray(c.before) || Array.isArray(c.nameservers));
+}
+
+/** A DS change is a record-shaped DS, applied at the registrar rather than in the DNS zone. */
+function isDsChange(c) {
+ if (!c) return false;
+ if (String(c.type || '').toUpperCase() === 'DS') return true;
+ const r = c.after || c.before;
+ if (!r || Array.isArray(r)) return false;
+ return String(r.type || '').toUpperCase() === 'DS' || r.keyTag != null || r.key_tag != null || r.keytag != null;
+}
+
+function sameDs(a, b) {
+ if (sameRecord(a, b)) return true;
+ return Boolean(a && b && a.type === 'DS' && b.type === 'DS' && lowerName(a.name) === lowerName(b.name)
+ && String(a.content).toLowerCase() === String(b.content).toLowerCase());
+}
+
+/** The nameservers a person asked for, checked and de-duplicated. */
+function cleanNameservers(list, where) {
+ const wanted = Array.isArray(list) ? list : (list && Array.isArray(list.ns) ? list.ns : (list && Array.isArray(list.nameservers) ? list.nameservers : null));
+ if (!wanted || !wanted.length) fail(422, 'proposal.invalid', `${where}: the new name servers are missing.`);
+ const out = [];
+ for (const raw of wanted) {
+ const ns = lowerName(raw);
+ if (!ns || !HOST_RE.test(ns)) fail(422, 'proposal.invalid', `${where}: ${ns || 'that'} is not a name server host name.`);
+ if (!out.includes(ns)) out.push(ns);
+ }
+ if (out.length > MAX_NAMESERVERS) fail(422, 'proposal.invalid', `${where}: at most ${MAX_NAMESERVERS} name servers.`);
+ return out;
+}
+
+/** A DS record's four registry fields, as domains.proposal@1's record shape. */
+function cleanDs(rec, zone, where) {
+ if (!rec || typeof rec !== 'object') fail(422, 'proposal.invalid', `${where}: the DS record is missing.`);
+ const parts = typeof rec.content === 'string' ? rec.content.trim().split(/\s+/).filter(Boolean) : [];
+ const keyTag = String(rec.keyTag ?? rec.key_tag ?? rec.keytag ?? parts[0] ?? '').trim();
+ const alg = String(rec.alg ?? rec.algorithm ?? parts[1] ?? '').trim();
+ const digestType = String(rec.digestType ?? rec.digest_type ?? rec.digesttype ?? parts[2] ?? '').trim();
+ const digest = String(rec.digest ?? parts.slice(3).join('') ?? '').trim();
+ if (!/^\d{1,5}$/.test(keyTag) || Number(keyTag) > 65535) fail(422, 'proposal.invalid', `${where}: a DS key tag is 0 to 65 535.`);
+ if (!/^\d{1,3}$/.test(alg) || Number(alg) > 255) fail(422, 'proposal.invalid', `${where}: a DS algorithm is 0 to 255.`);
+ if (!/^\d{1,3}$/.test(digestType) || Number(digestType) > 255) fail(422, 'proposal.invalid', `${where}: a DS digest type is 0 to 255.`);
+ if (!/^[0-9a-f]{4,128}$/i.test(digest)) fail(422, 'proposal.invalid', `${where}: the DS digest is hex, 4 to 128 characters.`);
+ let name = lowerName(rec.name);
+ if (!name || name === '@') name = lowerName(zone);
+ else if (!inZone(name, zone)) name = `${name}.${lowerName(zone)}`;
+ if (!inZone(name, zone)) fail(422, 'proposal.invalid', `${where}: ${name} is not a name in ${zone}.`);
+ return { type: 'DS', name, content: `${keyTag} ${alg} ${digestType} ${digest}`, ttl: 1, proxied: null, priority: null, comment: null };
+}
+
+/** The key tag of a DS record, from its id or the first field of its content. */
+function dsKey(record) {
+ if (!record) return null;
+ if (record.id != null && record.id !== '') return String(record.id);
+ const first = String(record.content || '').trim().split(/\s+/)[0];
+ return first || null;
+}
+
/** One change in plain words: "Add TXT example.com", "Change A www.example.com: 192.0.2.1 → 192.0.2.7". */
/** One change in plain words: "Add TXT example.com", "Change A www.example.com: 192.0.2.1 → 192.0.2.7". */
function describe(c) {
function describe(c) {
+ if (isNameserverChange(c)) {
+ const list = (v) => (Array.isArray(v) ? v.join(', ') : '?');
+ return `Change name servers: ${list(c.before)} → ${list(c.after)}`;
+ }
const r = c.after || c.before || {};
const r = c.after || c.before || {};
+ if (isDsChange(c) || r.type === 'DS') {
+ if (c.op === 'delete') return `Remove DS ${r.name} (${r.content})`;
+ if (c.op === 'update') return `Change DS ${r.name}: ${c.before ? c.before.content : '?'} → ${c.after ? c.after.content : '?'}`;
+ return `Add DS ${r.name} (${r.content})`;
+ }
if (c.op === 'create') return `Add ${r.type} ${r.name} → ${r.content}`;
if (c.op === 'create') return `Add ${r.type} ${r.name} → ${r.content}`;
if (c.op === 'delete') return `Remove ${r.type} ${r.name} (${r.content})`;
if (c.op === 'delete') return `Remove ${r.type} ${r.name} (${r.content})`;
return `Change ${r.type} ${r.name}: ${c.before ? c.before.content : '?'} → ${c.after ? c.after.content : '?'}`;
return `Change ${r.type} ${r.name}: ${c.before ? c.before.content : '?'} → ${c.after ? c.after.content : '?'}`;
@@ -88,7 +168,9 @@ function riskOf(changes) {
@@ -88,7 +168,9 @@ function riskOf(changes) {
const words = new Set();
const words = new Set();
for (const c of changes) {
for (const c of changes) {
const r = c.after || c.before || {};
const r = c.after || c.before || {};
- if (r.type === 'TXT' || r.type === 'CAA') words.add(c.op === 'delete' && r.type === 'CAA' ? 'Removing a CAA record lets any certificate authority issue for the name.' : 'Low: a TXT record changes no traffic, though a service that checks it (mail, verification) reads the new value.');
+ if (isNameserverChange(c)) words.add('Name servers decide which DNS host answers for the domain: a wrong list makes the domain stop resolving.');
+ else if (isDsChange(c) || r.type === 'DS') words.add('A DS record tells validating resolvers which DNSKEY to trust: if it does not match the DNS host\'s DNSKEY, validating resolvers refuse the domain.');
+ else if (r.type === 'TXT' || r.type === 'CAA') words.add(c.op === 'delete' && r.type === 'CAA' ? 'Removing a CAA record lets any certificate authority issue for the name.' : 'Low: a TXT record changes no traffic, though a service that checks it (mail, verification) reads the new value.');
else if (r.type === 'MX') words.add('Mail for the domain goes where the MX records say; a mistake here bounces or loses mail.');
else if (r.type === 'MX') words.add('Mail for the domain goes where the MX records say; a mistake here bounces or loses mail.');
else if (r.type === 'NS') words.add('NS records delegate the name: a wrong one makes it unreachable.');
else if (r.type === 'NS') words.add('NS records delegate the name: a wrong one makes it unreachable.');
else words.add(c.op === 'delete' ? `${r.name} stops answering at ${r.content}: visitors and services using it lose it.` : `Visitors to ${r.name} reach the new address once caches expire (the record's TTL).`);
else words.add(c.op === 'delete' ? `${r.name} stops answering at ${r.content}: visitors and services using it lose it.` : `Visitors to ${r.name} reach the new address once caches expire (the record's TTL).`);
@@ -100,7 +182,7 @@ function toPublic(row) {
@@ -100,7 +182,7 @@ function toPublic(row) {
return {
return {
id: row.id, domain: row.domain, provider: row.provider || 'cloudflare', connection_id: row.connection_id,
id: row.id, domain: row.domain, provider: row.provider || 'cloudflare', connection_id: row.connection_id,
title: row.title, why: row.why || null, risk: row.risk || null, status: row.status,
title: row.title, why: row.why || null, risk: row.risk || null, status: row.status,
- changes: parse(row.changes).map((c) => ({ op: c.op, record_id: c.record_id || null, before: c.before || null, after: c.after || null })),
+ changes: parse(row.changes).map((c) => ({ ...(c.type ? { type: c.type } : {}), op: c.op, record_id: c.record_id || null, before: c.before || null, after: c.after || null })),
created_by: row.created_by, created_at: iso(row.created_at), decided_at: iso(row.decided_at), applied_at: iso(row.applied_at),
created_by: row.created_by, created_at: iso(row.created_at), decided_at: iso(row.decided_at), applied_at: iso(row.applied_at),
rolled_back_at: iso(row.rolled_back_at), expires_at: iso(row.expires_at), error: row.error || null,
rolled_back_at: iso(row.rolled_back_at), expires_at: iso(row.expires_at), error: row.error || null,
href: `${PUBLIC_ORIGIN}/panel/p/${row.id}`,
href: `${PUBLIC_ORIGIN}/panel/p/${row.id}`,
@@ -176,13 +258,18 @@ function createPanel({ store, vault, providers, log = console }) {
@@ -176,13 +258,18 @@ function createPanel({ store, vault, providers, log = console }) {
}
}
async function connect(person, { provider, label, token }) {
async function connect(person, { provider, label, token }) {
- if (!PROVIDERS.includes(provider)) fail(422, 'connection.provider', 'Cloudflare is the provider you can connect today.');
- const name = String(label || '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 60) || 'Cloudflare';
+ if (!PROVIDERS.includes(provider)) fail(422, 'connection.provider', 'Cloudflare and Porkbun are the providers you can connect today.');
+ const providerName = provider === 'porkbun' ? 'Porkbun' : 'Cloudflare';
+ const name = String(label || '').replace(/[\u0000-\u001f\u007f]/g, '').trim().slice(0, 60) || providerName;
const secret = String(token || '').trim();
const secret = String(token || '').trim();
- if (secret.length < 20 || secret.length > 400 || /\s/.test(secret)) fail(422, 'connection.token', 'Paste the API token exactly as Cloudflare showed it.');
+ if (provider === 'porkbun') {
+ if (secret.length < 20 || secret.length > 800 || /\s/.test(secret)) fail(422, 'connection.token', 'Paste the API key and secret key exactly as Porkbun showed them.');
+ } else if (secret.length < 20 || secret.length > 400 || /\s/.test(secret)) {
+ fail(422, 'connection.token', 'Paste the API token exactly as Cloudflare showed it.');
+ }
const p = providerOf(provider);
const p = providerOf(provider);
const verified = await p.verify(secret);
const verified = await p.verify(secret);
- if (!verified.ok) fail(422, 'connection.token', verified.reason || 'Cloudflare did not accept this token.');
+ if (!verified.ok) fail(422, 'connection.token', verified.reason || `${providerName} did not accept these keys.`);
const count = await db.maybe("SELECT count(*)::int AS n FROM domains_connections WHERE subject = $1 AND status <> 'removed'", [person.subject]);
const count = await db.maybe("SELECT count(*)::int AS n FROM domains_connections WHERE subject = $1 AND status <> 'removed'", [person.subject]);
if (count && count.n >= 20) fail(409, 'connection.limit', 'You can keep 20 connections. Remove one first.');
if (count && count.n >= 20) fail(409, 'connection.limit', 'You can keep 20 connections. Remove one first.');
const id = store.newId('dcn');
const id = store.newId('dcn');
@@ -229,16 +316,43 @@ function createPanel({ store, vault, providers, log = console }) {
@@ -229,16 +316,43 @@ function createPanel({ store, vault, providers, log = console }) {
return list.sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type));
return list.sort((a, b) => a.name.localeCompare(b.name) || a.type.localeCompare(b.type));
}
}
- /** A checked proposal from the asker's changes; update and delete read the record's current value as `before`. */
+ /** A checked proposal from the asker's changes; update and delete read the current value as `before`. */
async function propose(subject, createdBy, { connection_id: connectionId, zone_id: zoneId, title, why, risk, changes }) {
async function propose(subject, createdBy, { connection_id: connectionId, zone_id: zoneId, title, why, risk, changes }) {
const { conn, zone: z } = await zone(subject, connectionId, zoneId);
const { conn, zone: z } = await zone(subject, connectionId, zoneId);
if (!Array.isArray(changes) || !changes.length) fail(422, 'proposal.invalid', 'A proposal holds at least one change.');
if (!Array.isArray(changes) || !changes.length) fail(422, 'proposal.invalid', 'A proposal holds at least one change.');
if (changes.length > MAX_CHANGES) fail(422, 'proposal.invalid', `A proposal holds at most ${MAX_CHANGES} changes.`);
if (changes.length > MAX_CHANGES) fail(422, 'proposal.invalid', `A proposal holds at most ${MAX_CHANGES} changes.`);
- const current = await withToken(conn, (p, token) => p.records(token, zoneId));
- const byId = new Map(current.map((r) => [r.id, r]));
+ const kinds = changes.map((c) => (isNameserverChange(c) ? 'ns' : isDsChange(c) ? 'ds' : 'record'));
+ const current = await withToken(conn, async (p, token) => {
+ const out = { records: [], ns: null, ds: [] };
+ if (kinds.includes('record')) out.records = await p.records(token, zoneId);
+ if (kinds.includes('ns')) {
+ if (typeof p.nameservers !== 'function') throw new PanelError(422, 'proposal.unsupported', `${conn.provider} cannot change name servers here.`);
+ out.ns = await p.nameservers(token, z.name);
+ }
+ if (kinds.includes('ds')) {
+ if (typeof p.dsRecords !== 'function') throw new PanelError(422, 'proposal.unsupported', `${conn.provider} cannot change DS records here.`);
+ out.ds = await p.dsRecords(token, z.name);
+ }
+ return out;
+ });
+ const byId = new Map(current.records.map((r) => [r.id, r]));
const clean = changes.map((c, i) => {
const clean = changes.map((c, i) => {
const where = `Change ${i + 1}`;
const where = `Change ${i + 1}`;
const op = String(c && c.op || '');
const op = String(c && c.op || '');
+ if (isNameserverChange(c)) {
+ if (op !== 'update') fail(422, 'proposal.invalid', `${where}: name servers are changed, not created or removed.`);
+ return { type: 'NS', op: 'update', record_id: null, before: current.ns || [], after: cleanNameservers(c.after == null ? c.nameservers : c.after, where) };
+ }
+ if (isDsChange(c)) {
+ if (op === 'create') return { op, record_id: null, before: null, after: cleanDs(c.after, z.name, where) };
+ if (op !== 'delete') fail(422, 'proposal.invalid', `${where}: a DS record is created or removed, not changed.`);
+ const rawKey = c.record_id == null ? (c.keyTag == null ? (c.key_tag == null ? c.keytag : c.key_tag) : c.keyTag) : c.record_id;
+ const wanted = rawKey == null || rawKey === '' ? null : String(rawKey);
+ const before = (current.ds || []).find((r) => (wanted && String(r.id) === wanted)
+ || (c.before && sameDs(r, c.before)) || (c.after && sameDs(r, c.after)));
+ if (!before) fail(409, 'proposal.stale', `${where}: that DS record is not there any more. Reload the domain.`);
+ return { op, record_id: before.id, before, after: null };
+ }
if (op === 'create') return { op, record_id: null, before: null, after: cleanRecord(c.after, z.name, where) };
if (op === 'create') return { op, record_id: null, before: null, after: cleanRecord(c.after, z.name, where) };
if (op !== 'update' && op !== 'delete') fail(422, 'proposal.invalid', `${where}: create, update or delete.`);
if (op !== 'update' && op !== 'delete') fail(422, 'proposal.invalid', `${where}: create, update or delete.`);
const before = byId.get(String(c.record_id || ''));
const before = byId.get(String(c.record_id || ''));
@@ -246,7 +360,7 @@ function createPanel({ store, vault, providers, log = console }) {
@@ -246,7 +360,7 @@ function createPanel({ store, vault, providers, log = console }) {
const { id: _id, ...plain } = before;
const { id: _id, ...plain } = before;
return { op, record_id: before.id, before: plain, after: op === 'update' ? cleanRecord({ ...plain, ...c.after }, z.name, where) : null };
return { op, record_id: before.id, before: plain, after: op === 'update' ? cleanRecord({ ...plain, ...c.after }, z.name, where) : null };
});
});
- const noop = clean.find((c) => c.op === 'update' && sameRecord(c.before, c.after));
+ const noop = clean.find((c) => (c.type === 'NS' ? sameNameservers(c.before, c.after) : c.op === 'update' && sameRecord(c.before, c.after)));
if (noop) fail(422, 'proposal.invalid', `${describe(noop)} changes nothing.`);
if (noop) fail(422, 'proposal.invalid', `${describe(noop)} changes nothing.`);
const id = store.newId('dpr');
const id = store.newId('dpr');
const name = String(title || '').trim().slice(0, 160) || (clean.length === 1 ? describe(clean[0]).slice(0, 160) : `${clean.length} DNS changes to ${z.name}`);
const name = String(title || '').trim().slice(0, 160) || (clean.length === 1 ? describe(clean[0]).slice(0, 160) : `${clean.length} DNS changes to ${z.name}`);
@@ -284,7 +398,26 @@ function createPanel({ store, vault, providers, log = console }) {
@@ -284,7 +398,26 @@ function createPanel({ store, vault, providers, log = console }) {
let done = 0;
let done = 0;
try {
try {
for (const c of ops) {
for (const c of ops) {
- if (c.op === 'create') {
+ if (isNameserverChange(c)) {
+ if (typeof p.nameservers !== 'function' || typeof p.setNameservers !== 'function') throw new PanelError(422, 'proposal.unsupported', `${conn.provider} cannot change name servers here.`);
+ const now = await p.nameservers(token, zoneId);
+ if (!sameNameservers(now, c.before)) throw new PanelError(409, 'proposal.stale', `${describe(c)}: the name servers changed since this was proposed, so nothing more was applied.`);
+ await p.setNameservers(token, zoneId, c.after);
+ rollback.unshift({ type: 'NS', op: 'update', record_id: null, before: c.after, after: c.before });
+ } else if (isDsChange(c)) {
+ if (typeof p.dsRecords !== 'function' || typeof p.createDs !== 'function' || typeof p.deleteDs !== 'function') throw new PanelError(422, 'proposal.unsupported', `${conn.provider} cannot change DS records here.`);
+ if (c.op === 'create') {
+ const made = await p.createDs(token, zoneId, c.after);
+ const record = made && made.type === 'DS' ? made : c.after;
+ rollback.unshift({ op: 'delete', record_id: dsKey(record), before: record, after: null });
+ } else {
+ const list = await p.dsRecords(token, zoneId);
+ const now = list.find((r) => (c.record_id != null && String(r.id) === String(c.record_id)) || sameDs(r, c.before));
+ if (!now || !sameDs(now, c.before)) throw new PanelError(409, 'proposal.stale', `${describe(c)}: the DS record changed since this was proposed, so nothing more was applied.`);
+ await p.deleteDs(token, zoneId, now.id || c.record_id);
+ rollback.unshift({ op: 'create', record_id: null, before: null, after: now });
+ }
+ } else if (c.op === 'create') {
const made = await p.create(token, zoneId, c.after);
const made = await p.create(token, zoneId, c.after);
const { id: madeId, ...plain } = made;
const { id: madeId, ...plain } = made;
rollback.unshift({ op: 'delete', record_id: madeId, before: plain, after: null });
rollback.unshift({ op: 'delete', record_id: madeId, before: plain, after: null });
@@ -358,4 +491,4 @@ function createPanel({ store, vault, providers, log = console }) {
@@ -358,4 +491,4 @@ function createPanel({ store, vault, providers, log = console }) {
return { connect, remove, sync, connections, zones, zone, records, propose, get, list, decide, rollBack, recover, toPublic, toActionItem, describe };
return { connect, remove, sync, connections, zones, zone, records, propose, get, list, decide, rollBack, recover, toPublic, toActionItem, describe };
}
}
-module.exports = { createPanel, PanelError, cleanRecord, sameRecord, describe, riskOf, toPublic, toActionItem, inZone, PROVIDERS, TYPES, normalize };
+module.exports = { createPanel, PanelError, cleanRecord, cleanNameservers, cleanDs, sameRecord, sameNameservers, sameDs, describe, riskOf, toPublic, toActionItem, inZone, PROVIDERS, TYPES, normalize };
new file mode 100644
new file mode 100644
@@ -0,0 +1,265 @@
@@ -0,0 +1,265 @@
+'use strict';
+
+/**
+ * Porkbun's official API (v3) for the control panel: one person's API key pair, passed to each call and never kept,
+ * logged or returned. Only https://api.porkbun.com is ever called (ORIGIN), the same origin the key pair's Network
+ * grant names, so a revealed key pair cannot be sent anywhere else.
+ *
+ * Porkbun takes every call as a POST whose JSON body carries apikey and secretapikey. The Network secret is that pair
+ * as one JSON string; it is parsed here and nowhere else.
+ *
+ * const pb = createPorkbun({ fetchImpl });
+ * await pb.verify(token) → { ok: true } | { ok: false, reason }
+ * await pb.zones(token) → [{ id, name, status, name_servers, plan }] (id is the domain)
+ * await pb.records(token, domain) → [record]
+ * await pb.record(token, domain, id) → record | null
+ * await pb.create(token, domain, rec) / update(token, domain, id, rec) / remove(token, domain, id)
+ * await pb.nameservers(token, domain) / setNameservers(token, domain, list)
+ * await pb.dsRecords(token, domain) / createDs(token, domain, rec) / deleteDs(token, domain, id)
+ *
+ * A record is domains.proposal@1's shape: { id, type, name, content, ttl, proxied, priority, comment }.
+ * Porkbun's create and edit calls take a name relative to the domain ('' for the apex); its reads are fully qualified.
+ * Both are accepted and normalized to the full name, and a full name is written back as relative.
+ *
+ * A DS record is the same shape with type DS and content "keyTag alg digestType digest"; its id is the key tag, which
+ * is what Porkbun's registry API deletes by.
+ */
+const ORIGIN = 'https://api.porkbun.com';
+const BASE = `${ORIGIN}/api/json/v3`;
+const PRIORITY = new Set(['MX', 'SRV']);
+const MAX_DOMAIN_PAGES = 20; // 20 000 domains
+const PAGE_SIZE = 1000; // Porkbun's listAll page size
+
+class ProviderError extends Error {
+ constructor(status, message, code = 'provider.error') {
+ super(message);
+ this.status = status;
+ this.code = code;
+ }
+}
+
+const lowerName = (n) => String(n || '').trim().toLowerCase().replace(/\.$/, '');
+
+/** The pair in the Network secret, or a ProviderError that never contains it. */
+function keysOf(token) {
+ if (typeof token !== 'string' || !token.trim()) throw new ProviderError(401, 'The connection has no Porkbun keys.', 'provider.no_token');
+ let data;
+ try {
+ data = JSON.parse(token);
+ } catch {
+ throw new ProviderError(401, 'The connection is not a Porkbun key pair. Connect it again.', 'provider.no_token');
+ }
+ const apikey = data && typeof data.apikey === 'string' ? data.apikey.trim() : '';
+ const secretapikey = data && typeof data.secretapikey === 'string' ? data.secretapikey.trim() : '';
+ if (!apikey || !secretapikey) throw new ProviderError(401, 'The connection is missing one of its Porkbun keys. Connect it again.', 'provider.no_token');
+ return { apikey, secretapikey };
+}
+
+function redact(words, keys) {
+ let out = String(words);
+ for (const key of [keys.apikey, keys.secretapikey]) {
+ if (key) out = out.split(key).join('[redacted]');
+ }
+ return out;
+}
+
+/** The provider's record as domains.proposal@1 keeps it, with a relative name made fully qualified. */
+function normalize(r, domain = '') {
+ if (!r || typeof r !== 'object') return null;
+ const zone = lowerName(domain);
+ const type = String(r.type || '').toUpperCase();
+ let name = lowerName(r.name);
+ if (!name || name === '@') name = zone;
+ else if (zone && name !== zone && !name.endsWith(`.${zone}`)) name = `${name}.${zone}`;
+ const ttl = Number(r.ttl);
+ const rawPrio = r.prio == null ? r.priority : r.prio;
+ const prio = rawPrio == null || rawPrio === '' ? NaN : Number(rawPrio);
+ const notes = typeof r.notes === 'string' ? r.notes : r.comment;
+ return {
+ id: r.id == null || r.id === '' ? null : String(r.id),
+ type,
+ name,
+ content: String(r.content == null ? '' : r.content),
+ ttl: Number.isInteger(ttl) && ttl > 0 ? ttl : 1,
+ proxied: null,
+ priority: PRIORITY.has(type) && Number.isInteger(prio) ? prio : null,
+ comment: typeof notes === 'string' && notes ? notes.slice(0, 100) : null,
+ };
+}
+
+/** The body Porkbun takes for a create or edit, with a full name written as a subdomain ('' for the apex). */
+function bodyOf(rec, domain = '') {
+ const zone = lowerName(domain);
+ const type = String(rec.type || '').toUpperCase();
+ let name = lowerName(rec.name);
+ if (!name || name === '@' || (zone && name === zone)) name = '';
+ else if (zone && name.endsWith(`.${zone}`)) name = name.slice(0, -(zone.length + 1));
+ else if (name.endsWith('.')) name = name.slice(0, -1);
+ const ttl = Number(rec.ttl);
+ const out = { type, name, content: String(rec.content == null ? '' : rec.content), ttl: Number.isInteger(ttl) && ttl > 1 ? ttl : 0 };
+ const prio = Number(rec.priority);
+ if (PRIORITY.has(type) && Number.isInteger(prio)) out.prio = prio;
+ if (typeof rec.comment === 'string' && rec.comment) out.notes = rec.comment.slice(0, 100);
+ return out;
+}
+
+/** A DS record's four registry fields, from a record (content) or the registry's own object. */
+function dsOf(rec) {
+ if (!rec || typeof rec !== 'object') throw new ProviderError(422, 'A DS record needs keyTag, alg, digestType and digest.', 'provider.invalid');
+ const parts = typeof rec.content === 'string' ? rec.content.trim().split(/\s+/).filter(Boolean) : [];
+ const keyTag = String(rec.keyTag == null ? (rec.key_tag == null ? (rec.keytag == null ? (parts[0] || '') : rec.keytag) : rec.key_tag) : rec.keyTag).trim();
+ const alg = String(rec.alg == null ? (rec.algorithm == null ? (parts[1] || '') : rec.algorithm) : rec.alg).trim();
+ const digestType = String(rec.digestType == null ? (rec.digest_type == null ? (rec.digesttype == null ? (parts[2] || '') : rec.digesttype) : rec.digest_type) : rec.digestType).trim();
+ const digest = String(rec.digest == null ? parts.slice(3).join('') : rec.digest).trim();
+ if (!keyTag || !alg || !digestType || !digest) throw new ProviderError(422, 'A DS record needs keyTag, alg, digestType and digest.', 'provider.invalid');
+ return { keyTag, alg, digestType, digest };
+}
+
+/** A DS record in the same shape as every other record. */
+function normalizeDs(record, key, domain) {
+ const keyTag = String((record && record.keyTag) == null ? key : record.keyTag).trim();
+ const alg = String((record && record.alg) == null ? '' : record.alg).trim();
+ const digestType = String((record && record.digestType) == null ? '' : record.digestType).trim();
+ const digest = String((record && record.digest) == null ? '' : record.digest).trim();
+ return {
+ id: keyTag,
+ type: 'DS',
+ name: lowerName(domain),
+ content: `${keyTag} ${alg} ${digestType} ${digest}`.trim(),
+ ttl: 1,
+ proxied: null,
+ priority: null,
+ comment: null,
+ };
+}
+
+function createPorkbun({ fetchImpl = globalThis.fetch, timeoutMs = 10_000 } = {}) {
+ async function call(token, path, body = {}) {
+ const keys = keysOf(token);
+ let res;
+ try {
+ res = await fetchImpl(`${BASE}${path}`, {
+ method: 'POST',
+ headers: { accept: 'application/json', 'content-type': 'application/json' },
+ body: JSON.stringify({ ...body, apikey: keys.apikey, secretapikey: keys.secretapikey }),
+ redirect: 'error',
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ } catch (err) {
+ const timedOut = err && (err.name === 'TimeoutError' || err.name === 'AbortError');
+ throw new ProviderError(502, timedOut ? 'Porkbun did not answer in time.' : 'Porkbun could not be reached.', 'provider.unreachable');
+ }
+ const data = await res.json().catch(() => null);
+ const status = data && typeof data.status === 'string' ? data.status.toUpperCase() : '';
+ if (res.ok && data && status !== 'ERROR') return data;
+ // Porkbun's own words for what went wrong, with any key it might echo removed.
+ const words = data && data.message ? redact(data.message, keys).slice(0, 300) : `Porkbun answered ${res.status}.`;
+ const code = data && data.code ? String(data.code) : '';
+ if (res.status === 401 || res.status === 403 || /INVALID_API_KEY|INVALID_CREDENTIALS|AUTHENTICATION|API_KEY_MISSING/.test(code)
+ || /invalid (api )?key|invalid credentials|authentication/i.test(words)) {
+ throw new ProviderError(res.status === 403 ? 403 : 401, `Porkbun refused the keys: ${words}`, 'provider.forbidden');
+ }
+ if (res.status === 404 || /NOT_FOUND|DOMAIN_NOT_FOUND|RECORD_NOT_FOUND/.test(code)) throw new ProviderError(404, words, 'provider.not_found');
+ throw new ProviderError(res.status >= 500 ? 502 : 422, words, 'provider.refused');
+ }
+
+ async function record(token, domain, id) {
+ try {
+ const data = await call(token, `/dns/retrieve/${encodeURIComponent(domain)}/${encodeURIComponent(id)}`);
+ const list = Array.isArray(data.records) ? data.records : [];
+ const one = list.find((r) => String(r.id) === String(id)) || (list.length === 1 && list[0].id == null ? list[0] : null);
+ if (one) return normalize(one, domain);
+ } catch (err) {
+ if (err.code !== 'provider.not_found' && !/not found|does not exist/i.test(err.message)) throw err;
+ }
+ // Some accounts and stand-ins answer retrieve-by-id with the whole zone: find it there.
+ try {
+ const data = await call(token, `/dns/retrieve/${encodeURIComponent(domain)}`);
+ const found = (Array.isArray(data.records) ? data.records : []).find((r) => String(r.id) === String(id));
+ return found ? normalize(found, domain) : null;
+ } catch (err) {
+ if (err.code === 'provider.not_found' || /not found|does not exist/i.test(err.message)) return null;
+ throw err;
+ }
+ }
+
+ return {
+ ORIGIN,
+ /** /ping with the pair: Porkbun says whether the keys are valid and for which account. */
+ async verify(token) {
+ try {
+ const data = await call(token, '/ping');
+ const valid = data.credentialsValid == null ? true : data.credentialsValid === true;
+ return valid ? { ok: true } : { ok: false, reason: 'Porkbun did not confirm these keys.' };
+ } catch (err) {
+ return { ok: false, reason: err.message };
+ }
+ },
+ /** Every domain in the account, paged. Porkbun has no separate zone id, so the domain is the id. */
+ async zones(token) {
+ const out = [];
+ for (let page = 0, start = 0; page < MAX_DOMAIN_PAGES; page++) {
+ const data = await call(token, '/domain/listAll', { start });
+ const list = Array.isArray(data.domains) ? data.domains : [];
+ out.push(...list);
+ if (list.length < PAGE_SIZE) break;
+ start += list.length;
+ }
+ return out.map((d) => ({
+ id: lowerName(d.domain), name: lowerName(d.domain), status: d.status ? String(d.status) : null,
+ name_servers: [], plan: null,
+ })).filter((z) => z.id);
+ },
+ async records(token, domain) {
+ const data = await call(token, `/dns/retrieve/${encodeURIComponent(domain)}`);
+ return (Array.isArray(data.records) ? data.records : []).map((r) => normalize(r, domain));
+ },
+ record,
+ async create(token, domain, rec) {
+ const body = bodyOf(rec, domain);
+ const data = await call(token, `/dns/create/${encodeURIComponent(domain)}`, body);
+ const returned = data.record || (Array.isArray(data.records) ? data.records[0] : null);
+ const id = data.id == null ? (returned && returned.id) : data.id;
+ if (id == null || id === '') throw new ProviderError(502, 'Porkbun did not return the new record id.', 'provider.refused');
+ return (await record(token, domain, id)) || normalize(returned || { ...body, id }, domain);
+ },
+ async update(token, domain, id, rec) {
+ const body = bodyOf(rec, domain);
+ await call(token, `/dns/edit/${encodeURIComponent(domain)}/${encodeURIComponent(id)}`, body);
+ return (await record(token, domain, id)) || normalize({ ...body, id }, domain);
+ },
+ async remove(token, domain, id) {
+ await call(token, `/dns/delete/${encodeURIComponent(domain)}/${encodeURIComponent(id)}`);
+ return true;
+ },
+ async nameservers(token, domain) {
+ const data = await call(token, `/domain/getNs/${encodeURIComponent(domain)}`);
+ return (Array.isArray(data.ns) ? data.ns : []).map(lowerName).filter(Boolean);
+ },
+ async setNameservers(token, domain, list) {
+ const wanted = Array.isArray(list) ? list : (list && Array.isArray(list.ns) ? list.ns : []);
+ const ns = wanted.map(lowerName).filter(Boolean);
+ if (!ns.length) throw new ProviderError(422, 'A domain needs at least one name server.', 'provider.invalid');
+ await call(token, `/domain/updateNs/${encodeURIComponent(domain)}`, { ns });
+ return true;
+ },
+ async dsRecords(token, domain) {
+ const data = await call(token, `/dns/getDnssecRecords/${encodeURIComponent(domain)}`);
+ const records = data.records;
+ if (Array.isArray(records)) return records.map((r) => normalizeDs(r, r && (r.keyTag || r.id), domain));
+ return Object.entries(records && typeof records === 'object' ? records : {}).map(([key, r]) => normalizeDs(r, key, domain));
+ },
+ async createDs(token, domain, rec) {
+ const ds = dsOf(rec);
+ await call(token, `/dns/createDnssecRecord/${encodeURIComponent(domain)}`, ds);
+ return normalizeDs(ds, ds.keyTag, domain);
+ },
+ async deleteDs(token, domain, id) {
+ const key = id && typeof id === 'object' ? (id.id == null ? dsOf(id).keyTag : id.id) : id;
+ await call(token, `/dns/deleteDnssecRecord/${encodeURIComponent(domain)}/${encodeURIComponent(key)}`);
+ return true;
+ },
+ };
+}
+
+module.exports = { createPorkbun, normalize, bodyOf, normalizeDs, dsOf, ProviderError, ORIGIN, PRIORITY };
@@ -37,20 +37,21 @@ function head(kicker, h1, lede = '') {
@@ -37,20 +37,21 @@ function head(kicker, h1, lede = '') {
function signedOut() {
function signedOut() {
const steps = [
const steps = [
- ['link', 'Connect', 'Paste a Cloudflare API token. It is kept as a secret in your own OpenVibe project and only ever sent to Cloudflare.'],
+ ['link', 'Connect', 'Paste a Cloudflare API token or Porkbun API keys. They are kept as a secret in your own OpenVibe project and only ever sent to that provider.'],
['spark', 'Propose', 'Every DNS change, yours or an agent\'s, becomes a proposal: the exact records, why, and what could break.'],
['spark', 'Propose', 'Every DNS change, yours or an agent\'s, becomes a proposal: the exact records, why, and what could break.'],
['check', 'Approve', 'Nothing changes until you approve it, here or in the Action Center. Applied changes roll back in one tap.'],
['check', 'Approve', 'Nothing changes until you approve it, here or in the Action Center. Applied changes roll back in one tap.'],
];
];
return page('Control panel', `${head('Control panel', 'Run every domain you own from one place', 'Connect your DNS once and see every zone together. Changes wait for your approval, and each one can be undone.')}
return page('Control panel', `${head('Control panel', 'Run every domain you own from one place', 'Connect your DNS once and see every zone together. Changes wait for your approval, and each one can be undone.')}
<ol class="dmp-steps">${steps.map(([icon, t, d], i) => `<li><span class="dmp-step-ic">${kit.icon(icon)}</span><b>${i + 1}. ${esc(t)}</b><span>${esc(d)}</span></li>`).join('')}</ol>
<ol class="dmp-steps">${steps.map(([icon, t, d], i) => `<li><span class="dmp-step-ic">${kit.icon(icon)}</span><b>${i + 1}. ${esc(t)}</b><span>${esc(d)}</span></li>`).join('')}</ol>
<p class="dmp-actions"><a class="dm-btn" href="/auth/login?next=%2Fpanel">Sign in with OpenVibe</a></p>
<p class="dmp-actions"><a class="dm-btn" href="/auth/login?next=%2Fpanel">Sign in with OpenVibe</a></p>
-<p class="dm-fine">Cloudflare today; Porkbun, Namecheap and Spaceship follow. OpenVibe never stores your token in its own database and never shows it again.</p>`);
+<p class="dm-fine">Cloudflare and Porkbun today; Namecheap and Spaceship follow. OpenVibe never stores your keys in its own database and never shows them again.</p>`);
}
}
function connectionRow(c) {
function connectionRow(c) {
const broken = c.status === 'broken';
const broken = c.status === 'broken';
+ const provider = c.provider === 'porkbun' ? 'Porkbun' : 'Cloudflare';
return `<li class="dmp-conn${broken ? ' is-broken' : ''}" id="dmp-conn-${esc(c.id)}">
return `<li class="dmp-conn${broken ? ' is-broken' : ''}" id="dmp-conn-${esc(c.id)}">
-<div class="dmp-conn-main"><b>${esc(c.label)}</b><span class="dmp-sub">Cloudflare · ${c.zone_count} zone${c.zone_count === 1 ? '' : 's'}${c.checked_at ? ` · checked ${when(c.checked_at)}` : ''}</span>
+<div class="dmp-conn-main"><b>${esc(c.label)}</b><span class="dmp-sub">${provider} · ${c.zone_count} zone${c.zone_count === 1 ? '' : 's'}${c.checked_at ? ` · checked ${when(c.checked_at)}` : ''}</span>
${broken ? `<span class="dmp-err">${kit.icon('alert')}${esc(c.last_error || 'The token stopped working.')}</span>` : ''}</div>
${broken ? `<span class="dmp-err">${kit.icon('alert')}${esc(c.last_error || 'The token stopped working.')}</span>` : ''}</div>
<div class="dmp-conn-act">
<div class="dmp-conn-act">
<form method="post" action="/panel/c/${esc(c.id)}/sync"><button class="dmp-btn" type="submit">Sync</button></form>
<form method="post" action="/panel/c/${esc(c.id)}/sync"><button class="dmp-btn" type="submit">Sync</button></form>
@@ -69,7 +70,7 @@ function proposalCard(p) {
@@ -69,7 +70,7 @@ function proposalCard(p) {
function home({ connections = [], zones = [], waiting = [], recent = [], notice = null } = {}) {
function home({ connections = [], zones = [], waiting = [], recent = [], notice = null } = {}) {
if (!connections.length) {
if (!connections.length) {
return page('Control panel', `${head('Control panel', 'Connect your first account', 'Domains reads your zones through the provider\'s own API. Every change is proposed to you first.')}
return page('Control panel', `${head('Control panel', 'Connect your first account', 'Domains reads your zones through the provider\'s own API. Every change is proposed to you first.')}
-<p class="dmp-actions"><a class="dm-btn" href="/panel/connect">${kit.icon('plus')}Connect Cloudflare</a></p>`, { notice });
+<p class="dmp-actions"><a class="dm-btn" href="/panel/connect">${kit.icon('plus')}Connect Cloudflare or Porkbun</a></p>`, { notice });
}
}
const facts = `<dl class="dm-facts"><div><dt>Zones</dt><dd><b>${zones.length}</b></dd></div><div><dt>Waiting for you</dt><dd><b>${waiting.length}</b></dd></div><div><dt>Connections</dt><dd><b>${connections.length}</b>${connections.some((c) => c.status === 'broken') ? '<small>one needs attention</small>' : ''}</dd></div></dl>`;
const facts = `<dl class="dm-facts"><div><dt>Zones</dt><dd><b>${zones.length}</b></dd></div><div><dt>Waiting for you</dt><dd><b>${waiting.length}</b></dd></div><div><dt>Connections</dt><dd><b>${connections.length}</b>${connections.some((c) => c.status === 'broken') ? '<small>one needs attention</small>' : ''}</dd></div></dl>`;
const zoneRows = zones.map((z) => `<a class="dm-tr dmp-zone" id="dmp-zone-${esc(z.zone_id)}" href="/panel/z/${esc(z.connection_id)}/${esc(z.zone_id)}">
const zoneRows = zones.map((z) => `<a class="dm-tr dmp-zone" id="dmp-zone-${esc(z.zone_id)}" href="/panel/z/${esc(z.connection_id)}/${esc(z.zone_id)}">
@@ -84,21 +85,34 @@ ${recent.length ? `<section aria-labelledby="dmp-recent-h"><h2 id="dmp-recent-h"
@@ -84,21 +85,34 @@ ${recent.length ? `<section aria-labelledby="dmp-recent-h"><h2 id="dmp-recent-h"
}
}
function connect({ error = null, values = {} } = {}) {
function connect({ error = null, values = {} } = {}) {
- return page('Connect Cloudflare', `${head('<a href="/panel">Control panel</a>', 'Connect Cloudflare')}
+ const provider = values.provider === 'porkbun' ? 'porkbun' : 'cloudflare';
+ const label = values.label || (provider === 'porkbun' ? 'Porkbun' : 'Cloudflare');
+ return page('Connect a provider', `${head('<a href="/panel">Control panel</a>', 'Connect a provider')}
<div class="dmp-split">
<div class="dmp-split">
<form class="dmp-form" method="post" action="/panel/connect" id="dmp-connect" autocomplete="off">
<form class="dmp-form" method="post" action="/panel/connect" id="dmp-connect" autocomplete="off">
${error ? `<p class="dm-warn" role="alert">${kit.icon('alert')}<span>${esc(error)}</span></p>` : ''}
${error ? `<p class="dm-warn" role="alert">${kit.icon('alert')}<span>${esc(error)}</span></p>` : ''}
-<input type="hidden" name="provider" value="cloudflare">
-<label class="dmp-field"><span>A name for it</span><input name="label" maxlength="60" value="${esc(values.label || 'Cloudflare')}" required></label>
-<label class="dmp-field"><span>API token</span><input name="token" type="password" minlength="20" maxlength="400" required spellcheck="false" autocomplete="off" placeholder="Paste the token Cloudflare showed you"></label>
+<label class="dmp-field"><span>Provider</span><select name="provider">
+<option value="cloudflare"${provider === 'cloudflare' ? ' selected' : ''}>Cloudflare</option>
+<option value="porkbun"${provider === 'porkbun' ? ' selected' : ''}>Porkbun</option>
+</select></label>
+<label class="dmp-field"><span>A name for it</span><input name="label" maxlength="60" value="${esc(label)}" required></label>
+<label class="dmp-field"><span>Cloudflare API token</span><input name="token" type="password" maxlength="400" spellcheck="false" autocomplete="off" placeholder="Paste the token Cloudflare showed you"></label>
+<label class="dmp-field"><span>Porkbun API key</span><input name="apikey" type="password" maxlength="400" spellcheck="false" autocomplete="off" placeholder="pk1_… from porkbun.com/account/api"></label>
+<label class="dmp-field"><span>Porkbun secret API key</span><input name="secretapikey" type="password" maxlength="400" spellcheck="false" autocomplete="off" placeholder="sk1_… from the same page"></label>
+<p class="dm-fine">Use the token for Cloudflare or the two keys for Porkbun.</p>
<p class="dmp-actions"><button class="dm-btn" type="submit">${kit.icon('lock')}Connect</button><a class="dmp-btn dmp-btn--quiet" href="/panel">Cancel</a></p>
<p class="dmp-actions"><button class="dm-btn" type="submit">${kit.icon('lock')}Connect</button><a class="dmp-btn dmp-btn--quiet" href="/panel">Cancel</a></p>
</form>
</form>
-<aside class="dmp-help" aria-labelledby="dmp-help-h"><h2 id="dmp-help-h">Make a token that can do only this</h2>
+<aside class="dmp-help" aria-labelledby="dmp-help-h"><h2 id="dmp-help-h">Make keys that can do only this</h2>
+<h3>Cloudflare</h3>
<ol><li>Open <a href="https://dash.cloudflare.com/profile/api-tokens" rel="noopener noreferrer" target="_blank">API tokens</a> in Cloudflare and choose <b>Create token</b>.</li>
<ol><li>Open <a href="https://dash.cloudflare.com/profile/api-tokens" rel="noopener noreferrer" target="_blank">API tokens</a> in Cloudflare and choose <b>Create token</b>.</li>
<li>Start from <b>Edit zone DNS</b>. Add <b>Zone · Zone · Read</b> so Domains can list your zones.</li>
<li>Start from <b>Edit zone DNS</b>. Add <b>Zone · Zone · Read</b> so Domains can list your zones.</li>
<li>Under <b>Zone resources</b>, pick the zones you want here (or all of them).</li>
<li>Under <b>Zone resources</b>, pick the zones you want here (or all of them).</li>
<li>Create it and paste it on the left. Cloudflare shows it once.</li></ol>
<li>Create it and paste it on the left. Cloudflare shows it once.</li></ol>
-<p class="dmp-where">${kit.icon('shield')}<span>The token is stored as a secret in your own OpenVibe project, granted to OpenVibe.Domains only for calls to <b>api.cloudflare.com</b>. Domains keeps its name, never its value. Remove the connection and the secret goes with it.</span></p>
+<h3>Porkbun</h3>
+<ol><li>Create API keys at <a href="https://porkbun.com/account/api" rel="noopener noreferrer" target="_blank">porkbun.com/account/api</a>.</li>
+<li>Then switch on <b>API access</b> for each domain you want here: Porkbun enables it per domain.</li>
+<li>Paste the API key and secret API key on the left.</li></ol>
+<p class="dmp-where">${kit.icon('shield')}<span>The keys are stored as a secret in your own OpenVibe project, granted to OpenVibe.Domains only for calls to the provider's API (<b>api.cloudflare.com</b> or <b>api.porkbun.com</b>). Domains keeps their name, never their value. Remove the connection and the secret goes with it.</span></p>
</aside></div>`);
</aside></div>`);
}
}
@@ -133,20 +147,27 @@ ${error ? `<p class="dm-warn" role="alert">${kit.icon('alert')}<span>${esc(error
@@ -133,20 +147,27 @@ ${error ? `<p class="dm-warn" role="alert">${kit.icon('alert')}<span>${esc(error
${recordFields(values)}<label class="dmp-field dmp-field--wide"><span>Why (optional)</span><input name="why" maxlength="300" value="${esc(values.why || '')}"></label>
${recordFields(values)}<label class="dmp-field dmp-field--wide"><span>Why (optional)</span><input name="why" maxlength="300" value="${esc(values.why || '')}"></label>
<p class="dmp-actions"><button class="dm-btn" type="submit">${kit.icon('plus')}Propose</button></p></form></section>
<p class="dmp-actions"><button class="dm-btn" type="submit">${kit.icon('plus')}Propose</button></p></form></section>
<section aria-labelledby="dmp-recs-h"><h2 id="dmp-recs-h">Records</h2>
<section aria-labelledby="dmp-recs-h"><h2 id="dmp-recs-h">Records</h2>
-${records.length ? `<ul class="dmp-recs">${rows}</ul>` : '<p class="dm-fine">This zone has no records Cloudflare showed us.</p>'}</section>`);
+${records.length ? `<ul class="dmp-recs">${rows}</ul>` : `<p class="dm-fine">This zone has no records ${conn.provider === 'porkbun' ? 'Porkbun' : 'Cloudflare'} showed us.</p>`}</section>`);
}
}
function changeCard(c) {
function changeCard(c) {
const b = c.before;
const b = c.before;
const a = c.after;
const a = c.after;
+ if (String(c.type || '').toUpperCase() === 'NS' || Array.isArray(b) || Array.isArray(a)) {
+ const list = (x, side) => `<div class="${side}"><span class="dmp-sub">${side === 'dmp-before' ? 'Now' : 'After'}</span>${Array.isArray(x) && x.length ? `<div class="dmp-ns">${x.map((n) => `<code>${esc(n)}</code>`).join('<br>')}</div>` : '<em>—</em>'}</div>`;
+ return `<li class="dmp-change dmp-change--${esc(c.op)}"><span class="dmp-op">${esc(OP_WORDS[c.op] || c.op)}</span>
+<div class="dmp-change-main"><p class="dmp-change-rec"><span class="dmp-type">NS</span><b>Name servers</b></p>
+<div class="dmp-diff">${list(b, 'dmp-before')}${list(a, 'dmp-after')}</div></div></li>`;
+ }
const r = a || b || {};
const r = a || b || {};
- const val = (x) => (x ? `<code>${esc(x.content)}</code><span class="dmp-sub">${x.ttl === 1 ? 'Auto TTL' : `TTL ${esc(x.ttl)}`}${x.priority != null ? ` · priority ${esc(x.priority)}` : ''}${x.proxied ? ' · proxied' : ''}</span>` : '');
+ const val = (x) => (x ? `<code>${esc(x.content)}</code>${x.type === 'DS' ? '' : `<span class="dmp-sub">${x.ttl === 1 ? 'Auto TTL' : `TTL ${esc(x.ttl)}`}${x.priority != null ? ` · priority ${esc(x.priority)}` : ''}${x.proxied ? ' · proxied' : ''}</span>`}` : '');
return `<li class="dmp-change dmp-change--${esc(c.op)}"><span class="dmp-op">${esc(OP_WORDS[c.op] || c.op)}</span>
return `<li class="dmp-change dmp-change--${esc(c.op)}"><span class="dmp-op">${esc(OP_WORDS[c.op] || c.op)}</span>
<div class="dmp-change-main"><p class="dmp-change-rec"><span class="dmp-type">${esc(r.type)}</span><b>${esc(r.name)}</b></p>
<div class="dmp-change-main"><p class="dmp-change-rec"><span class="dmp-type">${esc(r.type)}</span><b>${esc(r.name)}</b></p>
<div class="dmp-diff">${b ? `<div class="dmp-before"><span class="dmp-sub">Now</span>${val(b)}</div>` : ''}${a ? `<div class="dmp-after"><span class="dmp-sub">${b ? 'After' : 'New'}</span>${val(a)}</div>` : '<div class="dmp-after"><span class="dmp-sub">After</span><em>Removed</em></div>'}</div></div></li>`;
<div class="dmp-diff">${b ? `<div class="dmp-before"><span class="dmp-sub">Now</span>${val(b)}</div>` : ''}${a ? `<div class="dmp-after"><span class="dmp-sub">${b ? 'After' : 'New'}</span>${val(a)}</div>` : '<div class="dmp-after"><span class="dmp-sub">After</span><em>Removed</em></div>'}</div></div></li>`;
}
}
function proposal({ p, notice = null } = {}) {
function proposal({ p, notice = null } = {}) {
+ const providerName = p.provider === 'porkbun' ? 'Porkbun' : 'Cloudflare';
const rolledBackable = (p.status === 'applied' || p.status === 'failed') && p.can_roll_back;
const rolledBackable = (p.status === 'applied' || p.status === 'failed') && p.can_roll_back;
const actions = p.status === 'pending'
const actions = p.status === 'pending'
? `<form method="post" action="/panel/p/${esc(p.id)}/decide"><input type="hidden" name="decision" value="approve"><button class="dm-btn" type="submit">${kit.icon('check')}Approve and apply</button></form>
? `<form method="post" action="/panel/p/${esc(p.id)}/decide"><input type="hidden" name="decision" value="approve"><button class="dm-btn" type="submit">${kit.icon('check')}Approve and apply</button></form>
@@ -160,7 +181,7 @@ ${p.error ? `<p class="dm-warn" role="alert">${kit.icon('alert')}<span>${esc(p.e
@@ -160,7 +181,7 @@ ${p.error ? `<p class="dm-warn" role="alert">${kit.icon('alert')}<span>${esc(p.e
${p.why ? `<section aria-labelledby="dmp-why-h"><h2 id="dmp-why-h">Why</h2><p class="dmp-text">${esc(p.why)}</p></section>` : ''}
${p.why ? `<section aria-labelledby="dmp-why-h"><h2 id="dmp-why-h">Why</h2><p class="dmp-text">${esc(p.why)}</p></section>` : ''}
${p.risk ? `<section aria-labelledby="dmp-risk-h"><h2 id="dmp-risk-h">What could break</h2><p class="dmp-text">${esc(p.risk)}</p></section>` : ''}
${p.risk ? `<section aria-labelledby="dmp-risk-h"><h2 id="dmp-risk-h">What could break</h2><p class="dmp-text">${esc(p.risk)}</p></section>` : ''}
${actions ? `<div class="dmp-decide">${actions}</div>` : ''}
${actions ? `<div class="dmp-decide">${actions}</div>` : ''}
-<p class="dm-fine">${p.status === 'pending' ? 'Approving applies the changes through Cloudflare\'s API, one by one. If a record changed since this was proposed, nothing more is applied and you can roll back what was.' : 'Every step is kept in your panel\'s audit.'}</p>`, { notice });
+<p class="dm-fine">${p.status === 'pending' ? `Approving applies the changes through ${providerName}'s API, one by one. If a record or nameserver set changed since this was proposed, nothing more is applied and you can roll back what was.` : 'Every step is kept in your panel\'s audit.'}</p>`, { notice });
}
}
module.exports = { signedOut, home, connect, zone, proposal, describe };
module.exports = { signedOut, home, connect, zone, proposal, describe };
new file mode 100644
new file mode 100644
@@ -0,0 +1,195 @@
@@ -0,0 +1,195 @@
+'use strict';
+/**
+ * A stand-in for Porkbun's API v3 behind createPorkbun(): an in-memory account answering the same paths, so the real
+ * client (server/panel/porkbun.js) is what the tests drive. Only https://api.porkbun.com is answered; any other origin
+ * is recorded as a leak.
+ *
+ * const pb = fakePorkbun({ apikey: 'pk1_…', secretapikey: 'sk1_…', domains: [{ domain: 'example.com' }] });
+ * createPorkbun({ fetchImpl: pb.fetch })
+ * pb.records('example.com') / pb.setRecord(…) / pb.nameservers(…) / pb.setNameservers(…) / pb.dsRecords(…)
+ * pb.revoke() / pb.calls / pb.leaks
+ *
+ * The fake keeps records fully qualified, as Porkbun's reads return them, and expands the relative names its create and
+ * edit calls take.
+ */
+function fakePorkbun({ apikey, secretapikey, domains = [] } = {}) {
+ const st = {
+ apikey, secretapikey, revoked: false, domains: [], records: new Map(), ns: new Map(), ds: new Map(),
+ nextId: 1, calls: [], leaks: [],
+ };
+ for (const input of domains) {
+ const d = typeof input === 'string' ? { domain: input } : input;
+ const domain = String(d.domain).toLowerCase();
+ st.domains.push({ domain, status: d.status || 'ACTIVE', apiAccess: d.apiAccess !== false });
+ st.records.set(domain, []);
+ st.ns.set(domain, (d.nameservers || ['ns1.porkbun.com', 'ns2.porkbun.com']).map((n) => String(n).toLowerCase()));
+ st.ds.set(domain, new Map());
+ }
+
+ const json = (status, body) => new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } });
+ const ok = (body = {}) => json(200, { status: 'SUCCESS', ...body });
+ const err = (status, message, code) => json(status, { status: 'ERROR', message, ...(code ? { code } : {}) });
+ const page = (domain, name) => {
+ const zone = String(domain).toLowerCase();
+ const n = String(name || '').trim().toLowerCase().replace(/\.$/, '');
+ if (!n || n === zone || n === '@') return zone;
+ return n.endsWith(`.${zone}`) ? n : `${n}.${zone}`;
+ };
+ const domainOf = (name) => st.domains.find((d) => d.domain === String(name).toLowerCase());
+
+ async function fetchImpl(input, init = {}) {
+ const u = new URL(String(input));
+ if (u.origin !== 'https://api.porkbun.com') { st.leaks.push(String(input)); return err(400, 'wrong origin'); }
+ const method = init.method || 'GET';
+ let body = {};
+ try { body = init.body ? JSON.parse(init.body) : {}; } catch { return err(400, 'Invalid JSON.'); }
+ st.calls.push({ method, path: u.pathname, keys: body.apikey === st.apikey && body.secretapikey === st.secretapikey });
+ if (method !== 'POST') return err(405, 'Use POST.');
+ if (st.revoked || body.apikey !== st.apikey || body.secretapikey !== st.secretapikey) return err(400, 'Invalid API key', 'INVALID_API_KEY');
+ const p = u.pathname.replace('/api/json/v3', '');
+
+ if (p === '/ping') return ok({ credentialsValid: true, account: 'ada' });
+ if (p === '/domain/listAll') {
+ const start = Number.isInteger(body.start) && body.start > 0 ? body.start : 0;
+ const slice = st.domains.slice(start, start + 1000);
+ return ok({
+ count: slice.length,
+ domains: slice.map((d) => ({ domain: d.domain, status: d.status, tld: d.domain.split('.').pop(), apiAccess: d.apiAccess ? 1 : 0 })),
+ });
+ }
+
+ let m = p.match(/^\/domain\/getNs\/([^/]+)$/);
+ if (m) {
+ const d = domainOf(decodeURIComponent(m[1]));
+ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');
+ if (!d.apiAccess) return err(400, `API access is not enabled for ${d.domain}.`, 'API_ACCESS_DISABLED');
+ return ok({ ns: [...st.ns.get(d.domain)] });
+ }
+ m = p.match(/^\/domain\/updateNs\/([^/]+)$/);
+ if (m) {
+ const d = domainOf(decodeURIComponent(m[1]));
+ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');
+ if (!Array.isArray(body.ns) || !body.ns.length) return err(400, 'At least one name server is required.');
+ st.ns.set(d.domain, body.ns.map((n) => String(n).toLowerCase()));
+ return ok();
+ }
+
+ m = p.match(/^\/dns\/retrieve\/([^/]+)$/);
+ if (m) {
+ const d = domainOf(decodeURIComponent(m[1]));
+ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');
+ if (!d.apiAccess) return err(400, `API access is not enabled for ${d.domain}.`, 'API_ACCESS_DISABLED');
+ return ok({ records: st.records.get(d.domain).map((r) => ({ ...r })) });
+ }
+ m = p.match(/^\/dns\/retrieve\/([^/]+)\/([^/]+)$/);
+ if (m) {
+ const d = domainOf(decodeURIComponent(m[1]));
+ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');
+ const rec = st.records.get(d.domain).find((r) => r.id === decodeURIComponent(m[2]));
+ if (!rec) return err(404, 'Record not found', 'RECORD_NOT_FOUND');
+ return ok({ records: [{ ...rec }] });
+ }
+ m = p.match(/^\/dns\/create\/([^/]+)$/);
+ if (m) {
+ const d = domainOf(decodeURIComponent(m[1]));
+ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');
+ if (!body.type || body.content == null) return err(400, 'A record needs a type and content.');
+ const rec = {
+ id: String(st.nextId++), name: page(d.domain, body.name), type: String(body.type).toUpperCase(),
+ content: String(body.content), ttl: String(body.ttl == null ? 0 : body.ttl),
+ prio: body.prio == null ? null : String(body.prio), notes: body.notes == null ? null : String(body.notes),
+ };
+ st.records.get(d.domain).push(rec);
+ return ok({ id: rec.id });
+ }
+ m = p.match(/^\/dns\/edit\/([^/]+)\/([^/]+)$/);
+ if (m) {
+ const d = domainOf(decodeURIComponent(m[1]));
+ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');
+ const rec = st.records.get(d.domain).find((r) => r.id === decodeURIComponent(m[2]));
+ if (!rec) return err(404, 'Record not found', 'RECORD_NOT_FOUND');
+ if (body.name !== undefined) rec.name = page(d.domain, body.name);
+ if (body.type !== undefined) rec.type = String(body.type).toUpperCase();
+ if (body.content !== undefined) rec.content = String(body.content);
+ if (body.ttl !== undefined) rec.ttl = String(body.ttl);
+ if (body.prio !== undefined) rec.prio = body.prio == null ? null : String(body.prio);
+ if (body.notes !== undefined) rec.notes = body.notes == null ? null : String(body.notes);
+ return ok();
+ }
+ m = p.match(/^\/dns\/delete\/([^/]+)\/([^/]+)$/);
+ if (m) {
+ const d = domainOf(decodeURIComponent(m[1]));
+ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');
+ const list = st.records.get(d.domain);
+ const at = list.findIndex((r) => r.id === decodeURIComponent(m[2]));
+ if (at < 0) return err(404, 'Record not found', 'RECORD_NOT_FOUND');
+ list.splice(at, 1);
+ return ok();
+ }
+
+ m = p.match(/^\/dns\/getDnssecRecords\/([^/]+)$/);
+ if (m) {
+ const d = domainOf(decodeURIComponent(m[1]));
+ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');
+ return ok({ records: Object.fromEntries([...st.ds.get(d.domain)].map(([key, value]) => [key, { ...value }])) });
+ }
+ m = p.match(/^\/dns\/createDnssecRecord\/([^/]+)$/);
+ if (m) {
+ const d = domainOf(decodeURIComponent(m[1]));
+ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');
+ for (const field of ['keyTag', 'alg', 'digestType', 'digest']) {
+ if (body[field] == null || body[field] === '') return err(400, `A DNSSEC record needs ${field}.`);
+ }
+ const rec = {
+ keyTag: String(body.keyTag), alg: String(body.alg), digestType: String(body.digestType), digest: String(body.digest),
+ };
+ st.ds.get(d.domain).set(rec.keyTag, rec);
+ return ok();
+ }
+ m = p.match(/^\/dns\/deleteDnssecRecord\/([^/]+)\/([^/]+)$/);
+ if (m) {
+ const d = domainOf(decodeURIComponent(m[1]));
+ if (!d) return err(400, 'Invalid domain.', 'INVALID_DOMAIN');
+ st.ds.get(d.domain).delete(decodeURIComponent(m[2]));
+ return ok();
+ }
+ return err(404, 'not found');
+ }
+
+ return {
+ fetch: fetchImpl,
+ state: st,
+ records: (domain) => st.records.get(String(domain).toLowerCase()).map((r) => ({ ...r })),
+ addRecord: (domain, r) => {
+ const zone = String(domain).toLowerCase();
+ const rec = {
+ id: String(st.nextId++), ttl: '600', prio: null, notes: null,
+ ...r, name: page(zone, r.name), type: String(r.type).toUpperCase(), content: String(r.content),
+ };
+ st.records.get(zone).push(rec);
+ return { ...rec };
+ },
+ setRecord: (domain, id, patch) => {
+ const zone = String(domain).toLowerCase();
+ const list = st.records.get(zone);
+ const at = list.findIndex((r) => r.id === id);
+ if (at < 0) return null;
+ list[at] = { ...list[at], ...patch };
+ if (patch.name !== undefined) list[at].name = page(zone, patch.name);
+ return { ...list[at] };
+ },
+ nameservers: (domain) => [...st.ns.get(String(domain).toLowerCase())],
+ setNameservers: (domain, list) => { st.ns.set(String(domain).toLowerCase(), list.map((n) => String(n).toLowerCase())); },
+ dsRecords: (domain) => [...st.ds.get(String(domain).toLowerCase()).values()].map((r) => ({ ...r })),
+ addDs: (domain, r) => {
+ const rec = { keyTag: String(r.keyTag), alg: String(r.alg), digestType: String(r.digestType), digest: String(r.digest) };
+ st.ds.get(String(domain).toLowerCase()).set(rec.keyTag, rec);
+ return { ...rec };
+ },
+ revoke: () => { st.revoked = true; },
+ get calls() { return st.calls; },
+ get leaks() { return st.leaks; },
+ };
+}
+
+module.exports = { fakePorkbun };
new file mode 100644
new file mode 100644
@@ -0,0 +1,239 @@
@@ -0,0 +1,239 @@
+'use strict';
+/**
+ * Porkbun in the control panel (DOM-2) end to end, on a migrated test database with a Network stand-in and a
+ * stand-in Porkbun account behind the real client:
+ *
+ * connect stores the two keys as one JSON secret granted for reveal to api.porkbun.com only, with no key anywhere in
+ * this database; a DNS record is proposed, applied and rolled back; a nameserver list is changed through the registrar
+ * and rolled back; a DS record is created through the registrar and rolled back; a nameserver list that moved is
+ * refused as stale; the Action Center reads the proposal.
+ */
+const assert = require('assert');
+const contracts = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers');
+const { startNetwork } = require('./helpers/network');
+const { fakePorkbun } = require('./helpers/fake-porkbun');
+const { createPorkbun, normalize, bodyOf } = require('../server/panel/porkbun');
+
+const API_KEY = 'pk1_example-not-a-real-key-aaaaaaaa';
+const SECRET_KEY = 'sk1_example-not-a-real-key-bbbbbbbb';
+const STUB = { tlds: async () => ({ tlds: [] }), tld: async () => null, prices: async () => null, lists: async () => ({}), history: async () => null, check: async () => null };
+const sorted = (list) => [...list].sort();
+
+(async () => {
+ const network = await startNetwork();
+ const pb = fakePorkbun({ apikey: API_KEY, secretapikey: SECRET_KEY, domains: [{ domain: 'example.com' }] });
+ pb.addRecord('example.com', { type: 'A', name: 'www', content: '192.0.2.1', ttl: 600 });
+ const t = await boot({ host: STUB, tools: STUB, network, providers: { porkbun: createPorkbun({ fetchImpl: pb.fetch }) } });
+ const ada = network.addUser('ada');
+ const cookie = t.signIn(ada);
+ const db = t.store.db;
+ const locationOf = (r) => r.headers.get('location');
+ let conn = null;
+
+ await check('a relative record name is made fully qualified and a full name is written back relative', async () => {
+ const rec = normalize({ id: '1', name: 'www', type: 'A', content: '192.0.2.1', ttl: '600', prio: null }, 'example.com');
+ assert.strictEqual(rec.name, 'www.example.com');
+ assert.strictEqual(rec.ttl, 600);
+ assert.strictEqual(bodyOf({ type: 'A', name: 'www.example.com', content: '192.0.2.1', ttl: 600 }, 'example.com').name, 'www');
+ assert.strictEqual(bodyOf({ type: 'A', name: 'example.com', content: '192.0.2.1', ttl: 1 }, 'example.com').name, '');
+ });
+
+ await check('bad keys are refused in Porkbun\'s words, and nothing is stored', async () => {
+ const r = await t.get('/panel/connect', { cookie, form: { provider: 'porkbun', label: 'Main', apikey: 'pk1_wrong', secretapikey: 'sk1_wrong' } });
+ assert.strictEqual(r.status, 422, r.text);
+ assert.match(r.text, /Porkbun refused the keys: Invalid API key/);
+ assert.strictEqual(network.state.secrets.size, 0);
+ assert.strictEqual(await db.value('SELECT count(*)::int FROM domains_connections'), 0);
+ assert.deepStrictEqual(pb.leaks, []);
+ });
+
+ await check('connecting stores the pair as one JSON secret granted to api.porkbun.com only, and syncs the domains', async () => {
+ const r = await t.get('/panel/connect', { cookie, form: { provider: 'porkbun', label: 'Main account', apikey: API_KEY, secretapikey: SECRET_KEY } });
+ assert.strictEqual(r.status, 303, r.text);
+ assert.strictEqual(locationOf(r), '/panel?done=connected');
+ conn = await db.maybe('SELECT * FROM domains_connections WHERE subject = $1', [ada.subject]);
+ assert.ok(conn && conn.status === 'active' && conn.provider === 'porkbun');
+ const [key, secret] = [...network.state.secrets.entries()][0];
+ assert.strictEqual(key, `${conn.project_id}/production/${conn.secret_name}`);
+ assert.strictEqual(secret.value, JSON.stringify({ apikey: API_KEY, secretapikey: SECRET_KEY }));
+ assert.deepStrictEqual(secret.grants.domains, { purposes: ['reveal'], origins: ['https://api.porkbun.com'] });
+ // Neither key is anywhere in this service's database.
+ for (const table of ['domains_connections', 'domains_zones', 'domains_proposals', 'domains_audit']) {
+ const dump = JSON.stringify(await db.many(`SELECT * FROM ${table}`));
+ assert.ok(!dump.includes(API_KEY), `${table} holds the API key`);
+ assert.ok(!dump.includes(SECRET_KEY), `${table} holds the secret key`);
+ }
+ const zones = await db.many('SELECT * FROM domains_zones WHERE connection_id = $1', [conn.id]);
+ assert.deepStrictEqual(zones.map((z) => z.name), ['example.com']);
+ const page = await t.get('/panel?done=connected', { cookie });
+ assert.match(page.text, /id="dmp-zone-example\.com"/);
+ assert.match(page.text, /Porkbun · 1 zone/);
+ assert.ok(pb.calls.some((c) => c.path === '/api/json/v3/ping'), 'verify used /ping');
+ assert.deepStrictEqual(pb.leaks, []);
+ });
+
+ await check('a record proposal changes nothing until approved; approving applies and rolling back undoes it', async () => {
+ const r = await t.get('/api/v1/proposals', {
+ bearer: network.userToken(ada),
+ json: { connection_id: conn.id, zone_id: 'example.com', why: 'Prove the zone is ours', changes: [{ op: 'create', after: { type: 'TXT', name: '@', content: 'openvibe-proof=1' } }] },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const pub = r.json();
+ assert.ok(contracts.validate('domains.proposal', pub).valid);
+ assert.strictEqual(pb.records('example.com').some((x) => x.type === 'TXT'), false, 'nothing applied yet');
+ const decided = await t.get(`/api/v1/proposals/${pub.id}/decide`, { bearer: network.userToken(ada), json: { decision: 'approve' } });
+ assert.strictEqual(decided.status, 200, decided.text);
+ assert.strictEqual(decided.json().status, 'applied');
+ const added = pb.records('example.com').find((x) => x.type === 'TXT');
+ assert.ok(added && added.name === 'example.com' && added.content === 'openvibe-proof=1');
+ const row = await db.maybe('SELECT * FROM domains_proposals WHERE id = $1', [pub.id]);
+ assert.strictEqual(row.rollback[0].op, 'delete');
+ const back = await t.get(`/api/v1/proposals/${pub.id}/rollback`, { bearer: network.userToken(ada), json: {} });
+ assert.strictEqual(back.status, 200, back.text);
+ assert.strictEqual(back.json().status, 'rolled_back');
+ assert.strictEqual(pb.records('example.com').some((x) => x.type === 'TXT'), false, 'the record is gone again');
+ });
+
+ await check('a nameserver change is applied through the registrar and rolls back; its risk is in plain words', async () => {
+ const before = pb.nameservers('example.com');
+ const after = ['ns1.new.example', 'ns2.new.example'];
+ const r = await t.get('/api/v1/proposals', {
+ bearer: network.userToken(ada),
+ json: { connection_id: conn.id, zone_id: 'example.com', changes: [{ type: 'NS', op: 'update', after }] },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const pub = r.json();
+ assert.deepStrictEqual(pub.changes[0].before, before);
+ assert.deepStrictEqual(pub.changes[0].after, after);
+ assert.match(pub.title, /Change name servers/);
+ assert.match(pub.risk, /wrong list makes the domain stop resolving/);
+ assert.deepStrictEqual(pb.nameservers('example.com'), before, 'nothing applied yet');
+ const decided = await t.get(`/api/v1/proposals/${pub.id}/decide`, { bearer: network.userToken(ada), json: { decision: 'approve' } });
+ assert.strictEqual(decided.status, 200, decided.text);
+ assert.strictEqual(decided.json().status, 'applied');
+ assert.deepStrictEqual(sorted(pb.nameservers('example.com')), sorted(after));
+ const row = await db.maybe('SELECT * FROM domains_proposals WHERE id = $1', [pub.id]);
+ assert.strictEqual(row.rollback[0].type, 'NS');
+ const back = await t.get(`/api/v1/proposals/${pub.id}/rollback`, { bearer: network.userToken(ada), json: {} });
+ assert.strictEqual(back.status, 200, back.text);
+ assert.strictEqual(back.json().status, 'rolled_back');
+ assert.deepStrictEqual(sorted(pb.nameservers('example.com')), sorted(before));
+ });
+
+ await check('a DS record is created through the registrar and rolls back', async () => {
+ const digest = 'ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789';
+ const after = { type: 'DS', name: 'example.com', content: `12345 13 2 ${digest}` };
+ const r = await t.get('/api/v1/proposals', {
+ bearer: network.userToken(ada),
+ json: { connection_id: conn.id, zone_id: 'example.com', changes: [{ op: 'create', after }] },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const pub = r.json();
+ const valid = contracts.validate('domains.proposal', pub);
+ assert.ok(valid.valid, JSON.stringify(valid.errors));
+ assert.strictEqual(pb.dsRecords('example.com').length, 0, 'nothing applied yet');
+ assert.ok(pb.calls.some((c) => c.path === '/api/json/v3/dns/getDnssecRecords/example.com'));
+ assert.match(pub.risk, /does not match the DNS host's DNSKEY/);
+ const decided = await t.get(`/api/v1/proposals/${pub.id}/decide`, { bearer: network.userToken(ada), json: { decision: 'approve' } });
+ assert.strictEqual(decided.status, 200, decided.text);
+ assert.strictEqual(decided.json().status, 'applied');
+ const ds = pb.dsRecords('example.com');
+ assert.strictEqual(ds.length, 1);
+ assert.strictEqual(ds[0].keyTag, '12345');
+ assert.strictEqual(ds[0].alg, '13');
+ assert.strictEqual(ds[0].digestType, '2');
+ assert.ok(pb.calls.some((c) => c.path === '/api/json/v3/dns/createDnssecRecord/example.com'), 'created at the registrar, not in the zone');
+ const row = await db.maybe('SELECT * FROM domains_proposals WHERE id = $1', [pub.id]);
+ assert.strictEqual(row.rollback[0].op, 'delete');
+ assert.strictEqual(row.rollback[0].record_id, '12345');
+ const back = await t.get(`/api/v1/proposals/${pub.id}/rollback`, { bearer: network.userToken(ada), json: {} });
+ assert.strictEqual(back.status, 200, back.text);
+ assert.strictEqual(back.json().status, 'rolled_back');
+ assert.strictEqual(pb.dsRecords('example.com').length, 0);
+ });
+
+ await check('a DS record is removed through the registrar and rolls back', async () => {
+ pb.addDs('example.com', { keyTag: '777', alg: '13', digestType: '2', digest: 'ABCDEF0123456789' });
+ const r = await t.get('/api/v1/proposals', {
+ bearer: network.userToken(ada),
+ json: { connection_id: conn.id, zone_id: 'example.com', changes: [{ type: 'DS', op: 'delete', record_id: '777' }] },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const pub = r.json();
+ assert.strictEqual(pub.changes[0].before.content, '777 13 2 ABCDEF0123456789');
+ assert.strictEqual(pub.changes[0].after, null);
+ const decided = await t.get(`/api/v1/proposals/${pub.id}/decide`, { bearer: network.userToken(ada), json: { decision: 'approve' } });
+ assert.strictEqual(decided.status, 200, decided.text);
+ assert.strictEqual(decided.json().status, 'applied');
+ assert.strictEqual(pb.dsRecords('example.com').length, 0);
+ const row = await db.maybe('SELECT * FROM domains_proposals WHERE id = $1', [pub.id]);
+ assert.strictEqual(row.rollback[0].op, 'create');
+ const back = await t.get(`/api/v1/proposals/${pub.id}/rollback`, { bearer: network.userToken(ada), json: {} });
+ assert.strictEqual(back.status, 200, back.text);
+ assert.strictEqual(back.json().status, 'rolled_back');
+ assert.deepStrictEqual(pb.dsRecords('example.com').map((x) => x.keyTag), ['777']);
+ });
+
+ await check('a nameserver list that moved since the proposal is refused as stale', async () => {
+ const r = await t.get('/api/v1/proposals', {
+ bearer: network.userToken(ada),
+ json: { connection_id: conn.id, zone_id: 'example.com', changes: [{ type: 'NS', op: 'update', after: ['ns3.new.example', 'ns4.new.example'] }] },
+ });
+ assert.strictEqual(r.status, 201, r.text);
+ const id = r.json().id;
+ const moved = ['ns9.other.example', 'ns10.other.example'];
+ pb.setNameservers('example.com', moved);
+ const decided = await t.get(`/api/v1/proposals/${id}/decide`, { bearer: network.userToken(ada), json: { decision: 'approve' } });
+ assert.strictEqual(decided.status, 200, decided.text);
+ assert.strictEqual(decided.json().status, 'failed');
+ assert.match(decided.json().error, /name servers changed since this was proposed/);
+ assert.deepStrictEqual(sorted(pb.nameservers('example.com')), sorted(moved), 'left as someone set it');
+ const row = await db.maybe('SELECT rollback FROM domains_proposals WHERE id = $1', [id]);
+ assert.deepStrictEqual(row.rollback, []);
+ });
+
+ await check('the Action Center reads the item with domains.actions.read and declines with domains.proposal.decide', async () => {
+ const fresh = await t.get('/api/v1/proposals', {
+ bearer: network.userToken(ada),
+ json: { connection_id: conn.id, zone_id: 'example.com', title: 'Add a TXT proof', changes: [{ op: 'create', after: { type: 'TXT', name: '@', content: 'action-center=1' } }] },
+ });
+ assert.strictEqual(fresh.status, 201, fresh.text);
+ const id = fresh.json().id;
+ const noCap = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: network.serviceToken(['domains.proposal.decide']) });
+ assert.strictEqual(noCap.status, 403);
+ const list = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: network.serviceToken(['domains.actions.read']) });
+ assert.strictEqual(list.status, 200, list.text);
+ const item = list.json().items.find((x) => x.id === id);
+ assert.ok(item, 'the proposal is in the Action Center');
+ assert.ok(contracts.validate('common.action-item', item).valid);
+ assert.deepStrictEqual([item.bucket, item.actions], ['waiting', ['approve', 'decline', 'open']]);
+ assert.match(item.summary, /Add TXT example\.com/);
+ const decided = await t.get(`/api/v1/proposals/${id}/decide`, {
+ bearer: network.serviceToken(['domains.proposal.decide']), headers: { 'x-ov-subject': ada.subject }, json: { decision: 'decline' }, origin: '',
+ });
+ assert.strictEqual(decided.status, 200, decided.text);
+ assert.strictEqual(decided.json().state, 'declined');
+ assert.ok(contracts.validate('common.action-item', decided.json()).valid);
+ assert.ok(!pb.records('example.com').some((x) => x.type === 'TXT' && x.content === 'action-center=1'));
+ });
+
+ await check('a revoked key pair marks the connection broken, and removing it deletes the secret', async () => {
+ pb.revoke();
+ const synced = await t.get(`/panel/c/${conn.id}/sync`, { cookie, form: {} });
+ assert.strictEqual(synced.status, 303);
+ assert.match(locationOf(synced), /problem=provider\.forbidden/);
+ let row = await db.maybe('SELECT status, last_error FROM domains_connections WHERE id = $1', [conn.id]);
+ assert.strictEqual(row.status, 'broken');
+ assert.match(row.last_error, /Invalid API key/);
+ const removed = await t.get(`/panel/c/${conn.id}/remove`, { cookie, form: {} });
+ assert.strictEqual(removed.status, 303);
+ assert.strictEqual(network.state.secrets.size, 0);
+ row = await db.maybe('SELECT status FROM domains_connections WHERE id = $1', [conn.id]);
+ assert.strictEqual(row.status, 'removed');
+ });
+
+ await t.close();
+ await network.close();
+ done();
+})();