aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAdam Soltys <[email protected]>2026-10-09 21:55:11 -0700
committerAdam Soltys <[email protected]>2026-10-09 21:55:11 -0700
commit3d6bf81a19ca080d3710893b1e65f5aa3f01315f (patch)
tree71a102a6440a3d0b730f635010f723b5f624f212
parentfaec1f84ed1e9e8ee78055796a8ebc92936abe9d (diff)
downloadcoinosv3-3d6bf81a19ca080d3710893b1e65f5aa3f01315f.tar.gz
coinosv3-3d6bf81a19ca080d3710893b1e65f5aa3f01315f.zip
Names: letters and numbers only, 2–24 — coinos v2's rule. The registrar took a-z, 0-9 and . _ - (1–30), so bitcoin-education and a dozen others got through. A NEW claim must now match /^[a-z0-9]{2,24}$/ (v2's /^[\p{L}\p{N}]{2,24}$/u, held to ASCII since a name is a DNS label); a name already held (19 with . _ -, plus "f" and "g") is grandfathered — its owner can still update it — as are v2 migrations carrying their own name and npub-shaped defaults. /name/<n> answers invalid: true for a free name that breaks the rule; the claim form says "Letters and numbers only" as it's typed, and the suggested name from a nostr profile is stripped to letters and digits.
Co-Authored-By: Claude Opus 5.5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Vps2H27rhbG9ZqseGfMrc3
-rw-r--r--names/server.js13
-rw-r--r--src/features/names.js12
-rw-r--r--src/i18n.js1
3 files changed, 22 insertions, 4 deletions
diff --git a/names/server.js b/names/server.js
index 463ab8e..aff4e54 100644
--- a/names/server.js
+++ b/names/server.js
@@ -57,6 +57,13 @@ const RESERVED = new Set([
'user', 'users', 'coinos', 'nostr', 'ark', 'test', 'dev', 'security', 'abuse',
]);
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,29}$/;
+// What a NEW name may be: letters and digits, 2–24 — coinos v2's rule
+// (/^[\p{L}\p{N}]{2,24}$/u there), held to ASCII because a name is a DNS
+// label here. NAME_RE stays the wider test for names that already exist
+// (a dozen claimed before the rule: bitcoin-education, barefoot.strider…),
+// v2 migrations carrying their own name, and npub-shaped defaults.
+const NEW_NAME_RE = /^[a-z0-9]{2,24}$/;
+const NEW_NAME_ERR = 'names can only have letters and numbers (2–24)';
let state = (() => {
try { return JSON.parse(readFileSync(STATE, 'utf8')); } catch { return { names: {} }; }
@@ -1228,8 +1235,9 @@ Bun.serve({
const granted = !!g && (keys.includes(g.pubkey) || (!!g.manager && keys.includes(g.manager)));
return json({ name: m[1], domain, taken: true, pubkey: rec.pubkey, manager: rec.manager || null, uri: rec.uri, noffer: rec.noffer?.raw || null, lno: rec.lno || null, granted });
}
- 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 });
+ const invalid = !NEW_NAME_RE.test(m[1]) && !npubPrefixOwner(m[1]);
+ const reserved = RESERVED.has(m[1]) || !NAME_RE.test(m[1]) || invalid || await takenByCoinosUser(domain, m[1]);
+ return json({ name: m[1], domain, taken: reserved, reserved, ...(invalid ? { invalid: true } : {}) });
}
// Which name(s) does a wallet key own? Lets an imported seed find its
@@ -1659,6 +1667,7 @@ Bun.serve({
if (existing && existing.pubkey !== auth.pubkey && existing.manager !== auth.pubkey) {
return json({ error: 'name is taken' }, 409);
}
+ if (!existing && !granted && !npubPrefixOwner(name) && !NEW_NAME_RE.test(name)) return json({ error: NEW_NAME_ERR }, 400);
if (!existing && !granted && await takenByCoinosUser(domain, name, auth.pubkey)) return json({ error: 'name is taken' }, 409);
// an npub-shaped name belongs to that identity alone
if (npubPrefixOwner(name)) {
diff --git a/src/features/names.js b/src/features/names.js
index a29ee7d..fe5ab0a 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -420,8 +420,9 @@ export function namesFeature(ctx) {
(async () => {
try {
const prof = hook('cachedProfile', me);
- const cand = String((prof && prof.name) || '').toLowerCase().replace(/[^a-z0-9._-]/g, '').slice(0, 30);
- if (!cand || /^npub1/.test(cand)) return;
+ // letters and digits only, 2–24: what the registrar takes for a new name
+ const cand = String((prof && prof.name) || '').toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 24);
+ if (cand.length < 2 || /^npub1/.test(cand)) return;
const r = await withTimeout(
fetch(`${REGISTRAR}/name/${encodeURIComponent(cand)}?domain=${encodeURIComponent(DOMAIN())}`).then((x) => x.json()),
8000, 'registrar');
@@ -432,6 +433,8 @@ export function namesFeature(ctx) {
})();
}
+ // letters and digits, 2–24 (coinos v2's rule) — a hint while typing
+ const nameRuleHint = (v) => !!v && !/^npub1/.test(v) && /[^a-z0-9]/.test(v);
function claimForm(big) {
suggestName();
return h('div', { class: 'col', style: 'gap:8px;width:100%' },
@@ -442,11 +445,16 @@ export function namesFeature(ctx) {
autocapitalize: 'none', autocomplete: 'off', spellcheck: 'false',
value: ui.nameClaim || '',
onInput: (e) => {
+ const was = nameRuleHint(ui.nameClaim);
ui.nameClaim = e.target.value.toLowerCase().trim();
if (ui.nameClaimMigration) { ui.nameClaimMigration = null; render(); }
+ else if (nameRuleHint(ui.nameClaim) !== was) render();
},
}),
h('span', { class: 'muted', style: 'align-self:center' }, '@' + DOMAIN())),
+ // said as it's typed, not after a round trip (the registrar is the judge:
+ // a name claimed before the rule can still be retaken by its owner)
+ nameRuleHint(ui.nameClaim) ? h('div', { class: 'small', style: 'color:var(--red,#c0392b)' }, t('namesRule')) : null,
h('button', { class: 'btn-primary btn-block', disabled: ui.busy, onClick: async () => {
const name = (ui.nameClaim || '').toLowerCase().trim();
if (!name) return;
diff --git a/src/i18n.js b/src/i18n.js
index d8ced5b..32c55b0 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1251,6 +1251,7 @@ const STR = {
namesPosCopyLink: 'Copy link',
namesDesc: 'Claim your payment address, like [email protected]. People can pay you just by typing it, no codes to copy or QR to scan.',
namesPlaceholder: 'username',
+ namesRule: 'Letters and numbers only',
namesResolving: 'Looking up {name}…',
namesSendTo: 'Send to {name}',
namesClaim: 'Claim',