Skip to content

Commit e404892

Browse files
authored
Merge pull request #297 from commandlayer/codex/add-internal-admin-claims-view
Add internal admin claims APIs and review UI
2 parents daec652 + ca59ee9 commit e404892

5 files changed

Lines changed: 334 additions & 0 deletions

File tree

api/admin/_auth.js

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
'use strict';
2+
3+
function requireAdminAuth(req, res) {
4+
const configured = process.env.ADMIN_API_KEY;
5+
if (!configured) {
6+
res.status(503).json({ ok: false, status: 'ADMIN_NOT_CONFIGURED' });
7+
return false;
8+
}
9+
10+
const authorization = req.headers && (req.headers.authorization || req.headers.Authorization);
11+
const token = typeof authorization === 'string' && authorization.startsWith('Bearer ')
12+
? authorization.slice('Bearer '.length)
13+
: '';
14+
15+
if (!token || token !== configured) {
16+
res.status(401).json({ ok: false, status: 'UNAUTHORIZED' });
17+
return false;
18+
}
19+
20+
return true;
21+
}
22+
23+
module.exports = {
24+
requireAdminAuth
25+
};

api/admin/claim.js

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
'use strict';
2+
3+
const db = require('../../lib/db');
4+
const { requireAdminAuth } = require('./_auth');
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 !== 'GET') {
11+
res.setHeader('Allow', 'GET');
12+
return res.status(405).json({ ok: false, status: 'METHOD_NOT_ALLOWED' });
13+
}
14+
15+
if (!requireAdminAuth(req, res)) {
16+
return;
17+
}
18+
19+
const claimId = req.query && req.query.claimId;
20+
if (!claimId || typeof claimId !== 'string') {
21+
return res.status(400).json({ ok: false, status: 'INVALID_CLAIM_ID' });
22+
}
23+
24+
try {
25+
const claimResult = await db.query('select * from claim_requests where claim_id = $1 limit 1', [claimId]);
26+
if (!claimResult.rows.length) {
27+
return res.status(404).json({ ok: false, status: 'CLAIM_NOT_FOUND' });
28+
}
29+
30+
const agentsResult = await db.query(
31+
`select ens, capability, canonical_parent, skill, skill_family, created_at
32+
from claim_agents where claim_id = $1 order by created_at asc`,
33+
[claimId]
34+
);
35+
const eventsResult = await db.query(
36+
`select event_type, message, metadata_json, created_at
37+
from claim_events where claim_id = $1 order by created_at asc`,
38+
[claimId]
39+
);
40+
41+
return res.status(200).json({
42+
ok: true,
43+
claim: claimResult.rows[0],
44+
agents: agentsResult.rows,
45+
events: eventsResult.rows
46+
});
47+
} catch (error) {
48+
return res.status(500).json({ ok: false, status: 'ADMIN_CLAIM_QUERY_FAILED' });
49+
}
50+
};

api/admin/claims.js

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
'use strict';
2+
3+
const db = require('../../lib/db');
4+
const { requireAdminAuth } = require('./_auth');
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 !== 'GET') {
11+
res.setHeader('Allow', 'GET');
12+
return res.status(405).json({ ok: false, status: 'METHOD_NOT_ALLOWED' });
13+
}
14+
15+
if (!requireAdminAuth(req, res)) {
16+
return;
17+
}
18+
19+
const requestedLimit = Number.parseInt(req.query && req.query.limit, 10);
20+
const limit = Number.isFinite(requestedLimit) && requestedLimit > 0
21+
? Math.min(requestedLimit, 200)
22+
: 50;
23+
24+
try {
25+
const result = await db.query(
26+
`select claim_id, tenant, authenticated_address, activation_mode, pack_id, status, created_at
27+
from claim_requests
28+
order by created_at desc
29+
limit $1`,
30+
[limit]
31+
);
32+
33+
const claims = [];
34+
for (const row of result.rows) {
35+
const countResult = await db.query(
36+
'select count(*)::int as agent_count from claim_agents where claim_id = $1',
37+
[row.claim_id]
38+
);
39+
const agentCount = countResult.rows && countResult.rows[0] ? Number(countResult.rows[0].agent_count || 0) : 0;
40+
claims.push({
41+
claimId: row.claim_id,
42+
tenant: row.tenant,
43+
authenticatedAddress: row.authenticated_address,
44+
activationMode: row.activation_mode,
45+
packId: row.pack_id,
46+
status: row.status,
47+
agentCount,
48+
createdAt: row.created_at
49+
});
50+
}
51+
52+
return res.status(200).json({ ok: true, claims });
53+
} catch (error) {
54+
return res.status(500).json({ ok: false, status: 'ADMIN_CLAIMS_QUERY_FAILED' });
55+
}
56+
};

public/admin/claims.html

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>CommandLayer Claims Admin</title>
7+
<link rel="stylesheet" href="/css/site.css" />
8+
<style>
9+
body { background: #fafafa; color: #111827; }
10+
.container { max-width: 1200px; margin: 0 auto; padding: 24px; }
11+
.card { background: white; border: 1px solid #e5e7eb; border-radius: 10px; padding: 16px; margin-bottom: 16px; }
12+
table { width: 100%; border-collapse: collapse; }
13+
th, td { text-align: left; border-bottom: 1px solid #e5e7eb; padding: 8px; }
14+
tr:hover { background: #f9fafb; cursor: pointer; }
15+
pre { white-space: pre-wrap; overflow-wrap: anywhere; background: #f8fafc; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px; }
16+
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
17+
input { padding: 8px; min-width: 300px; }
18+
button { padding: 8px 12px; }
19+
.muted { color: #6b7280; }
20+
</style>
21+
</head>
22+
<body>
23+
<div class="container">
24+
<h1>CommandLayer Claims Admin</h1>
25+
<div class="card">
26+
<div class="row">
27+
<label for="apiKey">Admin API key</label>
28+
<input id="apiKey" type="password" placeholder="Enter ADMIN_API_KEY" />
29+
<button id="saveKey">Save key</button>
30+
<button id="loadClaims">Load claims</button>
31+
<span id="status" class="muted"></span>
32+
</div>
33+
</div>
34+
35+
<div class="card">
36+
<h2>Claims</h2>
37+
<table>
38+
<thead>
39+
<tr><th>Claim ID</th><th>Tenant</th><th>Wallet</th><th>Pack</th><th>Status</th><th>Agents</th><th>Created</th></tr>
40+
</thead>
41+
<tbody id="claimsBody"></tbody>
42+
</table>
43+
</div>
44+
45+
<div class="card">
46+
<h2>Claim detail</h2>
47+
<div id="detail" class="muted">Select a claim row to view details.</div>
48+
</div>
49+
</div>
50+
<script>
51+
(() => {
52+
const apiKeyInput = document.getElementById('apiKey');
53+
const saveKeyBtn = document.getElementById('saveKey');
54+
const loadClaimsBtn = document.getElementById('loadClaims');
55+
const statusEl = document.getElementById('status');
56+
const claimsBody = document.getElementById('claimsBody');
57+
const detail = document.getElementById('detail');
58+
59+
apiKeyInput.value = sessionStorage.getItem('cl_admin_api_key') || localStorage.getItem('cl_admin_api_key') || '';
60+
61+
function authHeaders() {
62+
return { Authorization: `Bearer ${apiKeyInput.value.trim()}` };
63+
}
64+
65+
saveKeyBtn.addEventListener('click', () => {
66+
const v = apiKeyInput.value.trim();
67+
sessionStorage.setItem('cl_admin_api_key', v);
68+
localStorage.setItem('cl_admin_api_key', v);
69+
statusEl.textContent = 'Saved.';
70+
});
71+
72+
async function loadClaims() {
73+
statusEl.textContent = 'Loading claims...';
74+
claimsBody.innerHTML = '';
75+
const res = await fetch('/api/admin/claims', { headers: authHeaders() });
76+
const data = await res.json();
77+
if (!res.ok || !data.ok) {
78+
statusEl.textContent = `${res.status} ${data.status || 'error'}`;
79+
return;
80+
}
81+
for (const claim of data.claims) {
82+
const tr = document.createElement('tr');
83+
tr.innerHTML = `<td>${claim.claimId}</td><td>${claim.tenant}</td><td>${claim.authenticatedAddress}</td><td>${claim.packId}</td><td>${claim.status}</td><td>${claim.agentCount}</td><td>${claim.createdAt}</td>`;
84+
tr.addEventListener('click', () => loadDetail(claim.claimId));
85+
claimsBody.appendChild(tr);
86+
}
87+
statusEl.textContent = `Loaded ${data.claims.length} claims.`;
88+
}
89+
90+
async function loadDetail(claimId) {
91+
const res = await fetch(`/api/admin/claim?claimId=${encodeURIComponent(claimId)}`, { headers: authHeaders() });
92+
const data = await res.json();
93+
if (!res.ok || !data.ok) {
94+
detail.textContent = `${res.status} ${data.status || 'error'}`;
95+
return;
96+
}
97+
const reqJson = data.claim.request_json || {};
98+
const ensRecords = reqJson && reqJson.records ? reqJson.records : null;
99+
detail.innerHTML = `
100+
<p><strong>claim ID:</strong> ${data.claim.claim_id}</p>
101+
<p><strong>tenant:</strong> ${data.claim.tenant}</p>
102+
<p><strong>wallet:</strong> ${data.claim.authenticated_address}</p>
103+
<p><strong>pack:</strong> ${data.claim.pack_id}</p>
104+
<p><strong>public key:</strong> ${data.claim.public_key || ''}</p>
105+
<p><strong>kid:</strong> ${data.claim.kid || ''}</p>
106+
<p><strong>status:</strong> ${data.claim.status}</p>
107+
<h3>Agents</h3>
108+
<pre>${JSON.stringify(data.agents, null, 2)}</pre>
109+
<h3>Events</h3>
110+
<pre>${JSON.stringify(data.events, null, 2)}</pre>
111+
<button id="copyClaimJson">Copy claim JSON</button>
112+
${ensRecords ? '<button id="copyEnsRecords">Copy ENS records</button>' : ''}
113+
`;
114+
document.getElementById('copyClaimJson').addEventListener('click', async () => {
115+
await navigator.clipboard.writeText(JSON.stringify(data.claim, null, 2));
116+
});
117+
if (ensRecords) {
118+
document.getElementById('copyEnsRecords').addEventListener('click', async () => {
119+
await navigator.clipboard.writeText(JSON.stringify(ensRecords, null, 2));
120+
});
121+
}
122+
}
123+
124+
loadClaimsBtn.addEventListener('click', loadClaims);
125+
})();
126+
</script>
127+
</body>
128+
</html>

tests/api-admin-claims.test.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
'use strict';
2+
3+
const test = require('node:test');
4+
const assert = require('node:assert/strict');
5+
6+
function makeRes() {
7+
return {
8+
statusCode: 200,
9+
headers: {},
10+
body: null,
11+
setHeader(name, value) { this.headers[name.toLowerCase()] = value; },
12+
status(code) { this.statusCode = code; return this; },
13+
json(payload) { this.body = payload; return this; }
14+
};
15+
}
16+
17+
function load(modulePath, mockQuery) {
18+
const handlerPath = require.resolve(modulePath);
19+
const dbPath = require.resolve('../lib/db');
20+
delete require.cache[handlerPath];
21+
delete require.cache[dbPath];
22+
require.cache[dbPath] = { exports: { query: mockQuery, getDatabaseUrl: () => process.env.DATABASE_URL } };
23+
return require(modulePath);
24+
}
25+
26+
test('admin claims returns ADMIN_NOT_CONFIGURED when key missing', async () => {
27+
delete process.env.ADMIN_API_KEY;
28+
const handler = load('../api/admin/claims', async () => ({ rows: [] }));
29+
const res = makeRes();
30+
await handler({ method: 'GET', headers: {}, query: {} }, res);
31+
assert.equal(res.statusCode, 503);
32+
assert.equal(res.body.status, 'ADMIN_NOT_CONFIGURED');
33+
});
34+
35+
test('admin claims returns UNAUTHORIZED when auth missing', async () => {
36+
process.env.ADMIN_API_KEY = 'secret';
37+
const handler = load('../api/admin/claims', async () => ({ rows: [] }));
38+
const res = makeRes();
39+
await handler({ method: 'GET', headers: {}, query: {} }, res);
40+
assert.equal(res.statusCode, 401);
41+
assert.equal(res.body.status, 'UNAUTHORIZED');
42+
});
43+
44+
test('admin claims returns list when authorized', async () => {
45+
process.env.ADMIN_API_KEY = 'secret';
46+
const calls = [];
47+
const handler = load('../api/admin/claims', async (text, params) => {
48+
calls.push(String(text));
49+
if (String(text).includes('from claim_requests')) {
50+
return { rows: [{ claim_id: 'clm_1', tenant: 'commandlayer', authenticated_address: '0x1', activation_mode: 'cl', pack_id: 'trust', status: 'created', created_at: '2026-05-23T00:00:00.000Z' }] };
51+
}
52+
return { rows: [{ agent_count: 2 }] };
53+
});
54+
const res = makeRes();
55+
await handler({ method: 'GET', headers: { authorization: 'Bearer secret' }, query: {} }, res);
56+
assert.equal(res.statusCode, 200);
57+
assert.equal(res.body.ok, true);
58+
assert.equal(res.body.claims[0].agentCount, 2);
59+
});
60+
61+
test('admin claim detail returns agents and events when authorized', async () => {
62+
process.env.ADMIN_API_KEY = 'secret';
63+
const handler = load('../api/admin/claim', async (text) => {
64+
const q = String(text);
65+
if (q.includes('from claim_requests')) return { rows: [{ claim_id: 'clm_1', tenant: 'commandlayer', request_json: {} }] };
66+
if (q.includes('from claim_agents')) return { rows: [{ ens: 'x.signagent.eth' }] };
67+
return { rows: [{ event_type: 'claim.created' }] };
68+
});
69+
const res = makeRes();
70+
await handler({ method: 'GET', headers: { authorization: 'Bearer secret' }, query: { claimId: 'clm_1' } }, res);
71+
assert.equal(res.statusCode, 200);
72+
assert.equal(res.body.ok, true);
73+
assert.equal(Array.isArray(res.body.agents), true);
74+
assert.equal(Array.isArray(res.body.events), true);
75+
});

0 commit comments

Comments
 (0)