diff --git a/api/claim/commandlayer-namespace.js b/api/claim/commandlayer-namespace.js index da2951c..82c234c 100644 --- a/api/claim/commandlayer-namespace.js +++ b/api/claim/commandlayer-namespace.js @@ -40,10 +40,17 @@ function safeAgent(agent) { canonicalParent: String(agent.canonicalParent || agent.canonical_parent || '').toLowerCase().trim(), skill: String(agent.skill || '').trim(), skillFamily: String(agent.skillFamily || agent.skill_family || '').trim(), + records: agent.records && typeof agent.records === 'object' && !Array.isArray(agent.records) ? JSON.parse(JSON.stringify(agent.records)) : undefined, cardJson: sanitizedCard, }; } +function recordsFromAgent(agent) { + const card = agent && agent.cardJson && typeof agent.cardJson === 'object' ? agent.cardJson : {}; + const records = agent && agent.records && typeof agent.records === 'object' ? agent.records : card.records; + return records && typeof records === 'object' && !Array.isArray(records) ? records : {}; +} + module.exports = async function handler(req, res) { res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.setHeader('Cache-Control', 'no-store'); @@ -76,6 +83,19 @@ module.exports = async function handler(req, res) { if (tenantSignerCanonicalization !== CANONICALIZATION) return invalid(res, 'invalid_canonicalization', `canonicalization must be ${CANONICALIZATION}.`); if (!agents.length || agents.length > 10) return invalid(res, 'invalid_agents', 'At least one and at most ten generated agents are required.'); if (agents.some((a) => !ENS_RE.test(a.ens) || !a.capability || !a.canonicalParent || !a.skill || !a.skillFamily)) return invalid(res, 'invalid_agent', 'Every agent requires ens, capability, canonicalParent, skill, and skillFamily.'); + if (activationMode === 'managed_namespace') { + const primaryManagedSignerEns = agents[0].ens; + if (tenantSignerEns !== primaryManagedSignerEns) { + return invalid(res, 'managed_signer_mismatch', 'Managed namespace tenantSignerEns must match the first generated managed agent ENS.'); + } + const mismatchedAgent = agents.find((agent) => { + const recordSigner = recordsFromAgent(agent)['cl.receipt.signer']; + return recordSigner && String(recordSigner).toLowerCase().trim() !== agent.ens; + }); + if (mismatchedAgent) { + return invalid(res, 'managed_agent_signer_mismatch', 'Managed namespace agent TXT records must use the agent ENS as cl.receipt.signer.'); + } + } if (!process.env.DATABASE_URL) return res.status(503).json({ ok: false, status: 'STORAGE_UNAVAILABLE' }); const claimId = `clm_${crypto.randomUUID().replace(/-/g, '')}`; @@ -91,6 +111,7 @@ module.exports = async function handler(req, res) { activationMode, packId, tenantSignerEns, + primaryTenantSignerEns: activationMode === 'managed_namespace' ? agents[0].ens : tenantSignerEns, tenantSignerPublicKey, tenantSignerKid, tenantSignerCanonicalization, diff --git a/public/claim.html b/public/claim.html index e8235fc..1d2c4ff 100644 --- a/public/claim.html +++ b/public/claim.html @@ -672,8 +672,11 @@

Activation pipeline

} function getTenantSignerEns() { - const names = agentNames(); - return (names[0] || state.ens || '').toLowerCase(); + if (state.activationMode === 'cl') { + const names = agentNames(); + return (names[0] || state.ens || '').toLowerCase(); + } + return (state.ens || '').toLowerCase(); } function signerTxtRecordsObject() { @@ -1422,6 +1425,7 @@

Activation pipeline

key_model: 'single_ed25519_key_for_package', kid: state.kid, tenant_signer_ens: getTenantSignerEns(), + primary_tenant_signer_ens: getTenantSignerEns(), public_key: `ed25519:${state.pubKeyB64}`, tenant_signer_public_key: `ed25519:${state.pubKeyB64}`, tenant_signer_kid: state.kid, @@ -1469,8 +1473,9 @@

Activation pipeline

first_action_receipt: 'not_generated', agent_live: 'not_live' }; + const claim = data && data.claim; const rows = [ - ['1. Tenant signing identity generated locally', pipeline.tenant_signing_identity], + [`1. Signer ENS: ${claim && claim.tenant_signer_ens ? claim.tenant_signer_ens : getTenantSignerEns()}`, pipeline.tenant_signing_identity], ['2. Claim request created', pipeline.claim_request], ['3. Payment confirmed', pipeline.payment], ['4. ENS signer records verified', pipeline.ens_records], @@ -1485,7 +1490,6 @@

Activation pipeline

const icon = cls === 'complete' ? '✅' : (cls === 'active' ? '⏳' : '○'); return `
${icon}
${label}${status || 'pending'}
`; }).join(''); - const claim = data && data.claim; const firstAction = claim && (claim.first_action_receipt_status || 'not_generated'); const receiptNote = data && data.claim ? `
ℹ️
First Action ReceiptThis proves the claimed agent can produce a scoped execution receipt. It does not prove payment settlement. Status: ${firstAction}
` : ''; if (receiptNote) el.insertAdjacentHTML('beforeend', receiptNote); @@ -1494,7 +1498,7 @@

Activation pipeline

intro.textContent = claim.tenant_signer_record_status === 'records_verified' ? 'ENS signer identity verified on Ethereum mainnet' : (claim.activation_mode === 'managed_namespace' - ? 'Managed ENS publication automation is pending activation infrastructure.' + ? 'Managed namespace mode signs receipts from the generated VerbAgent ENS, not the tenant root ENS.' : 'Publish the four records with your ENS owner wallet, then verify records.'); } const box = document.getElementById('verifyRecordsBox'); diff --git a/tests/claim-activation-pipeline.test.js b/tests/claim-activation-pipeline.test.js index d758706..cc44a5a 100644 --- a/tests/claim-activation-pipeline.test.js +++ b/tests/claim-activation-pipeline.test.js @@ -350,3 +350,89 @@ test('public mutation endpoints have rate-limiting protection', async () => { assert.equal(proof.statusCode, 429); assert.equal(proof.body.status, 'RATE_LIMITED'); }); + +test('managed namespace package/recovery UI uses generated agent ENS as signer', () => { + const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'claim.html'), 'utf8'); + assert.ok(html.includes("if (state.activationMode === 'cl')")); + assert.ok(html.includes('primary_tenant_signer_ens: getTenantSignerEns()')); + assert.ok(html.includes("'cl.receipt.signer': agentName(v)")); + assert.ok(html.includes('tenant_signer_txt_package: signerTxtRecordPackage()')); + assert.ok(html.includes('tenant_signer_ens: result.tenantSignerEns || getTenantSignerEns()')); + assert.ok(html.includes('Signer ENS:')); + assert.ok(html.includes('Managed namespace mode signs receipts from the generated VerbAgent ENS, not the tenant root ENS.')); +}); + +test('managed namespace claim submission stores generated agent ENS and TXT signer', async () => { + resetRateLimitForTests(); + process.env.DATABASE_URL = 'postgres://example'; + const queries = []; + db.query = async (q, params) => { queries.push({ q, params }); return { rows: [], rowCount: 1 }; }; + const res = makeRes(); + await submitClaim({ method: 'POST', headers: { 'x-forwarded-for': '198.51.100.20' }, body: basePayload }, res); + assert.equal(res.statusCode, 202); + assert.equal(res.body.tenantSignerEns, 'acme.approveagent.eth'); + const insert = queries.find((entry) => entry.q.includes('insert into claim_requests')); + assert.ok(insert); + assert.equal(insert.params[10], 'acme.approveagent.eth'); + const txtRecords = JSON.parse(insert.params[15]); + assert.equal(txtRecords['cl.receipt.signer'], 'acme.approveagent.eth'); + const requestJson = JSON.parse(insert.params[19]); + assert.equal(requestJson.tenantSignerEns, 'acme.approveagent.eth'); + assert.equal(requestJson.primaryTenantSignerEns, 'acme.approveagent.eth'); +}); + +test('managed namespace multiple capabilities uses first agent as primary while allowing per-agent signers', async () => { + resetRateLimitForTests(); + process.env.DATABASE_URL = 'postgres://example'; + const queries = []; + db.query = async (q, params) => { queries.push({ q, params }); return { rows: [], rowCount: 1 }; }; + const payload = { + ...basePayload, + tenantSignerEns: 'acme.approveagent.eth', + agents: [ + { ens: 'acme.approveagent.eth', capability: 'approve', canonicalParent: 'approveagent.eth', skill: 'trust-verification.approve', skillFamily: 'trust-verification', cardJson: { records: { 'cl.receipt.signer': 'acme.approveagent.eth' } } }, + { ens: 'acme.attestagent.eth', capability: 'attest', canonicalParent: 'attestagent.eth', skill: 'trust-verification.attest', skillFamily: 'trust-verification', cardJson: { records: { 'cl.receipt.signer': 'acme.attestagent.eth' } } }, + ], + }; + const res = makeRes(); + await submitClaim({ method: 'POST', headers: { 'x-forwarded-for': '198.51.100.21' }, body: payload }, res); + assert.equal(res.statusCode, 202); + assert.equal(res.body.tenantSignerEns, 'acme.approveagent.eth'); + const insert = queries.find((entry) => entry.q.includes('insert into claim_requests')); + const requestJson = JSON.parse(insert.params[19]); + assert.equal(requestJson.primaryTenantSignerEns, 'acme.approveagent.eth'); + assert.deepEqual(requestJson.agents.map((a) => a.ens), ['acme.approveagent.eth', 'acme.attestagent.eth']); +}); + +test('bring your own ENS mode can still store root signer ENS', async () => { + resetRateLimitForTests(); + process.env.DATABASE_URL = 'postgres://example'; + const queries = []; + db.query = async (q, params) => { queries.push({ q, params }); return { rows: [], rowCount: 1 }; }; + const res = makeRes(); + await submitClaim({ method: 'POST', headers: { 'x-forwarded-for': '198.51.100.22' }, body: { ...basePayload, activationMode: 'bring_your_own_ens', tenantSignerEns: 'acme.eth' } }, res); + assert.equal(res.statusCode, 202); + assert.equal(res.body.tenantSignerEns, 'acme.eth'); + const insert = queries.find((entry) => entry.q.includes('insert into claim_requests')); + assert.equal(insert.params[10], 'acme.eth'); +}); + +test('managed namespace mismatch between tenant signer and primary agent is rejected', async () => { + resetRateLimitForTests(); + process.env.DATABASE_URL = 'postgres://example'; + db.query = async () => { throw new Error('db should not be called'); }; + const res = makeRes(); + await submitClaim({ method: 'POST', headers: { 'x-forwarded-for': '198.51.100.23' }, body: { ...basePayload, tenantSignerEns: 'acme.eth' } }, res); + assert.equal(res.statusCode, 400); + assert.equal(res.body.error, 'managed_signer_mismatch'); +}); + +test('managed namespace agent cl.receipt.signer mismatch is rejected', async () => { + resetRateLimitForTests(); + process.env.DATABASE_URL = 'postgres://example'; + db.query = async () => { throw new Error('db should not be called'); }; + const res = makeRes(); + await submitClaim({ method: 'POST', headers: { 'x-forwarded-for': '198.51.100.24' }, body: { ...basePayload, agents: [{ ...basePayload.agents[0], cardJson: { records: { 'cl.receipt.signer': 'acme.eth' } } }] } }, res); + assert.equal(res.statusCode, 400); + assert.equal(res.body.error, 'managed_agent_signer_mismatch'); +});