Skip to content

Commit 3d61c98

Browse files
authored
Merge pull request #282 from commandlayer/codex/upgrade-claim-endpoint-for-persistence
Persist CommandLayer namespace claim requests in Neon/Postgres
2 parents 46700d9 + c3b3ee4 commit 3d61c98

6 files changed

Lines changed: 196 additions & 51 deletions

File tree

api/claim/commandlayer-namespace.js

Lines changed: 58 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
'use strict';
22

33
const crypto = require('node:crypto');
4+
const db = require('../../lib/db');
45

56
const TRUST_VERIFICATION_MAP = {
67
sign: 'signagent.eth',
@@ -24,6 +25,14 @@ function invalid(res, error, reason) {
2425
return res.status(400).json({ ok: false, status: 'CLAIM_REQUEST_INVALID', error, reason });
2526
}
2627

28+
function storageUnavailable(res) {
29+
return res.status(503).json({
30+
ok: false,
31+
status: 'STORAGE_UNAVAILABLE',
32+
error: 'DATABASE_URL is not configured'
33+
});
34+
}
35+
2736
module.exports = async function handler(req, res) {
2837
res.setHeader('Content-Type', 'application/json; charset=utf-8');
2938
res.setHeader('Cache-Control', 'no-store');
@@ -38,7 +47,7 @@ module.exports = async function handler(req, res) {
3847
return invalid(res, 'invalid_body', 'Missing or invalid JSON body.');
3948
}
4049

41-
const { authenticatedAddress, tenant, activationMode, packId, capabilities, agents, publicKey, kid, verifier, runtime } = body;
50+
const { authenticatedAddress, tenant, activationMode, packId, capabilities, agents, publicKey, kid, verifier, runtime, schemaVersion } = body;
4251
if (!authenticatedAddress || !ADDRESS_RE.test(authenticatedAddress)) return invalid(res, 'invalid_authenticated_address', 'authenticatedAddress must be a valid 0x Ethereum address.');
4352
if (activationMode !== 'cl') return invalid(res, 'invalid_activation_mode', 'activationMode must be "cl".');
4453
if (typeof tenant !== 'string' || !tenant.trim()) return invalid(res, 'invalid_tenant', 'tenant is required.');
@@ -71,23 +80,64 @@ module.exports = async function handler(req, res) {
7180
if (ens !== `${tenant}.${canonicalParent}`) return invalid(res, 'invalid_agent_ens', `Agent ENS must equal "${tenant}.${canonicalParent}".`);
7281
}
7382

74-
const nowIso = new Date().toISOString();
75-
const digest = crypto.createHash('sha256').update(`${tenant}${authenticatedAddress}${kid}${nowIso}`).digest('hex');
76-
const claimId = `clm_${digest.slice(0, 24)}`;
83+
if (!process.env.DATABASE_URL) {
84+
return storageUnavailable(res);
85+
}
86+
87+
const claimId = `clm_${crypto.randomUUID().replace(/-/g, '')}`;
88+
89+
try {
90+
await db.query('BEGIN');
91+
await db.query(
92+
`insert into claim_requests
93+
(claim_id, authenticated_address, tenant, activation_mode, pack_id, public_key, kid, runtime, verifier, schema_version, request_json)
94+
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb)`,
95+
[claimId, authenticatedAddress, tenant, activationMode, packId, publicKey, kid, runtime, verifier, schemaVersion || '1.1.0', JSON.stringify(body)]
96+
);
97+
98+
for (const agent of agents) {
99+
await db.query(
100+
`insert into claim_agents
101+
(claim_id, ens, capability, canonical_parent, skill, skill_family)
102+
values ($1,$2,$3,$4,$5,$6)`,
103+
[claimId, agent.ens, agent.capability, agent.canonicalParent, agent.skill || '', agent.skillFamily || '']
104+
);
105+
}
106+
107+
await db.query(
108+
`insert into claim_events (claim_id, event_type, message, metadata_json)
109+
values ($1,$2,$3,$4::jsonb)`,
110+
[
111+
claimId,
112+
'claim.created',
113+
'CommandLayer namespace claim request created.',
114+
JSON.stringify({ agentCount: agents.length, packId, activationMode })
115+
]
116+
);
117+
await db.query('COMMIT');
118+
} catch (error) {
119+
await db.query('ROLLBACK');
120+
if (error && error.code === 'DATABASE_URL_MISSING') {
121+
return storageUnavailable(res);
122+
}
123+
return res.status(500).json({ ok: false, status: 'CLAIM_REQUEST_PERSISTENCE_ERROR', error: 'Failed to persist claim request.' });
124+
}
77125

78126
return res.status(200).json({
79127
ok: true,
80-
status: 'CLAIM_REQUEST_VALIDATED',
128+
status: 'CLAIM_REQUEST_CREATED',
81129
claimId,
82130
activationMode: 'cl',
83131
tenant,
84132
authenticatedAddress,
85133
agents,
86-
next: {
87-
operatorReview: true,
134+
lifecycle: {
135+
claim: 'created',
136+
operatorReview: 'not_started',
88137
ensProvisioning: 'not_started',
89138
agentCards: 'not_started',
90-
erc8004: 'not_started'
139+
erc8004: 'not_started',
140+
liveReceiptTest: 'not_started'
91141
}
92142
});
93143
};
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
create extension if not exists pgcrypto;
2+
3+
create table if not exists claim_requests (
4+
id uuid primary key default gen_random_uuid(),
5+
claim_id text unique not null,
6+
authenticated_address text not null,
7+
tenant text not null,
8+
activation_mode text not null,
9+
pack_id text not null,
10+
public_key text not null,
11+
kid text not null,
12+
runtime text not null,
13+
verifier text not null,
14+
schema_version text not null,
15+
status text not null default 'created',
16+
request_json jsonb not null,
17+
created_at timestamptz not null default now(),
18+
updated_at timestamptz not null default now()
19+
);
20+
21+
create table if not exists claim_agents (
22+
id uuid primary key default gen_random_uuid(),
23+
claim_id text not null references claim_requests(claim_id) on delete cascade,
24+
ens text not null,
25+
capability text not null,
26+
canonical_parent text not null,
27+
skill text not null,
28+
skill_family text not null,
29+
status text not null default 'created',
30+
created_at timestamptz not null default now()
31+
);
32+
33+
create table if not exists claim_events (
34+
id uuid primary key default gen_random_uuid(),
35+
claim_id text not null references claim_requests(claim_id) on delete cascade,
36+
event_type text not null,
37+
message text not null,
38+
metadata_json jsonb,
39+
created_at timestamptz not null default now()
40+
);
41+
42+
create index if not exists idx_claim_requests_claim_id on claim_requests(claim_id);
43+
create index if not exists idx_claim_requests_tenant on claim_requests(tenant);
44+
create index if not exists idx_claim_requests_wallet on claim_requests(authenticated_address);
45+
create index if not exists idx_claim_agents_claim_id on claim_agents(claim_id);
46+
create index if not exists idx_claim_events_claim_id on claim_events(claim_id);

lib/db.js

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
'use strict';
2+
3+
function getDatabaseUrl() {
4+
const databaseUrl = process.env.DATABASE_URL;
5+
if (!databaseUrl) {
6+
const error = new Error('DATABASE_URL is not configured');
7+
error.code = 'DATABASE_URL_MISSING';
8+
throw error;
9+
}
10+
return databaseUrl;
11+
}
12+
13+
async function query(text, params = []) {
14+
// Lazy-load Neon so tests can mock this module without requiring installed drivers.
15+
const { neon } = require('@neondatabase/serverless');
16+
const sql = neon(getDatabaseUrl());
17+
return sql.query(text, params);
18+
}
19+
20+
module.exports = {
21+
query,
22+
getDatabaseUrl
23+
};

package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"ajv": "^8.17.1",
1212
"ajv-formats": "^3.0.1",
1313
"ethers": "^6.16.0",
14-
"siwe": "^3.0.0"
14+
"siwe": "^3.0.0",
15+
"@neondatabase/serverless": "^1.0.0"
1516
}
1617
}

public/claim.html

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -546,7 +546,7 @@ <h3>Submit CommandLayer namespace activation</h3>
546546
</div>
547547
<div id="activationResult" style="display:none;margin-top:12px;color:var(--red);font-size:13px"></div>
548548
<div id="activationOk" style="display:none;margin-top:14px;text-align:left;padding:12px;border:1px solid var(--border);border-radius:12px;background:#fff">
549-
<strong>Activation request validated</strong><br>
549+
<strong>Activation request created</strong><br>
550550
Claim ID: <span id="activationClaimId"></span><br>
551551
Wallet: <span id="activationWallet"></span><br>
552552
Generated agents: <span id="activationAgentCount"></span>
@@ -1368,15 +1368,23 @@ <h3>Payment and provisioning are coming next.</h3>
13681368
try {
13691369
const res = await fetch('/api/claim/commandlayer-namespace', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload) });
13701370
const data = await res.json();
1371-
if (!res.ok || !data.ok) throw new Error(`${data.error || 'request_failed'}${data.reason || 'Unknown error'}`);
1371+
if (!res.ok || !data.ok) {
1372+
const err = new Error(`${data.error || 'request_failed'}${data.reason || 'Unknown error'}`);
1373+
err.status = data.status;
1374+
throw err;
1375+
}
13721376
state.submitResult = data;
13731377
document.getElementById('activationOk').style.display = 'block';
13741378
document.getElementById('activationClaimId').textContent = data.claimId;
13751379
document.getElementById('activationWallet').textContent = data.authenticatedAddress;
13761380
document.getElementById('activationAgentCount').textContent = String((data.agents || []).length);
13771381
} catch (err) {
13781382
document.getElementById('activationOk').style.display = 'none';
1379-
resultEl.textContent = `Activation request failed: ${(err && err.message) || 'unknown_error'}`;
1383+
if (err && err.status === 'STORAGE_UNAVAILABLE') {
1384+
resultEl.textContent = 'Activation request could not be saved: database is not configured.';
1385+
} else {
1386+
resultEl.textContent = `Activation request failed: ${(err && err.message) || 'unknown_error'}`;
1387+
}
13801388
resultEl.style.display = 'block';
13811389
} finally {
13821390
btn.disabled = !canSubmitActivationRequest(); btn.textContent = 'Submit activation request';

tests/api-claim-commandlayer-namespace.test.js

Lines changed: 56 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,6 @@
33
const test = require('node:test');
44
const assert = require('node:assert/strict');
55

6-
const handler = require('../api/claim/commandlayer-namespace');
7-
86
function makeRes() {
97
return {
108
statusCode: 200,
@@ -34,71 +32,90 @@ function validBody() {
3432
};
3533
}
3634

35+
function loadHandlerWithMockQuery(mockQuery) {
36+
const handlerPath = require.resolve('../api/claim/commandlayer-namespace');
37+
const dbPath = require.resolve('../lib/db');
38+
delete require.cache[handlerPath];
39+
delete require.cache[dbPath];
40+
require.cache[dbPath] = { exports: { query: mockQuery, getDatabaseUrl: () => process.env.DATABASE_URL } };
41+
return require('../api/claim/commandlayer-namespace');
42+
}
43+
3744
test('rejects non-POST', async () => {
45+
const handler = loadHandlerWithMockQuery(async () => ({ rows: [] }));
3846
const res = makeRes();
3947
await handler({ method: 'GET', body: validBody() }, res);
4048
assert.equal(res.statusCode, 405);
4149
});
4250

43-
test('rejects missing authenticatedAddress', async () => {
44-
const body = validBody();
45-
delete body.authenticatedAddress;
51+
test('missing DATABASE_URL returns STORAGE_UNAVAILABLE for valid payload', async () => {
52+
const original = process.env.DATABASE_URL;
53+
delete process.env.DATABASE_URL;
54+
const handler = loadHandlerWithMockQuery(async () => ({ rows: [] }));
4655
const res = makeRes();
47-
await handler({ method: 'POST', body }, res);
48-
assert.equal(res.statusCode, 400);
49-
assert.equal(res.body.error, 'invalid_authenticated_address');
56+
await handler({ method: 'POST', body: validBody() }, res);
57+
assert.equal(res.statusCode, 503);
58+
assert.equal(res.body.status, 'STORAGE_UNAVAILABLE');
59+
process.env.DATABASE_URL = original;
5060
});
5161

52-
test('rejects invalid tenant', async () => {
62+
test('invalid tenant fails before DB', async () => {
63+
process.env.DATABASE_URL = 'postgres://example.com/db';
64+
let dbCalled = false;
65+
const handler = loadHandlerWithMockQuery(async () => { dbCalled = true; return { rows: [] }; });
5366
const body = validBody();
5467
body.tenant = 'Acme';
5568
const res = makeRes();
5669
await handler({ method: 'POST', body }, res);
5770
assert.equal(res.statusCode, 400);
5871
assert.equal(res.body.error, 'invalid_tenant');
72+
assert.equal(dbCalled, false);
5973
});
6074

61-
test('rejects .eth tenant', async () => {
75+
test('unsupported pack fails before DB', async () => {
76+
process.env.DATABASE_URL = 'postgres://example.com/db';
77+
let dbCalled = false;
78+
const handler = loadHandlerWithMockQuery(async () => { dbCalled = true; return { rows: [] }; });
6279
const body = validBody();
63-
body.tenant = 'acme.eth';
80+
body.packId = 'commerce';
6481
const res = makeRes();
6582
await handler({ method: 'POST', body }, res);
6683
assert.equal(res.statusCode, 400);
67-
assert.equal(res.body.error, 'invalid_tenant');
84+
assert.equal(res.body.error, 'unsupported_pack');
85+
assert.equal(dbCalled, false);
6886
});
6987

70-
test('rejects too many capabilities', async () => {
71-
const body = validBody();
72-
body.capabilities = ['sign', 'attest', 'authorize', 'approve', 'reject', 'permit', 'grant', 'authenticate', 'endorse', 'verify', 'extra'];
73-
const res = makeRes();
74-
await handler({ method: 'POST', body }, res);
75-
assert.equal(res.statusCode, 400);
76-
assert.equal(res.body.error, 'invalid_capabilities');
77-
});
88+
test('valid payload with mocked DB returns CLAIM_REQUEST_CREATED', async () => {
89+
process.env.DATABASE_URL = 'postgres://example.com/db';
90+
const calls = [];
91+
const handler = loadHandlerWithMockQuery(async (text, params) => {
92+
calls.push({ text, params });
93+
return { rows: [] };
94+
});
7895

79-
test('rejects malformed publicKey', async () => {
80-
const body = validBody();
81-
body.publicKey = 'nope';
8296
const res = makeRes();
83-
await handler({ method: 'POST', body }, res);
84-
assert.equal(res.statusCode, 400);
85-
assert.equal(res.body.error, 'invalid_public_key');
97+
await handler({ method: 'POST', body: validBody() }, res);
98+
assert.equal(res.statusCode, 200);
99+
assert.equal(res.body.ok, true);
100+
assert.equal(res.body.status, 'CLAIM_REQUEST_CREATED');
101+
assert.match(res.body.claimId, /^clm_[a-f0-9]{32}$/);
102+
assert.equal(Array.isArray(res.body.agents), true);
103+
assert.equal(calls.length >= 5, true);
86104
});
87105

88-
test('rejects non-trust pack', async () => {
89-
const body = validBody();
90-
body.packId = 'commerce';
91-
const res = makeRes();
92-
await handler({ method: 'POST', body }, res);
93-
assert.equal(res.statusCode, 400);
94-
assert.equal(res.body.error, 'unsupported_pack');
95-
});
106+
test('claim.created event insertion is attempted', async () => {
107+
process.env.DATABASE_URL = 'postgres://example.com/db';
108+
const calls = [];
109+
const handler = loadHandlerWithMockQuery(async (text, params) => {
110+
calls.push({ text, params });
111+
return { rows: [] };
112+
});
96113

97-
test('accepts valid Trust Verification request', async () => {
98114
const res = makeRes();
99115
await handler({ method: 'POST', body: validBody() }, res);
100-
assert.equal(res.statusCode, 200);
101-
assert.equal(res.body.ok, true);
102-
assert.equal(res.body.status, 'CLAIM_REQUEST_VALIDATED');
103-
assert.match(res.body.claimId, /^clm_[a-f0-9]{24}$/);
116+
117+
const eventInsert = calls.find((entry) => String(entry.text).includes('insert into claim_events'));
118+
assert.ok(eventInsert);
119+
assert.equal(eventInsert.params[1], 'claim.created');
120+
assert.equal(eventInsert.params[2], 'CommandLayer namespace claim request created.');
104121
});

0 commit comments

Comments
 (0)