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
66 changes: 58 additions & 8 deletions api/claim/commandlayer-namespace.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use strict';

const crypto = require('node:crypto');
const db = require('../../lib/db');

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

function storageUnavailable(res) {
return res.status(503).json({
ok: false,
status: 'STORAGE_UNAVAILABLE',
error: 'DATABASE_URL is not configured'
});
}

module.exports = async function handler(req, res) {
res.setHeader('Content-Type', 'application/json; charset=utf-8');
res.setHeader('Cache-Control', 'no-store');
Expand All @@ -38,7 +47,7 @@ module.exports = async function handler(req, res) {
return invalid(res, 'invalid_body', 'Missing or invalid JSON body.');
}

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

const nowIso = new Date().toISOString();
const digest = crypto.createHash('sha256').update(`${tenant}${authenticatedAddress}${kid}${nowIso}`).digest('hex');
const claimId = `clm_${digest.slice(0, 24)}`;
if (!process.env.DATABASE_URL) {
return storageUnavailable(res);
}

const claimId = `clm_${crypto.randomUUID().replace(/-/g, '')}`;

try {
await db.query('BEGIN');
await db.query(
`insert into claim_requests
(claim_id, authenticated_address, tenant, activation_mode, pack_id, public_key, kid, runtime, verifier, schema_version, request_json)
values ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11::jsonb)`,
[claimId, authenticatedAddress, tenant, activationMode, packId, publicKey, kid, runtime, verifier, schemaVersion || '1.1.0', JSON.stringify(body)]
);

for (const agent of agents) {
await db.query(
`insert into claim_agents
(claim_id, ens, capability, canonical_parent, skill, skill_family)
values ($1,$2,$3,$4,$5,$6)`,
[claimId, agent.ens, agent.capability, agent.canonicalParent, agent.skill || '', agent.skillFamily || '']
);
}

await db.query(
`insert into claim_events (claim_id, event_type, message, metadata_json)
values ($1,$2,$3,$4::jsonb)`,
[
claimId,
'claim.created',
'CommandLayer namespace claim request created.',
JSON.stringify({ agentCount: agents.length, packId, activationMode })
]
);
await db.query('COMMIT');
} catch (error) {
await db.query('ROLLBACK');
if (error && error.code === 'DATABASE_URL_MISSING') {
return storageUnavailable(res);
}
return res.status(500).json({ ok: false, status: 'CLAIM_REQUEST_PERSISTENCE_ERROR', error: 'Failed to persist claim request.' });
}

return res.status(200).json({
ok: true,
status: 'CLAIM_REQUEST_VALIDATED',
status: 'CLAIM_REQUEST_CREATED',
claimId,
activationMode: 'cl',
tenant,
authenticatedAddress,
agents,
next: {
operatorReview: true,
lifecycle: {
claim: 'created',
operatorReview: 'not_started',
ensProvisioning: 'not_started',
agentCards: 'not_started',
erc8004: 'not_started'
erc8004: 'not_started',
liveReceiptTest: 'not_started'
}
});
};
46 changes: 46 additions & 0 deletions db/migrations/001_claim_requests.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
create extension if not exists pgcrypto;

create table if not exists claim_requests (
id uuid primary key default gen_random_uuid(),
claim_id text unique not null,
authenticated_address text not null,
tenant text not null,
activation_mode text not null,
pack_id text not null,
public_key text not null,
kid text not null,
runtime text not null,
verifier text not null,
schema_version text not null,
status text not null default 'created',
request_json jsonb not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now()
);

create table if not exists claim_agents (
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,
capability text not null,
canonical_parent text not null,
skill text not null,
skill_family text not null,
status text not null default 'created',
created_at timestamptz not null default now()
);

create table if not exists claim_events (
id uuid primary key default gen_random_uuid(),
claim_id text not null references claim_requests(claim_id) on delete cascade,
event_type text not null,
message text not null,
metadata_json jsonb,
created_at timestamptz not null default now()
);

create index if not exists idx_claim_requests_claim_id on claim_requests(claim_id);
create index if not exists idx_claim_requests_tenant on claim_requests(tenant);
create index if not exists idx_claim_requests_wallet on claim_requests(authenticated_address);
create index if not exists idx_claim_agents_claim_id on claim_agents(claim_id);
create index if not exists idx_claim_events_claim_id on claim_events(claim_id);
23 changes: 23 additions & 0 deletions lib/db.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
'use strict';

function getDatabaseUrl() {
const databaseUrl = process.env.DATABASE_URL;
if (!databaseUrl) {
const error = new Error('DATABASE_URL is not configured');
error.code = 'DATABASE_URL_MISSING';
throw error;
}
return databaseUrl;
}

async function query(text, params = []) {
// Lazy-load Neon so tests can mock this module without requiring installed drivers.
const { neon } = require('@neondatabase/serverless');
const sql = neon(getDatabaseUrl());
return sql.query(text, params);
}

module.exports = {
query,
getDatabaseUrl
};
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"ajv": "^8.17.1",
"ajv-formats": "^3.0.1",
"ethers": "^6.16.0",
"siwe": "^3.0.0"
"siwe": "^3.0.0",
"@neondatabase/serverless": "^1.0.0"
}
}
14 changes: 11 additions & 3 deletions public/claim.html
Original file line number Diff line number Diff line change
Expand Up @@ -546,7 +546,7 @@ <h3>Submit CommandLayer namespace activation</h3>
</div>
<div id="activationResult" style="display:none;margin-top:12px;color:var(--red);font-size:13px"></div>
<div id="activationOk" style="display:none;margin-top:14px;text-align:left;padding:12px;border:1px solid var(--border);border-radius:12px;background:#fff">
<strong>Activation request validated</strong><br>
<strong>Activation request created</strong><br>
Claim ID: <span id="activationClaimId"></span><br>
Wallet: <span id="activationWallet"></span><br>
Generated agents: <span id="activationAgentCount"></span>
Expand Down Expand Up @@ -1368,15 +1368,23 @@ <h3>Payment and provisioning are coming next.</h3>
try {
const res = await fetch('/api/claim/commandlayer-namespace', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(payload) });
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(`${data.error || 'request_failed'} — ${data.reason || 'Unknown error'}`);
if (!res.ok || !data.ok) {
const err = new Error(`${data.error || 'request_failed'} — ${data.reason || 'Unknown error'}`);
err.status = data.status;
throw err;
}
state.submitResult = data;
document.getElementById('activationOk').style.display = 'block';
document.getElementById('activationClaimId').textContent = data.claimId;
document.getElementById('activationWallet').textContent = data.authenticatedAddress;
document.getElementById('activationAgentCount').textContent = String((data.agents || []).length);
} catch (err) {
document.getElementById('activationOk').style.display = 'none';
resultEl.textContent = `Activation request failed: ${(err && err.message) || 'unknown_error'}`;
if (err && err.status === 'STORAGE_UNAVAILABLE') {
resultEl.textContent = 'Activation request could not be saved: database is not configured.';
} else {
resultEl.textContent = `Activation request failed: ${(err && err.message) || 'unknown_error'}`;
}
resultEl.style.display = 'block';
} finally {
btn.disabled = !canSubmitActivationRequest(); btn.textContent = 'Submit activation request';
Expand Down
95 changes: 56 additions & 39 deletions tests/api-claim-commandlayer-namespace.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,6 @@
const test = require('node:test');
const assert = require('node:assert/strict');

const handler = require('../api/claim/commandlayer-namespace');

function makeRes() {
return {
statusCode: 200,
Expand Down Expand Up @@ -34,71 +32,90 @@ function validBody() {
};
}

function loadHandlerWithMockQuery(mockQuery) {
const handlerPath = require.resolve('../api/claim/commandlayer-namespace');
const dbPath = require.resolve('../lib/db');
delete require.cache[handlerPath];
delete require.cache[dbPath];
require.cache[dbPath] = { exports: { query: mockQuery, getDatabaseUrl: () => process.env.DATABASE_URL } };
return require('../api/claim/commandlayer-namespace');
}

test('rejects non-POST', async () => {
const handler = loadHandlerWithMockQuery(async () => ({ rows: [] }));
const res = makeRes();
await handler({ method: 'GET', body: validBody() }, res);
assert.equal(res.statusCode, 405);
});

test('rejects missing authenticatedAddress', async () => {
const body = validBody();
delete body.authenticatedAddress;
test('missing DATABASE_URL returns STORAGE_UNAVAILABLE for valid payload', async () => {
const original = process.env.DATABASE_URL;
delete process.env.DATABASE_URL;
const handler = loadHandlerWithMockQuery(async () => ({ rows: [] }));
const res = makeRes();
await handler({ method: 'POST', body }, res);
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'invalid_authenticated_address');
await handler({ method: 'POST', body: validBody() }, res);
assert.equal(res.statusCode, 503);
assert.equal(res.body.status, 'STORAGE_UNAVAILABLE');
process.env.DATABASE_URL = original;
});

test('rejects invalid tenant', async () => {
test('invalid tenant fails before DB', async () => {
process.env.DATABASE_URL = 'postgres://example.com/db';
let dbCalled = false;
const handler = loadHandlerWithMockQuery(async () => { dbCalled = true; return { rows: [] }; });
const body = validBody();
body.tenant = 'Acme';
const res = makeRes();
await handler({ method: 'POST', body }, res);
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'invalid_tenant');
assert.equal(dbCalled, false);
});

test('rejects .eth tenant', async () => {
test('unsupported pack fails before DB', async () => {
process.env.DATABASE_URL = 'postgres://example.com/db';
let dbCalled = false;
const handler = loadHandlerWithMockQuery(async () => { dbCalled = true; return { rows: [] }; });
const body = validBody();
body.tenant = 'acme.eth';
body.packId = 'commerce';
const res = makeRes();
await handler({ method: 'POST', body }, res);
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'invalid_tenant');
assert.equal(res.body.error, 'unsupported_pack');
assert.equal(dbCalled, false);
});

test('rejects too many capabilities', async () => {
const body = validBody();
body.capabilities = ['sign', 'attest', 'authorize', 'approve', 'reject', 'permit', 'grant', 'authenticate', 'endorse', 'verify', 'extra'];
const res = makeRes();
await handler({ method: 'POST', body }, res);
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'invalid_capabilities');
});
test('valid payload with mocked DB returns CLAIM_REQUEST_CREATED', async () => {
process.env.DATABASE_URL = 'postgres://example.com/db';
const calls = [];
const handler = loadHandlerWithMockQuery(async (text, params) => {
calls.push({ text, params });
return { rows: [] };
});

test('rejects malformed publicKey', async () => {
const body = validBody();
body.publicKey = 'nope';
const res = makeRes();
await handler({ method: 'POST', body }, res);
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'invalid_public_key');
await handler({ method: 'POST', body: validBody() }, res);
assert.equal(res.statusCode, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.status, 'CLAIM_REQUEST_CREATED');
assert.match(res.body.claimId, /^clm_[a-f0-9]{32}$/);
assert.equal(Array.isArray(res.body.agents), true);
assert.equal(calls.length >= 5, true);
});

test('rejects non-trust pack', async () => {
const body = validBody();
body.packId = 'commerce';
const res = makeRes();
await handler({ method: 'POST', body }, res);
assert.equal(res.statusCode, 400);
assert.equal(res.body.error, 'unsupported_pack');
});
test('claim.created event insertion is attempted', async () => {
process.env.DATABASE_URL = 'postgres://example.com/db';
const calls = [];
const handler = loadHandlerWithMockQuery(async (text, params) => {
calls.push({ text, params });
return { rows: [] };
});

test('accepts valid Trust Verification request', async () => {
const res = makeRes();
await handler({ method: 'POST', body: validBody() }, res);
assert.equal(res.statusCode, 200);
assert.equal(res.body.ok, true);
assert.equal(res.body.status, 'CLAIM_REQUEST_VALIDATED');
assert.match(res.body.claimId, /^clm_[a-f0-9]{24}$/);

const eventInsert = calls.find((entry) => String(entry.text).includes('insert into claim_events'));
assert.ok(eventInsert);
assert.equal(eventInsert.params[1], 'claim.created');
assert.equal(eventInsert.params[2], 'CommandLayer namespace claim request created.');
});
Loading