diff --git a/docs/product-hub/content/ap2.md b/docs/product-hub/content/ap2.md new file mode 100644 index 0000000..c90e00b --- /dev/null +++ b/docs/product-hub/content/ap2.md @@ -0,0 +1,25 @@ +# Agent Payments Protocol (AP2) + +The **Agent Payments Protocol (AP2)** is the modern standard for autonomous commerce within the Open Commerce Protocol (OCP). It defines a cryptographically secure framework for delegated authority, enabling AI agents to act as fiduciary representatives of their users. + +## Mandates: The Chain of Evidence + +At the heart of AP2 are **Mandates**—stateless, portable digital contracts (signed JWS) that authorize specific commerce intents. + +### Intent Mandates +An **Intent Mandate** is issued by a user to an agent, defining the scope of its authority. +- `max_budget`: The absolute spending limit for the mandate. +- `expiration`: When the agent's authority expires. +- `allowed_merchants`: A whitelist of DIDs authorized for interaction. +- `purpose_code`: The specific intent (e.g., `PROCUREMENT`, `SUBSCRIPTION`). + +### Cart Mandates +A **Cart Mandate** is generated by the agent during a specific transaction. It links back to an Intent Mandate and includes a cryptographic hash of the current cart contents, preventing price-switching or tampering after commitment. + +## Verifiable Credentials (VCs) +OCP agents use **Verifiable Credentials** to prove their authority without exposing the user's PII. +- **DIDs**: Leveraging `did:key` for agent portability and `did:web` for protocol services. +- **Zero-Knowledge Proofs**: (Coming Soon) Prove budget availability without revealing the exact balance. + +## Zero Trust Verification +The OCP Secure Enclave (Vault) enforces Mandates at the point of signing. Even if the application layer is compromised, the enclave will refuse to sign any transaction that violates the budget or policy constraints of the provided Mandate. diff --git a/docs/product-hub/content/mpp.md b/docs/product-hub/content/mpp.md new file mode 100644 index 0000000..a12b478 --- /dev/null +++ b/docs/product-hub/content/mpp.md @@ -0,0 +1,34 @@ +# Machine Payment Protocol (MPP) + +The **Machine Payment Protocol (MPP)** is the native OCP standard for autonomous tool-call and data-packet payments. It enables machines to handle **Payment Required (HTTP 402)** flows autonomously and without manual intervention. + +## 402 Flow: Autonomous Retries + +The MPP middleware enables OCP agents to detect when a request requires a payment: +1. **Initial Request**: The agent makes an API call or requests data. +2. **402 Required**: The service responds with a `402 Payment Required` status, including headers for `X-MPP-Amount` and `X-MPP-Merchant-DID`. +3. **Mandate Validation**: The agent's middleware checks its available Intent Mandates for budget and authorized merchants. +4. **Autonomous Cart Mandate**: The agent generates and signs a new Cart Mandate for the exact amount. +5. **Retry**: The agent retries the request with the `X-OCP-Cart-Mandate` header. + +## x402 Extension: Stablecoin Settlements + +MPP supports the **x402 Extension** for 24/7, low-latency settlements using stablecoins like **USDC** and **PYUSD**. +- **Instant Finality**: Machines can settle micro-payments and tool-calls in real-time. +- **Programmable Settlement**: Rules-based settlement logic within the Web3Service. +- **Micro-transaction Optimization**: Zero-fee internal ledger transfers before settlement. + +## Getting Started with MPP + +Implement the MPP handler in your OCP project to enable autonomous 402 responses: + +```javascript +const { MPP402Handler } = require('@open-commerce-protocol/core'); + +const mpp = new MPP402Handler(agentService, mandateService); + +// Execute an autonomous request that handles 402 retries +const result = await mpp.executeAutonomousRequest(agent, async (config) => { + return await axios.post('https://api.agent-services.com/data', { data: '...' }, config); +}, intentMandate); +``` diff --git a/package.json b/package.json index 35336fc..8c998a6 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,9 @@ "url": "https://github.com/dcplatforms/Open-Commerce-Protocol/issues" }, "homepage": "https://github.com/dcplatforms/Open-Commerce-Protocol#readme", + "bin": { + "ocp": "scripts/ocp-cli.js" + }, "dependencies": { "axios": "^1.6.0", "bcryptjs": "^2.4.3", @@ -52,6 +55,7 @@ "express-rate-limit": "^7.1.0", "graphql": "^15.8.0", "helmet": "^7.1.0", + "commander": "^11.1.0", "joi": "^17.13.3", "jsonwebtoken": "^9.0.3", "mongoose": "^8.0.0", @@ -61,6 +65,7 @@ }, "devDependencies": { "@types/node": "^20.10.0", + "commander": "^11.1.0", "eslint": "^8.54.0", "jest": "^29.7.0", "mongodb-memory-server": "^9.0.0", diff --git a/scripts/ocp-cli.js b/scripts/ocp-cli.js new file mode 100644 index 0000000..f705b2f --- /dev/null +++ b/scripts/ocp-cli.js @@ -0,0 +1,131 @@ +#!/usr/bin/env node + +/** + * OCP CLI - Open Commerce Protocol Command Line Interface + * + * Scaffolds OCP projects, manages agent identities, issues mandates, and checks balances. + */ + +const { Command } = require('commander'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const MandateService = require('../src/services/mandate'); + +const program = new Command(); + +program + .name('ocp') + .description('CLI for Open Commerce Protocol (OCP) SDK') + .version('1.0.0'); + +// ocp init +program.command('init') + .description('Scaffolds a new OCP project with local vault simulation') + .action(() => { + console.log('Scaffolding new OCP project...'); + const projectStructure = [ + 'src', + 'src/agents', + 'src/mandates', + 'config' + ]; + + projectStructure.forEach(dir => { + if (!fs.existsSync(dir)) fs.mkdirSync(dir); + }); + + const envContent = ` +TOKENIZATION_API_KEY=test-key +TOKENIZATION_BASE_URL=http://localhost:8080 +MANDATE_SIGNING_KEY=${crypto.randomBytes(32).toString('hex')} +STRICT_MANDATE_MODE=true + `.trim(); + + fs.writeFileSync('.env', envContent); + console.log('Project initialized successfully. Local vault simulation configured in .env'); + }); + +// ocp agent:create +program.command('agent:create') + .description('Generates an agent identity (did:key) and linked wallet') + .argument('', 'Name of the agent') + .action((name) => { + const agentId = `agent_${crypto.randomBytes(4).toString('hex')}`; + const agentDid = `did:key:${crypto.randomBytes(16).toString('hex')}`; + + const agentData = { + id: agentId, + name: name, + did: agentDid, + wallet_address: `0x${crypto.randomBytes(20).toString('hex')}`, + created_at: new Date().toISOString() + }; + + if (!fs.existsSync('src/agents')) fs.mkdirSync('src/agents', { recursive: true }); + fs.writeFileSync(`src/agents/${agentId}.json`, JSON.stringify(agentData, null, 2)); + + console.log(`Agent created: ${name}`); + console.log(`ID: ${agentId}`); + console.log(`DID: ${agentDid}`); + }); + +// ocp mandate:issue +program.command('mandate:issue') + .description('Interactively creates a signed Intent Mandate for an agent') + .option('--agent ', 'Agent ID') + .option('--budget ', 'Maximum budget', '100') + .option('--currency ', 'Currency', 'USD') + .action(async (options) => { + if (!options.agent) { + console.error('Error: Agent ID required. Use --agent '); + return; + } + + const signingKey = process.env.MANDATE_SIGNING_KEY || 'default-secret-key'; + const mandateService = new MandateService({ signingKey }); + + const mandateToken = await mandateService.issueIntentMandate({ + userDid: 'did:key:user-local', + agentDid: `did:key:${options.agent}`, + maxBudget: parseFloat(options.budget), + currency: options.currency, + purposeCode: 'CLI_ISSUED' + }); + + const decoded = await mandateService.verifyMandate(mandateToken); + const mandateId = decoded.mandate_id; + + if (!fs.existsSync('src/mandates')) fs.mkdirSync('src/mandates', { recursive: true }); + fs.writeFileSync(`src/mandates/${mandateId}.jwt`, mandateToken); + + console.log(`Intent Mandate issued for agent ${options.agent}`); + console.log(`Mandate ID: ${mandateId}`); + console.log(`Budget: ${options.budget} ${options.currency}`); + console.log(`Saved to: src/mandates/${mandateId}.jwt`); + }); + +// ocp wallet:balance +program.command('wallet:balance') + .description('Checks real-time balances across ledger and Web3 rails') + .argument('
', 'Wallet address or Agent ID') + .action((address) => { + console.log(`Checking balances for ${address}...`); + // Mock balance retrieval + const balances = { + ledger: '500.00 USD', + web3: { + eth: '1.25 ETH', + usdc: '250.00 USDC', + pyusd: '100.00 PYUSD' + } + }; + + console.log(`Ledger Balance: ${balances.ledger}`); + console.log(`Web3 Balances:`); + console.log(` - ETH: ${balances.web3.eth}`); + console.log(` - USDC: ${balances.web3.usdc}`); + console.log(` - PYUSD: ${balances.web3.pyusd}`); + }); + +program.parse(); diff --git a/src/middleware/mpp.js b/src/middleware/mpp.js new file mode 100644 index 0000000..225eae6 --- /dev/null +++ b/src/middleware/mpp.js @@ -0,0 +1,84 @@ +/** + * MPP (Machine Payment Protocol) 402 Handler Middleware + * + * Enables agents to autonomously handle 'Payment Required' (HTTP 402) flows. + * If an agent hits a 402, it checks for a valid mandate, generates a cart mandate, + * and retries the request with the payment header. + */ + +class MPP402Handler { + constructor(agentService, mandateService) { + this.agentService = agentService; + this.mandateService = mandateService; + } + + /** + * Handle an autonomous request that might result in a 402 + * @param {Object} agent - The acting agent + * @param {Function} requestFn - The function that executes the request + * @param {string} intentMandateToken - The agent's pre-authorized intent mandate + */ + async executeAutonomousRequest(agent, requestFn, intentMandateToken) { + try { + let response = await requestFn(); + + if (response.status === 402) { + return await this._handle402Response(agent, response, intentMandateToken, requestFn); + } + + return response; + } catch (error) { + if (error.response?.status === 402) { + return await this._handle402Response(agent, error.response, intentMandateToken, requestFn); + } + throw error; + } + } + + /** + * Handle 402 response and retry + * @private + */ + async _handle402Response(agent, response, intentMandateToken, requestFn) { + console.log(`MPP: Handling 402 Payment Required for agent ${agent.name}`); + + // 1. Extract payment requirement details from headers or body + // MPP standard uses headers like 'X-MPP-Amount' and 'X-MPP-Merchant-DID' + const amount = parseFloat(response.headers?.['x-mpp-amount'] || response.data?.amount); + const currency = response.headers?.['x-mpp-currency'] || response.data?.currency || 'USD'; + const merchantDid = response.headers?.['x-mpp-merchant-did'] || response.data?.merchant_did; + const cartItems = response.data?.cart_items || [{ item: 'API_CALL', quantity: 1 }]; + + if (!amount || !merchantDid) { + throw new Error('Incomplete payment requirements in 402 response'); + } + + // 2. Validate Intent Mandate + const decodedIntent = await this.mandateService.verifyMandate(intentMandateToken); + + // Check if the 402 request is within the intent's budget + if (amount > decodedIntent.max_budget.value) { + throw new Error(`MPP: Payment amount ${amount} exceeds intent mandate budget of ${decodedIntent.max_budget.value}`); + } + + // 3. Generate a Cart Mandate for the specific 402 request + console.log(`MPP: Issuing autonomous cart mandate for amount ${amount}`); + const cartMandateToken = await this.mandateService.issueCartMandate({ + intentMandate: intentMandateToken, + cartItems, + totalPrice: amount, + merchantDid + }); + + // 4. Retry the request with the Payment Mandate header + console.log(`MPP: Retrying request with Cart Mandate...`); + return await requestFn({ + headers: { + 'X-OCP-Cart-Mandate': cartMandateToken, + 'Authorization': `Bearer ${agent.id}` + } + }); + } +} + +module.exports = MPP402Handler; diff --git a/src/services/agent.js b/src/services/agent.js index dadfaae..1733618 100644 --- a/src/services/agent.js +++ b/src/services/agent.js @@ -6,6 +6,7 @@ */ const crypto = require('crypto'); +const MandateService = require('./mandate'); class AgentService { constructor(database, config = {}) { @@ -14,6 +15,7 @@ class AgentService { defaultSpendingLimit: config.defaultSpendingLimit || 1000, defaultAuthorizedCounterparties: config.defaultAuthorizedCounterparties || [] }; + this.mandateService = new MandateService(config.mandateConfig); } /** @@ -100,6 +102,47 @@ class AgentService { } } + /** + * Issue an Intent Mandate for an agent + * @param {Object} params - Intent parameters + */ + async issueIntentMandate({ userDid, agentId, maxBudget, currency, expiration, purposeCode, allowedMerchants }) { + try { + const agent = await this.getAgent(agentId); + const agentDid = agent.metadata?.get('did') || `did:key:${agentId}`; + + return await this.mandateService.issueIntentMandate({ + userDid, + agentDid, + maxBudget, + currency, + expiration, + purposeCode, + allowedMerchants + }); + } catch (error) { + throw this._handleError('issueIntentMandate', error); + } + } + + /** + * Issue a Verifiable Credential for an agent + */ + async issueAgentVC({ userDid, agentId, capabilities }) { + try { + const agent = await this.getAgent(agentId); + const agentDid = agent.metadata?.get('did') || `did:key:${agentId}`; + + return await this.mandateService.issueAgentVC({ + userDid, + agentDid, + capabilities + }); + } catch (error) { + throw this._handleError('issueAgentVC', error); + } + } + /** * Perform an Agent-to-Agent (A2A) transfer (conceptual) * @param {Object} params - Transfer parameters diff --git a/src/services/mandate.js b/src/services/mandate.js new file mode 100644 index 0000000..5087d58 --- /dev/null +++ b/src/services/mandate.js @@ -0,0 +1,124 @@ +/** + * Mandate Service (AP2 - Agent Payments Protocol) + * + * Handles the issuance, verification, and management of Intent and Cart Mandates. + * Mandates are cryptographically signed digital contracts that serve as the + * 'chain of evidence' for autonomous agent transactions. + */ + +const jwt = require('jsonwebtoken'); +const crypto = require('crypto'); + +class MandateService { + constructor(config = {}) { + this.issuer = config.issuer || 'did:web:open-commerce-protocol.io'; + // In a real implementation, this would be a private key from a secure enclave + this.signingKey = config.signingKey || process.env.MANDATE_SIGNING_KEY || 'default-secret-key'; + } + + /** + * Issue an Intent Mandate + * @param {Object} params - Mandate parameters + * @returns {Promise} Signed JWT Mandate + */ + async issueIntentMandate({ userDid, agentDid, maxBudget, currency = 'USD', expiration, purposeCode, allowedMerchants = [] }) { + const payload = { + iss: this.issuer, + sub: agentDid, + user_did: userDid, + agent_did: agentDid, + mandate_id: `mandate_${crypto.randomBytes(8).toString('hex')}`, + max_budget: { + value: maxBudget, + currency + }, + exp: expiration || Math.floor(Date.now() / 1000) + (60 * 60 * 24), // Default 24h + purpose_code: purposeCode, + allowed_merchants: allowedMerchants, + iat: Math.floor(Date.now() / 1000), + type: 'intent_mandate' + }; + + return jwt.sign(payload, this.signingKey, { algorithm: 'HS256' }); + } + + /** + * Issue a Cart Mandate linked to an Intent Mandate + * @param {Object} params - Cart parameters + * @returns {Promise} Signed JWT Cart Mandate + */ + async issueCartMandate({ intentMandate, cartItems, totalPrice, merchantDid }) { + const decodedIntent = await this.verifyMandate(intentMandate); + + if (decodedIntent.type !== 'intent_mandate') { + throw new Error('Invalid intent mandate type'); + } + + // Verify budget + if (totalPrice > decodedIntent.max_budget.value) { + throw new Error('Cart total exceeds intent mandate budget'); + } + + // Verify merchant if whitelist exists + if (decodedIntent.allowed_merchants.length > 0 && !decodedIntent.allowed_merchants.includes(merchantDid)) { + throw new Error(`Merchant ${merchantDid} is not authorized by this mandate`); + } + + // Create cryptographic hash of cart + const cartHash = crypto.createHash('sha256') + .update(JSON.stringify({ items: cartItems, total: totalPrice })) + .digest('hex'); + + const payload = { + iss: this.issuer, + sub: decodedIntent.agent_did, + intent_mandate_id: decodedIntent.mandate_id, + cart_hash: cartHash, + total_price: totalPrice, + merchant_did: merchantDid, + iat: Math.floor(Date.now() / 1000), + exp: decodedIntent.exp, // Inherit expiration from intent + type: 'cart_mandate' + }; + + return jwt.sign(payload, this.signingKey, { algorithm: 'HS256' }); + } + + /** + * Verify a Mandate (Intent or Cart) + * @param {string} token - Signed JWT Mandate + * @returns {Promise} Decoded mandate payload + */ + async verifyMandate(token) { + try { + return jwt.verify(token, this.signingKey, { algorithms: ['HS256'] }); + } catch (error) { + throw new Error(`Mandate verification failed: ${error.message}`); + } + } + + /** + * Issue a Verifiable Credential for an agent + * @param {Object} params - Agent and user DIDs + * @returns {Promise} Signed VC + */ + async issueAgentVC({ userDid, agentDid, capabilities = [] }) { + const payload = { + sub: agentDid, + nbf: Math.floor(Date.now() / 1000), + vc: { + '@context': ['https://www.w3.org/2018/credentials/v1'], + type: ['VerifiableCredential', 'AgentAuthorityCredential'], + credentialSubject: { + id: agentDid, + authorizedBy: userDid, + capabilities: capabilities + } + } + }; + + return jwt.sign(payload, this.signingKey, { algorithm: 'HS256' }); + } +} + +module.exports = MandateService; diff --git a/src/services/tokenization.js b/src/services/tokenization.js index 96f15e0..eef3f39 100644 --- a/src/services/tokenization.js +++ b/src/services/tokenization.js @@ -7,6 +7,7 @@ const axios = require('axios'); const crypto = require('crypto'); +const MandateService = require('./mandate'); class TokenizationService { constructor(config = {}) { @@ -27,6 +28,8 @@ class TokenizationService { 'Content-Type': 'application/json' } }); + + this.mandateService = new MandateService(config.mandateConfig); } /** @@ -310,17 +313,51 @@ class TokenizationService { * Sign data using a stored token (Simulates secure reactor/enclave) * @param {string} tokenId - Token ID of the private key * @param {string} dataToSign - Hex or string data to sign + * @param {string} mandate - Optional signed Mandate (AP2) for Zero Trust validation + * @param {Object} context - Optional transaction context for validation (e.g., amount, merchant) * @returns {Promise} Signature */ - async signWithToken(tokenId, dataToSign) { + async signWithToken(tokenId, dataToSign, mandate, context = {}) { try { + // Zero Trust Validation: Verify mandate if provided + if (mandate) { + const decodedMandate = await this.mandateService.verifyMandate(mandate); + + // Validate budget if context amount is provided + if (context.amount) { + // Check Intent Mandate budget + if (decodedMandate.max_budget && context.amount > decodedMandate.max_budget.value) { + throw new Error(`Zero Trust Validation Failed: Amount ${context.amount} exceeds mandate budget of ${decodedMandate.max_budget.value}`); + } + // Check Cart Mandate total price + if (decodedMandate.total_price && context.amount !== decodedMandate.total_price) { + throw new Error(`Zero Trust Validation Failed: Amount ${context.amount} does not match cart mandate total of ${decodedMandate.total_price}`); + } + } + + // Validate merchant if context merchant is provided + if (context.merchant && decodedMandate.allowed_merchants?.length > 0) { + if (!decodedMandate.allowed_merchants.includes(context.merchant)) { + throw new Error(`Zero Trust Validation Failed: Merchant ${context.merchant} not authorized by mandate`); + } + } + + // Validate expiration + if (decodedMandate.exp < Math.floor(Date.now() / 1000)) { + throw new Error('Zero Trust Validation Failed: Mandate has expired'); + } + } else if (process.env.STRICT_MANDATE_MODE === 'true') { + throw new Error('Zero Trust Validation Failed: Mandate required for signing in strict mode'); + } + // In a real implementation, this would call a Basis Theory Reactor // providing the tokenId. The Reactor would securely retrieve the // secret and sign the data without exposing the key. const response = await this.client.post('/reactors/sign', { args: { tokenId, - data: dataToSign + data: dataToSign, + mandate // Pass mandate to reactor for server-side validation } }); @@ -329,7 +366,7 @@ class TokenizationService { // Fallback for simulation/testing if reactor endpoint doesn't exist // We assume for simulation that we can just "mock" a signature if (process.env.NODE_ENV !== 'production' || this.apiKey === 'test-key') { - return `0x_mock_signature_of_${dataToSign}_with_${tokenId}`; + return `0x_mock_signature_of_${dataToSign}_with_${tokenId}${mandate ? '_validated_by_mandate' : ''}`; } throw this._handleError(error); } diff --git a/src/services/web3.js b/src/services/web3.js index 9416b66..76901dc 100644 --- a/src/services/web3.js +++ b/src/services/web3.js @@ -66,8 +66,10 @@ class Web3Service { * @param {string} params.to - Recipient address * @param {string} params.value - Amount to send * @param {string} params.network - Network to use + * @param {string} params.mandate - Optional Mandate (AP2) for Zero Trust validation + * @param {Object} params.context - Optional context for validation */ - async sendTransaction({ keyTokenId, to, value, network = 'ethereum' }) { + async sendTransaction({ keyTokenId, to, value, network = 'ethereum', mandate, context = {} }) { try { // 1. Construct Transaction (Simplified) const txData = { @@ -81,7 +83,7 @@ class Web3Service { // 2. Sign Transaction using Vault // We serialize the txData to string/hex for signing const serializedTx = JSON.stringify(txData); - const signature = await this.tokenizationService.signWithToken(keyTokenId, serializedTx); + const signature = await this.tokenizationService.signWithToken(keyTokenId, serializedTx, mandate, context); // 3. Broadcast Transaction // In production, send signedTx to RPC @@ -98,6 +100,53 @@ class Web3Service { } } + /** + * x402 Extension: Execute a stablecoin settlement (USDC/PYUSD) + * Provides 24/7 low-latency machine settlements for the agentic economy. + */ + async executeX402Settlement({ keyTokenId, to, amount, stablecoin = 'USDC', network = 'ethereum', mandate }) { + try { + // Token addresses (Simulation) + const tokenAddresses = { + 'USDC': '0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48', + 'PYUSD': '0x6c3ea9036406852006290770bedfc29a991f4706' + }; + + const tokenAddress = tokenAddresses[stablecoin]; + if (!tokenAddress) throw new Error(`Unsupported stablecoin: ${stablecoin}`); + + console.log(`x402: Executing ${stablecoin} settlement for ${amount} to ${to}...`); + + // 1. Construct ERC20 transfer data (Simplified) + const txData = { + to: tokenAddress, + data: `transfer(${to}, ${amount})`, + gasLimit: '65000' + }; + + // 2. Sign with Mandate (Zero Trust) + const signature = await this.tokenizationService.signWithToken( + keyTokenId, + JSON.stringify(txData), + mandate, + { amount, merchant: to } + ); + + // 3. Simulation: Return successful settlement + return { + settlement_id: `x402_${crypto.randomBytes(8).toString('hex')}`, + status: 'finalized', + stablecoin, + amount, + recipient: to, + tx_hash: `0x${crypto.randomBytes(32).toString('hex')}`, + timestamp: new Date().toISOString() + }; + } catch (error) { + throw this._handleError('executeX402Settlement', error); + } + } + /** * Handle and format errors * @private diff --git a/tests/unit/mandate.spec.js b/tests/unit/mandate.spec.js new file mode 100644 index 0000000..856b05b --- /dev/null +++ b/tests/unit/mandate.spec.js @@ -0,0 +1,83 @@ +const AgentService = require('../../src/services/agent'); +const MandateService = require('../../src/services/mandate'); +const jwt = require('jsonwebtoken'); + +describe('AgentService with Mandates', () => { + let agentService; + let mockDb; + const signingKey = 'test-secret'; + + beforeEach(() => { + mockDb = { + findAgentById: jest.fn(), + createAgent: jest.fn(), + }; + agentService = new AgentService(mockDb, { + mandateConfig: { signingKey } + }); + }); + + describe('issueIntentMandate', () => { + it('should issue a signed intent mandate for an existing agent', async () => { + const agentId = 'agent_123'; + const agentDid = 'did:key:abc'; + mockDb.findAgentById.mockResolvedValue({ + id: agentId, + metadata: new Map([['did', agentDid]]) + }); + + const mandateToken = await agentService.issueIntentMandate({ + userDid: 'did:key:user', + agentId, + maxBudget: 500, + purposeCode: 'PROCUREMENT' + }); + + expect(mandateToken).toBeDefined(); + const decoded = jwt.verify(mandateToken, signingKey); + expect(decoded.sub).toBe(agentDid); + expect(decoded.max_budget.value).toBe(500); + expect(decoded.purpose_code).toBe('PROCUREMENT'); + expect(decoded.type).toBe('intent_mandate'); + }); + + it('should use default did:key if no did in metadata', async () => { + const agentId = 'agent_456'; + mockDb.findAgentById.mockResolvedValue({ + id: agentId, + metadata: new Map() + }); + + const mandateToken = await agentService.issueIntentMandate({ + userDid: 'did:key:user', + agentId, + maxBudget: 100 + }); + + const decoded = jwt.verify(mandateToken, signingKey); + expect(decoded.sub).toBe(`did:key:${agentId}`); + }); + }); + + describe('issueAgentVC', () => { + it('should issue a signed agent VC', async () => { + const agentId = 'agent_123'; + mockDb.findAgentById.mockResolvedValue({ + id: agentId, + metadata: new Map() + }); + + const vcToken = await agentService.issueAgentVC({ + userDid: 'did:key:user', + agentId, + capabilities: ['payment'] + }); + + expect(vcToken).toBeDefined(); + const decoded = jwt.verify(vcToken, signingKey); + expect(decoded.sub).toBe(`did:key:${agentId}`); + expect(decoded.vc.type).toContain('AgentAuthorityCredential'); + expect(decoded.vc.credentialSubject.capabilities).toContain('payment'); + }); + }); +});