diff --git a/api/admin/publish-agent-cards.js b/api/admin/publish-agent-cards.js new file mode 100644 index 0000000..c2fdd87 --- /dev/null +++ b/api/admin/publish-agent-cards.js @@ -0,0 +1,116 @@ +'use strict'; + +const db = require('../../lib/db'); +const { requireAdminAuth } = require('./_auth'); + +const BASE_URL = 'https://www.commandlayer.org'; +const CARD_VERSION = '1.1.0'; + +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 !== 'POST') { + res.setHeader('Allow', 'POST'); + return res.status(405).json({ ok: false, status: 'METHOD_NOT_ALLOWED' }); + } + if (!requireAdminAuth(req, res)) return; + + const claimId = typeof req.body?.claimId === 'string' ? req.body.claimId.trim() : ''; + if (!claimId) return res.status(400).json({ ok: false, status: 'INVALID_CLAIM_ID' }); + + try { + const claimRows = db.normalizeRows(await db.query('select * from claim_requests where claim_id = $1 limit 1', [claimId])); + if (!claimRows.length) return res.status(404).json({ ok: false, status: 'CLAIM_NOT_FOUND' }); + if (claimRows[0].status !== 'approved' && claimRows[0].status !== 'cards_published') { + return res.status(409).json({ ok: false, status: 'INVALID_STATUS', error: 'Claim must be approved before publishing cards.' }); + } + + const agents = db.normalizeRows(await db.query('select * from claim_agents where claim_id = $1 order by capability asc', [claimId])); + if (!agents.length) return res.status(409).json({ ok: false, status: 'NO_AGENTS_FOR_CLAIM' }); + + const existing = db.normalizeRows(await db.query('select * from agent_cards where claim_id = $1 order by ens asc', [claimId])); + if (existing.length) { + return res.status(200).json({ ok: true, claimId, status: 'CARDS_ALREADY_PUBLISHED', cards: existing.map((r) => ({ ens: r.ens, cardUrl: r.card_url })) }); + } + + const cards = []; + for (const agent of agents) { + const ens = String(agent.ens || '').trim(); + const capability = String(agent.capability || '').trim(); + const cardPath = `/agent-cards/agents/v${CARD_VERSION}/trust/${ens}.json`; + const cardUrl = `${BASE_URL}${cardPath}`; + const cardJson = buildCardJson(agent, ens, capability); + + await db.query( + `insert into agent_cards (claim_id, ens, card_url, card_json, version, status) + values ($1, $2, $3, $4::jsonb, $5, 'published') + on conflict (card_url) + do update set + claim_id = excluded.claim_id, + ens = excluded.ens, + card_json = excluded.card_json, + version = excluded.version, + status = 'published', + updated_at = now()`, + [claimId, ens, cardUrl, JSON.stringify(cardJson), CARD_VERSION] + ); + + await db.query( + `update claim_agents + set card_url = $3, + card_status = 'published', + card_published_at = now() + where claim_id = $1 and id = $2`, + [claimId, agent.id, cardUrl] + ); + cards.push({ ens, cardUrl }); + } + + await db.query("update claim_requests set status = 'cards_published' where claim_id = $1", [claimId]); + await db.query( + `insert into claim_events (claim_id, event_type, actor, message, event_json) + values ($1, 'agent_cards.published', 'admin', 'Agent cards published', $2::jsonb)`, + [claimId, JSON.stringify({ count: cards.length, cards })] + ); + await db.query( + `insert into claim_status_transitions (claim_id, from_status, to_status, action, actor, reason, metadata_json) + values ($1, 'approved', 'cards_published', 'publish_agent_cards', 'admin', null, $2::jsonb)`, + [claimId, JSON.stringify({ cardCount: cards.length })] + ); + + return res.status(200).json({ ok: true, claimId, status: 'CARDS_PUBLISHED', cards }); + } catch (error) { + console.error('ADMIN_PUBLISH_AGENT_CARDS_FAILED', { message: error.message, code: error.code, claimId }); + const payload = { ok: false, status: 'ADMIN_PUBLISH_AGENT_CARDS_FAILED', error: 'Failed to publish agent cards.' }; + if (process.env.NODE_ENV !== 'production') payload.debug = { message: error.message, code: error.code }; + return res.status(500).json(payload); + } +}; + +function buildCardJson(agent, ens, capability) { + return { + type: 'erc8004/registration/v1', + name: ens, + description: `CommandLayer Trust Verification agent for ${capability}.`, + image: 'https://www.commandlayer.org/icon2.png', + services: [ + { type: 'ens', endpoint: ens }, + { type: 'commandlayer_runtime', endpoint: 'https://runtime.commandlayer.org' }, + { type: 'commandlayer_verifier', endpoint: 'https://runtime.commandlayer.org/verify' } + ], + commandlayer: { + version: CARD_VERSION, + tenant: agent.tenant, + capability, + canonicalParent: agent.canonical_parent, + skill: agent.skill, + skillFamily: agent.skill_family, + kid: agent.kid, + publicKey: agent.public_key, + runtime: 'https://runtime.commandlayer.org', + verifier: 'https://runtime.commandlayer.org/verify' + }, + registrations: [] + }; +} diff --git a/api/agent-cards/card.js b/api/agent-cards/card.js new file mode 100644 index 0000000..4437824 --- /dev/null +++ b/api/agent-cards/card.js @@ -0,0 +1,29 @@ +'use strict'; + +const db = require('../../lib/db'); + +module.exports = async function handler(req, res) { + if (req.method !== 'GET') { + res.setHeader('Allow', 'GET'); + return res.status(405).json({ ok: false, status: 'METHOD_NOT_ALLOWED' }); + } + + const ens = typeof req.query?.ens === 'string' ? req.query.ens.trim() : ''; + const path = typeof req.query?.path === 'string' ? req.query.path.trim() : ''; + + if (!ens && !path) return res.status(400).json({ ok: false, status: 'INVALID_CARD_LOOKUP' }); + + try { + const rows = db.normalizeRows(await db.query( + 'select card_json from agent_cards where ens = $1 or card_url like $2 limit 1', + [ens, `%${path}`] + )); + if (!rows.length) return res.status(404).json({ ok: false, status: 'CARD_NOT_FOUND' }); + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'public, max-age=300'); + return res.status(200).json(rows[0].card_json); + } catch (error) { + console.error('PUBLIC_AGENT_CARD_LOOKUP_FAILED', { message: error.message, code: error.code }); + return res.status(500).json({ ok: false, status: 'PUBLIC_AGENT_CARD_LOOKUP_FAILED' }); + } +}; diff --git a/db/migrations/003_agent_cards.sql b/db/migrations/003_agent_cards.sql new file mode 100644 index 0000000..7fa3d56 --- /dev/null +++ b/db/migrations/003_agent_cards.sql @@ -0,0 +1,20 @@ +alter table if exists claim_agents + add column if not exists card_url text, + add column if not exists card_status text, + add column if not exists card_published_at timestamptz; + +create table if not exists agent_cards ( + id uuid primary key default gen_random_uuid(), + claim_id text not null references claim_requests(claim_id) on delete cascade, + ens text not null, + card_url text unique not null, + card_json jsonb not null, + version text not null default '1.1.0', + status text not null default 'published', + published_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists idx_agent_cards_claim_id on agent_cards (claim_id); +create index if not exists idx_agent_cards_ens on agent_cards (ens); +create index if not exists idx_agent_cards_card_url on agent_cards (card_url); diff --git a/public/admin/claims.html b/public/admin/claims.html index c951bdd..0529904 100644 --- a/public/admin/claims.html +++ b/public/admin/claims.html @@ -67,6 +67,19 @@

CommandLayer Claims Admin

await loadDetail(selectedClaimId); await loadClaims(); } + async function publishAgentCards() { + const res = await fetch('/api/admin/publish-agent-cards', { method: 'POST', headers: authHeaders(), body: JSON.stringify({ claimId: selectedClaimId }) }); + const data = await res.json(); + if (!res.ok || !data.ok) { + claimActionError = { status: data.status || 'REQUEST_FAILED', error: data.error || 'Request failed.', debug: data.debug || null }; + await loadDetail(selectedClaimId); + return; + } + claimActionError = null; + await loadDetail(selectedClaimId); + await loadClaims(); + } + async function loadDetail(claimId) { selectedClaimId = claimId; const res = await fetch(`/api/admin/claim?claimId=${encodeURIComponent(claimId)}`, { headers: { Authorization: authHeaders().Authorization } }); @@ -94,8 +107,23 @@

Agents

${JSON.stringify(data.agents || [], null, 2)}
`; } const mk = (label, cb) => { const b = document.createElement('button'); b.textContent = label; b.addEventListener('click', cb); actionRow.appendChild(b); }; if (status === 'created') { mk('Approve', () => claimAction('approve', { notes: document.getElementById('notesInput').value })); mk('Reject', () => claimAction('reject', { reason: document.getElementById('reasonInput').value })); mk('Mark failed', () => claimAction('mark_failed', { reason: document.getElementById('reasonInput').value })); } - if (status === 'approved') { document.getElementById('nextStep').textContent = 'Next: Publish agent cards (placeholder)'; mk('Mark failed', () => claimAction('mark_failed', { reason: document.getElementById('reasonInput').value })); } + if (status === 'approved') { document.getElementById('nextStep').textContent = 'Next: Publish agent cards'; mk('Publish agent cards', () => publishAgentCards()); mk('Mark failed', () => claimAction('mark_failed', { reason: document.getElementById('reasonInput').value })); } if (status === 'rejected') { mk('Reopen / Approve (override)', () => claimAction('approve', { override: true, reason: document.getElementById('reasonInput').value, notes: document.getElementById('notesInput').value })); mk('Mark failed', () => claimAction('mark_failed', { reason: document.getElementById('reasonInput').value })); } + if (status === 'cards_published') { + const urls = (data.agents || []).map((a) => a.card_url).filter(Boolean); + document.getElementById('nextStep').textContent = 'Next: Stripe checkout / payment placeholder'; + if (urls.length) { + const wrap = document.createElement('div'); + wrap.innerHTML = `

Published card URLs

${JSON.stringify(urls, null, 2)}
`; + const row = document.createElement('div'); row.className = 'row'; + const copyBtn = document.createElement('button'); copyBtn.textContent = 'Copy all card URLs'; + copyBtn.addEventListener('click', async () => { await navigator.clipboard.writeText(urls.join('\n')); statusEl.textContent = 'Card URLs copied.'; }); + const openBtn = document.createElement('button'); openBtn.textContent = 'Open first card'; + openBtn.addEventListener('click', () => window.open(urls[0], '_blank')); + row.appendChild(copyBtn); row.appendChild(openBtn); wrap.appendChild(row); + detail.appendChild(wrap); + } + } if (status === 'failed') { document.getElementById('nextStep').textContent = 'Failed status: auto-retry is not enabled yet.'; } document.getElementById('saveNoteBtn').addEventListener('click', () => claimAction('add_note', { notes: document.getElementById('notesInput').value, reason: document.getElementById('reasonInput').value })); document.getElementById('refreshBtn').addEventListener('click', () => loadDetail(claimId)); diff --git a/tests/api-admin-publish-agent-cards.test.js b/tests/api-admin-publish-agent-cards.test.js new file mode 100644 index 0000000..d8d8110 --- /dev/null +++ b/tests/api-admin-publish-agent-cards.test.js @@ -0,0 +1,92 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); + +function makeRes() { return { statusCode: 200, headers: {}, body: null, setHeader(n,v){this.headers[n.toLowerCase()]=v;}, status(c){this.statusCode=c;return this;}, json(p){this.body=p;return this;} }; } +function normalizeRows(result) { if (Array.isArray(result)) return result; if (result && Array.isArray(result.rows)) return result.rows; return []; } + +function load(modulePath, mockQuery) { + const handlerPath = require.resolve(modulePath); + const dbPath = require.resolve('../lib/db'); + delete require.cache[handlerPath]; delete require.cache[dbPath]; + require.cache[dbPath] = { exports: { query: mockQuery, normalizeRows, getDatabaseUrl: () => process.env.DATABASE_URL } }; + return require(modulePath); +} + +test('publish cards missing ADMIN_API_KEY', async () => { + delete process.env.ADMIN_API_KEY; + const handler = load('../api/admin/publish-agent-cards', async () => []); + const res = makeRes(); + await handler({ method: 'POST', headers: {}, body: { claimId: 'clm_1' } }, res); + assert.equal(res.statusCode, 503); +}); + +test('publish cards unauthorized', async () => { + process.env.ADMIN_API_KEY = 'secret'; + const handler = load('../api/admin/publish-agent-cards', async () => []); + const res = makeRes(); + await handler({ method: 'POST', headers: {}, body: { claimId: 'clm_1' } }, res); + assert.equal(res.statusCode, 401); +}); + +test('non-approved claim cannot publish cards', async () => { + process.env.ADMIN_API_KEY = 'secret'; + const handler = load('../api/admin/publish-agent-cards', async (text) => String(text).includes('from claim_requests') ? [{ claim_id: 'clm_1', status: 'created' }] : []); + const res = makeRes(); + await handler({ method: 'POST', headers: { authorization: 'Bearer secret' }, body: { claimId: 'clm_1' } }, res); + assert.equal(res.statusCode, 409); +}); + +test('approved claim publishes cards and updates status/event/transition', async () => { + process.env.ADMIN_API_KEY = 'secret'; + const calls = []; + const handler = load('../api/admin/publish-agent-cards', async (text, params) => { + calls.push({ text: String(text), params }); + if (String(text).includes('from claim_requests')) return [{ claim_id: 'clm_1', status: 'approved' }]; + if (String(text).includes('from claim_agents')) return [{ id: 'a1', claim_id: 'clm_1', ens: 'a.signagent.eth', capability: 'trust-verification', tenant: 'commandlayer', canonical_parent: 'x', skill: 's', skill_family: 'sf', kid: 'k', public_key: 'pk' }]; + if (String(text).includes('from agent_cards')) return []; + return []; + }); + const res = makeRes(); + await handler({ method: 'POST', headers: { authorization: 'Bearer secret' }, body: { claimId: 'clm_1' } }, res); + assert.equal(res.statusCode, 200); + assert.equal(res.body.status, 'CARDS_PUBLISHED'); + assert.ok(calls.some((c) => c.text.includes('insert into agent_cards'))); + assert.ok(calls.some((c) => c.text.includes('update claim_agents'))); + assert.ok(calls.some((c) => c.text.includes("update claim_requests set status = 'cards_published'"))); + assert.ok(calls.some((c) => c.text.includes("insert into claim_events") && c.text.includes('agent_cards.published'))); + assert.ok(calls.some((c) => c.text.includes('insert into claim_status_transitions'))); +}); + +test('idempotent publish returns existing cards', async () => { + process.env.ADMIN_API_KEY = 'secret'; + const calls = []; + const handler = load('../api/admin/publish-agent-cards', async (text) => { + calls.push(String(text)); + if (String(text).includes('from claim_requests')) return [{ claim_id: 'clm_1', status: 'approved' }]; + if (String(text).includes('from claim_agents')) return [{ id: 'a1', ens: 'a.signagent.eth' }]; + if (String(text).includes('from agent_cards')) return [{ ens: 'a.signagent.eth', card_url: 'https://www.commandlayer.org/agent-cards/agents/v1.1.0/trust/a.signagent.eth.json' }]; + return []; + }); + const res = makeRes(); + await handler({ method: 'POST', headers: { authorization: 'Bearer secret' }, body: { claimId: 'clm_1' } }, res); + assert.equal(res.statusCode, 200); + assert.equal(res.body.status, 'CARDS_ALREADY_PUBLISHED'); + assert.equal(calls.some((q) => q.includes('insert into agent_cards')), false); +}); + +test('public card route returns JSON', async () => { + const handler = load('../api/agent-cards/card', async () => [{ card_json: { ok: true } }]); + const res = makeRes(); + await handler({ method: 'GET', query: { ens: 'a.signagent.eth', path: '/agent-cards/agents/v1.1.0/trust/a.signagent.eth.json' } }, res); + assert.equal(res.statusCode, 200); + assert.deepEqual(res.body, { ok: true }); +}); + +test('public card route missing card returns 404', async () => { + const handler = load('../api/agent-cards/card', async () => []); + const res = makeRes(); + await handler({ method: 'GET', query: { ens: 'missing.signagent.eth' } }, res); + assert.equal(res.statusCode, 404); +});