aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
-rw-r--r--src/features/names.js11
-rw-r--r--tools/name-migration-test.js25
2 files changed, 32 insertions, 4 deletions
diff --git a/src/features/names.js b/src/features/names.js
index 43d4895..ea94d2a 100644
--- a/src/features/names.js
+++ b/src/features/names.js
@@ -146,6 +146,17 @@ export function namesFeature(ctx) {
const domain = DOMAIN();
try { return await claim(name, opts); }
catch (err) {
+ // Signed by the login identity, a name this WALLET's key claimed
+ // earlier (before the identity was attached) reads as someone else's —
+ // the record names the wallet key as owner and no manager. The wallet
+ // key may always update its own record, so retry with it.
+ if (opts.signer && err.message === 'name is taken') {
+ const mine = await fetch(`${REGISTRAR}/name/${encodeURIComponent(name)}?domain=${encodeURIComponent(domain)}`,
+ { signal: AbortSignal.timeout(8000), cache: 'no-store' }).then((r) => (r.ok ? r.json() : null)).catch(() => null);
+ const pk = wallet.nostrPubkey && wallet.nostrPubkey();
+ if (pk && mine?.taken && (mine.pubkey === pk || mine.manager === pk))
+ return claim(name, { ...opts, signer: undefined, manager: undefined });
+ }
if (domain === 'coinos.io' && ['name is taken', 'name is reserved'].includes(err.message)) {
try {
const r = await fetch(`${REGISTRAR}/name/${encodeURIComponent(name)}?domain=${encodeURIComponent(domain)}`,
diff --git a/tools/name-migration-test.js b/tools/name-migration-test.js
index 0c1e817..cbc087d 100644
--- a/tools/name-migration-test.js
+++ b/tools/name-migration-test.js
@@ -15,7 +15,7 @@ const nodes = n => typeof n === 'object' ? [n, ...n.children.flatMap(nodes)] : [
const text = n => typeof n === 'object' ? n.children.map(text).join('') : String(n);
const migration = tree => nodes(tree).find(n => n.tag === 'button' && /Migrate an existing/.test(text(n)));
-function setup({ error = 'name is taken', record, user, unavailable, network = 'mainnet', pending } = {}) {
+function setup({ error = 'name is taken', record, user, unavailable, network = 'mainnet', pending, identity } = {}) {
storage.set('btc-wallet-network', network);
const ui = { nameClaim: 'legacy', screen: 'wallet', nameEditOpen: true };
const calls = [];
@@ -24,6 +24,9 @@ function setup({ error = 'name is taken', record, user, unavailable, network = '
calls.push(String(url));
if (url.endsWith('/register')) {
if (opts.method === 'DELETE') return Response.json({});
+ const by = JSON.parse(atob(opts.headers.authorization.slice(6))).pubkey;
+ calls.push('register-by:' + by);
+ if (typeof error === 'function') { const e = error(by); return Response.json(e ? { error: e } : {}, { status: e ? 409 : 200 }); }
return Response.json(error ? { error } : {}, { status: error ? 409 : 200 });
}
if (url.includes('/name/')) {
@@ -37,10 +40,11 @@ function setup({ error = 'name is taken', record, user, unavailable, network = '
const feature = namesFeature({
h, ui, render() {}, toast() {}, brandHeader: () => null,
wallet: {
- nostrPubkey: () => 'a'.repeat(64), nostrSign: async e => e,
+ nostrPubkey: () => 'a'.repeat(64), nostrSign: async e => ({ ...e, pubkey: 'a'.repeat(64) }),
loadFeatureState: () => state, saveFeatureState() {},
},
- hook: name => name === 'arkStaticAddress' ? 'ark-test-address' : null,
+ hook: name => name === 'arkStaticAddress' ? 'ark-test-address'
+ : name === 'nostrLoginResume' && identity ? { pubkey: identity, signEvent: async e => ({ ...e, pubkey: identity }) } : null,
});
const form = () => feature.namesClaimForm();
const submit = () => nodes(form()).find(n => n.tag === 'button' && n.props.class === 'btn-primary btn-block').props.onClick();
@@ -64,7 +68,7 @@ try {
s = setup({ error: null });
await s.submit();
assert.equal(migration(s.form()), undefined);
- assert.ok(s.calls.every(url => url.endsWith('/register')), 'successful claims skip migration lookups');
+ assert.ok(s.calls.filter(c => !c.startsWith('register-by:')).every(url => url.endsWith('/register')), 'successful claims skip migration lookups');
console.log('✓ free names claim normally without a migration prompt');
s = setup({ record: { name: 'legacy', domain: 'coinos.io', taken: true, pubkey: 'b'.repeat(64) }, user: { username: 'legacy' } });
@@ -93,6 +97,19 @@ try {
s = setup({ error: 'name is reserved', user: { username: 'legacy', migrated: true } });
await assert.rejects(s.feature.namesClaimName('legacy', { quietProfile: true }), err => err.migrationName === 'legacy');
console.log('✓ profile saves receive the same confirmed migration result');
+
+ // A name the wallet key claimed before a login identity was attached: the
+ // identity-signed claim is refused, the wallet key's retry goes through.
+ s = setup({ identity: 'c'.repeat(64), error: by => (by === 'a'.repeat(64) ? null : 'name is taken'),
+ record: { name: 'legacy', domain: 'coinos.io', taken: true, pubkey: 'a'.repeat(64), manager: null } });
+ await s.submit();
+ assert.equal(s.ui.nameClaimError, null);
+ assert.deepEqual(s.calls.filter(c => c.startsWith('register-by:')), ['register-by:' + 'c'.repeat(64), 'register-by:' + 'a'.repeat(64)]);
+ s = setup({ identity: 'c'.repeat(64), record: { name: 'legacy', domain: 'coinos.io', taken: true, pubkey: 'b'.repeat(64) } });
+ await s.submit();
+ assert.equal(s.ui.nameClaimError, 'name is taken');
+ assert.equal(s.calls.filter(c => c.startsWith('register-by:')).length, 1, 'someone else\'s name is not retried');
+ console.log('✓ the wallet key reclaims its own name when the login identity is refused');
} finally {
globalThis.fetch = originalFetch;
}