From 4e1ed1298862768f089eaa060f3d4e26923509e2 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Thu, 21 May 2026 23:47:03 -0400 Subject: [PATCH] Add ENS reverse lookup and claim prefill after SIWE --- api/ens/lookup.js | 57 +++++++++++++++++++++++++ public/claim.html | 82 +++++++++++++++++++++++++++++++++--- tests/api-ens-lookup.test.js | 51 ++++++++++++++++++++++ 3 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 api/ens/lookup.js create mode 100644 tests/api-ens-lookup.test.js diff --git a/api/ens/lookup.js b/api/ens/lookup.js new file mode 100644 index 0000000..5210b3e --- /dev/null +++ b/api/ens/lookup.js @@ -0,0 +1,57 @@ +'use strict'; + +function getAddressParam(req) { + const direct = req.query && req.query.address; + if (typeof direct === 'string') return direct; + if (Array.isArray(direct)) return direct[0] || ''; + try { + const host = (req.headers && req.headers.host) || 'localhost'; + const url = new URL(req.url || '', `http://${host}`); + return url.searchParams.get('address') || ''; + } catch { + return ''; + } +} + +module.exports = async function handler(req, res) { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + + if (req.method !== 'GET') { + res.setHeader('Allow', 'GET'); + return res.status(405).json({ ok: false, status: 'METHOD_NOT_ALLOWED', error: 'Method not allowed. Use GET.' }); + } + + const rawAddress = String(getAddressParam(req) || '').trim(); + if (!rawAddress) { + return res.status(400).json({ ok: false, status: 'INVALID_REQUEST', error: 'Missing required query parameter: address' }); + } + + if (!/^0x[a-fA-F0-9]{40}$/.test(rawAddress)) { + return res.status(400).json({ ok: false, status: 'INVALID_ADDRESS', error: 'Invalid Ethereum address.' }); + } + const address = rawAddress.toLowerCase(); + + const rpcUrl = process.env.ETH_RPC_URL || process.env.BASE_RPC_URL || ''; + if (!rpcUrl) { + return res.status(503).json({ ok: false, status: 'PROVIDER_UNAVAILABLE', error: 'ETH_RPC_URL is not configured' }); + } + + try { + let ethers; + try { + ({ ethers } = require('ethers')); + } catch { + return res.status(503).json({ ok: false, status: 'DEPENDENCY_UNAVAILABLE', error: 'ethers dependency unavailable' }); + } + const provider = new ethers.JsonRpcProvider(rpcUrl); + const primaryName = await provider.lookupAddress(address); + return res.status(200).json({ ok: true, address, primaryName: primaryName || null, source: 'reverse_resolution' }); + } catch (error) { + return res.status(502).json({ + ok: false, + status: 'LOOKUP_FAILED', + error: error && error.message ? error.message : 'ENS reverse lookup failed.' + }); + } +}; diff --git a/public/claim.html b/public/claim.html index a555798..5fa75f5 100644 --- a/public/claim.html +++ b/public/claim.html @@ -277,6 +277,9 @@

Bring one ENS name.
Generate 10 verifiable agents.
+
+ +
Only ENS identity is checked. Claim does not scan token balances or unrelated wallet assets.
@@ -341,10 +344,10 @@

Bring one ENS name.
Generate 10 verifiable agents.

- + Please enter a valid .eth name - Must end in .eth · The name you own or control + Must end in .eth · The name you own or control
@@ -587,7 +590,8 @@

Payment and provisioning are coming next.

pubKeyB64:'', privKeyB64:'', kid:'', keyGenerated:false, keyDownloaded:false, keyAcked:false, _cardJson:'', - authenticatedAddress:'', authStatus:'NOT_AUTHENTICATED', authChainId:null, siweAuthenticated:false + authenticatedAddress:'', authStatus:'NOT_AUTHENTICATED', authChainId:null, siweAuthenticated:false, + primaryEnsName:null }; const SIWE_STATEMENT = 'Authenticate with CommandLayer Claim activation.'; @@ -654,10 +658,11 @@

Payment and provisioning are coming next.

// ── STEP 2: MODE ────────────────────────────────────────────────────────────── function updateModeExamples() { - const base = state.ens.replace('.eth',''); + const currentName = state.ens || 'acme.eth'; + const base = currentName.replace('.eth',''); document.getElementById('eg-cl').textContent = base + '.approveagent.eth'; - document.getElementById('eg-own').textContent = 'approve.' + state.ens; - document.getElementById('eg-single').textContent = state.ens; + document.getElementById('eg-own').textContent = 'approve.' + currentName; + document.getElementById('eg-single').textContent = currentName; } function selectMode(m) { @@ -667,6 +672,7 @@

Payment and provisioning are coming next.

}); updateNamePreview(); updateSiweModeHint(); + updateEnsFieldByMode(); } function updateNamePreview() { @@ -689,11 +695,13 @@

Payment and provisioning are coming next.

if (!document.querySelector('.mode-card.selected')) selectMode('cl'); updateSiweModeHint(); goToStep(3); + updateEnsFieldByMode(); } // Auto-select CL mode on load selectMode('cl'); updateSiweModeHint(); +updateEnsFieldByMode(); // ── STEP 3: CAPABILITIES ────────────────────────────────────────────────────── function setCapMode(m) { @@ -750,7 +758,8 @@

Payment and provisioning are coming next.

} function goStep3() { - const val = document.getElementById('ensInput').value.trim().toLowerCase(); + const raw = document.getElementById('ensInput').value.trim().toLowerCase(); + const val = state.activationMode==='cl' && raw && !raw.endsWith('.eth') ? `${raw}.eth` : raw; const err = document.getElementById('ensError'); if (!val.endsWith('.eth') || val.length < 6) { err.classList.add('show'); @@ -801,6 +810,63 @@

Payment and provisioning are coming next.

} } +function labelFromEnsName(name){ + return String(name || '').toLowerCase().replace(/\.eth$/,'').replace(/[^a-z0-9-]/g,''); +} + +function updateEnsFieldByMode() { + const labelEl = document.getElementById('ensFieldLabel'); + const inputEl = document.getElementById('ensInput'); + const hintEl = document.getElementById('ensFieldHint'); + if(state.activationMode==='cl'){ + labelEl.textContent='Tenant label'; + hintEl.textContent='This creates names like hydroseal.approveagent.eth'; + inputEl.placeholder='hydroseal'; + if(state.primaryEnsName && !inputEl.value.trim()){ inputEl.value=labelFromEnsName(state.primaryEnsName); } + } else if (state.activationMode==='own'){ + labelEl.textContent='ENS Name'; + hintEl.textContent='This creates names like approve.hydroseal.eth'; + inputEl.placeholder='hydroseal.eth'; + if(state.primaryEnsName && !inputEl.value.trim()){ inputEl.value=state.primaryEnsName; } + } else { + labelEl.textContent='ENS Name'; + hintEl.textContent='Single agent mode uses your ENS name directly'; + inputEl.placeholder='hydroseal.eth'; + if(state.primaryEnsName && !inputEl.value.trim()){ inputEl.value=state.primaryEnsName; } + } +} + +async function lookupPrimaryEns(address){ + const statusEl=document.getElementById('ensLookupStatus'); + const btn=document.getElementById('usePrimaryEnsBtn'); + statusEl.textContent='Checking primary ENS name...'; + btn.style.display='none'; + const res=await fetch(`/api/ens/lookup?address=${encodeURIComponent(address)}`,{headers:{Accept:'application/json'}}); + const data=await res.json(); + if(!res.ok || !data.ok){ + statusEl.textContent=(data && data.status==='PROVIDER_UNAVAILABLE') + ? 'Primary ENS lookup unavailable: ETH_RPC_URL is not configured.' + : 'No primary ENS name found. You can still enter a tenant label or ENS name manually.'; + state.primaryEnsName=null; + return; + } + if(data.primaryName){ + state.primaryEnsName=data.primaryName.toLowerCase(); + statusEl.textContent=`Primary ENS detected: ${state.primaryEnsName}`; + btn.style.display='inline-flex'; + }else{ + state.primaryEnsName=null; + statusEl.textContent='No primary ENS name found. You can still enter a tenant label or ENS name manually.'; + } +} + +window.usePrimaryEnsName = function usePrimaryEnsName() { + if(!state.primaryEnsName) return; + const inputEl=document.getElementById('ensInput'); + inputEl.value = state.activationMode==='cl' ? labelFromEnsName(state.primaryEnsName) : state.primaryEnsName; + inputEl.focus(); +}; + window.connectWallet = async function connectWallet(){ const statusEl=document.getElementById('siweStatus'); const walletEl=document.getElementById('siweWallet'); @@ -888,6 +954,8 @@

Payment and provisioning are coming next.

walletEl.textContent=`Connected wallet: ${shortAddr(verify.address)}`; btn.textContent='Authenticated'; nextBtn.disabled=false; + await lookupPrimaryEns(verify.address); + updateEnsFieldByMode(); } catch (err) { const msg=((err && err.message)||'SIWE verification failed.').trim(); errorEl.textContent=err && err.code===4001 ? 'Signature rejected.' : msg; diff --git a/tests/api-ens-lookup.test.js b/tests/api-ens-lookup.test.js new file mode 100644 index 0000000..5efe2d8 --- /dev/null +++ b/tests/api-ens-lookup.test.js @@ -0,0 +1,51 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +const lookupHandler = require('../api/ens/lookup'); + +function makeRes() { + return { + statusCode: 200, + headers: {}, + body: null, + setHeader(name, value) { this.headers[name.toLowerCase()] = value; }, + status(code) { this.statusCode = code; return this; }, + json(payload) { this.body = payload; return this; } + }; +} + +test('GET /api/ens/lookup rejects missing address', async () => { + const res = makeRes(); + await lookupHandler({ method: 'GET', query: {}, headers: {} }, res); + assert.equal(res.statusCode, 400); + assert.equal(res.body.ok, false); + assert.equal(res.body.status, 'INVALID_REQUEST'); +}); + +test('GET /api/ens/lookup rejects invalid address', async () => { + const res = makeRes(); + await lookupHandler({ method: 'GET', query: { address: 'not-an-address' }, headers: {} }, res); + assert.equal(res.statusCode, 400); + assert.equal(res.body.ok, false); + assert.equal(res.body.status, 'INVALID_ADDRESS'); +}); + +test('GET /api/ens/lookup returns provider unavailable when ETH_RPC_URL is missing', async () => { + const prevEth = process.env.ETH_RPC_URL; + const prevBase = process.env.BASE_RPC_URL; + delete process.env.ETH_RPC_URL; + delete process.env.BASE_RPC_URL; + + const res = makeRes(); + await lookupHandler({ method: 'GET', query: { address: '0x0000000000000000000000000000000000000001' }, headers: {} }, res); + + assert.equal(res.statusCode, 503); + assert.equal(res.body.ok, false); + assert.equal(res.body.status, 'PROVIDER_UNAVAILABLE'); + assert.equal(res.body.error, 'ETH_RPC_URL is not configured'); + + if (typeof prevEth === 'undefined') delete process.env.ETH_RPC_URL; else process.env.ETH_RPC_URL = prevEth; + if (typeof prevBase === 'undefined') delete process.env.BASE_RPC_URL; else process.env.BASE_RPC_URL = prevBase; +});