sc-dev-deploy/lib/peers.js
2026-06-01 16:43:43 -05:00

145 lines
4.5 KiB
JavaScript

// Peer model: CRUD on _dd_peers.
//
// Peer secrets are stored AES-256-GCM sealed; plaintext only crosses the
// process boundary at pairing time (when the operator copies the secret to
// the other instance's UI) and at HMAC sign/verify time.
const db = require("@saltcorn/data/db");
const {
seal,
open,
randomSecret
} = require("./crypto");
const rowToPeer = (row) => {
if (!row) return null;
return {
peer_id: row.peer_id,
env_id: row.env_id,
label: row.label,
base_url: row.base_url,
require_tls: !!row.require_tls,
created_at: row.created_at,
last_seen_at: row.last_seen_at,
// sealed components kept out of plain accessors -- use peerSecret()
};
};
const listPeers = async () => {
const rows = await db.select("_dd_peers", {}, { orderBy: "peer_id" });
return rows.map(rowToPeer);
};
const findPeer = async (peerId) => {
const row = await db.selectMaybeOne("_dd_peers", { peer_id: peerId });
return rowToPeer(row);
};
const findPeerByEnvId = async (envId) => {
return await db.selectMaybeOne("_dd_peers", { env_id: envId });
};
// Returns the plaintext 32-byte secret for an existing peer by id.
// Throws if the peer is missing or has no sealed secret.
const peerSecret = async (peerId) => {
const row = await db.selectMaybeOne("_dd_peers", { peer_id: peerId });
if (!row) {
throw new Error(`peer ${peerId} not found`);
}
if (!row.peer_secret_ciphertext || !row.peer_secret_iv || !row.peer_secret_tag) {
throw new Error(`peer ${peerId} has no sealed secret`);
}
return open({
ciphertext: Buffer.from(row.peer_secret_ciphertext, "hex"),
iv: Buffer.from(row.peer_secret_iv, "hex"),
tag: Buffer.from(row.peer_secret_tag, "hex")
});
};
const peerSecretByEnvId = async (envId) => {
const row = await findPeerByEnvId(envId);
if (!row) {
return null;
}
return await peerSecret(row.peer_id);
};
// Create a new peer. If `existingSecret` is provided, use it; otherwise
// generate a fresh one. Returns { peer, secret } where secret is the plaintext
// Buffer -- only available at this single moment.
const addPeer = async ({ envId, label, baseUrl, requireTls, existingSecret }) => {
if (!envId || !baseUrl) {
throw new Error("addPeer requires envId and baseUrl");
}
const dup = await findPeerByEnvId(envId);
if (dup) {
throw new Error(`peer with env_id ${envId} already exists`);
}
const secret = existingSecret || randomSecret();
const sealed = seal(secret);
const row = {
env_id: envId,
label: label || null,
base_url: baseUrl,
peer_secret_ciphertext: sealed.ciphertext.toString("hex"),
peer_secret_iv: sealed.iv.toString("hex"),
peer_secret_tag: sealed.tag.toString("hex"),
require_tls: requireTls ? 1 : 0,
created_at: new Date().toISOString(),
last_seen_at: null
};
// noid: _dd_peers' PK is peer_id (serial on PG), not "id"; without this
// Saltcorn's default RETURNING id makes the insert fail on Postgres with
// 'column "id" does not exist'. The peer_id is auto-assigned; re-select below.
await db.insert("_dd_peers", row, { noid: true });
const fresh = await findPeerByEnvId(envId);
return { peer: rowToPeer(fresh), secret: secret };
};
const rotatePeerSecret = async (peerId) => {
const peer = await findPeer(peerId);
if (!peer) {
throw new Error(`peer ${peerId} not found`);
}
const secret = randomSecret();
const sealed = seal(secret);
await db.updateWhere("_dd_peers", {
peer_secret_ciphertext: sealed.ciphertext.toString("hex"),
peer_secret_iv: sealed.iv.toString("hex"),
peer_secret_tag: sealed.tag.toString("hex")
}, { peer_id: peerId });
return { peer: peer, secret: secret };
};
const deletePeer = async (peerId) => {
await db.deleteWhere("_dd_peers", { peer_id: peerId });
await db.deleteWhere("_dd_anchors", { peer_id: peerId });
};
const touchPeerLastSeen = async (peerId) => {
await db.updateWhere("_dd_peers", { last_seen_at: new Date().toISOString() }, { peer_id: peerId });
};
module.exports = {
listPeers,
findPeer,
findPeerByEnvId,
peerSecret,
peerSecretByEnvId,
addPeer,
rotatePeerSecret,
deletePeer,
touchPeerLastSeen
};