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
34 changes: 34 additions & 0 deletions api/admin/prepare-managed-ens-publication.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
'use strict';

const db = require('../../lib/db');
const { requireAdminAuth } = require('./_auth');
const { buildManagedEnsPublicationPackage } = require('../../lib/claims/managed-ens-publication');

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.claim_id || req.body.claimId);
if (!claimId) return res.status(400).json({ ok: false, status: 'CLAIM_ID_REQUIRED' });
try {
const result = await db.query('select * from claim_requests where claim_id = $1 limit 1', [claimId]);
const claim = result.rows[0];
if (!claim) return res.status(404).json({ ok: false, status: 'CLAIM_NOT_FOUND' });
const publication = buildManagedEnsPublicationPackage(claim);
await db.query(
`update claim_requests
set managed_ens_publication_status = 'ready_to_publish',
managed_ens_required_txt_records = $2::jsonb,
managed_ens_publication_instructions = $3::jsonb,
managed_ens_parent_namespace = $4,
managed_ens_publication_error = null,
updated_at = now()
where claim_id = $1`,
[claimId, JSON.stringify(publication.required_txt_records), JSON.stringify(publication.instructions), publication.parent_namespace]
);
return res.status(200).json({ ok: true, claim_id: claimId, publication });
} catch (error) {
return res.status(400).json({ ok: false, status: error.status || 'MANAGED_ENS_PUBLICATION_PREPARE_FAILED', error: error.message });
}
};
2 changes: 1 addition & 1 deletion api/admin/run-activation-pipeline.js
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,11 @@ module.exports = async function handler(req, res) {

const steps = {
payment: paymentStep(claim),
managed_ens_publication: claim.managed_ens_publication_status || (claim.activation_mode === 'managed_namespace' ? 'not_started' : 'not_applicable'),
ens_records: claim.tenant_signer_record_status || 'records_pending',
agent_cards: 'not_started',
genesis_receipt: claim.genesis_receipt_id ? 'already_generated' : 'not_started',
tenant_action_proof: claim.tenant_proof_status || 'not_submitted',
managed_ens_publication: claim.managed_ens_publication_status || (claim.activation_mode === 'managed_namespace' ? 'not_started' : 'not_applicable'),
};

if (steps.payment !== 'already_paid') {
Expand Down
42 changes: 42 additions & 0 deletions api/admin/verify-managed-ens-publication.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
'use strict';

const db = require('../../lib/db');
const { requireAdminAuth } = require('./_auth');
const { verifyManagedEnsPublication } = require('../../lib/claims/managed-ens-publication');

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.claim_id || req.body.claimId);
if (!claimId) return res.status(400).json({ ok: false, status: 'CLAIM_ID_REQUIRED' });
try {
const result = await db.query('select * from claim_requests where claim_id = $1 limit 1', [claimId]);
const claim = result.rows[0];
if (!claim) return res.status(404).json({ ok: false, status: 'CLAIM_NOT_FOUND' });
const verification = await verifyManagedEnsPublication(claim, req.verifyOptions || {});
if (verification.ok) {
await db.query(
`update claim_requests
set managed_ens_publication_status = 'verified', tenant_signer_record_status = 'records_verified',
tenant_signer_records_verified_at = now(), managed_ens_verified_at = now(),
managed_ens_publication_error = null, tenant_signer_verification_error = null, updated_at = now()
where claim_id = $1`,
[claimId]
);
} else {
await db.query(
`update claim_requests
set managed_ens_publication_status = $2, managed_ens_publication_error = $3,
tenant_signer_verification_error = $3, updated_at = now()
Comment on lines +30 to +32

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 Keep signer status in sync on failed verification

When live TXT records are present but mismatched, this branch records only the managed publication status/error and leaves tenant_signer_record_status at its previous value (commonly records_generated). Since both the public pipeline and admin list read tenant_signer_record_status for ENS record state, an operator-run failed verification still surfaces stale signer-record status instead of records_mismatch/records_unavailable; update it the same way the existing signer-record verifier does.

Useful? React with 👍 / 👎.

where claim_id = $1`,
[claimId, verification.status, verification.error]
);
}
return res.status(200).json({ ok: true, claim_id: claimId, verification });
} catch (error) {
try { await db.query(`update claim_requests set managed_ens_publication_status = 'failed', managed_ens_publication_error = $2, updated_at = now() where claim_id = $1`, [claimId, error.message]); } catch (_) {}

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 Store a bounded error code for resolver failures

When the ENS/RPC resolver throws, this catch persists the raw error.message into managed_ens_publication_error, and api/claims/status.js returns that field to claim-token callers. Unlike the existing signer-record verifier, which stores fixed error codes, this can expose arbitrary provider exception text in claimant-visible status after a transient resolver failure; persist a stable code instead.

Useful? React with 👍 / 👎.

return res.status(400).json({ ok: false, status: error.status || 'MANAGED_ENS_PUBLICATION_VERIFY_FAILED', error: error.message });
}
};
5 changes: 3 additions & 2 deletions api/claims/status.js
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ module.exports = async function handler(req, res) {
`select claim_id, claim_access_token_hash, tenant, activation_mode, status, payment_status, paid_at,
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,
managed_ens_required_txt_records, managed_ens_verified_at, managed_ens_publication_error, managed_ens_parent_authority_audited, tenant_proof_status, tenant_proof_signer, tenant_proof_verified_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 @@ -49,14 +49,15 @@ module.exports = async function handler(req, res) {
tenant_signing_identity: claim.tenant_signer_ens ? 'generated' : 'missing',
claim_request: 'created',
payment: paymentConfirmed ? 'paid' : (claim.payment_status || 'unpaid'),
managed_ens_publication: claim.activation_mode === 'managed_namespace' ? (claim.managed_ens_publication_status || 'not_started') : 'not_applicable',
ens_records: claim.tenant_signer_record_status || 'records_pending',
agent_cards: cardsStatus(cards),
genesis_receipt: claim.genesis_receipt_id ? 'generated' : 'not_generated',
tenant_action_proof: claim.tenant_proof_status || 'not_submitted',
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 });
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 });
} catch (_error) {
return res.status(500).json({ ok: false, status: 'CLAIM_STATUS_UNAVAILABLE' });
}
Expand Down
7 changes: 7 additions & 0 deletions db/migrations/011_managed_ens_publication.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
alter table if exists claim_requests
add column if not exists managed_ens_publication_status text default 'not_started',
add column if not exists managed_ens_parent_namespace text,
add column if not exists managed_ens_publication_instructions jsonb,
add column if not exists managed_ens_required_txt_records jsonb,
add column if not exists managed_ens_verified_at timestamptz,
add column if not exists managed_ens_publication_error text;
59 changes: 59 additions & 0 deletions lib/claims/managed-ens-publication.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
'use strict';

const { buildSignerRecords, CANONICALIZATION, resolveRequiredSignerRecords, compareSignerRecords } = require('./signer-records');

const APPROVED_MANAGED_PARENTS = ['attestagent.eth', 'approveagent.eth', 'verifyagent.eth', 'authorizeagent.eth'];

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 Accept generated trust parents in the managed allow-list

For CL-mode claims submitted with the current Trust Verification pack, public/claim.html selects all TRUST_PARENTS in order, and the first generated tenant signer is *.signagent.eth; this allow-list omits signagent.eth (and several other generated trust parents), so buildManagedEnsPublicationPackage() rejects those normal managed claims as MANAGED_PARENT_NOT_APPROVED before operators can prepare TXT records. Please keep this list aligned with the generated managed parents or the helper is unusable for the default trust pack.

Useful? React with 👍 / 👎.

const PUBLICATION_STATUSES = ['not_started', 'ready_to_publish', 'published_pending_verification', 'verified', 'failed'];

function normalizeEns(value) { return String(value || '').trim().toLowerCase(); }
function tenantRootEns(claim) { return normalizeEns(claim.tenant_root_ens || claim.root_ens || (claim.tenant ? `${claim.tenant}.eth` : '')); }
function parentNamespace(signerEns) { const parts = normalizeEns(signerEns).split('.'); return parts.length > 2 ? parts.slice(1).join('.') : ''; }
function isApprovedParent(parent) { return APPROVED_MANAGED_PARENTS.includes(normalizeEns(parent)); }
function readReceiptSigner(claim) {
const pkg = claim.claim_package || claim.request_json || claim.receipt_json || {};
return normalizeEns(pkg?.cl?.receipt?.signer || pkg?.['cl.receipt.signer'] || pkg?.receipt?.signer || claim.receipt_signer || '');
}
function validationError(status, error) { const e = new Error(error); e.status = status; return e; }

function validateManagedEnsPublicationClaim(claim) {
if (!claim || claim.activation_mode !== 'managed_namespace') throw validationError('MANAGED_NAMESPACE_REQUIRED', 'Managed ENS publication is only available for managed namespace claims.');
const signerEns = normalizeEns(claim.tenant_signer_ens);
if (!signerEns) throw validationError('TENANT_SIGNER_ENS_REQUIRED', 'tenant_signer_ens is required.');
const root = tenantRootEns(claim);
if (root && signerEns === root) throw validationError('TENANT_ROOT_ENS_NOT_ALLOWED', 'Managed signer ENS must not equal the tenant root ENS.');
const parent = parentNamespace(signerEns);
if (!parent || !isApprovedParent(parent)) throw validationError('MANAGED_PARENT_NOT_APPROVED', 'tenant_signer_ens must end in an approved managed parent namespace.');
if (!claim.tenant_signer_public_key || !claim.tenant_signer_kid || !claim.tenant_signer_canonicalization) throw validationError('SIGNER_RECORD_FIELDS_REQUIRED', 'public key, kid, and canonicalization are required.');
const receiptSigner = readReceiptSigner(claim);
if (receiptSigner && receiptSigner !== signerEns) throw validationError('RECEIPT_SIGNER_MISMATCH', 'claim package cl.receipt.signer must match tenant_signer_ens.');
return { signerEns, parent, tenant: claim.tenant || signerEns.split('.')[0] };
}

function buildManagedEnsPublicationPackage(claim) {
const { signerEns, parent, tenant } = validateManagedEnsPublicationClaim(claim);
const required = buildSignerRecords({ publicKey: claim.tenant_signer_public_key, kid: claim.tenant_signer_kid, canonicalization: claim.tenant_signer_canonicalization || CANONICALIZATION, signerEns });
return {
signer_ens: signerEns,
parent_namespace: parent,
tenant,
required_txt_records: required,
agent_records: {
'cl.capability': 'attest',
'cl.runtime': 'https://runtime.commandlayer.org',
'cl.verifier': 'https://runtime.commandlayer.org/verify',
'cl.trust_verification_entry': 'https://runtime.commandlayer.org/trust-verification/attest/v1.0.0',
Comment on lines +41 to +44

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 Derive agent records from the signer capability

For managed claims whose signer is one of the other allowed parents, such as acme.approveagent.eth or acme.verifyagent.eth, the publication package still tells the operator to publish cl.capability=attest and the attest trust-verification entry. That makes the helper emit incorrect TXT guidance for valid non-attest managed signer names, so these values should be derived from the signer parent/capability instead of hard-coded.

Useful? React with 👍 / 👎.

'cl.agent.card': 'pending_provisioning',
},
instructions: ['Open ENS Manager', 'Select the managed signer name', 'Add the required TXT records', 'Save changes', 'Return to CommandLayer and run verification'],
};
}

async function verifyManagedEnsPublication(claim, options = {}) {
const pkg = buildManagedEnsPublicationPackage(claim);
const resolved = await resolveRequiredSignerRecords(pkg.signer_ens, options);
const { checks, verified } = compareSignerRecords({ ...claim, tenant_signer_ens: pkg.signer_ens }, resolved);
const missing = Object.values(resolved).some((value) => !value);
return { ok: verified && !missing, status: verified && !missing ? 'verified' : (missing ? 'published_pending_verification' : 'failed'), signer_ens: pkg.signer_ens, parent_namespace: pkg.parent_namespace, required_txt_records: pkg.required_txt_records, resolved_txt_records: resolved, checks, error: verified && !missing ? null : (missing ? 'required_txt_record_missing' : 'required_txt_record_mismatch') };
}

module.exports = { APPROVED_MANAGED_PARENTS, PUBLICATION_STATUSES, validateManagedEnsPublicationClaim, buildManagedEnsPublicationPackage, verifyManagedEnsPublication };
Loading
Loading