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
49 changes: 49 additions & 0 deletions api/admin/generate-first-action-receipt.js
Original file line number Diff line number Diff line change
@@ -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.' });
};
5 changes: 3 additions & 2 deletions api/claims/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -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]
);
Expand All @@ -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) {
Expand Down
42 changes: 42 additions & 0 deletions api/claims/submit-first-action-receipt.js
Original file line number Diff line number Diff line change
@@ -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 });
};
9 changes: 9 additions & 0 deletions db/migrations/010_first_action_receipt_fields.sql
Original file line number Diff line number Diff line change
@@ -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);
98 changes: 98 additions & 0 deletions lib/receipts/first-action-receipt.js
Original file line number Diff line number Diff line change
@@ -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 };
Loading
Loading