diff --git a/package.json b/package.json index 580fea39d0..f072dbdf6c 100644 --- a/package.json +++ b/package.json @@ -7,6 +7,7 @@ "platform": "devcontainer up --workspace-folder .; devcontainer exec --workspace-folder . fish -c 'aesthetic'", "test": "node run-tests.mjs", "doctor": "node toolchain/doctor.mjs", + "domain": "node toolchain/domains/domain.mjs", "test:keeps:v5": "jasmine spec/keeps-v5-production-spec.js", "test:keeps:v4:network": "RUN_KEEPS_NETWORK_TESTS=true jasmine spec/keeps-v4-readonly-spec.js spec/keeps-v4-security-spec.js", "test:keeps:network": "RUN_KEEPS_NETWORK_TESTS=true jasmine spec/keeps-v4-readonly-spec.js spec/keeps-v4-security-spec.js", diff --git a/toolchain/domains/README.md b/toolchain/domains/README.md new file mode 100644 index 0000000000..af82c73753 --- /dev/null +++ b/toolchain/domains/README.md @@ -0,0 +1,56 @@ +# Domains + +Buy and wire up vanity domains for AC pieces from the command line, via +[Porkbun](https://porkbun.com)'s API. + +A vanity domain (e.g. `notepat.com`, `laklok.com`) is just two things: + +1. **DNS** pointing the apex at lith — `A → 209.38.133.33` +2. **A host block in `lith/Caddyfile`** that rewrites `/` to the piece and + reverse-proxies the rest to `localhost:8888` + +This tool owns buying the domain and hands you the Caddy block for the rest. + +## Setup (one time) + +1. **Account + API access.** Log into Porkbun → **Account → API Access** → + create an **API Key** (`pk1_…`) and **Secret Key** (`sk1_…`). Enable + API access, and set the account's **default WHOIS contact** — registration + borrows it, so buys fail without it. +2. **Store the keys** in the vault so every machine picks them up: + + ``` + PORKBUN_API_KEY=pk1_… + PORKBUN_SECRET_API_KEY=sk1_… + ``` + + Add those lines to `aesthetic-computer-vault/.env`. The client reads the + environment first, then falls back to that file. +3. **Verify:** `npm run domain ping` → should print your IP. + +## Commands + +```bash +npm run domain ping # test the keys +npm run domain price .games .com .ac # register/renew/transfer prices per TLD +npm run domain check nom.games a.com # availability + price for full domains +npm run domain buy nom.games # register (prompts to confirm price) +npm run domain buy nom.games --yes # register without the prompt +npm run domain caddy nom.games nom # print the Caddyfile block for a domain→piece +``` + +`buy` re-checks availability and price at purchase time; Porkbun aborts if the +price moved since your `check`. + +## After buying + +1. Point DNS at lith. Either move the domain onto the Cloudflare account and add + an `A` record → `209.38.133.33`, or set Porkbun DNS directly. +2. Add the host block (`npm run domain caddy ` prints it) to + `lith/Caddyfile`, and add the domain to the `@mainspa` host list. +3. Deploy: `fish lith/deploy.fish`. + +## Roadmap + +- Fold Cloudflare DNS creation + the Caddy edit + redeploy into a single + `npm run domain add ` once a CF API token is wired in. diff --git a/toolchain/domains/domain.mjs b/toolchain/domains/domain.mjs new file mode 100644 index 0000000000..7dc0b3ae37 --- /dev/null +++ b/toolchain/domains/domain.mjs @@ -0,0 +1,140 @@ +#!/usr/bin/env node +// domain.mjs — buy and wire up vanity domains for Aesthetic Computer pieces. +// +// A vanity domain (notepat.com, laklok.com, prompt.ac) is just two things: +// 1. DNS pointing the apex at lith (209.38.133.33) +// 2. A host block in lith/Caddyfile that rewrites "/" to the piece +// +// This tool owns step 0 (buying the domain via Porkbun) and hands you a +// ready-to-paste Caddy block for the rest. Cloudflare DNS automation lands +// once a CF token is wired in. +// +// Usage: +// node toolchain/domains/domain.mjs ping test API keys +// node toolchain/domains/domain.mjs price .games .com price some TLDs +// node toolchain/domains/domain.mjs check a.com b.games availability + price +// node toolchain/domains/domain.mjs buy nom.games register (prompts) +// node toolchain/domains/domain.mjs buy nom.games --yes register (no prompt) +// node toolchain/domains/domain.mjs caddy nom.games nom print the Caddy block +// +// Keys: PORKBUN_API_KEY / PORKBUN_SECRET_API_KEY (env or vault .env). + +import { createInterface } from "node:readline/promises"; +import * as pb from "./porkbun.mjs"; + +const LITH_IP = "209.38.133.33"; +const [cmd, ...rest] = process.argv.slice(2); +const flags = new Set(rest.filter((a) => a.startsWith("--"))); +const args = rest.filter((a) => !a.startsWith("--")); + +const money = (v) => (v == null ? "—" : `$${Number(v).toFixed(2)}`); + +async function confirm(question) { + if (flags.has("--yes")) return true; + const rl = createInterface({ input: process.stdin, output: process.stdout }); + const answer = await rl.question(question + " "); + rl.close(); + return /^y(es)?$/i.test(answer.trim()); +} + +async function cmdPing() { + const r = await pb.ping(); + console.log(`✅ Porkbun keys valid. Your IP: ${r.yourIp}`); +} + +async function cmdPrice(tlds) { + const { pricing } = await pb.pricing(); + const wanted = tlds.map((t) => t.replace(/^\./, "").toLowerCase()); + const rows = wanted.length ? wanted : Object.keys(pricing).sort(); + for (const tld of rows) { + const p = pricing[tld]; + if (!p) { console.log(`.${tld} — not offered`); continue; } + console.log( + `.${tld.padEnd(12)} register ${money(p.registration).padStart(8)}` + + ` renew ${money(p.renewal).padStart(8)} transfer ${money(p.transfer).padStart(8)}`, + ); + } +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +// Porkbun's checkDomain allows ~1 call per 10s. We space calls out and, if we +// still trip the limit, back off and retry once so a batch just completes. +async function checkOne(domain, retried = false) { + try { + const r = await pb.check(domain); + const a = r.response || {}; + const avail = a.avail === "yes"; + const price = a.price ?? a.firstYearPromo ?? null; + console.log( + `${avail ? "🟢 available" : "🔴 taken "} ${domain.padEnd(22)} ${avail ? money(price) : ""}`, + ); + } catch (e) { + if (/within \d+ seconds/i.test(e.message) && !retried) { + await sleep(11000); + return checkOne(domain, true); + } + console.log(`⚠️ ${domain.padEnd(22)} ${e.message}`); + } +} + +async function cmdCheck(domains) { + if (!domains.length) return console.error("Usage: check [domain…]"); + for (let i = 0; i < domains.length; i++) { + if (i > 0) await sleep(11000); // stay under the 1-check-per-10s cap + await checkOne(domains[i]); + } +} + +async function cmdBuy(domains) { + if (!domains.length) return console.error("Usage: buy [--yes]"); + for (const domain of domains) { + const r = await pb.check(domain); + const a = r.response || {}; + if (a.avail !== "yes") { console.log(`🔴 ${domain} is not available — skipping.`); continue; } + const price = Number(a.price ?? a.firstYearPromo ?? 0); + const ok = await confirm(`Register ${domain} for ${money(price)}? [y/N]`); + if (!ok) { console.log(`Skipped ${domain}.`); continue; } + const cents = Math.round(price * 100); + const result = await pb.register(domain, cents); + console.log(`🎉 Registered ${domain}. ${result.message || ""}`); + console.log(` Next: point DNS at lith (${LITH_IP}) and add the Caddy block:`); + printCaddy(domain, domain.split(".")[0]); + } +} + +function printCaddy(domain, piece) { + const label = piece.replace(/[^a-z0-9]/gi, ""); + console.log(` + # --- ${domain} --- + @${label}root host ${domain} www.${domain} + handle @${label}root { + @${label}index path / + handle @${label}index { rewrite * /${piece} } + reverse_proxy localhost:8888 + } + # …then add ${domain} www.${domain} to the @mainspa host list.`); +} + +function cmdCaddy([domain, piece]) { + if (!domain || !piece) return console.error("Usage: caddy "); + printCaddy(domain, piece); +} + +const commands = { + ping: cmdPing, + price: () => cmdPrice(args), + check: () => cmdCheck(args), + buy: () => cmdBuy(args), + caddy: () => cmdCaddy(args), +}; + +const run = commands[cmd]; +if (!run) { + console.error("Commands: ping | price | check | buy | caddy"); + process.exit(1); +} +Promise.resolve(run()).catch((e) => { + console.error("❌ " + e.message); + process.exit(1); +}); diff --git a/toolchain/domains/porkbun.mjs b/toolchain/domains/porkbun.mjs new file mode 100644 index 0000000000..a8f2f0d443 --- /dev/null +++ b/toolchain/domains/porkbun.mjs @@ -0,0 +1,87 @@ +// porkbun.mjs — a small, knowable client for the Porkbun domain API. +// +// Porkbun speaks JSON-over-POST. Every call carries the account keys in its +// body; registration additionally borrows the account's default WHOIS contact, +// so there are no per-call contact fields to thread through here. +// +// Endpoints we touch (v3): +// /ping — auth smoke test, echoes your IP +// /pricing/get — every TLD's register/renew/transfer price +// /domain/checkDomain/{d} — availability + live price for one domain +// /domain/create/{d} — register (cost guard in cents + agreeToTerms) +// /domain/getNs/{d} — current nameservers +// /domain/updateNs/{d} — repoint nameservers +// /dns/create/{d} — add a DNS record +// +// Keys come from PORKBUN_API_KEY / PORKBUN_SECRET_API_KEY. If those aren't in +// the environment we fall back to the plaintext vault .env, matching the rest +// of scripts/ (which expect `source aesthetic-computer-vault/.env`). + +import { readFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; +import { dirname, resolve } from "node:path"; + +const BASE = "https://api.porkbun.com/api/json/v3"; +const HERE = dirname(fileURLToPath(import.meta.url)); +const VAULT_ENV = resolve(HERE, "../../aesthetic-computer-vault/.env"); + +// Pull a key from the environment, or from the vault .env as a fallback. +function key(name) { + if (process.env[name]) return process.env[name].trim(); + try { + const line = readFileSync(VAULT_ENV, "utf8") + .split("\n") + .find((l) => l.startsWith(name + "=")); + if (line) return line.slice(name.length + 1).trim().replace(/^["']|["']$/g, ""); + } catch { + // No vault on this machine — that's fine, the caller reports the miss. + } + return undefined; +} + +export function credentials() { + const apikey = key("PORKBUN_API_KEY"); + const secretapikey = key("PORKBUN_SECRET_API_KEY"); + return { apikey, secretapikey }; +} + +// One POST. Throws on transport failure or a non-SUCCESS body so callers can +// just `await` and trust the result. +async function call(path, body = {}) { + const { apikey, secretapikey } = credentials(); + if (!apikey || !secretapikey) { + throw new Error( + "Missing Porkbun keys. Set PORKBUN_API_KEY and PORKBUN_SECRET_API_KEY " + + "(env or aesthetic-computer-vault/.env).", + ); + } + const res = await fetch(BASE + path, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ apikey, secretapikey, ...body }), + }); + const data = await res.json().catch(() => ({})); + if (data.status !== "SUCCESS") { + throw new Error(data.message || `Porkbun ${path} failed (HTTP ${res.status})`); + } + return data; +} + +export const ping = () => call("/ping"); +export const pricing = () => call("/pricing/get"); +export const check = (domain) => call(`/domain/checkDomain/${domain}`); +export const getNs = (domain) => call(`/domain/getNs/${domain}`); + +// Register a domain. `costInCents` is Porkbun's own guard: pass the price from +// a fresh availability check so the buy aborts if the price moved under us. +export const register = (domain, costInCents) => + call(`/domain/create/${domain}`, { cost: costInCents, agreeToTerms: "yes" }); + +// Repoint nameservers (e.g. to Cloudflare's pair). Porkbun wants ns1, ns2, … +export function updateNs(domain, nameservers) { + const body = {}; + nameservers.forEach((ns, i) => (body["ns" + (i + 1)] = ns)); + return call(`/domain/updateNs/${domain}`, body); +} + +export const dnsCreate = (domain, record) => call(`/dns/create/${domain}`, record);