aboutsummaryrefslogtreecommitdiffstats
path: root/names/server.js
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-08-01 21:08:01 -0700
committerAdam Soltys <[email protected]>2026-08-01 21:08:01 -0700
commit161e0ad3b205c1423aaa709377c128980336d3c1 (patch)
treeec165e8211f644f0ec48115f4310ed64189ba3a0 /names/server.js
parented1d996f9dbd6cedc18ec293daba0cfcd745ae2b (diff)
downloadcoinosv3-161e0ad3b205c1423aaa709377c128980336d3c1.tar.gz
coinosv3-161e0ad3b205c1423aaa709377c128980336d3c1.zip
Coinos v3: rebrand from Hal, payment addresses move to @coinos.io
The wallet is the next version of coinos, deployed at v3.coinos.io (same build as halwallet.app) ahead of an eventual migration of coinos.io itself. App name, manifest, notifications, and the NWC alias all say Coinos; the Hal Finney tribute stays. The registrar is multi-domain (per-domain Cloudflare zones; existing halwallet.app names keep working) and new claims default to @coinos.io. Names belonging to existing coinos users are reserved for them — the registrar checks the live coinos API before granting a claim — until the migration gives those users a way to claim their own. Co-Authored-By: Claude Fable 5 <[email protected]>
Diffstat (limited to 'names/server.js')
-rw-r--r--names/server.js73
1 files changed, 48 insertions, 25 deletions
diff --git a/names/server.js b/names/server.js
index d416d80..383a9ce 100644
--- a/names/server.js
+++ b/names/server.js
@@ -23,12 +23,13 @@ import { ArkManager } from '../src/ark/manager.js';
const CFG = JSON.parse(readFileSync(process.env.NAMES_CONFIG
|| join(import.meta.dir, 'config.json'), 'utf8'));
-// { port, domain, zoneId, cfEmail, cfKey, stateFile,
+// { port, domains: { '<domain>': { zoneId } }, domain (default), cfEmail, cfKey, stateFile,
// ln: { kind:'socket', path } — the ASP's CLN, for per-name bolt12 offers,
// forwarder: { mnemonic, ark, esplora, network } — a small float ark wallet
// that forwards settled offer payments to the name's ark address }
-const DOMAIN = CFG.domain || 'halwallet.app';
+const DOMAIN = CFG.domain || 'coinos.io'; // the default domain for claims
+const DOMAINS = CFG.domains || { [DOMAIN]: { zoneId: CFG.zoneId } };
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
@@ -62,25 +63,29 @@ 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}`;
+const recordName = (name, domain) => `${name}.user._bitcoin-payment.${domain}`;
-async function cfWrite(name, uri, existingId) {
+async function cfWrite(name, domain, uri, existingId) {
// TXT character-strings cap at 255 bytes; BIP-353 readers concatenate the
// chunks in order, so a long ark+lno URI just spans several quoted strings.
+ const zoneId = DOMAINS[domain]?.zoneId;
+ if (!zoneId) throw new Error('unknown domain');
const chunks = uri.match(/.{1,255}/g).map((c) => `"${c}"`).join(' ');
const body = JSON.stringify({
- type: 'TXT', name: recordName(name), content: chunks, ttl: TTL,
+ type: 'TXT', name: recordName(name, domain), content: chunks, ttl: TTL,
});
const url = existingId
- ? `${CF}/zones/${CFG.zoneId}/dns_records/${existingId}`
- : `${CF}/zones/${CFG.zoneId}/dns_records`;
+ ? `${CF}/zones/${zoneId}/dns_records/${existingId}`
+ : `${CF}/zones/${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}`, {
+async function cfDelete(domain, recordId) {
+ const zoneId = DOMAINS[domain]?.zoneId;
+ if (!zoneId) throw new Error('unknown domain');
+ const r = await fetch(`${CF}/zones/${zoneId}/dns_records/${recordId}`, {
method: 'DELETE', headers: cfHeaders,
});
const j = await r.json();
@@ -112,9 +117,9 @@ if (CFG.forwarder?.mnemonic) {
log(`forwarder ark wallet ready — float ${fwd.balance().spendableSat} sat, receive ${fwd.address().slice(0, 24)}…`);
}
-async function makeOffer(name) {
+async function makeOffer(address) { // "name@domain"
if (!ln) return null;
- const o = await ln.call('offer', { amount: 'any', description: `${name}@${DOMAIN}` });
+ const o = await ln.call('offer', { amount: 'any', description: address });
return { offerId: o.offer_id, bolt12: o.bolt12 };
}
@@ -186,6 +191,16 @@ function checkAuth(auth) {
return null;
}
+// coinos.io names that belong to existing coinos users are reserved for
+// them until the migration gives those users a way to claim their own.
+async function takenByCoinosUser(domain, name) {
+ if (domain !== 'coinos.io') return false;
+ try {
+ const r = await fetch(`https://coinos.io/api/users/${encodeURIComponent(name)}`);
+ return r.status === 200;
+ } catch { return true; } // can't verify → refuse rather than squat
+}
+
const validUri = (u) => typeof u === 'string' && /^bitcoin:/i.test(u) && u.length <= 480
&& !/[\s"\\]/.test(u);
@@ -234,9 +249,11 @@ Bun.serve({
// 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 });
+ const domain = (url.searchParams.get('domain') || DOMAIN).toLowerCase();
+ const rec = state.names[`${m[1]}@${domain}`];
+ if (rec) return json({ name: m[1], domain, taken: true, pubkey: rec.pubkey, uri: rec.uri });
+ const reserved = RESERVED.has(m[1]) || !NAME_RE.test(m[1]) || await takenByCoinosUser(domain, m[1]);
+ return json({ name: m[1], domain, taken: reserved, reserved });
}
if (url.pathname === '/register' && req.method === 'POST') {
@@ -248,29 +265,33 @@ Bun.serve({
let claim;
try { claim = JSON.parse(auth.content); } catch { return json({ error: 'bad claim content' }, 400); }
const name = String(claim.name || '').toLowerCase();
+ const domain = String(claim.domain || 'halwallet.app').toLowerCase();
+ if (!DOMAINS[domain]) return json({ error: 'unknown domain' }, 400);
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];
+ const key = `${name}@${domain}`;
+ const existing = state.names[key];
if (existing && existing.pubkey !== auth.pubkey) return json({ error: 'name is taken' }, 409);
+ if (!existing && await takenByCoinosUser(domain, name)) return json({ error: 'name is taken' }, 409);
if (/[?&]lno=/.test(claim.uri)) return json({ error: 'lno is added by the registrar' }, 400);
// Every name also gets a static Lightning offer on our node; payments
// to it are forwarded to the ark destination in the claim.
let offer = existing?.offerId ? { offerId: existing.offerId, bolt12: existing.bolt12 } : null;
- if (!offer) { try { offer = await makeOffer(name); } catch (e) { log('offer creation failed: ' + e.message); } }
+ if (!offer) { try { offer = await makeOffer(key); } catch (e) { log('offer creation failed: ' + e.message); } }
const published = offer ? `${claim.uri}${claim.uri.includes('?') ? '&' : '?'}lno=${offer.bolt12}` : claim.uri;
- const recordId = await cfWrite(name, published, existing?.recordId);
- state.names[name] = {
- pubkey: auth.pubkey, uri: published, recordId, updated: Date.now(),
+ const recordId = await cfWrite(name, domain, published, existing?.recordId);
+ state.names[key] = {
+ pubkey: auth.pubkey, uri: published, recordId, updated: Date.now(), domain,
offerId: offer?.offerId, bolt12: offer?.bolt12,
};
persist();
- log(`${existing ? 'updated' : 'registered'} ${name} for ${auth.pubkey.slice(0, 12)}`);
- return json({ ok: true, name, address: `${name}@${DOMAIN}`, record: recordName(name) });
+ log(`${existing ? 'updated' : 'registered'} ${key} for ${auth.pubkey.slice(0, 12)}`);
+ return json({ ok: true, name, address: key, record: recordName(name, domain) });
}
if (url.pathname === '/register' && req.method === 'DELETE') {
@@ -282,15 +303,17 @@ Bun.serve({
let claim;
try { claim = JSON.parse(auth.content); } catch { return json({ error: 'bad claim content' }, 400); }
const name = String(claim.name || '').toLowerCase();
+ const domain = String(claim.domain || 'halwallet.app').toLowerCase();
if (claim.action !== 'delete') return json({ error: 'wrong action' }, 400);
- const existing = state.names[name];
+ const key = `${name}@${domain}`;
+ const existing = state.names[key];
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(() => {});
+ await cfDelete(domain, existing.recordId).catch(() => {});
if (existing.offerId && ln) await ln.call('disableoffer', { offer_id: existing.offerId }).catch(() => {});
- delete state.names[name];
+ delete state.names[key];
persist();
- log(`released ${name}`);
+ log(`released ${key}`);
return json({ ok: true });
}