diff --git a/.env.example b/.env.example index 3640339..23eef8d 100644 --- a/.env.example +++ b/.env.example @@ -2,3 +2,8 @@ # Used by api/verify-receipt.js (proxy) and api/health.js. # Example: https://runtime.commandlayer.org RUNTIME_BASE_URL=https://runtime.commandlayer.org + +# ERC-8004 discovery metadata recorded when claimed agent cards are pinned. +# Tracking these values does not submit an onchain transaction. +ERC8004_CHAIN_ID=1 +ERC8004_REGISTRY_ADDRESS= diff --git a/api/admin/claim.js b/api/admin/claim.js index baca6f0..90b12bc 100644 --- a/api/admin/claim.js +++ b/api/admin/claim.js @@ -82,7 +82,12 @@ module.exports = async function handler(req, res) { latestPayment = await queryOptionalOne('select * from claim_payments where claim_id = $1 order by updated_at desc nulls last, paid_at desc nulls last, id desc limit 1', [claimId], 'claim_payments'); } - return res.status(200).json({ ok: true, claim: stripClaimSecrets(claim), agents: agentsResult.rows, events: eventsResult.rows, transitions, cards, latestPayment }); + let registrations = []; + if (await hasTable('agent_registrations')) { + registrations = await queryOptionalRows('select * from agent_registrations where claim_id = $1 order by ens asc, standard asc', [claimId], [], 'agent_registrations'); + } + + return res.status(200).json({ ok: true, claim: stripClaimSecrets(claim), agents: agentsResult.rows, events: eventsResult.rows, transitions, cards, registrations, latestPayment }); } catch (error) { console.error('[admin.claim] failed to load claim detail', { code: error && error.code }); const payload = { diff --git a/api/admin/pin-agent-cards.js b/api/admin/pin-agent-cards.js index e875cf2..6ea230a 100644 --- a/api/admin/pin-agent-cards.js +++ b/api/admin/pin-agent-cards.js @@ -2,6 +2,7 @@ const db = require('../../lib/db'); const { stableStringify, sha256Hex, pinJsonToPinata } = require('../../lib/ipfsPinning'); +const { trackPinnedCardRegistration } = require('../../lib/claims/agent-registrations'); function firstObjectValue(obj) { if (!obj || typeof obj !== 'object' || Array.isArray(obj)) return null; @@ -114,7 +115,10 @@ module.exports = async function handler(req, res) { } const allPinned = cardsResult.rows.every((r) => r.card_cid && r.card_ipfs_uri && r.card_sha256); - if (allPinned) return res.status(200).json({ ok: true, status: 'ALREADY_PINNED', claimId, cards: cardsResult.rows }); + if (allPinned) { + await Promise.all(cardsResult.rows.map(trackPinnedCardRegistration)); + return res.status(200).json({ ok: true, status: 'ALREADY_PINNED', claimId, cards: cardsResult.rows }); + } const gatewayBase = (process.env.IPFS_GATEWAY_BASE_URL || 'https://gateway.pinata.cloud/ipfs').replace(/\/$/, ''); @@ -130,6 +134,7 @@ module.exports = async function handler(req, res) { const hash = sha256Hex(canonical); if (row.card_cid && row.card_ipfs_uri && row.card_sha256) { + await trackPinnedCardRegistration(row); pinned.push({ ens: row.ens, card_cid: row.card_cid, card_sha256: row.card_sha256, card_gateway_url: row.card_gateway_url }); continue; } @@ -145,6 +150,7 @@ module.exports = async function handler(req, res) { where id = $1`, [row.id, cid, ipfsUri, gatewayUrl, hash, provider] ); + await trackPinnedCardRegistration({ ...row, card_cid: cid, card_ipfs_uri: ipfsUri }); pinned.push({ ens: row.ens, card_cid: cid, card_sha256: hash, card_gateway_url: gatewayUrl }); } catch (error) { await db.query("update agent_cards set pin_status = 'error', pin_error = $2 where id = $1", [row.id, String(error && error.message ? error.message : 'pinning_failed')]); diff --git a/api/claims/status.js b/api/claims/status.js index a399f58..88e8589 100644 --- a/api/claims/status.js +++ b/api/claims/status.js @@ -35,6 +35,7 @@ module.exports = async function handler(req, res) { const auth = getClaimAuth(req, claim); if (!auth.ok) return unauthorizedClaimResponse(res); let cards = []; + let registrations = []; try { const cardsResult = await db.query( `select ens, card_cid, card_ipfs_uri, card_gateway_url, card_sha256, pin_status from agent_cards where claim_id = $1 order by ens asc`, @@ -44,6 +45,17 @@ module.exports = async function handler(req, res) { } catch (_error) { cards = []; } + try { + const registrationsResult = await db.query( + `select standard, ens, chain_id, registry_address, agent_id, agent_uri, registration_status, + ensip25_status, verified_at + from agent_registrations where claim_id = $1 order by ens asc, standard asc`, + [claimId] + ); + registrations = registrationsResult.rows; + } catch (_error) { + registrations = []; + } const recordsVerified = claim.tenant_signer_record_status === 'records_verified' || claim.tenant_signer_record_status === 'verified'; const paymentConfirmed = claim.status === 'paid' || claim.status === 'cards_pinned' || claim.status === 'active' || claim.payment_status === 'paid' || Boolean(claim.paid_at); const pipeline = { @@ -58,7 +70,7 @@ module.exports = async function handler(req, res) { first_action_receipt: claim.first_action_receipt_status || 'not_generated', agent_live: paymentConfirmed && recordsVerified && cardsStatus(cards) === 'cards_pinned' && claim.genesis_receipt_id && claim.tenant_proof_status === 'verified' && (claim.first_action_receipt_status === 'verified' || typeof claim.first_action_receipt_status === 'undefined') ? 'live' : 'not_live', }; - return res.status(200).json({ ok: true, read_only: true, claim: { ...stripClaimSecrets(claim), cardsStatus: cardsStatus(cards), managed_ens_publication: { status: claim.managed_ens_publication_status || 'not_started', signer_ens: claim.tenant_signer_ens || null, parent_namespace: claim.managed_ens_parent_namespace || null, record_names: Object.keys(claim.managed_ens_required_txt_records || claim.tenant_signer_txt_records || {}), helper_copy: 'The operator must publish the generated TXT records to ENS before signer verification can pass.', verified_at: claim.managed_ens_verified_at || null, error: claim.managed_ens_publication_error || null } }, pipeline, cards }); + return res.status(200).json({ ok: true, read_only: true, claim: { ...stripClaimSecrets(claim), cardsStatus: cardsStatus(cards), managed_ens_publication: { status: claim.managed_ens_publication_status || 'not_started', signer_ens: claim.tenant_signer_ens || null, parent_namespace: claim.managed_ens_parent_namespace || null, record_names: Object.keys(claim.managed_ens_required_txt_records || claim.tenant_signer_txt_records || {}), helper_copy: 'The operator must publish the generated TXT records to ENS before signer verification can pass.', verified_at: claim.managed_ens_verified_at || null, error: claim.managed_ens_publication_error || null } }, pipeline, cards, registrations }); } catch (_error) { return res.status(500).json({ ok: false, status: 'CLAIM_STATUS_UNAVAILABLE' }); } diff --git a/db/migrations/012_agent_registrations.sql b/db/migrations/012_agent_registrations.sql new file mode 100644 index 0000000..cc564bb --- /dev/null +++ b/db/migrations/012_agent_registrations.sql @@ -0,0 +1,48 @@ +create table if not exists public.agent_registrations ( + id uuid primary key default gen_random_uuid(), + claim_id text not null, + ens text not null, + standard text not null default 'erc8004', + chain_id text not null, + registry_address text not null, + agent_id text, + agent_uri text, + agent_card_cid text, + registration_tx_hash text, + registration_status text not null default 'pending', + ensip25_status text not null default 'not_checked', + ensip25_claim_key text, + ensip25_verified_at timestamptz, + registered_at timestamptz, + verified_at timestamptz, + metadata_json jsonb, + last_error text, + last_error_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint agent_registrations_claim_id_fkey foreign key (claim_id) + references public.claim_requests(claim_id) on delete cascade +); + +create index if not exists idx_agent_registrations_claim_id on public.agent_registrations(claim_id); +create index if not exists idx_agent_registrations_ens on public.agent_registrations(ens); +create index if not exists idx_agent_registrations_standard on public.agent_registrations(standard); +create index if not exists idx_agent_registrations_registration_status on public.agent_registrations(registration_status); +create index if not exists idx_agent_registrations_agent_id on public.agent_registrations(agent_id); +create unique index if not exists idx_agent_registrations_registry_identity + on public.agent_registrations(standard, ens, chain_id, registry_address); + +create or replace function public.set_agent_registrations_updated_at() +returns trigger +language plpgsql +as $$ +begin + new.updated_at = now(); + return new; +end; +$$; + +drop trigger if exists set_agent_registrations_updated_at on public.agent_registrations; +create trigger set_agent_registrations_updated_at +before update on public.agent_registrations +for each row execute function public.set_agent_registrations_updated_at(); diff --git a/lib/claims/agent-registrations.js b/lib/claims/agent-registrations.js new file mode 100644 index 0000000..22422f2 --- /dev/null +++ b/lib/claims/agent-registrations.js @@ -0,0 +1,40 @@ +'use strict'; + +const db = require('../db'); + +/** Idempotently records card discovery metadata; it does not perform registration or verification. */ +async function upsertErc8004Registration({ claimId, ens, chainId, registryAddress, agentUri = null, agentCardCid = null, metadata = null }) { + if (!claimId || !ens || !chainId || !registryAddress) { + throw new Error('claimId, ens, chainId, and registryAddress are required'); + } + + const result = await db.query( + `insert into agent_registrations + (claim_id, ens, standard, chain_id, registry_address, agent_uri, agent_card_cid, metadata_json) + values ($1, $2, 'erc8004', $3, $4, $5, $6, $7::jsonb) + on conflict (standard, ens, chain_id, registry_address) do update + set claim_id = excluded.claim_id, + agent_uri = coalesce(excluded.agent_uri, agent_registrations.agent_uri), + agent_card_cid = coalesce(excluded.agent_card_cid, agent_registrations.agent_card_cid), + metadata_json = coalesce(excluded.metadata_json, agent_registrations.metadata_json) + returning *`, + [claimId, ens, chainId, registryAddress, agentUri, agentCardCid, metadata == null ? null : JSON.stringify(metadata)] + ); + return result.rows[0] || null; +} + +async function trackPinnedCardRegistration(card) { + const chainId = process.env.ERC8004_CHAIN_ID; + const registryAddress = process.env.ERC8004_REGISTRY_ADDRESS; + if (!chainId || !registryAddress) return null; + return upsertErc8004Registration({ + claimId: card.claim_id, + ens: card.ens, + chainId, + registryAddress, + agentUri: card.card_ipfs_uri || null, + agentCardCid: card.card_cid || null, + }); +} + +module.exports = { upsertErc8004Registration, trackPinnedCardRegistration }; diff --git a/public/admin/claims.html b/public/admin/claims.html index a3f05b5..cc49b1f 100644 --- a/public/admin/claims.html +++ b/public/admin/claims.html @@ -81,7 +81,7 @@
Internal operator dashboard f function advancementPanel(){const a=s.activationAdvanced;if(!a)return '';const err=a.errors||{};const rows=[['Managed ENS','verified'],['Agent card pinning',a.agent_cards||'not_started'],['Genesis receipt',a.genesis_receipt||'not_started'],['First Action Receipt',a.first_action_receipt||'not_generated']];return `
${v}Prepare TXT records to display required values.
';} -function renderDetail(loadingMsg=''){const el=document.getElementById('detailPanel');if(loadingMsg){el.innerHTML=`${loadingMsg}
`;return;}if(!s.detail){el.innerHTML=`${s.error||'Select a claim to review.'}
`;return;}const payload=s.detail||{};const claim=payload.claim||{};const agents=payload.agents||[];const genesis=claim.genesis_receipt_json||null;const events=payload.events||[];const transitions=payload.transitions||[];const cards=payload.cards||[];const urls=cards.map(c=>c.card_url).filter(Boolean);const missing=claim.status==='cards_published'&&agents.some(a=>!a.card_url);const firstUrl=urls[0];el.innerHTML=`${s.error?`Tenant: ${claim.tenant||'-'} · Wallet: ${claim.authenticated_address||'-'} · Status: ${claim.status||'-'} · Payment: ${claim.payment_status||'-'} · Agents: ${agents.length} · Created: ${dt(claim.created_at)}
${JSON.stringify(claim.tenant_signer_txt_records||{},null,2)}Network required: Ethereum mainnet. Managed ENS publication is operator-ready only; no onchain TXT writes are executed here.
| ENS | Capability | Parent | Card | Actions |
|---|---|---|---|---|
| ${a.ens||'-'} | ${a.capability||'-'} | ${a.canonical_parent||'-'} | ${a.card_status||'-'} | ${a.card_url?`Open `:'-'} |
Checkout has been created. Open it to complete payment.
${claim.stripe_checkout_session_id||'-'}
`:''}${claim.status==='paid'?`Next: ERC-8004 registration
`:''}${claim.status==='cards_published'?`Founding Activation: $20 first year for 10 Trust Verification namespaces.
${urls.length} cards${missing?' · Missing card URLs detected':''}
${s.checkoutUrl?`Some cards are missing URLs. Use Repair / Publish cards.
`:''}`:''}${JSON.stringify(e.event_json||{},null,2)}No events.
'}No transitions.
'}${JSON.stringify(s.detail,null,2)}${loadingMsg}
`;return;}if(!s.detail){el.innerHTML=`${s.error||'Select a claim to review.'}
`;return;}const payload=s.detail||{};const claim=payload.claim||{};const agents=payload.agents||[];const genesis=claim.genesis_receipt_json||null;const events=payload.events||[];const transitions=payload.transitions||[];const cards=payload.cards||[];const registrations=payload.registrations||[];const urls=cards.map(c=>c.card_url).filter(Boolean);const missing=claim.status==='cards_published'&&agents.some(a=>!a.card_url);const firstUrl=urls[0];el.innerHTML=`${s.error?`Tenant: ${claim.tenant||'-'} · Wallet: ${claim.authenticated_address||'-'} · Status: ${claim.status||'-'} · Payment: ${claim.payment_status||'-'} · Agents: ${agents.length} · Created: ${dt(claim.created_at)}
${JSON.stringify(claim.tenant_signer_txt_records||{},null,2)}Network required: Ethereum mainnet. Managed ENS publication is operator-ready only; no onchain TXT writes are executed here.
| ENS | Capability | Parent | Card | Actions |
|---|---|---|---|---|
| ${a.ens||'-'} | ${a.capability||'-'} | ${a.canonical_parent||'-'} | ${a.card_status||'-'} | ${a.card_url?`Open `:'-'} |
| Standard | ENS | Chain | Registry | Agent ID | Agent URI | Registration status | ENSIP-25 status | Verified at |
|---|---|---|---|---|---|---|---|---|
| ${r.standard||'-'} | ${r.ens||'-'} | ${r.chain_id||'-'} | ${r.registry_address||'-'} | ${r.agent_id||'-'} | ${r.agent_uri||'-'} | ${r.registration_status||'-'} | ${r.ensip25_status||'-'} | ${dt(r.verified_at)} |
No ERC-8004 registration metadata has been tracked yet.
`}${claim.status==='payment_pending'?`Checkout has been created. Open it to complete payment.
${claim.stripe_checkout_session_id||'-'}
`:''}${claim.status==='paid'?`Next: ERC-8004 registration
`:''}${claim.status==='cards_published'?`Founding Activation: $20 first year for 10 Trust Verification namespaces.
${urls.length} cards${missing?' · Missing card URLs detected':''}
${s.checkoutUrl?`Some cards are missing URLs. Use Repair / Publish cards.
`:''}`:''}${JSON.stringify(e.event_json||{},null,2)}No events.
'}No transitions.
'}${JSON.stringify(s.detail,null,2)}