From c3b3ee4fe6f76ae8b8b2691fa1b7109d39a8ffdb Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Fri, 22 May 2026 16:25:05 -0400 Subject: [PATCH] Add persistent claim request storage lifecycle --- api/claim/commandlayer-namespace.js | 66 +++++++++++-- db/migrations/001_claim_requests.sql | 46 +++++++++ lib/db.js | 23 +++++ package.json | 3 +- public/claim.html | 14 ++- .../api-claim-commandlayer-namespace.test.js | 95 +++++++++++-------- 6 files changed, 196 insertions(+), 51 deletions(-) create mode 100644 db/migrations/001_claim_requests.sql create mode 100644 lib/db.js diff --git a/api/claim/commandlayer-namespace.js b/api/claim/commandlayer-namespace.js index 03dd71e..93c457f 100644 --- a/api/claim/commandlayer-namespace.js +++ b/api/claim/commandlayer-namespace.js @@ -1,6 +1,7 @@ 'use strict'; const crypto = require('node:crypto'); +const db = require('../../lib/db'); const TRUST_VERIFICATION_MAP = { sign: 'signagent.eth', @@ -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'); @@ -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.'); @@ -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' } }); }; diff --git a/db/migrations/001_claim_requests.sql b/db/migrations/001_claim_requests.sql new file mode 100644 index 0000000..2898ac0 --- /dev/null +++ b/db/migrations/001_claim_requests.sql @@ -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); diff --git a/lib/db.js b/lib/db.js new file mode 100644 index 0000000..9907715 --- /dev/null +++ b/lib/db.js @@ -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 +}; diff --git a/package.json b/package.json index 5c66f1b..dac4c23 100644 --- a/package.json +++ b/package.json @@ -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" } } diff --git a/public/claim.html b/public/claim.html index 77ab1b4..0c9d4ed 100644 --- a/public/claim.html +++ b/public/claim.html @@ -546,7 +546,7 @@

Submit CommandLayer namespace activation