Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions api/ens/lookup.js
Original file line number Diff line number Diff line change
@@ -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.'
});
}
};
82 changes: 75 additions & 7 deletions public/claim.html
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,9 @@ <h1>Bring one ENS name.<br><span class="grad">Generate 10 verifiable agents.</sp
<div id="siweWallet" style="font-family:var(--mono);font-size:12px;color:var(--muted);margin-top:4px"></div>
<div id="siweError" style="font-size:12px;color:#b42318;margin-top:8px;display:none"></div>
<div id="siweModeHint" style="font-size:12px;color:var(--muted);margin-top:8px"></div>
<div id="ensLookupStatus" style="font-size:12px;color:var(--muted);margin-top:8px"></div>
<button class="btn btn-secondary" id="usePrimaryEnsBtn" onclick="usePrimaryEnsName()" style="display:none;margin-top:8px">Use this ENS name</button>
<div style="font-size:12px;color:var(--muted);margin-top:8px">Only ENS identity is checked. Claim does not scan token balances or unrelated wallet assets.</div>
</div>
<div class="btn-row">
<button class="btn btn-primary" id="step1Next" disabled onclick="goStep1()">Continue →</button>
Expand Down Expand Up @@ -341,10 +344,10 @@ <h1>Bring one ENS name.<br><span class="grad">Generate 10 verifiable agents.</sp
<div id="cherryGroups"></div>
</div>
<div class="field" style="margin-top:14px">
<label>ENS Name</label>
<label id="ensFieldLabel">ENS Name</label>
<input type="text" id="ensInput" placeholder="acme.eth" autocomplete="off" spellcheck="false" />
<span class="field-error" id="ensError">Please enter a valid .eth name</span>
<span class="field-hint">Must end in .eth · The name you own or control</span>
<span class="field-hint" id="ensFieldHint">Must end in .eth · The name you own or control</span>
</div>
<div class="btn-row">
<button class="btn btn-ghost" onclick="goToStep(2)">← Back</button>
Expand Down Expand Up @@ -587,7 +590,8 @@ <h3>Payment and provisioning are coming next.</h3>
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.';

Expand Down Expand Up @@ -654,10 +658,11 @@ <h3>Payment and provisioning are coming next.</h3>

// ── 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) {
Expand All @@ -667,6 +672,7 @@ <h3>Payment and provisioning are coming next.</h3>
});
updateNamePreview();
updateSiweModeHint();
updateEnsFieldByMode();
}

function updateNamePreview() {
Expand All @@ -689,11 +695,13 @@ <h3>Payment and provisioning are coming next.</h3>
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) {
Expand Down Expand Up @@ -750,7 +758,8 @@ <h3>Payment and provisioning are coming next.</h3>
}

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');
Expand Down Expand Up @@ -801,6 +810,63 @@ <h3>Payment and provisioning are coming next.</h3>
}
}

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');
Expand Down Expand Up @@ -888,6 +954,8 @@ <h3>Payment and provisioning are coming next.</h3>
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;
Expand Down
51 changes: 51 additions & 0 deletions tests/api-ens-lookup.test.js
Original file line number Diff line number Diff line change
@@ -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;
});
Loading