Skip to content

Commit acc239d

Browse files
authored
Merge pull request #358 from commandlayer/codex/add-ipfs-pinning-for-paid-agent-cards
Add admin-controlled IPFS pinning for paid agent cards
2 parents 54cd933 + d1d16ca commit acc239d

5 files changed

Lines changed: 246 additions & 0 deletions

File tree

api/admin/pin-agent-cards.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
'use strict';
2+
3+
const db = require('../../lib/db');
4+
const { stableStringify, sha256Hex, pinJsonToPinata } = require('../../lib/ipfsPinning');
5+
6+
module.exports = async function handler(req, res) {
7+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
8+
res.setHeader('Cache-Control', 'no-store');
9+
10+
if (req.method !== 'POST') {
11+
res.setHeader('Allow', 'POST');
12+
return res.status(405).json({ ok: false, status: 'METHOD_NOT_ALLOWED' });
13+
}
14+
15+
if (!process.env.ADMIN_API_KEY || req.headers['x-admin-api-key'] !== process.env.ADMIN_API_KEY) {
16+
return res.status(401).json({ ok: false, status: 'UNAUTHORIZED' });
17+
}
18+
19+
const provider = process.env.IPFS_PINNING_PROVIDER || 'pinata';
20+
if (provider !== 'pinata') return res.status(400).json({ ok: false, status: 'UNSUPPORTED_PINNING_PROVIDER' });
21+
22+
const claimId = req.body && req.body.claimId;
23+
if (!claimId) return res.status(400).json({ ok: false, status: 'CLAIM_ID_REQUIRED' });
24+
25+
const claimResult = await db.query('select claim_id, status from claim_requests where claim_id = $1 limit 1', [claimId]);
26+
const claim = claimResult.rows[0];
27+
if (!claim) return res.status(404).json({ ok: false, status: 'CLAIM_NOT_FOUND' });
28+
if (claim.status !== 'paid' && claim.status !== 'cards_pinned') return res.status(400).json({ ok: false, status: 'CLAIM_NOT_PAID' });
29+
30+
const cardsResult = await db.query(
31+
`select id, claim_id, ens, card_json, card_cid, card_ipfs_uri, card_gateway_url, card_sha256, pin_status
32+
from agent_cards
33+
where claim_id = $1 and status = 'published'
34+
order by created_at asc`,
35+
[claimId]
36+
);
37+
if (!cardsResult.rows.length) return res.status(400).json({ ok: false, status: 'NO_PUBLISHED_CARDS' });
38+
39+
const allPinned = cardsResult.rows.every((r) => r.card_cid && r.card_ipfs_uri && r.card_sha256);
40+
if (allPinned) return res.status(200).json({ ok: true, status: 'ALREADY_PINNED', claimId, cards: cardsResult.rows });
41+
42+
const gatewayBase = (process.env.IPFS_GATEWAY_BASE_URL || 'https://gateway.pinata.cloud/ipfs').replace(/\/$/, '');
43+
44+
try {
45+
const pinned = [];
46+
for (const row of cardsResult.rows) {
47+
const canonical = stableStringify(row.card_json);
48+
const hash = sha256Hex(canonical);
49+
50+
if (row.card_cid && row.card_ipfs_uri && row.card_sha256) {
51+
pinned.push({ ens: row.ens, card_cid: row.card_cid, card_sha256: row.card_sha256, card_gateway_url: row.card_gateway_url });
52+
continue;
53+
}
54+
55+
const cid = await pinJsonToPinata(row.card_json);
56+
const ipfsUri = `ipfs://${cid}`;
57+
const gatewayUrl = `${gatewayBase}/${cid}`;
58+
await db.query(
59+
`update agent_cards
60+
set card_cid = $2, card_ipfs_uri = $3, card_gateway_url = $4, card_sha256 = $5,
61+
card_pinned_at = now(), pinning_provider = $6, pin_status = 'pinned', pin_error = null
62+
where id = $1`,
63+
[row.id, cid, ipfsUri, gatewayUrl, hash, provider]
64+
);
65+
pinned.push({ ens: row.ens, card_cid: cid, card_sha256: hash, card_gateway_url: gatewayUrl });
66+
}
67+
68+
await db.query('update claim_requests set status = $2, updated_at = now() where claim_id = $1', [claimId, 'cards_pinned']);
69+
await db.query(
70+
`insert into claim_events (claim_id, event_type, message, metadata_json)
71+
values ($1, 'agent_cards.pinned', 'Agent cards pinned to IPFS.', $2::jsonb)`,
72+
[claimId, JSON.stringify({ provider, count: pinned.length })]
73+
);
74+
await db.query(
75+
`insert into claim_status_transitions (claim_id, from_status, to_status)
76+
values ($1, 'paid', 'cards_pinned')`,
77+
[claimId]
78+
);
79+
80+
return res.status(200).json({ ok: true, status: 'CARDS_PINNED', claimId, provider, cards: pinned });
81+
} catch (error) {
82+
await db.query(
83+
`update agent_cards set pin_status = 'error', pin_error = $2 where claim_id = $1 and status = 'published'`,
84+
[claimId, String(error && error.message ? error.message : 'pinning_failed')]
85+
);
86+
return res.status(502).json({ ok: false, status: 'PINNING_FAILED', claimId });
87+
}
88+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
alter table if exists agent_cards
2+
add column if not exists card_cid text,
3+
add column if not exists card_ipfs_uri text,
4+
add column if not exists card_gateway_url text,
5+
add column if not exists card_sha256 text,
6+
add column if not exists card_pinned_at timestamptz,
7+
add column if not exists pinning_provider text,
8+
add column if not exists pin_status text,
9+
add column if not exists pin_error text;

lib/ipfsPinning.js

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
'use strict';
2+
3+
const crypto = require('node:crypto');
4+
5+
function stableStringify(value) {
6+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
7+
if (value && typeof value === 'object') {
8+
const keys = Object.keys(value).sort();
9+
return `{${keys.map((k) => `${JSON.stringify(k)}:${stableStringify(value[k])}`).join(',')}}`;
10+
}
11+
return JSON.stringify(value);
12+
}
13+
14+
function sha256Hex(value) {
15+
return crypto.createHash('sha256').update(value).digest('hex');
16+
}
17+
18+
async function pinJsonToPinata(cardJson) {
19+
const jwt = process.env.PINATA_JWT;
20+
if (!jwt) throw new Error('PINATA_JWT_MISSING');
21+
const response = await fetch('https://api.pinata.cloud/pinning/pinJSONToIPFS', {
22+
method: 'POST',
23+
headers: {
24+
Authorization: `Bearer ${jwt}`,
25+
'Content-Type': 'application/json',
26+
},
27+
body: JSON.stringify({ pinataContent: cardJson }),
28+
});
29+
if (!response.ok) throw new Error(`PINATA_ERROR_${response.status}`);
30+
const body = await response.json();
31+
if (!body || !body.IpfsHash) throw new Error('PINATA_MISSING_CID');
32+
return body.IpfsHash;
33+
}
34+
35+
module.exports = { stableStringify, sha256Hex, pinJsonToPinata };

public/admin-agent-cards.html

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<!doctype html>
2+
<html><head><meta charset="utf-8"><title>Admin Agent Cards</title></head>
3+
<body>
4+
<h1>Admin Agent Card Pinning</h1>
5+
<input id="claimId" placeholder="claim id" />
6+
<button id="loadBtn">Load</button>
7+
<div id="status"></div>
8+
<div id="actions"></div>
9+
<pre id="details"></pre>
10+
<script>
11+
async function loadClaim(){
12+
const claimId=document.getElementById('claimId').value.trim();
13+
const key=localStorage.getItem('adminApiKey')||'';
14+
const status=document.getElementById('status');
15+
const actions=document.getElementById('actions');
16+
const details=document.getElementById('details');
17+
status.textContent=''; actions.innerHTML=''; details.textContent='';
18+
if(!claimId) return;
19+
const paidBtn=document.createElement('button');
20+
paidBtn.textContent='Pin cards to IPFS';
21+
paidBtn.onclick=async()=>{
22+
const r=await fetch('/api/admin/pin-agent-cards',{method:'POST',headers:{'content-type':'application/json','x-admin-api-key':key},body:JSON.stringify({claimId})});
23+
details.textContent=JSON.stringify(await r.json(),null,2);
24+
};
25+
const ensBtn=document.createElement('button');
26+
ensBtn.textContent='Copy ENS records';
27+
ensBtn.onclick=async()=>navigator.clipboard.writeText('');
28+
status.textContent='If status = paid: pin cards. If status = cards_pinned: show CID/hash/gateway links.';
29+
actions.appendChild(paidBtn); actions.appendChild(ensBtn);
30+
}
31+
32+
document.getElementById('loadBtn').onclick=loadClaim;
33+
</script>
34+
</body></html>
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
'use strict';
2+
3+
const test = require('node:test');
4+
const assert = require('node:assert/strict');
5+
6+
const db = require('../lib/db');
7+
const handler = require('../api/admin/pin-agent-cards');
8+
9+
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; } }; }
10+
11+
test('unpaid claims rejected', async () => {
12+
process.env.ADMIN_API_KEY = 'k';
13+
db.query = async (q) => (q.includes('from claim_requests') ? { rows: [{ claim_id: 'c1', status: 'created' }] } : { rows: [] });
14+
const res = makeRes();
15+
await handler({ method: 'POST', headers: { 'x-admin-api-key': 'k' }, body: { claimId: 'c1' } }, res);
16+
assert.equal(res.statusCode, 400);
17+
assert.equal(res.body.status, 'CLAIM_NOT_PAID');
18+
});
19+
20+
test('paid claim pins cards', async () => {
21+
process.env.ADMIN_API_KEY = 'k';
22+
process.env.PINATA_JWT = 'jwt';
23+
const calls = [];
24+
global.fetch = async () => ({ ok: true, json: async () => ({ IpfsHash: 'bafy123' }) });
25+
db.query = async (q, p) => {
26+
calls.push(q);
27+
if (q.includes('from claim_requests')) return { rows: [{ claim_id: 'c2', status: 'paid' }] };
28+
if (q.includes('from agent_cards')) return { rows: [{ id: 'a1', ens: 'x.eth', claim_id: 'c2', card_json: { a: 1 }, status: 'published' }] };
29+
return { rows: [] };
30+
};
31+
const res = makeRes();
32+
await handler({ method: 'POST', headers: { 'x-admin-api-key': 'k' }, body: { claimId: 'c2' } }, res);
33+
assert.equal(res.statusCode, 200);
34+
assert.equal(res.body.status, 'CARDS_PINNED');
35+
assert.ok(calls.some((q) => q.includes('update claim_requests set status')));
36+
});
37+
38+
test('already pinned claim returns existing CID', async () => {
39+
process.env.ADMIN_API_KEY = 'k';
40+
db.query = async (q) => {
41+
if (q.includes('from claim_requests')) return { rows: [{ claim_id: 'c3', status: 'cards_pinned' }] };
42+
if (q.includes('from agent_cards')) return { rows: [{ id: 'a1', card_cid: 'bafy', card_ipfs_uri: 'ipfs://bafy', card_sha256: 'h' }] };
43+
return { rows: [] };
44+
};
45+
const res = makeRes();
46+
await handler({ method: 'POST', headers: { 'x-admin-api-key': 'k' }, body: { claimId: 'c3' } }, res);
47+
assert.equal(res.statusCode, 200);
48+
assert.equal(res.body.status, 'ALREADY_PINNED');
49+
});
50+
51+
test('provider error records pin_error and does not mark cards_pinned', async () => {
52+
process.env.ADMIN_API_KEY = 'k';
53+
process.env.PINATA_JWT = 'jwt';
54+
const queries = [];
55+
global.fetch = async () => ({ ok: false, status: 500, json: async () => ({}) });
56+
db.query = async (q) => {
57+
queries.push(q);
58+
if (q.includes('from claim_requests')) return { rows: [{ claim_id: 'c4', status: 'paid' }] };
59+
if (q.includes('from agent_cards')) return { rows: [{ id: 'a1', card_json: { a: 1 }, status: 'published' }] };
60+
return { rows: [] };
61+
};
62+
const res = makeRes();
63+
await handler({ method: 'POST', headers: { 'x-admin-api-key': 'k' }, body: { claimId: 'c4' } }, res);
64+
assert.equal(res.statusCode, 502);
65+
assert.ok(queries.some((q) => q.includes("set pin_status = 'error'")));
66+
assert.ok(!queries.some((q) => q.includes('update claim_requests set status')));
67+
});
68+
69+
test('no secrets are logged', async () => {
70+
process.env.ADMIN_API_KEY = 'k';
71+
process.env.PINATA_JWT = 'super-secret-jwt';
72+
const logs = [];
73+
const oldLog = console.log;
74+
console.log = (...a) => logs.push(a.join(' '));
75+
db.query = async (q) => (q.includes('from claim_requests') ? { rows: [{ claim_id: 'c5', status: 'paid' }] } : { rows: [] });
76+
const res = makeRes();
77+
await handler({ method: 'POST', headers: { 'x-admin-api-key': 'k' }, body: { claimId: 'c5' } }, res);
78+
console.log = oldLog;
79+
assert.equal(logs.join('\n').includes('super-secret-jwt'), false);
80+
});

0 commit comments

Comments
 (0)