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
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
7 changes: 6 additions & 1 deletion api/admin/claim.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
8 changes: 7 additions & 1 deletion api/admin/pin-agent-cards.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Backfill registrations through the main activation workflow

For an existing claim whose cards were pinned before this deployment, the main admin “Run activation pipeline” path checks cardsPinned in api/admin/run-activation-pipeline.js:79-83 and deliberately skips pinAgentCards, so this newly added all-pinned backfill is never reached. Such claims continue to show no registration metadata unless an operator knows to invoke the separate pin endpoint manually; perform the tracking from the orchestration path or provide an explicit migration/backfill so the normal workflow populates these rows.

Useful? React with 👍 / 👎.

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(/\/$/, '');

Expand All @@ -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;
}
Expand All @@ -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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep observational tracking out of the pinning failure path

When ERC-8004 configuration is enabled and this database upsert fails—for example, during a transient database error or before migration 012 is applied—the exception is handled as an IPFS failure even though Pinata and the preceding agent_cards update already succeeded. The catch then changes the card to pin_status = 'error', returns 502, and leaves the claim unpaid-to-pinned transition incomplete; subsequent requests detect the populated CID as already pinned and do not repair the claim status. Registration tracking is documented as observational, so its failure must not corrupt or block the successful pinning flow.

Useful? React with 👍 / 👎.

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')]);
Expand Down
14 changes: 13 additions & 1 deletion api/claims/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Return the ENSIP-25 verification timestamp

When an ENSIP-25 verifier populates the new ensip25_verified_at column, this authenticated status response omits that value and instead returns the separate generic verified_at field alongside ensip25_status. Clients therefore see a missing or unrelated timestamp for an ENSIP-25 verification; select ensip25_verified_at (or both timestamp fields) so the newly tracked proof state is observable.

Useful? React with 👍 / 👎.

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 = {
Expand All @@ -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' });
}
Expand Down
48 changes: 48 additions & 0 deletions db/migrations/012_agent_registrations.sql
Original file line number Diff line number Diff line change
@@ -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();
40 changes: 40 additions & 0 deletions lib/claims/agent-registrations.js
Original file line number Diff line number Diff line change
@@ -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,
Comment on lines +15 to +16

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep registration evidence scoped to its original claim

When a second claim contains the same ENS name, chain, and registry as an earlier claim—which the claim intake permits because ENS is not unique across claims—this conflict handler reassigns the existing registration row to the new claim while intentionally preserving its agent_id, transaction hash, statuses, and verification timestamps. The new claimant then receives the previous claim's registration evidence through the authenticated status API, while the original claim loses the row; include the claim in the conflict identity or create/reset a claim-scoped row rather than transferring verified evidence.

Useful? React with 👍 / 👎.

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 };
Loading
Loading