diff --git a/api/admin/generate-first-action-receipt.js b/api/admin/generate-first-action-receipt.js new file mode 100644 index 0000000..de9aac6 --- /dev/null +++ b/api/admin/generate-first-action-receipt.js @@ -0,0 +1,49 @@ +'use strict'; + +const db = require('../../lib/db'); +const { requireAdminAuth } = require('./_auth'); +const { buildFirstActionReceiptChallenge } = require('../../lib/receipts/first-action-receipt'); + +function paidOrApproved(claim) { + return claim.payment_status === 'paid' || Boolean(claim.paid_at) || ['paid', 'cards_pinned', 'active', 'activation_approved'].includes(claim.status); +} + +function preflight(claim) { + if (!paidOrApproved(claim)) return 'FIRST_ACTION_REQUIRES_PAID_CLAIM'; + if (!claim.tenant_signer_ens || !claim.tenant_signer_kid) return 'TENANT_SIGNER_ENS_REQUIRED'; + if (claim.tenant_signer_record_status !== 'records_verified') return 'TENANT_SIGNER_RECORDS_NOT_VERIFIED'; + if (!claim.genesis_receipt_id) return 'GENESIS_RECEIPT_REQUIRED'; + if ('tenant_proof_status' in claim && claim.tenant_proof_status && claim.tenant_proof_status !== 'verified') return 'TENANT_PROOF_NOT_VERIFIED'; + return null; +} + +module.exports = async function handler(req, res) { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + if (req.method !== 'POST') { res.setHeader('Allow', 'POST'); return res.status(405).json({ ok: false, status: 'METHOD_NOT_ALLOWED' }); } + if (!requireAdminAuth(req, res)) return; + const claimId = req.body && req.body.claimId; + if (!claimId) return res.status(400).json({ ok: false, status: 'CLAIM_ID_REQUIRED' }); + + const claimResult = await db.query('select * from claim_requests where claim_id = $1 limit 1', [claimId]); + const claim = claimResult.rows[0]; + if (!claim) return res.status(404).json({ ok: false, status: 'CLAIM_NOT_FOUND' }); + const failed = preflight(claim); + if (failed) return res.status(400).json({ ok: false, status: failed }); + + const agentsResult = await db.query('select ens, capability from claim_agents where claim_id = $1 order by id asc', [claimId]); + const challenge = await buildFirstActionReceiptChallenge({ claim, agents: agentsResult.rows }); + await db.query( + `update claim_requests + set first_action_receipt_json = $2::jsonb, + first_action_receipt_id = $3, + first_action_receipt_hash = null, + first_action_receipt_status = 'challenge_ready', + first_action_receipt_verified_at = null, + first_action_receipt_error = null, + updated_at = now() + where claim_id = $1`, + [claimId, JSON.stringify(challenge), challenge.receipt_id] + ); + return res.status(200).json({ ok: true, claimId, status: 'challenge_ready', receipt_id: challenge.receipt_id, challenge, note: 'Non-custodial flow: tenant signs this payload locally and submits the signed scoped execution receipt.' }); +}; diff --git a/api/claims/status.js b/api/claims/status.js index 5588cc0..6a288d9 100644 --- a/api/claims/status.js +++ b/api/claims/status.js @@ -26,7 +26,7 @@ module.exports = async function handler(req, res) { tenant_signer_ens, tenant_signer_record_status, tenant_signer_records_verified_at, tenant_signer_records_network, tenant_signer_txt_records, managed_ens_publication_status, managed_ens_parent_namespace, managed_ens_parent_authority_audited, tenant_proof_status, tenant_proof_signer, tenant_proof_verified_at, - genesis_receipt_id, genesis_generated_at + genesis_receipt_id, genesis_generated_at, first_action_receipt_status, first_action_receipt_id, first_action_receipt_hash, first_action_receipt_error from claim_requests where claim_id = $1 limit 1`, [claimId] ); @@ -53,7 +53,8 @@ module.exports = async function handler(req, res) { agent_cards: cardsStatus(cards), genesis_receipt: claim.genesis_receipt_id ? 'generated' : 'not_generated', tenant_action_proof: claim.tenant_proof_status || 'not_submitted', - agent_live: paymentConfirmed && claim.tenant_signer_record_status === 'records_verified' && cardsStatus(cards) === 'cards_pinned' && claim.genesis_receipt_id && claim.tenant_proof_status === 'verified' ? 'live' : 'not_live', + first_action_receipt: claim.first_action_receipt_status || 'not_generated', + agent_live: paymentConfirmed && claim.tenant_signer_record_status === 'records_verified' && 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) }, pipeline, cards }); } catch (_error) { diff --git a/api/claims/submit-first-action-receipt.js b/api/claims/submit-first-action-receipt.js new file mode 100644 index 0000000..b9029c4 --- /dev/null +++ b/api/claims/submit-first-action-receipt.js @@ -0,0 +1,42 @@ +'use strict'; + +const db = require('../../lib/db'); +const { getClaimAuth, unauthorizedClaimResponse } = require('../../lib/claims/access-token'); +const { requireRateLimit } = require('../../lib/rateLimit'); +const { verifyFirstActionReceipt } = require('../../lib/receipts/first-action-receipt'); + +module.exports = async function handler(req, res) { + res.setHeader('Content-Type', 'application/json; charset=utf-8'); + res.setHeader('Cache-Control', 'no-store'); + if (req.method !== 'POST') { res.setHeader('Allow', 'POST'); return res.status(405).json({ ok: false, status: 'METHOD_NOT_ALLOWED' }); } + if (!requireRateLimit(req, res, { bucket: 'claim-first-action-receipt', max: 30, windowMs: 60_000 })) return; + const claimId = req.body && (req.body.claim_id || req.body.claimId); + const receipt = req.body && req.body.receipt; + if (!claimId) return res.status(400).json({ ok: false, status: 'CLAIM_ID_REQUIRED' }); + if (!receipt) return res.status(400).json({ ok: false, status: 'RECEIPT_REQUIRED' }); + const claimResult = await db.query('select * from claim_requests where claim_id = $1 limit 1', [claimId]); + const claim = claimResult.rows[0]; + if (!claim) return res.status(404).json({ ok: false, status: 'CLAIM_NOT_FOUND' }); + const auth = getClaimAuth(req, claim); + if (!auth.ok) return unauthorizedClaimResponse(res); + if (claim.tenant_signer_record_status !== 'records_verified') return res.status(400).json({ ok: false, status: 'TENANT_SIGNER_RECORDS_NOT_VERIFIED' }); + + const verification = await verifyFirstActionReceipt(receipt, claim); + if (!verification.ok) { + await db.query(`update claim_requests set first_action_receipt_status = 'failed', first_action_receipt_error = $2, updated_at = now() where claim_id = $1`, [claimId, verification.error || verification.status]); + return res.status(400).json({ ok: false, status: verification.status, error: verification.error }); + } + await db.query( + `update claim_requests + set first_action_receipt_json = $2::jsonb, + first_action_receipt_id = $3, + first_action_receipt_hash = $4, + first_action_receipt_status = 'verified', + first_action_receipt_verified_at = now(), + first_action_receipt_error = null, + updated_at = now() + where claim_id = $1`, + [claimId, JSON.stringify(receipt), verification.receipt_id, verification.hash] + ); + return res.status(200).json({ ok: true, status: 'verified', receipt_id: verification.receipt_id, receipt_hash: verification.hash }); +}; diff --git a/db/migrations/010_first_action_receipt_fields.sql b/db/migrations/010_first_action_receipt_fields.sql new file mode 100644 index 0000000..83048c7 --- /dev/null +++ b/db/migrations/010_first_action_receipt_fields.sql @@ -0,0 +1,9 @@ +alter table if exists claim_requests + add column if not exists first_action_receipt_json jsonb, + add column if not exists first_action_receipt_id text, + add column if not exists first_action_receipt_hash text, + add column if not exists first_action_receipt_status text default 'not_generated', + add column if not exists first_action_receipt_verified_at timestamptz, + add column if not exists first_action_receipt_error text; + +create index if not exists idx_claim_requests_first_action_receipt_status on claim_requests(first_action_receipt_status); diff --git a/lib/receipts/first-action-receipt.js b/lib/receipts/first-action-receipt.js new file mode 100644 index 0000000..1d9ff58 --- /dev/null +++ b/lib/receipts/first-action-receipt.js @@ -0,0 +1,98 @@ +'use strict'; + +const crypto = require('node:crypto'); +const { canonicalize, sha256Hex } = require('../receiptSigning'); + +const EXECUTION_SCHEMA = 'clas.execution.receipt.v1'; +const EXECUTION_COVERS = ['receipt_id', 'verb', 'agent', 'action']; + +function safeIdPart(value) { + return String(value || '').toLowerCase().replace(/[^a-z0-9_-]+/g, '_').replace(/^_+|_+$/g, '') || 'claim'; +} + +function firstClaimVerb(claim, agents = []) { + const row = agents.find((a) => a.capability || a.ens) || {}; + if (row.capability) return safeIdPart(row.capability); + const ensLabel = String(row.ens || claim.tenant_signer_ens || 'verify').split('.')[0]; + return safeIdPart(ensLabel.replace(String(claim.tenant || ''), '') || ensLabel || 'verify'); +} + +async function hashObject(value) { + return `sha256:${await sha256Hex(canonicalize(value))}`; +} + +function executionPayload(receipt) { + return { + clas: receipt?.clas, + schema: receipt?.schema, + receipt_id: receipt?.receipt_id, + verb: receipt?.verb, + agent: receipt?.agent, + action: receipt?.action, + }; +} + +async function buildFirstActionReceiptChallenge({ claim, agents = [], now = new Date() }) { + const verb = firstClaimVerb(claim, agents); + const iso = now.toISOString(); + const receipt = { + clas: '1.0', + schema: EXECUTION_SCHEMA, + receipt_id: `clas_exec_${safeIdPart(claim.claim_id)}_${verb}_activation_001`, + verb, + agent: { + ens: claim.tenant_signer_ens, + kid: claim.tenant_signer_kid, + public_key_source: 'ens_txt', + }, + action: { + input_hash: await hashObject({ claim_id: claim.claim_id, verb, checkpoint: 'activation_first_action' }), + output_hash: await hashObject({ ok: true, checkpoint: 'activation_first_action', schema: EXECUTION_SCHEMA }), + started_at: iso, + completed_at: iso, + }, + }; + return { + ...receipt, + proofs: [{ + type: 'execution', + covers: EXECUTION_COVERS, + signer: claim.tenant_signer_ens, + canonicalization: 'json.sorted_keys.v1', + signature: { alg: 'Ed25519', kid: claim.tenant_signer_kid, value: '' }, + }], + }; +} + +function parseEd25519PublicKey(value) { + const raw = String(value || '').replace(/^ed25519:/, ''); + if (!raw) return null; + return Buffer.from(raw, 'base64'); +} + +async function verifyFirstActionReceipt(receipt, claim) { + const proof = Array.isArray(receipt?.proofs) ? receipt.proofs[0] : null; + const coversOk = JSON.stringify(proof?.covers || []) === JSON.stringify(EXECUTION_COVERS); + if (receipt?.schema !== EXECUTION_SCHEMA) return { ok: false, status: 'INVALID_SCHEMA', error: 'schema must be clas.execution.receipt.v1' }; + if (proof?.type !== 'execution') return { ok: false, status: 'INVALID_PROOF_TYPE', error: 'proof type must be execution' }; + if (!coversOk) return { ok: false, status: 'INVALID_PROOF_COVERS', error: 'execution proof covers must exactly receipt_id, verb, agent, action' }; + if (proof.signer !== claim.tenant_signer_ens || receipt?.agent?.ens !== claim.tenant_signer_ens) return { ok: false, status: 'SIGNER_MISMATCH', error: 'receipt signer must match claim tenant signer ENS' }; + if (receipt?.agent?.kid !== claim.tenant_signer_kid || proof?.signature?.kid !== claim.tenant_signer_kid) return { ok: false, status: 'KID_MISMATCH', error: 'receipt kid must match claim tenant signer kid' }; + if (proof?.signature?.alg !== 'Ed25519' || !proof?.signature?.value) return { ok: false, status: 'INVALID_SIGNATURE', error: 'Ed25519 signature is required' }; + const pub = parseEd25519PublicKey(claim.tenant_signer_public_key); + if (!pub) return { ok: false, status: 'PUBLIC_KEY_UNAVAILABLE', error: 'claim tenant public key unavailable' }; + const hash = await sha256Hex(canonicalize(executionPayload(receipt))); + let valid = false; + try { + valid = crypto.verify(null, Buffer.from(hash, 'utf8'), { key: pub, format: 'der', type: 'spki' }, Buffer.from(proof.signature.value, 'base64')); + } catch { + try { + const key = await crypto.webcrypto.subtle.importKey('raw', pub, { name: 'Ed25519' }, false, ['verify']); + valid = await crypto.webcrypto.subtle.verify({ name: 'Ed25519' }, key, Buffer.from(proof.signature.value, 'base64'), new TextEncoder().encode(hash)); + } catch { valid = false; } + } + if (!valid) return { ok: false, status: 'SIGNATURE_INVALID', error: 'action receipt signature invalid or payload tampered' }; + return { ok: true, status: 'VERIFIED', hash: `sha256:${await sha256Hex(canonicalize(receipt))}`, receipt_id: receipt.receipt_id }; +} + +module.exports = { EXECUTION_SCHEMA, EXECUTION_COVERS, buildFirstActionReceiptChallenge, verifyFirstActionReceipt, executionPayload }; diff --git a/public/admin/claims.html b/public/admin/claims.html index c8d181b..400c0d8 100644 --- a/public/admin/claims.html +++ b/public/admin/claims.html @@ -71,11 +71,15 @@

CommandLayer Claims Admin

Internal operator dashboard f function copyCheckout(claim){const url=checkoutUrlFromClaim(claim);if(url)navigator.clipboard.writeText(url);} function pipeline(status){const steps=['created','approved','cards_published','payment_pending','paid','erc8004','ens_provisioned','live'];return `

${steps.map(x=>`${x.replaceAll('_',' ')}`).join('')}

Future steps are coming next.

`} -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?`
${s.error}
`:''}

Claim ${short(claim.claim_id,16)} ${badge(claim.status)}

Tenant: ${claim.tenant||'-'} · Wallet: ${claim.authenticated_address||'-'} · Status: ${claim.status||'-'} · Payment: ${claim.payment_status||'-'} · Agents: ${agents.length} · Created: ${dt(claim.created_at)}

Pipeline

${pipeline(claim.status)}

Signer identity

Activation mode: ${claim.activation_mode||'-'}
Signer ENS: ${claim.tenant_signer_ens||'-'}
ENS records: ${claim.tenant_signer_record_status||'records_pending'} · Network: ${claim.tenant_signer_records_network||'ethereum-mainnet'}
Managed ENS publication: ${claim.managed_ens_publication_status||'-'}
Desired parent namespace: ${claim.managed_ens_parent_namespace||'-'}
Parent-control/resolver authority audited: ${claim.managed_ens_parent_authority_audited?'yes':'no'}
Tenant proof: ${claim.tenant_proof_status||'not_submitted'} · signer ${claim.tenant_proof_signer||'-'}
Four public TXT records
${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.

Next action

Agents

${agents.map(a=>``).join('')}
ENSCapabilityParentCardActions
${a.ens||'-'}${a.capability||'-'}${a.canonical_parent||'-'}${a.card_status||'-'}${a.card_url?`Open `:'-'}
${claim.status==='payment_pending'?`

Payment pending

Checkout has been created. Open it to complete payment.

${claim.stripe_checkout_session_id||'-'}

`:''}${claim.status==='paid'?`

Payment received

Next: ERC-8004 registration

`:''}${claim.status==='cards_published'?`

Agent cards published

Founding Activation: $20 first year for 10 Trust Verification namespaces.

${urls.length} cards${missing?' · Missing card URLs detected':''}

${s.checkoutUrl?`
Checkout created
Open checkout
${s.checkoutUrl}
`:''}
${firstUrl?``:''}
${urls.map(u=>`
${u.split('/').pop()?.replace(/\.json$/,'')||u}Open${copyBtn(u)}
`).join('')}
${missing?`

Some cards are missing URLs. Use Repair / Publish cards.

`:''}`:''}

Events

${events.map(e=>`
${e.event_type}
${e.message||''}
${dt(e.created_at)}
metadata
${JSON.stringify(e.event_json||{},null,2)}
`).join('')||'

No events.

'}

Genesis Receipt

Status: ${genesis?'generated':'not generated'}
Receipt ID: ${claim.genesis_receipt_id||'-'}
Receipt Hash: ${claim.genesis_receipt_hash||'-'}
Chain Root: ${claim.receipt_chain_root||'-'}
Generated at: ${dt(claim.genesis_generated_at)}
${s.genesisStatus?`
${s.genesisStatus}
`:''}${genesis?``:''}

Transitions

${transitions.map(t=>`
${t.from_status||'-'} → ${t.to_status||'-'} · ${t.action||'-'} · ${t.actor||'-'}
${dt(t.created_at)} ${t.reason?`· ${t.reason}`:''}
`).join('')||'

No transitions.

'}
Show raw JSON
${JSON.stringify(s.detail,null,2)}
`; + +async function generateFirstActionReceipt(){if(!s.selected){s.error='Request failed: 400';renderDetail();return;}s.error=null;try{const r=await fetch('/api/admin/generate-first-action-receipt',{method:'POST',headers:headers(),body:JSON.stringify({claimId:s.selected})});const d=await r.json().catch(()=>({}));if(!r.ok||!d.ok){s.error=d?.status||'FIRST_ACTION_RECEIPT_FAILED';renderDetail();return;}await loadClaims();await loadDetail(s.selected);}catch(e){s.error='FIRST_ACTION_RECEIPT_FAILED';renderDetail();}} + +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?`
${s.error}
`:''}

Claim ${short(claim.claim_id,16)} ${badge(claim.status)}

Tenant: ${claim.tenant||'-'} · Wallet: ${claim.authenticated_address||'-'} · Status: ${claim.status||'-'} · Payment: ${claim.payment_status||'-'} · Agents: ${agents.length} · Created: ${dt(claim.created_at)}

Pipeline

${pipeline(claim.status)}

Signer identity

Activation mode: ${claim.activation_mode||'-'}
Signer ENS: ${claim.tenant_signer_ens||'-'}
ENS records: ${claim.tenant_signer_record_status||'records_pending'} · Network: ${claim.tenant_signer_records_network||'ethereum-mainnet'}
Managed ENS publication: ${claim.managed_ens_publication_status||'-'}
Desired parent namespace: ${claim.managed_ens_parent_namespace||'-'}
Parent-control/resolver authority audited: ${claim.managed_ens_parent_authority_audited?'yes':'no'}
Tenant proof: ${claim.tenant_proof_status||'not_submitted'} · signer ${claim.tenant_proof_signer||'-'}
Four public TXT records
${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.

Next action

Agents

${agents.map(a=>``).join('')}
ENSCapabilityParentCardActions
${a.ens||'-'}${a.capability||'-'}${a.canonical_parent||'-'}${a.card_status||'-'}${a.card_url?`Open `:'-'}
${claim.status==='payment_pending'?`

Payment pending

Checkout has been created. Open it to complete payment.

${claim.stripe_checkout_session_id||'-'}

`:''}${claim.status==='paid'?`

Payment received

Next: ERC-8004 registration

`:''}${claim.status==='cards_published'?`

Agent cards published

Founding Activation: $20 first year for 10 Trust Verification namespaces.

${urls.length} cards${missing?' · Missing card URLs detected':''}

${s.checkoutUrl?`
Checkout created
Open checkout
${s.checkoutUrl}
`:''}
${firstUrl?``:''}
${urls.map(u=>`
${u.split('/').pop()?.replace(/\.json$/,'')||u}Open${copyBtn(u)}
`).join('')}
${missing?`

Some cards are missing URLs. Use Repair / Publish cards.

`:''}`:''}

Events

${events.map(e=>`
${e.event_type}
${e.message||''}
${dt(e.created_at)}
metadata
${JSON.stringify(e.event_json||{},null,2)}
`).join('')||'

No events.

'}

Genesis Receipt

Status: ${genesis?'generated':'not generated'}
Receipt ID: ${claim.genesis_receipt_id||'-'}
Receipt Hash: ${claim.genesis_receipt_hash||'-'}
Chain Root: ${claim.receipt_chain_root||'-'}
Generated at: ${dt(claim.genesis_generated_at)}
${s.genesisStatus?`
${s.genesisStatus}
`:''}${genesis?``:''}

First Action Receipt

Status: ${claim.first_action_receipt_status||'not_generated'}
Receipt ID: ${claim.first_action_receipt_id||'-'}
Receipt Hash: ${claim.first_action_receipt_hash||'-'}
${claim.first_action_receipt_error||'This proves the claimed agent can produce a scoped execution receipt. It does not prove payment settlement.'}

Transitions

${transitions.map(t=>`
${t.from_status||'-'} → ${t.to_status||'-'} · ${t.action||'-'} · ${t.actor||'-'}
${dt(t.created_at)} ${t.reason?`· ${t.reason}`:''}
`).join('')||'

No transitions.

'}
Show raw JSON
${JSON.stringify(s.detail,null,2)}
`; const a=document.getElementById('actions');const mk=(t,c,cls='btn',opts={})=>{const b=document.createElement('button');b.type='button';b.textContent=t;b.className=cls;b.disabled=Boolean(opts.disabled);if(opts.id)b.id=opts.id;b.onclick=c;a.appendChild(b);}; if(claim.status==='created'){mk('Approve',()=>action('approve',{notes:document.getElementById('notesInput').value}),'btn btn-primary');mk('Reject',()=>action('reject',{reason:document.getElementById('reasonInput').value}),'btn btn-secondary');mk('Mark failed',()=>action('mark_failed',{reason:document.getElementById('reasonInput').value}),'btn btn-danger');} if(claim.status==='approved'){mk('Publish agent cards',()=>publish(),'btn btn-primary');mk('Mark failed',()=>action('mark_failed',{reason:document.getElementById('reasonInput').value}),'btn btn-danger');mk('Add note',()=>action('add_note',{notes:document.getElementById('notesInput').value,reason:document.getElementById('reasonInput').value}),'btn');} if(claim.status==='cards_published'){if(missing)mk('Repair / Publish cards',()=>publish(),'btn btn-primary');mk(s.checkoutLoading?'Creating checkout...':'Create $20 checkout',()=>createCheckoutSession(claim.claim_id),'btn btn-primary',{disabled:s.checkoutLoading,id:'createCheckoutBtn'});mk('Add note',()=>action('add_note',{notes:document.getElementById('notesInput').value,reason:document.getElementById('reasonInput').value}),'btn');} +const firstActionBtn=document.getElementById('generateFirstAction');if(firstActionBtn)firstActionBtn.onclick=generateFirstActionReceipt; if(claim.status==='cards_pinned'){mk(s.genesisLoading?'Generating genesis...':'Generate genesis receipt',()=>generateGenesisReceipt(false),'btn btn-primary',{disabled:s.genesisLoading});if(claim.genesis_receipt_id)mk(s.genesisLoading?'Generating genesis...':'Regenerate genesis receipt',()=>generateGenesisReceipt(true),'btn btn-secondary',{disabled:s.genesisLoading});mk('Add note',()=>action('add_note',{notes:document.getElementById('notesInput').value,reason:document.getElementById('reasonInput').value}),'btn');} if(claim.status==='payment_pending'){mk('Open checkout',()=>openCheckout(claim),'btn btn-secondary');mk('Copy checkout URL',()=>copyCheckout(claim),'btn btn-secondary');mk(s.checkoutLoading?'Creating checkout...':'Regenerate checkout session',()=>createCheckoutSession(claim.claim_id,true),'btn btn-primary',{disabled:s.checkoutLoading,id:'regenerateCheckoutBtn'});mk('Add note',()=>action('add_note',{notes:document.getElementById('notesInput').value,reason:document.getElementById('reasonInput').value}),'btn');} mk('Run activation pipeline',()=>runActivationPipeline(claim.claim_id),'btn btn-primary'); diff --git a/public/claim.html b/public/claim.html index a73972a..e8235fc 100644 --- a/public/claim.html +++ b/public/claim.html @@ -1466,6 +1466,7 @@

Activation pipeline

agent_cards: 'not_pinned', genesis_receipt: 'not_generated', tenant_action_proof: 'not_submitted', + first_action_receipt: 'not_generated', agent_live: 'not_live' }; const rows = [ @@ -1476,15 +1477,19 @@

Activation pipeline

['5. Public agent cards pinned to IPFS', pipeline.agent_cards], ['6. Genesis receipt generated', pipeline.genesis_receipt], ['7. Tenant action proof verified', pipeline.tenant_action_proof], - ['8. Agent live', pipeline.agent_live] + ['8. First Action Receipt', pipeline.first_action_receipt], + ['9. Agent live', pipeline.agent_live] ]; el.innerHTML = rows.map(([label,status]) => { const cls = pipelineStatusLabel(status); const icon = cls === 'complete' ? '✅' : (cls === 'active' ? '⏳' : '○'); return `
${icon}
${label}${status || 'pending'}
`; }).join(''); - const intro = document.getElementById('pipelineIntro'); const claim = data && data.claim; + const firstAction = claim && (claim.first_action_receipt_status || 'not_generated'); + const receiptNote = data && data.claim ? `
ℹ️
First Action ReceiptThis proves the claimed agent can produce a scoped execution receipt. It does not prove payment settlement. Status: ${firstAction}
` : ''; + if (receiptNote) el.insertAdjacentHTML('beforeend', receiptNote); + const intro = document.getElementById('pipelineIntro'); if (intro && claim) { intro.textContent = claim.tenant_signer_record_status === 'records_verified' ? 'ENS signer identity verified on Ethereum mainnet' diff --git a/tests/first-action-receipt.test.js b/tests/first-action-receipt.test.js new file mode 100644 index 0000000..f197f5a --- /dev/null +++ b/tests/first-action-receipt.test.js @@ -0,0 +1,80 @@ +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { webcrypto } = require('node:crypto'); +const db = require('../lib/db'); +const adminGenerate = require('../api/admin/generate-first-action-receipt'); +const submitReceipt = require('../api/claims/submit-first-action-receipt'); +const { buildFirstActionReceiptChallenge, executionPayload } = require('../lib/receipts/first-action-receipt'); +const { canonicalize, sha256Hex } = require('../lib/receiptSigning'); +const { hashClaimAccessToken } = require('../lib/claims/access-token'); +const { resetRateLimitForTests } = require('../lib/rateLimit'); + +function makeRes(){return {statusCode:200,headers:{},body:null,setHeader(n,v){this.headers[n.toLowerCase()]=v;},status(c){this.statusCode=c;return this;},json(p){this.body=p;return this;}};} +const token='claim-token'; +const tokenHash=hashClaimAccessToken(token); + +async function keypair(){ + const kp=await webcrypto.subtle.generateKey({name:'Ed25519'}, true, ['sign','verify']); + const raw=Buffer.from(await webcrypto.subtle.exportKey('raw', kp.publicKey)).toString('base64'); + return { kp, publicKey:`ed25519:${raw}` }; +} +async function signedReceipt(claim, kp, mutate){ + const receipt=await buildFirstActionReceiptChallenge({claim, agents:[{capability:'verify'}], now:new Date('2026-01-01T00:00:00Z')}); + if (mutate) mutate(receipt); + const hash=await sha256Hex(canonicalize(executionPayload(receipt))); + const sig=await webcrypto.subtle.sign({name:'Ed25519'}, kp.privateKey, new TextEncoder().encode(hash)); + receipt.proofs[0].signature.value=Buffer.from(sig).toString('base64'); + return receipt; +} +function claim(overrides={}){return {claim_id:'c1',claim_access_token_hash:tokenHash,status:'paid',payment_status:'paid',paid_at:'2026-01-01T00:00:00Z',tenant:'acme',tenant_signer_ens:'acme.eth',tenant_signer_kid:'kid1',tenant_signer_public_key:'',tenant_signer_record_status:'records_verified',tenant_proof_status:'verified',genesis_receipt_id:'genesis1',...overrides};} + +test('cannot generate first action challenge before signer ENS exists', async()=>{ + process.env.ADMIN_API_KEY='k'; + db.query=async(q)=> q.includes('from claim_requests') ? {rows:[claim({tenant_signer_ens:null})]} : {rows:[]}; + const res=makeRes(); + await adminGenerate({method:'POST',headers:{'x-admin-api-key':'k'},body:{claimId:'c1'}},res); + assert.equal(res.statusCode,400); assert.equal(res.body.status,'TENANT_SIGNER_ENS_REQUIRED'); +}); + +test('cannot generate or verify if signer records are not verified', async()=>{ + resetRateLimitForTests(); process.env.ADMIN_API_KEY='k'; + const c=claim({tenant_signer_record_status:'records_pending'}); + db.query=async(q)=> q.includes('from claim_requests') ? {rows:[c]} : {rows:[]}; + let res=makeRes(); await adminGenerate({method:'POST',headers:{'x-admin-api-key':'k'},body:{claimId:'c1'}},res); + assert.equal(res.body.status,'TENANT_SIGNER_RECORDS_NOT_VERIFIED'); + res=makeRes(); await submitReceipt({method:'POST',headers:{'x-claim-access-token':token},body:{claimId:'c1',receipt:{}}},res); + assert.equal(res.body.status,'TENANT_SIGNER_RECORDS_NOT_VERIFIED'); +}); + +test('challenge receipt has correct schema and exact execution covers', async()=>{ + const r=await buildFirstActionReceiptChallenge({claim:claim({tenant_signer_public_key:'ed25519:AA=='}), agents:[{capability:'attest'}], now:new Date('2026-01-01T00:00:00Z')}); + assert.equal(r.schema,'clas.execution.receipt.v1'); + assert.deepEqual(r.proofs[0].covers,['receipt_id','verb','agent','action']); + assert.equal(r.proofs[0].type,'execution'); +}); + +test('submitted signed receipt validates signer, tamper, settlement-only and bad covers; success stores verified', async()=>{ + resetRateLimitForTests(); + const {kp, publicKey}=await keypair(); + const c=claim({tenant_signer_public_key:publicKey}); + const updates=[]; + db.query=async(q,params)=>{ if(q.includes('from claim_requests')) return {rows:[c]}; if(q.includes('update claim_requests')) {updates.push({q,params}); return {rows:[],rowCount:1};} return {rows:[]}; }; + let bad=await signedReceipt(c,kp,(r)=>{r.agent.ens='evil.eth';}); + let res=makeRes(); await submitReceipt({method:'POST',headers:{'x-claim-access-token':token},body:{claimId:'c1',receipt:bad}},res); + assert.equal(res.statusCode,400); assert.equal(res.body.status,'SIGNER_MISMATCH'); + bad=await signedReceipt(c,kp); bad.action.output_hash='sha256:tampered'; + res=makeRes(); await submitReceipt({method:'POST',headers:{'x-claim-access-token':token},body:{claimId:'c1',receipt:bad}},res); + assert.equal(res.body.status,'SIGNATURE_INVALID'); + bad=await signedReceipt(c,kp,(r)=>{r.proofs[0].type='settlement';}); + res=makeRes(); await submitReceipt({method:'POST',headers:{'x-claim-access-token':token},body:{claimId:'c1',receipt:bad}},res); + assert.equal(res.body.status,'INVALID_PROOF_TYPE'); + bad=await signedReceipt(c,kp,(r)=>{r.proofs[0].covers=['receipt_id','verb','agent','action','settlement'];}); + res=makeRes(); await submitReceipt({method:'POST',headers:{'x-claim-access-token':token},body:{claimId:'c1',receipt:bad}},res); + assert.equal(res.body.status,'INVALID_PROOF_COVERS'); + const good=await signedReceipt(c,kp); + res=makeRes(); await submitReceipt({method:'POST',headers:{'x-claim-access-token':token},body:{claimId:'c1',receipt:good}},res); + assert.equal(res.statusCode,200); assert.equal(res.body.status,'verified'); + assert.ok(updates.some(u=>u.q.includes("first_action_receipt_status = 'verified'"))); +});