aboutsummaryrefslogtreecommitdiffstats
path: root/names/server.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 20:28:56 -0700
committerAdam Soltys <[email protected]>2026-08-01 20:28:56 -0700
commit5986cbf5337593eb11fd984d284bd41da7719a87 (patch)
tree4ea35eb279b7213f5ebe9ec77118710612f35b6d /names/server.js
parent348a57004f94dd0f58c4e70769e65ca7ea3f7619 (diff)
downloadcoinosv3-5986cbf5337593eb11fd984d284bd41da7719a87.tar.gz
coinosv3-5986cbf5337593eb11fd984d284bd41da7719a87.zip
Payment names: ₿[email protected] via BIP-353 DNS payment instructions
Receiving: a Settings card claims a username, signed with the wallet's nostr key; the new names registrar (names/server.js, deployed at names.halwallet.app) enforces first-come ownership by that key and mirrors the claim into a DNSSEC-signed TXT record at <name>.user._bitcoin-payment.halwallet.app whose BIP-21 URI carries the wallet's reusable ark address. The record re-registers itself if the ark address ever changes, and a collapsible hint shows the single CNAME a user adds to serve the same name from their own domain (BIP-353 supports delegation when both zones are DNSSEC-signed). Sending: a pasted name@domain resolves over DNS first — two independent DoH resolvers must agree and both report the DNSSEC AD flag (full client-side chain validation is the stated follow-up) — an ark= instruction pays natively, an on-chain path fills the send form, and a name with no DNS record falls back to the Lightning-address zap flow. Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'names/server.js')
-rw-r--r--names/server.js189
1 files changed, 189 insertions, 0 deletions
diff --git a/names/server.js b/names/server.js
new file mode 100644
index 0000000..e9e5cc0
--- /dev/null
+++ b/names/server.js
@@ -0,0 +1,189 @@
+// BIP-353 name registrar for halwallet.app — gives every hal user a
+// ₿[email protected] payment address.
+//
+// A name is a DNSSEC-signed TXT record at
+// <name>.user._bitcoin-payment.halwallet.app
+// whose content is a BIP-21 `bitcoin:` URI (for hal users: an ark address,
+// extensible later with lno= etc). This service is the write path: it
+// validates a claim signed by the wallet's nostr key, enforces first-come
+// ownership by that key, and mirrors the record into Cloudflare DNS.
+//
+// WHAT THIS SERVICE LEARNS: name ↔ nostr pubkey ↔ payment URI. All of it is
+// public by construction (DNS is public). It cannot spend anything.
+//
+// Deliberately dumb: one JSON state file, no accounts, no email.
+
+import { readFileSync, writeFileSync, renameSync, mkdirSync } from 'node:fs';
+import { dirname, join } from 'node:path';
+import { verifyEvent } from 'nostr-tools/pure';
+
+const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG
+ || join(import.meta.dir, 'config.json'), 'utf8'));
+// { port, domain, zoneId, cfEmail, cfKey, stateFile }
+
+const DOMAIN = CFG.domain || 'halwallet.app';
+const STATE = CFG.stateFile || join(import.meta.dir, 'data', 'names.json');
+const AUTH_KIND = 21353; // arbitrary custom kind for signed claims
+const MAX_SKEW_SEC = 600; // claim events must be fresh
+const TTL = 300; // record TTL: BIP-353 wallets cache by this
+
+const log = (...a) => console.log(new Date().toISOString(), ...a);
+
+// names that must never be claimable
+const RESERVED = new Set([
+ 'admin', 'administrator', 'root', 'hal', 'halwallet', 'www', 'mail', 'help',
+ 'support', 'info', 'pay', 'payments', 'wallet', 'staging', 'names', 'api',
+ 'user', 'users', 'coinos', 'nostr', 'ark', 'test', 'dev', 'security', 'abuse',
+]);
+const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,29}$/;
+
+let state = (() => {
+ try { return JSON.parse(readFileSync(STATE, 'utf8')); } catch { return { names: {} }; }
+})();
+function persist() {
+ mkdirSync(dirname(STATE), { recursive: true });
+ const tmp = STATE + '.tmp';
+ writeFileSync(tmp, JSON.stringify(state));
+ renameSync(tmp, STATE);
+}
+
+// ---------------------------------------------------------------------------
+// Cloudflare DNS
+// ---------------------------------------------------------------------------
+
+const CF = 'https://api.cloudflare.com/client/v4';
+const cfHeaders = {
+ 'X-Auth-Email': CFG.cfEmail, 'X-Auth-Key': CFG.cfKey, 'content-type': 'application/json',
+};
+const recordName = (name) => `${name}.user._bitcoin-payment.${DOMAIN}`;
+
+async function cfWrite(name, uri, existingId) {
+ const body = JSON.stringify({
+ type: 'TXT', name: recordName(name), content: `"${uri}"`, ttl: TTL,
+ });
+ const url = existingId
+ ? `${CF}/zones/${CFG.zoneId}/dns_records/${existingId}`
+ : `${CF}/zones/${CFG.zoneId}/dns_records`;
+ const r = await fetch(url, { method: existingId ? 'PUT' : 'POST', headers: cfHeaders, body });
+ const j = await r.json();
+ if (!j.success) throw new Error('dns write failed: ' + JSON.stringify(j.errors).slice(0, 200));
+ return j.result.id;
+}
+async function cfDelete(recordId) {
+ const r = await fetch(`${CF}/zones/${CFG.zoneId}/dns_records/${recordId}`, {
+ method: 'DELETE', headers: cfHeaders,
+ });
+ const j = await r.json();
+ if (!j.success) throw new Error('dns delete failed');
+}
+
+// ---------------------------------------------------------------------------
+// claims
+// ---------------------------------------------------------------------------
+
+// The claim is a signed nostr event: proof the caller controls the wallet's
+// nostr key, which becomes (or must match) the name's owner.
+function checkAuth(auth) {
+ if (!auth || auth.kind !== AUTH_KIND) return 'bad auth kind';
+ if (Math.abs(Date.now() / 1000 - (auth.created_at || 0)) > MAX_SKEW_SEC) return 'auth event too old';
+ if (!verifyEvent(auth)) return 'bad signature';
+ return null;
+}
+
+const validUri = (u) => typeof u === 'string' && /^bitcoin:/i.test(u) && u.length <= 480
+ && !/[\s"\\]/.test(u);
+
+// crude per-IP limiter
+const rate = new Map();
+function rateOk(ip, limit = 10) {
+ const now = Date.now();
+ const arr = (rate.get(ip) || []).filter((t) => t > now - 60_000);
+ if (arr.length >= limit) return false;
+ arr.push(now);
+ rate.set(ip, arr);
+ if (rate.size > 1000) for (const [k, v] of rate) { if (!v.some((t) => t > now - 60_000)) rate.delete(k); }
+ return true;
+}
+
+// ---------------------------------------------------------------------------
+// HTTP
+// ---------------------------------------------------------------------------
+
+const json = (body, status = 200) => new Response(JSON.stringify(body), {
+ status,
+ headers: {
+ 'content-type': 'application/json',
+ 'access-control-allow-origin': '*',
+ 'access-control-allow-headers': 'content-type',
+ 'access-control-allow-methods': 'GET,POST,DELETE,OPTIONS',
+ },
+});
+
+Bun.serve({
+ port: CFG.port || 8798,
+ async fetch(req) {
+ const url = new URL(req.url);
+ if (req.method === 'OPTIONS') return json({});
+ const ip = req.headers.get('cf-connecting-ip') || req.headers.get('x-forwarded-for') || 'local';
+
+ if (url.pathname === '/health') {
+ return json({ ok: true, domain: DOMAIN, names: Object.keys(state.names).length });
+ }
+
+ // Availability / lookup. Public data (it's DNS).
+ const m = url.pathname.match(/^\/name\/([a-z0-9._-]{1,30})$/);
+ if (m && req.method === 'GET') {
+ const rec = state.names[m[1]];
+ if (!rec) return json({ name: m[1], taken: false, reserved: RESERVED.has(m[1]) || !NAME_RE.test(m[1]) });
+ return json({ name: m[1], taken: true, pubkey: rec.pubkey, uri: rec.uri });
+ }
+
+ if (url.pathname === '/register' && req.method === 'POST') {
+ if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
+ const body = await req.json().catch(() => null);
+ const auth = body?.auth;
+ const authErr = checkAuth(auth);
+ if (authErr) return json({ error: authErr }, 400);
+ let claim;
+ try { claim = JSON.parse(auth.content); } catch { return json({ error: 'bad claim content' }, 400); }
+ const name = String(claim.name || '').toLowerCase();
+ if (!NAME_RE.test(name)) return json({ error: 'invalid name (a-z, 0-9, . _ -, max 30)' }, 400);
+ if (RESERVED.has(name)) return json({ error: 'name is reserved' }, 400);
+ if (claim.action !== 'register') return json({ error: 'wrong action' }, 400);
+ if (!validUri(claim.uri)) return json({ error: 'uri must be a bitcoin: URI' }, 400);
+
+ const existing = state.names[name];
+ if (existing && existing.pubkey !== auth.pubkey) return json({ error: 'name is taken' }, 409);
+
+ const recordId = await cfWrite(name, claim.uri, existing?.recordId);
+ state.names[name] = { pubkey: auth.pubkey, uri: claim.uri, recordId, updated: Date.now() };
+ persist();
+ log(`${existing ? 'updated' : 'registered'} ${name} for ${auth.pubkey.slice(0, 12)}`);
+ return json({ ok: true, name, address: `${name}@${DOMAIN}`, record: recordName(name) });
+ }
+
+ if (url.pathname === '/register' && req.method === 'DELETE') {
+ if (!rateOk(ip)) return json({ error: 'rate limited' }, 429);
+ const body = await req.json().catch(() => null);
+ const auth = body?.auth;
+ const authErr = checkAuth(auth);
+ if (authErr) return json({ error: authErr }, 400);
+ let claim;
+ try { claim = JSON.parse(auth.content); } catch { return json({ error: 'bad claim content' }, 400); }
+ const name = String(claim.name || '').toLowerCase();
+ if (claim.action !== 'delete') return json({ error: 'wrong action' }, 400);
+ const existing = state.names[name];
+ if (!existing) return json({ error: 'unknown name' }, 404);
+ if (existing.pubkey !== auth.pubkey) return json({ error: 'not your name' }, 403);
+ await cfDelete(existing.recordId).catch(() => {});
+ delete state.names[name];
+ persist();
+ log(`released ${name}`);
+ return json({ ok: true });
+ }
+
+ return json({ error: 'not found' }, 404);
+ },
+});
+
+log(`names registrar for ${DOMAIN} on :${CFG.port || 8798} — ${Object.keys(state.names).length} name(s)`);