Skip to content

Commit 663ff88

Browse files
committed
Add claim namespace guardrails and canonical search UX
1 parent 08c4096 commit 663ff88

3 files changed

Lines changed: 121 additions & 31 deletions

File tree

api/claim/commandlayer-namespace.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,10 +71,12 @@ module.exports = async function handler(req, res) {
7171
if (!canonicalParent) return invalid(res, 'invalid_capability', `Unsupported capability "${capability}" for Trust Verification pack.`);
7272
}
7373

74+
const capabilitySet = new Set(capabilities);
7475
for (const agent of agents) {
7576
if (!agent || typeof agent !== 'object') return invalid(res, 'invalid_agents', 'Each agent must be an object.');
7677
const { ens, capability, canonicalParent } = agent;
7778
if (!TRUST_VERIFICATION_MAP[capability]) return invalid(res, 'invalid_agent_capability', `Unsupported agent capability "${capability}".`);
79+
if (!capabilitySet.has(capability)) return invalid(res, 'invalid_agent_capability', `Agent capability "${capability}" must be present in capabilities.`);
7880
if (TRUST_VERIFICATION_MAP[capability] !== canonicalParent) return invalid(res, 'invalid_agent_mapping', `Capability "${capability}" must map to canonical parent "${TRUST_VERIFICATION_MAP[capability]}".`);
7981
if (!Object.values(TRUST_VERIFICATION_MAP).includes(canonicalParent)) return invalid(res, 'invalid_canonical_parent', `Unsupported canonical parent "${canonicalParent}".`);
8082
if (ens !== `${tenant}.${canonicalParent}`) return invalid(res, 'invalid_agent_ens', `Agent ENS must equal "${tenant}.${canonicalParent}".`);

public/claim.html

Lines changed: 75 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -347,7 +347,7 @@ <h1>Bring one ENS name.<br><span class="grad">Generate 10 verifiable agents.</sp
347347
<!-- STEP 3: CAPABILITY SELECTION -->
348348
<div class="wizard-panel" id="panel-3">
349349
<div class="panel-step">Step 3 of 8</div>
350-
<div class="panel-title">Enter tenant / ENS name</div>
350+
<div class="panel-title">Namespace setup</div>
351351
<div class="panel-sub" id="capSubtext">This is your organization or agent identity used for namespace generation.</div>
352352
<div class="mode-tabs">
353353
<button class="mode-tab active" id="tabPacks" onclick="setCapMode('packs')">Recommended Packs</button>
@@ -358,11 +358,26 @@ <h1>Bring one ENS name.<br><span class="grad">Generate 10 verifiable agents.</sp
358358
<div class="cherry-counter">Selected: <span id="cherryCount">0</span> / 10</div>
359359
<div id="cherryGroups"></div>
360360
</div>
361-
<div class="field" style="margin-top:14px">
362-
<label>ENS Name</label>
363-
<input type="text" id="ensInput" placeholder="acme.eth" autocomplete="off" spellcheck="false" />
361+
<div class="field" style="margin-top:14px" id="tenantField">
362+
<label>Tenant label</label>
363+
<input type="text" id="tenantInput" placeholder="hydroseal" autocomplete="off" spellcheck="false" />
364+
<span class="field-error" id="tenantError">Use 3-32 lowercase letters, numbers, or hyphens; no .eth; cannot start/end with hyphen.</span>
365+
<span class="field-hint">Used for subnames under CommandLayer canonical parents.</span>
366+
</div>
367+
<div class="field" id="canonicalSearchField">
368+
<label>Search CommandLayer capability namespaces</label>
369+
<input type="text" id="canonicalSearchInput" placeholder="Type a letter, example: a, v, s" autocomplete="off" spellcheck="false" />
370+
<div class="field-hint">Select up to 10 canonical parents.</div>
371+
<div id="canonicalSearchResults" class="name-preview" style="display:block"></div>
372+
<div id="canonicalSelected" class="name-preview"></div>
373+
<div class="field-hint">CommandLayer namespace mode creates subnames under CommandLayer-controlled canonical parents, such as hydroseal.approveagent.eth. It does not grant ownership of approveagent.eth.</div>
374+
</div>
375+
<div class="field" id="ownEnsField" style="display:none">
376+
<label>ENS namespace</label>
377+
<input type="text" id="ensInput" placeholder="hydroseal.eth" autocomplete="off" spellcheck="false" />
364378
<span class="field-error" id="ensError">Please enter a valid .eth name</span>
365-
<span class="field-hint">Must end in .eth · The name you own or control</span>
379+
<span class="field-hint">To activate records under your ENS name, the connected wallet must control that ENS name. Ownership/control verification is required before activation.</span>
380+
<span class="field-hint">Manual package only. Activation request submission for user-owned ENS namespaces requires ENS control verification.</span>
366381
</div>
367382
<div class="btn-row">
368383
<button class="btn btn-ghost" onclick="goToStep(2)">← Back</button>
@@ -618,10 +633,11 @@ <h3>Payment and provisioning are coming next.</h3>
618633
classify:'classifyagent.eth', translate:'translateagent.eth', validate:'validateagent.eth',
619634
report:'reportagent.eth'
620635
};
636+
const TRUST_PARENTS = Array.from(new Set(Object.values(VERB_TO_AGENT).filter(v=>['signagent.eth','attestagent.eth','authorizeagent.eth','approveagent.eth','rejectagent.eth','permitagent.eth','grantagent.eth','authenticateagent.eth','endorseagent.eth','verifyagent.eth'].includes(v))));
621637

622638
// ── STATE ─────────────────────────────────────────────────────────────────────
623639
let state = {
624-
step:1, ens:'', activationMode:'cl',
640+
step:1, ens:'', tenantLabel:'', activationMode:'cl',
625641
capMode:'packs', selectedPack:null, cherryVerbs:[],
626642
pubKeyB64:'', privKeyB64:'', kid:'',
627643
keyGenerated:false, keyDownloaded:false, keyAcked:false,
@@ -641,7 +657,7 @@ <h3>Payment and provisioning are coming next.</h3>
641657

642658
// Generate the agent name for a verb based on activation mode
643659
function agentName(verb) {
644-
const base = state.ens.replace('.eth','');
660+
const base = state.activationMode==='cl' ? state.tenantLabel : state.ens.replace('.eth','');
645661
if (state.activationMode === 'cl') {
646662
// acme.approveagent.eth
647663
const canonical = VERB_TO_AGENT[verb] || (verb+'agent.eth');
@@ -667,6 +683,9 @@ <h3>Payment and provisioning are coming next.</h3>
667683
state.step = n;
668684
document.getElementById('panel-'+n).classList.add('active');
669685
updateProgress();
686+
document.getElementById('canonicalSearchInput').addEventListener('input', renderCanonicalSearch);
687+
renderCanonicalSearch();
688+
updateStep3ModeUI();
670689
if (n === 8) buildSummary();
671690
window.scrollTo({top:0, behavior:'smooth'});
672691
}
@@ -690,10 +709,11 @@ <h3>Payment and provisioning are coming next.</h3>
690709
goToStep(2);
691710
}
692711
document.getElementById('ensInput').addEventListener('keydown', e => { if(e.key==='Enter') goStep3(); });
712+
document.getElementById('tenantInput').addEventListener('keydown', e => { if(e.key==='Enter') goStep3(); });
693713

694714
// ── STEP 2: MODE ──────────────────────────────────────────────────────────────
695715
function updateModeExamples() {
696-
const base = state.ens.replace('.eth','');
716+
const base = state.activationMode==='cl' ? state.tenantLabel : state.ens.replace('.eth','');
697717
document.getElementById('eg-cl').textContent = base + '.approveagent.eth';
698718
document.getElementById('eg-own').textContent = 'approve.' + state.ens;
699719
document.getElementById('eg-single').textContent = state.ens;
@@ -706,6 +726,7 @@ <h3>Payment and provisioning are coming next.</h3>
706726
});
707727
updateNamePreview();
708728
updateSiweModeHint();
729+
updateStep3ModeUI();
709730
}
710731

711732
function updateNamePreview() {
@@ -724,6 +745,7 @@ <h3>Payment and provisioning are coming next.</h3>
724745

725746
function goStep2() {
726747
if (!state.activationMode) { alert('Please choose an activation mode.'); return; }
748+
updateStep3ModeUI();
727749
// default to CL mode if none selected
728750
if (!document.querySelector('.mode-card.selected')) selectMode('cl');
729751
updateSiweModeHint();
@@ -788,18 +810,43 @@ <h3>Payment and provisioning are coming next.</h3>
788810
updateNamePreview();
789811
}
790812

813+
814+
function validTenantLabel(label){ return /^[a-z0-9-]{3,32}$/.test(label) && !label.includes('.eth') && !label.startsWith('-') && !label.endsWith('-'); }
815+
function updateStep3ModeUI(){
816+
const isCl=state.activationMode==='cl';
817+
document.getElementById('tenantField').style.display=isCl?'grid':'none';
818+
document.getElementById('canonicalSearchField').style.display=isCl?'grid':'none';
819+
document.getElementById('ownEnsField').style.display=isCl?'none':'grid';
820+
}
821+
function renderCanonicalSearch(){
822+
const q=(document.getElementById('canonicalSearchInput').value||'').trim().toLowerCase();
823+
const out=TRUST_PARENTS.filter(p=>!q||p.includes(q)).slice(0,10);
824+
document.getElementById('canonicalSearchResults').innerHTML=out.map(p=>`<button type="button" class="btn btn-ghost" style="margin:4px" onclick="addCanonicalParent('${p}')">${p}</button>`).join('')||'<div class="field-hint">No matches.</div>';
825+
const selected=(state.selectedCanonicalParents||[]);
826+
document.getElementById('canonicalSelected').classList.toggle('show',selected.length>0);
827+
document.getElementById('canonicalSelected').innerHTML=selected.length?`<div class="name-preview-label">Selected canonical parents (${selected.length}/10)</div><div class="name-preview-list">${selected.map(p=>`<span class="name-preview-item">${state.tenantLabel||'tenant'}.${p}</span>`).join('')}</div>`:'';
828+
}
829+
function addCanonicalParent(parent){
830+
state.selectedCanonicalParents=state.selectedCanonicalParents||[];
831+
if(state.selectedCanonicalParents.includes(parent)||state.selectedCanonicalParents.length>=10)return;
832+
state.selectedCanonicalParents.push(parent);
833+
renderCanonicalSearch();
834+
}
835+
791836
function goStep3() {
792-
const val = document.getElementById('ensInput').value.trim().toLowerCase();
793-
const err = document.getElementById('ensError');
794-
if (!val.endsWith('.eth') || val.length < 6) {
795-
err.classList.add('show');
796-
document.getElementById('ensInput').classList.add('error');
797-
return;
837+
if (state.activationMode==='cl') {
838+
const label = document.getElementById('tenantInput').value.trim().toLowerCase();
839+
if (!validTenantLabel(label)) { document.getElementById('tenantError').classList.add('show'); return; }
840+
document.getElementById('tenantError').classList.remove('show');
841+
state.tenantLabel = label;
842+
state.ens = label+'.eth';
843+
} else {
844+
const val = document.getElementById('ensInput').value.trim().toLowerCase();
845+
const err = document.getElementById('ensError');
846+
if (!val.endsWith('.eth') || val.length < 6) { err.classList.add('show'); document.getElementById('ensInput').classList.add('error'); return; }
847+
err.classList.remove('show'); document.getElementById('ensInput').classList.remove('error'); document.getElementById('ensInput').classList.add('valid');
848+
state.ens = val;
798849
}
799-
err.classList.remove('show');
800-
document.getElementById('ensInput').classList.remove('error');
801-
document.getElementById('ensInput').classList.add('valid');
802-
state.ens = val;
803850
updateModeExamples();
804851
buildPacksGrid();
805852
buildCherryGroups();
@@ -918,6 +965,7 @@ <h3>Payment and provisioning are coming next.</h3>
918965
const address=toChecksumAddress(state.connectedWalletAddress);
919966
if(!address){ throw new Error('Connect wallet first.'); }
920967
updateSiweModeHint();
968+
updateStep3ModeUI();
921969
statusEl.textContent='Status: Requesting SIWE nonce...';
922970
setAuthStatusClasses('connected','connected',false);
923971
const chainIdHex=await window.ethereum.request({ method:'eth_chainId' });
@@ -999,27 +1047,20 @@ <h3>Payment and provisioning are coming next.</h3>
9991047
status.textContent='No ENS names found for this wallet. You can still type manually.';
10001048
return;
10011049
}
1002-
status.textContent='Detected ENS names';
1003-
list.innerHTML=names.map((entry)=>`<div class="ens-owned-card"><h4>${entry.name}</h4><div style="font-size:12px;color:var(--muted)">Owned ENS name</div><div style="font-size:12px;color:var(--muted)">Control not checked</div><div class="ens-owned-actions"><button class="btn btn-secondary" onclick="useEnsAsTenant('${entry.name}')">Use as tenant label</button><button class="btn btn-secondary" onclick="useEnsAsNamespace('${entry.name}')">Use as ENS namespace</button></div></div>`).join('');
1050+
status.textContent='ENS names detected from this wallet';
1051+
list.innerHTML=names.map((entry)=>`<div class="ens-owned-card"><h4>${entry.name}</h4><div style="font-size:12px;color:var(--muted)">Owned ENS name</div><div style="font-size:12px;color:var(--muted)">controlStatus: not_checked</div><div class="ens-owned-actions"><button class="btn btn-secondary" onclick="useEnsAsTenant('${entry.name}')">Use as tenant label</button><button class="btn btn-secondary" onclick="useEnsAsNamespace('${entry.name}')">Use as ENS namespace</button></div></div>`).join('');
10041052
}catch(_err){
10051053
status.textContent='ENS lookup unavailable. You can still type manually.';
10061054
}
10071055
}
10081056

1009-
function useEnsAsTenant(name){
1010-
const label=String(name||'').toLowerCase().replace(/\.eth$/,'');
1011-
if(!label) return;
1012-
selectMode('cl');
1013-
document.getElementById('ensInput').value=`${label}.eth`;
1014-
}
1057+
function useEnsAsTenant(name){ const label=String(name||'').toLowerCase().replace(/\.eth$/,''); if(!label) return; selectMode('cl'); document.getElementById('tenantInput').value=label; state.tenantLabel=label; renderCanonicalSearch(); }
10151058

1016-
function useEnsAsNamespace(name){
1017-
selectMode('own');
1018-
document.getElementById('ensInput').value=String(name||'').toLowerCase();
1019-
}
1059+
function useEnsAsNamespace(name){ selectMode('own'); document.getElementById('ensInput').value=String(name||'').toLowerCase(); }
10201060

10211061
function goStep5Auth(){
10221062
updateSiweModeHint();
1063+
updateStep3ModeUI();
10231064
if(state.activationMode==='cl' && !state.authenticatedAddress){
10241065
alert('Sign-In with Ethereum is required before submitting CommandLayer activation request.');
10251066
return;
@@ -1396,7 +1437,7 @@ <h3>Payment and provisioning are coming next.</h3>
13961437

13971438
function startOver() {
13981439
state = {
1399-
step:1, ens:'', activationMode:'cl', capMode:'packs', selectedPack:null, cherryVerbs:[],
1440+
step:1, ens:'', tenantLabel:'', activationMode:'cl', capMode:'packs', selectedPack:null, cherryVerbs:[],
14001441
pubKeyB64:'', privKeyB64:'', kid:'', keyGenerated:false, keyDownloaded:false, keyAcked:false,
14011442
_cardJson:'', _packageJson:'', submitResult:null, authenticatedAddress:'', authStatus:'NOT_AUTHENTICATED', authChainId:null, siweAuthenticated:false
14021443
};
@@ -1414,6 +1455,9 @@ <h3>Payment and provisioning are coming next.</h3>
14141455

14151456
// ── INIT ──────────────────────────────────────────────────────────────────────
14161457
updateProgress();
1458+
document.getElementById('canonicalSearchInput').addEventListener('input', renderCanonicalSearch);
1459+
renderCanonicalSearch();
1460+
updateStep3ModeUI();
14171461
</script>
14181462
</body>
14191463
</html>

tests/api-claim-commandlayer-namespace.test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,50 @@ test('valid payload with mocked DB returns CLAIM_REQUEST_CREATED', async () => {
103103
assert.equal(calls.length >= 5, true);
104104
});
105105

106+
107+
test('user-owned ENS activationMode rejected by commandlayer namespace endpoint', async () => {
108+
process.env.DATABASE_URL = 'postgres://example.com/db';
109+
const handler = loadHandlerWithMockQuery(async () => ({ rows: [] }));
110+
const body = validBody();
111+
body.activationMode = 'own';
112+
const res = makeRes();
113+
await handler({ method: 'POST', body }, res);
114+
assert.equal(res.statusCode, 400);
115+
assert.equal(res.body.error, 'invalid_activation_mode');
116+
});
117+
118+
test('agent.ens with wrong parent rejected', async () => {
119+
process.env.DATABASE_URL = 'postgres://example.com/db';
120+
const handler = loadHandlerWithMockQuery(async () => ({ rows: [] }));
121+
const body = validBody();
122+
body.agents[0].ens = 'acme.approve.someone.eth';
123+
const res = makeRes();
124+
await handler({ method: 'POST', body }, res);
125+
assert.equal(res.statusCode, 400);
126+
assert.equal(res.body.error, 'invalid_agent_ens');
127+
});
128+
129+
test('unsupported canonical parent rejected', async () => {
130+
process.env.DATABASE_URL = 'postgres://example.com/db';
131+
const handler = loadHandlerWithMockQuery(async () => ({ rows: [] }));
132+
const body = validBody();
133+
body.agents[0].canonicalParent = 'searchagent.eth';
134+
const res = makeRes();
135+
await handler({ method: 'POST', body }, res);
136+
assert.equal(res.statusCode, 400);
137+
assert.equal(res.body.error, 'invalid_agent_mapping');
138+
});
139+
140+
test('tenant containing .eth rejected', async () => {
141+
process.env.DATABASE_URL = 'postgres://example.com/db';
142+
const handler = loadHandlerWithMockQuery(async () => ({ rows: [] }));
143+
const body = validBody();
144+
body.tenant = 'acme.eth';
145+
const res = makeRes();
146+
await handler({ method: 'POST', body }, res);
147+
assert.equal(res.statusCode, 400);
148+
assert.equal(res.body.error, 'invalid_tenant');
149+
});
106150
test('claim.created event insertion is attempted', async () => {
107151
process.env.DATABASE_URL = 'postgres://example.com/db';
108152
const calls = [];

0 commit comments

Comments
 (0)