From cc71e58231d23b9c517a5842700c2d7446abf564 Mon Sep 17 00:00:00 2001 From: Greg Soucy Date: Tue, 16 Jun 2026 20:31:43 -0400 Subject: [PATCH] Add managed ENS publication helper --- api/admin/prepare-managed-ens-publication.js | 34 +++++++++++ api/admin/run-activation-pipeline.js | 2 +- api/admin/verify-managed-ens-publication.js | 42 +++++++++++++ api/claims/status.js | 5 +- db/migrations/011_managed_ens_publication.sql | 7 +++ lib/claims/managed-ens-publication.js | 59 +++++++++++++++++++ public/admin/claims.html | 12 +++- tests/managed-ens-publication.test.js | 50 ++++++++++++++++ 8 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 api/admin/prepare-managed-ens-publication.js create mode 100644 api/admin/verify-managed-ens-publication.js create mode 100644 db/migrations/011_managed_ens_publication.sql create mode 100644 lib/claims/managed-ens-publication.js create mode 100644 tests/managed-ens-publication.test.js diff --git a/api/admin/prepare-managed-ens-publication.js b/api/admin/prepare-managed-ens-publication.js new file mode 100644 index 0000000..0c79b41 --- /dev/null +++ b/api/admin/prepare-managed-ens-publication.js @@ -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 }); + } +}; diff --git a/api/admin/run-activation-pipeline.js b/api/admin/run-activation-pipeline.js index a51f537..a3baf89 100644 --- a/api/admin/run-activation-pipeline.js +++ b/api/admin/run-activation-pipeline.js @@ -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') { diff --git a/api/admin/verify-managed-ens-publication.js b/api/admin/verify-managed-ens-publication.js new file mode 100644 index 0000000..2b7271a --- /dev/null +++ b/api/admin/verify-managed-ens-publication.js @@ -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() + 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 (_) {} + return res.status(400).json({ ok: false, status: error.status || 'MANAGED_ENS_PUBLICATION_VERIFY_FAILED', error: error.message }); + } +}; diff --git a/api/claims/status.js b/api/claims/status.js index 6a288d9..d9414fd 100644 --- a/api/claims/status.js +++ b/api/claims/status.js @@ -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] @@ -49,6 +49,7 @@ 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', @@ -56,7 +57,7 @@ module.exports = async function handler(req, res) { 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' }); } diff --git a/db/migrations/011_managed_ens_publication.sql b/db/migrations/011_managed_ens_publication.sql new file mode 100644 index 0000000..ecebd6e --- /dev/null +++ b/db/migrations/011_managed_ens_publication.sql @@ -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; diff --git a/lib/claims/managed-ens-publication.js b/lib/claims/managed-ens-publication.js new file mode 100644 index 0000000..44ce61d --- /dev/null +++ b/lib/claims/managed-ens-publication.js @@ -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']; +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', + '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 }; diff --git a/public/admin/claims.html b/public/admin/claims.html index 400c0d8..189472b 100644 --- a/public/admin/claims.html +++ b/public/admin/claims.html @@ -51,7 +51,7 @@

CommandLayer Claims Admin

Internal operator dashboard f