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)

OP OpenVibers committed 16 days ago · 9bb8b3c26c · 8 files, +911 −45

Changes

ddiff --git a/README.md b/README.md
iindex f3481b1..71ec8a3 100644
---- a/README.md
++++ b/README.md
@@@ -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 Action
Center reads `GET /api/v1/actions?subject=` (`domains.actions.read`) and decides (`domains.proposal.decide`).
ddiff --git a/server/app.js b/server/app.js
iindex 3396113..d34ebdf 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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);
ddiff --git a/server/http/panel.js b/server/http/panel.js
iindex 650c78b..97fb80f 100644
---- a/server/http/panel.js
++++ b/server/http/panel.js
@@@ -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');
ddiff --git a/server/panel/panel.js b/server/panel/panel.js
iindex 07970bb..3e8b489 100644
---- a/server/panel/panel.js
++++ b/server/panel/panel.js
@@@ -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 };
ddiff --git a/server/panel/porkbun.js b/server/panel/porkbun.js
nnew file mode 100644
iindex 0000000..ee21d03
---- /dev/null
++++ b/server/panel/porkbun.js
@@@ -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 };
ddiff --git a/server/views/panel.js b/server/views/panel.js
iindex d554429..9df0511 100644
---- a/server/views/panel.js
++++ b/server/views/panel.js
@@@ -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 };
ddiff --git a/test/helpers/fake-porkbun.js b/test/helpers/fake-porkbun.js
nnew file mode 100644
iindex 0000000..afb42ed
---- /dev/null
++++ b/test/helpers/fake-porkbun.js
@@@ -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 };
ddiff --git a/test/porkbun.test.js b/test/porkbun.test.js
nnew file mode 100644
iindex 0000000..d61f0e1
---- /dev/null
++++ b/test/porkbun.test.js
@@@ -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();
++})();