Skip to content
Merged
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
116 changes: 116 additions & 0 deletions api/admin/publish-agent-cards.js
Original file line number Diff line number Diff line change
@@ -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: []
};
}
29 changes: 29 additions & 0 deletions api/agent-cards/card.js
Original file line number Diff line number Diff line change
@@ -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' });
}
};
20 changes: 20 additions & 0 deletions db/migrations/003_agent_cards.sql
Original file line number Diff line number Diff line change
@@ -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);
30 changes: 29 additions & 1 deletion public/admin/claims.html
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,19 @@ <h1>CommandLayer Claims Admin</h1>
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 } });
Expand Down Expand Up @@ -94,8 +107,23 @@ <h3>Agents</h3><pre>${JSON.stringify(data.agents || [], null, 2)}</pre>`;
}
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 = `<h3>Published card URLs</h3><pre>${JSON.stringify(urls, null, 2)}</pre>`;
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));
Expand Down
92 changes: 92 additions & 0 deletions tests/api-admin-publish-agent-cards.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
Loading