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 @@
${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 = `${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);
+});