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 @@
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 `
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?`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 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)}