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
6 changes: 3 additions & 3 deletions scripts/ocp-cli.js
Original file line number Diff line number Diff line change
Expand Up @@ -166,17 +166,17 @@ program.command('x402:settle')

// Validate budget
if (decodedMandate.max_budget && amountNum > decodedMandate.max_budget.value) {
console.error(`Fiduciary Validation Failed: Amount ${amountNum} exceeds mandate budget of ${decodedMandate.max_budget.value} ${decodedMandate.max_budget.currency}`);
console.error(`Zero Trust Validation Failed: Amount ${amountNum} exceeds mandate budget of ${decodedMandate.max_budget.value} ${decodedMandate.max_budget.currency}`);
return;
}

// Validate expiration
if (decodedMandate.exp < Math.floor(Date.now() / 1000)) {
console.error('Fiduciary Validation Failed: Mandate has expired');
console.error('Zero Trust Validation Failed: Mandate has expired');
return;
}
} catch (error) {
console.error(`Fiduciary Validation Failed: ${error.message}`);
console.error(`Zero Trust Validation Failed: ${error.message}`);
return;
}

Expand Down
8 changes: 5 additions & 3 deletions src/middleware/mpp.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* and retries the request with the payment header.
*/

const logger = require('../utils/logger');

class MPP402Handler {
constructor(agentService, mandateService) {
this.agentService = agentService;
Expand Down Expand Up @@ -40,7 +42,7 @@ class MPP402Handler {
* @private
*/
async _handle402Response(agent, response, intentMandateToken, requestFn) {
console.log(`MPP: Handling 402 Payment Required for agent ${agent.name}`);
logger.info(`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'
Expand All @@ -62,7 +64,7 @@ class MPP402Handler {
}

// 3. Generate a Cart Mandate for the specific 402 request
console.log(`MPP: Issuing autonomous cart mandate for amount ${amount}`);
logger.info(`MPP: Issuing autonomous cart mandate for amount ${amount}`);
const cartMandateToken = await this.mandateService.issueCartMandate({
intentMandate: intentMandateToken,
cartItems,
Expand All @@ -71,7 +73,7 @@ class MPP402Handler {
});

// 4. Retry the request with the Payment Mandate header
console.log(`MPP: Retrying request with Cart Mandate...`);
logger.info(`MPP: Retrying request with Cart Mandate...`);
return await requestFn({
headers: {
'X-OCP-Cart-Mandate': cartMandateToken,
Expand Down
130 changes: 130 additions & 0 deletions tests/unit/mpp.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
const MPP402Handler = require('../../src/middleware/mpp');
const MandateService = require('../../src/services/mandate');

describe('MPP402Handler', () => {
let mppHandler;
let mockAgentService;
let mockMandateService;
const signingKey = 'test-secret';

beforeEach(() => {
mockAgentService = {};
mockMandateService = new MandateService({ signingKey });
mppHandler = new MPP402Handler(mockAgentService, mockMandateService);
});

describe('executeAutonomousRequest', () => {
it('should return response if status is not 402', async () => {
const mockResponse = { status: 200, data: 'success' };
const requestFn = jest.fn().mockResolvedValue(mockResponse);
const agent = { name: 'TestAgent' };

const result = await mppHandler.executeAutonomousRequest(agent, requestFn, 'some-token');

expect(result).toBe(mockResponse);
expect(requestFn).toHaveBeenCalledTimes(1);
});

it('should handle 402, issue cart mandate and retry', async () => {
const agent = { id: 'agent_123', name: 'TestAgent' };
const intentMandate = await mockMandateService.issueIntentMandate({
userDid: 'did:key:user',
agentDid: 'did:key:agent',
maxBudget: 1000
});

const response402 = {
status: 402,
headers: {
'x-mpp-amount': '500',
'x-mpp-currency': 'USD',
'x-mpp-merchant-did': 'did:key:merchant'
},
data: {
cart_items: [{ item: 'API', quantity: 1 }]
}
};

const finalResponse = { status: 200, data: 'paid' };

// First call returns 402, second call returns 200
const requestFn = jest.fn()
.mockResolvedValueOnce(response402)
.mockResolvedValueOnce(finalResponse);

const result = await mppHandler.executeAutonomousRequest(agent, requestFn, intentMandate);

expect(result).toBe(finalResponse);
expect(requestFn).toHaveBeenCalledTimes(2);

// Verify retry call had the cart mandate header
const secondCallArgs = requestFn.mock.calls[1][0];
expect(secondCallArgs.headers['X-OCP-Cart-Mandate']).toBeDefined();
expect(secondCallArgs.headers['Authorization']).toBe(`Bearer ${agent.id}`);
});

it('should throw error if payment amount exceeds intent budget', async () => {
const agent = { name: 'TestAgent' };
const intentMandate = await mockMandateService.issueIntentMandate({
userDid: 'did:key:user',
agentDid: 'did:key:agent',
maxBudget: 100
});

const response402 = {
status: 402,
headers: {
'x-mpp-amount': '500',
'x-mpp-merchant-did': 'did:key:merchant'
}
};

const requestFn = jest.fn().mockResolvedValue(response402);

await expect(mppHandler.executeAutonomousRequest(agent, requestFn, intentMandate))
.rejects.toThrow('MPP: Payment amount 500 exceeds intent mandate budget of 100');
});

it('should throw error if 402 response is missing requirements', async () => {
const agent = { name: 'TestAgent' };
const intentMandate = 'some-token';
const response402 = {
status: 402,
headers: {} // Missing amount and merchant
};

const requestFn = jest.fn().mockResolvedValue(response402);

await expect(mppHandler.executeAutonomousRequest(agent, requestFn, intentMandate))
.rejects.toThrow('Incomplete payment requirements in 402 response');
});

it('should handle 402 thrown as an error (e.g. from axios)', async () => {
const agent = { id: 'agent_123', name: 'TestAgent' };
const intentMandate = await mockMandateService.issueIntentMandate({
userDid: 'did:key:user',
agentDid: 'did:key:agent',
maxBudget: 1000
});

const error402 = new Error('Payment Required');
error402.response = {
status: 402,
headers: {
'x-mpp-amount': '500',
'x-mpp-merchant-did': 'did:key:merchant'
}
};

const finalResponse = { status: 200, data: 'paid' };
const requestFn = jest.fn()
.mockRejectedValueOnce(error402)
.mockResolvedValueOnce(finalResponse);

const result = await mppHandler.executeAutonomousRequest(agent, requestFn, intentMandate);

expect(result).toBe(finalResponse);
expect(requestFn).toHaveBeenCalledTimes(2);
});
});
});
Loading