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
4 changes: 4 additions & 0 deletions .changeset/quiet-aep-mpp-example.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
---
---

Add an Express example that applies AEP API-key authentication before MPP payment enforcement.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,8 @@ Runnable end-to-end examples live in [`examples/`](./examples). Start a seller,

**MPP:**

- [`mpp-aep-seller-express`](./examples/mpp-aep-seller-express) — Express server applying AEP API-key authentication
before MPP payment enforcement on the same protected routes.
- [`mpp-seller-express`](./examples/mpp-seller-express) — Express server accepting MPP payments via `mppx`'s Express
adapter + InFlow's `inflow` seller method, plus a multi-currency `/api/checkout` route via
`inflowChargesNodeListener`.
Expand Down
6 changes: 6 additions & 0 deletions examples/mpp-aep-seller-express/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
INFLOW_API_KEY=
INFLOW_BASE_URL=https://sandbox.inflowpay.ai
MPP_SECRET_KEY=
SERVICE_DID=did:web:127.0.0.1%3A4100:services:example-service
HOST=127.0.0.1
PORT=3000
74 changes: 74 additions & 0 deletions examples/mpp-aep-seller-express/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# Example — AEP plus MPP seller on Express

This Express Service applies Agent Enrollment Protocol (AEP) authentication before Machine Payments Protocol (MPP)
payment enforcement. Its API-key credential uses `x-aep-api-key`, leaving `Authorization: Payment` available for the MPP
credential.

## Run

Start the local AEP Platform example first. It serves the Service DID used by this example.

```bash
cd /Users/nxkavian/Drive/Source/AEP/aep-node
pnpm --filter @aep-foundation/example-aep-platform-ephemeral start
```

Build the AEP packages, then use the existing unified local-link script in the InFlow command-line interface checkout
when exercising the command-line scenarios below. It links the local AEP SDK packages without adding an example-specific
linker.

```bash
cd /Users/nxkavian/Drive/Source/AEP/aep-node
pnpm --filter @aep-foundation/core build
pnpm --filter @aep-foundation/service build
pnpm --filter @aep-foundation/express build

cd /Users/nxkavian/Drive/Source/InFlow/inflow-cli
node scripts/link-local-inflow-node.mjs
```

Configure and start this example:

```bash
cd /Users/nxkavian/Drive/Source/InFlow/inflow-node/examples/mpp-aep-seller-express
cp .env.example .env
# Set INFLOW_API_KEY, INFLOW_BASE_URL, and MPP_SECRET_KEY.
pnpm install
pnpm start
```

`SERVICE_DID` defaults in `.env.example` to the local Platform example's Service DID. `HOST` and `PORT` default to
`127.0.0.1` and `3000`. `INFLOW_BASE_URL` selects the InFlow environment that issued `INFLOW_API_KEY` and defaults in
`.env.example` to `https://sandbox.inflowpay.ai`.

## Routes

| Route | Enforcement |
| ----------------------------------------------------- | --------------------------------------------------------------- |
| `GET /api/widgets` | AEP API key, then 0.01 USDC MPP charge |
| `POST /api/upload` | AEP API key, then 0.10 USDC MPP charge; echoes the request body |
| `GET /free` | No AEP or MPP enforcement |
| `GET /.well-known/aep`, `/aep/*`, `GET /openapi.json` | AEP discovery, lifecycle, and OpenAPI documents |

For a protected route, an anonymous request receives only the AEP `401` challenge. A request with `x-aep-api-key` but no
payment receives only the MPP `402` challenge. A completed payment replay carries both `x-aep-api-key` and
`Authorization: Payment …`.

## Command-line scenarios

Use the built command-line interface from `/Users/nxkavian/Drive/Source/InFlow/inflow-cli` with the local Platform and
this Service running:

```bash
node packages/cli/dist/cli.js inspect http://127.0.0.1:3000/api/widgets --format json
node packages/cli/dist/cli.js aep inspect http://127.0.0.1:3000 --format json
node packages/cli/dist/cli.js aep fetch http://127.0.0.1:3000/api/widgets --format json
node packages/cli/dist/cli.js aep grant http://127.0.0.1:3000 --grant-type api-key --format json
node packages/cli/dist/cli.js aep fetch http://127.0.0.1:3000/api/widgets --format json
node packages/cli/dist/cli.js mpp pay http://127.0.0.1:3000/api/widgets --format json
node packages/cli/dist/cli.js mpp pay http://127.0.0.1:3000/api/upload --method POST --data '{"widget":"one"}' --header 'X-Caller-Header: retained' --format json
```

The first `aep fetch` uses the API-key Grant path and stops with the downstream payment-required result. Re-running it
after explicit Grant reuses the stored key. `mpp pay` performs AEP authentication before payment creation; the returned
payment identifier can be completed with `mpp fetch` when approval is asynchronous.
30 changes: 30 additions & 0 deletions examples/mpp-aep-seller-express/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
{
"name": "@inflowpayai/example-mpp-aep-seller-express",
"version": "0.0.0",
"private": true,
"description": "Example: sequential AEP authentication and MPP payments via Express.",
"type": "module",
"scripts": {
"dev": "tsx watch src/index.ts",
"start": "tsx src/index.ts",
"test": "vitest run",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@aep-foundation/core": "^0.2.0",
"@aep-foundation/express": "^0.2.0",
"@aep-foundation/service": "^0.2.0",
"@inflowpayai/mpp-seller": "workspace:^",
"dotenv": "^16.4.0",
"express": "^5.0.0",
"mppx": "^0.6.28"
},
"devDependencies": {
"@inflowpayai/mpp": "workspace:^",
"@types/express": "^5.0.0",
"@types/node": "^24.0.0",
"tsx": "^4.0.0",
"typescript": "^5.6.0",
"vitest": "^2.1.0"
}
}
119 changes: 119 additions & 0 deletions examples/mpp-aep-seller-express/src/app.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
import { randomUUID } from 'node:crypto';

import { AEP_GRANT_TYPE_API_KEY } from '@aep-foundation/core';
import type { ApiKeyGrantResponse } from '@aep-foundation/core';
import { createExpressAepProtectedResourceHandler, registerExpressAepRoutes } from '@aep-foundation/express';
import {
createAepService,
createDidWebClientAssertionVerifier,
createInMemoryClientAssertionReplayStore,
createInMemoryCommandIdempotencyStore,
createInMemoryEnrollmentStore,
createInMemoryServiceCredentialStore,
createStaticEnrollmentPolicy,
didWebIdentityMethod,
storedApiKeyGrantType,
} from '@aep-foundation/service';
import type { AepServiceCredentialStore } from '@aep-foundation/service';
import { inflow } from '@inflowpayai/mpp-seller';
import express from 'express';
import type { Request, RequestHandler } from 'express';
import { Mppx } from 'mppx/express';

export interface CreateMppAepSellerAppOptions {
apiKey: string;
baseUrl?: string;
listenUrl: string;
mppSecretKey: string;
onAepPassed?: () => void;
onProtectedHandler?: (request: Request) => void;
serviceDid: string;
credentialStore?: AepServiceCredentialStore;
}

export function createMppAepSellerApp(options: CreateMppAepSellerAppOptions) {
const credentialStore = options.credentialStore ?? createInMemoryServiceCredentialStore();
const service = createAepService({
authenticationMethods: [AEP_GRANT_TYPE_API_KEY],
clientAssertionVerifier: createDidWebClientAssertionVerifier(),
commandIdempotencyStore: createInMemoryCommandIdempotencyStore(),
enrollmentPolicy: createStaticEnrollmentPolicy(),
enrollmentStore: createInMemoryEnrollmentStore(),
grantTypes: [
storedApiKeyGrantType({
issue: (): ApiKeyGrantResponse => ({
api_key: randomUUID(),
credential_id: randomUUID(),
expires_at: new Date(Date.now() + 60 * 60 * 1000).toISOString(),
header: 'x-aep-api-key',
scopes: ['read:widgets', 'write:uploads'],
}),
store: credentialStore,
}),
],
identityMethods: [didWebIdentityMethod()],
openapi: { url: '/openapi.json', pathMatching: { trailingSlash: 'strict' } },
replayStore: createInMemoryClientAssertionReplayStore(),
serviceDid: options.serviceDid,
});
const method = inflow({
apiKey: options.apiKey,
...(options.baseUrl === undefined ? { environment: 'sandbox' } : { baseUrl: options.baseUrl }),
});
const mppx = Mppx.create({ methods: [method], secretKey: options.mppSecretKey });
const authenticateAep = createExpressAepProtectedResourceHandler(service, options.listenUrl);
const requireAep: RequestHandler = (request, response, next) =>
authenticateAep(request, response, () => {
options.onAepPassed?.();
next();
});
const app = express();

app.use(express.json({ type: ['application/json', 'application/aep+json'] }));
app.use((request, response, next) => {
response.on('finish', () => {
console.log(`request method=${request.method} path=${request.path} status=${response.statusCode}`);
});
next();
});
registerExpressAepRoutes(app, service);
app.get('/openapi.json', (_request, response) => response.json(openApiDocument()));
app.get('/api/widgets', requireAep, mppx.charge({ amount: '0.01', currency: 'USDC' }), (request, response) => {
options.onProtectedHandler?.(request);
response.json({ widgets: [1, 2, 3] });
});
app.post('/api/upload', requireAep, mppx.charge({ amount: '0.10', currency: 'USDC' }), (request, response) => {
options.onProtectedHandler?.(request);
response.json({ received: request.body });
});
app.get('/free', (_request, response) => {
response.json({ ok: true, note: 'no AEP authentication or payment required' });
});

return { app, credentialStore, service };
}

function openApiDocument(): Record<string, unknown> {
return {
openapi: '3.1.0',
info: { title: 'AEP and MPP Express example', version: '1.0.0' },
components: {
securitySchemes: {
aepApiKey: {
type: 'apiKey',
in: 'header',
name: 'x-aep-api-key',
'x-aep-authentication-method': AEP_GRANT_TYPE_API_KEY,
},
},
},
paths: {
'/api/widgets': {
get: { security: [{ aepApiKey: [] }], responses: { '200': { description: 'Paid widgets' } } },
},
'/api/upload': {
post: { security: [{ aepApiKey: [] }], responses: { '200': { description: 'Paid upload' } } },
},
},
};
}
49 changes: 49 additions & 0 deletions examples/mpp-aep-seller-express/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import 'dotenv/config';
import type { Server } from 'node:http';

import { createMppAepSellerApp } from './app.js';

const apiKey = requiredEnvironment('INFLOW_API_KEY');
const mppSecretKey = requiredEnvironment('MPP_SECRET_KEY');
const serviceDid = requiredEnvironment('SERVICE_DID');
const baseUrl = process.env['INFLOW_BASE_URL'];
const host = process.env['HOST'] ?? '127.0.0.1';
const port = parsePort(process.env['PORT'] ?? '3000');
const listenUrl = `http://${host}:${port.toString()}`;
const { app } = createMppAepSellerApp({
apiKey,
...(baseUrl === undefined ? {} : { baseUrl }),
listenUrl,
mppSecretKey,
serviceDid,
});
const server: Server = app.listen(port, host);

server.once('error', (error) => {
console.error(`Unable to listen on ${listenUrl}:`, error);
process.exitCode = 1;
});
server.once('listening', () => {
console.log(`AEP and MPP seller listening on ${listenUrl}`);
console.log(` GET ${listenUrl}/.well-known/aep`);
console.log(` POST ${listenUrl}/aep/enroll`);
console.log(` POST ${listenUrl}/aep/grant`);
console.log(` GET ${listenUrl}/aep/status`);
console.log(` POST ${listenUrl}/aep/revoke`);
console.log(` GET ${listenUrl}/openapi.json`);
console.log(` GET ${listenUrl}/api/widgets`);
console.log(` POST ${listenUrl}/api/upload`);
console.log(` GET ${listenUrl}/free`);
});

function requiredEnvironment(name: string): string {
const value = process.env[name];
if (value === undefined || value.length === 0) throw new Error(`Missing required environment variable: ${name}`);
return value;
}

function parsePort(value: string): number {
const port = Number(value);
if (!Number.isInteger(port) || port <= 0 || port > 65_535) throw new TypeError(`Invalid PORT: ${value}`);
return port;
}
Loading
Loading