piree-merchant-middleware 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +3 -0
- package/dist/index.js +1 -0
- package/dist/middleware.d.ts +15 -0
- package/dist/middleware.js +40 -0
- package/dist/registry.d.ts +17 -0
- package/dist/registry.js +21 -0
- package/package.json +52 -0
- package/src/__tests__/middleware.test.ts +104 -0
- package/src/index.ts +3 -0
- package/src/middleware.ts +71 -0
- package/src/registry.ts +44 -0
- package/tsconfig.json +15 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { pireeAgentVerifier } from './middleware.js';
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express';
|
|
2
|
+
import fetch from 'node-fetch';
|
|
3
|
+
import { AgentRegistryEntry } from './registry.js';
|
|
4
|
+
type FetchFn = typeof fetch;
|
|
5
|
+
export interface VerifierOptions {
|
|
6
|
+
registryUrl: string;
|
|
7
|
+
header?: string;
|
|
8
|
+
refreshIntervalMs?: number;
|
|
9
|
+
onUnknownAgent?: 'reject' | 'allow' | 'log';
|
|
10
|
+
_fetchFn?: FetchFn;
|
|
11
|
+
}
|
|
12
|
+
export declare function pireeAgentVerifier(options: VerifierOptions): (req: Request & {
|
|
13
|
+
pireeAgent?: AgentRegistryEntry | null;
|
|
14
|
+
}, res: Response, next: NextFunction) => void;
|
|
15
|
+
export {};
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
import { fetchFullRegistry } from './registry.js';
|
|
3
|
+
export function pireeAgentVerifier(options) {
|
|
4
|
+
const { registryUrl, header = 'Piree-Agent-Id', refreshIntervalMs = 5 * 60 * 1000, onUnknownAgent = 'reject', _fetchFn = fetch, } = options;
|
|
5
|
+
let registry = new Map();
|
|
6
|
+
const headerLower = header.toLowerCase();
|
|
7
|
+
async function refresh() {
|
|
8
|
+
try {
|
|
9
|
+
registry = await fetchFullRegistry(registryUrl, _fetchFn);
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
console.error('[piree-middleware] Registry refresh failed:', err.message);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
refresh().then(() => {
|
|
16
|
+
setInterval(refresh, refreshIntervalMs).unref();
|
|
17
|
+
}).catch(err => {
|
|
18
|
+
console.error('[piree-middleware] Initial registry fetch failed:', err.message);
|
|
19
|
+
});
|
|
20
|
+
return function middleware(req, res, next) {
|
|
21
|
+
const agentId = req.headers[headerLower];
|
|
22
|
+
if (!agentId) {
|
|
23
|
+
return next();
|
|
24
|
+
}
|
|
25
|
+
const agent = registry.get(agentId);
|
|
26
|
+
if (agent) {
|
|
27
|
+
req.pireeAgent = agent;
|
|
28
|
+
return next();
|
|
29
|
+
}
|
|
30
|
+
if (onUnknownAgent === 'reject') {
|
|
31
|
+
res.status(401).json({ error: 'agent_not_trusted' });
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
if (onUnknownAgent === 'log') {
|
|
35
|
+
console.warn(`[piree-middleware] Unverified agent: ${agentId}`);
|
|
36
|
+
}
|
|
37
|
+
req.pireeAgent = null;
|
|
38
|
+
return next();
|
|
39
|
+
};
|
|
40
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
import type { RequestInfo, RequestInit } from 'node-fetch';
|
|
3
|
+
export interface AgentRegistryEntry {
|
|
4
|
+
credential_id: string;
|
|
5
|
+
agent_name: string;
|
|
6
|
+
org_did: string;
|
|
7
|
+
purpose: string;
|
|
8
|
+
scope_summary: {
|
|
9
|
+
categories: string[];
|
|
10
|
+
spend_limit_monthly: number;
|
|
11
|
+
};
|
|
12
|
+
agent_card_url: string;
|
|
13
|
+
valid_until: string;
|
|
14
|
+
}
|
|
15
|
+
type FetchFn = (url: RequestInfo, init?: RequestInit) => ReturnType<typeof fetch>;
|
|
16
|
+
export declare function fetchFullRegistry(baseUrl: string, fetchFn?: FetchFn): Promise<Map<string, AgentRegistryEntry>>;
|
|
17
|
+
export {};
|
package/dist/registry.js
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import fetch from 'node-fetch';
|
|
2
|
+
export async function fetchFullRegistry(baseUrl, fetchFn = fetch) {
|
|
3
|
+
const registry = new Map();
|
|
4
|
+
let cursor = null;
|
|
5
|
+
do {
|
|
6
|
+
const url = new URL(`${baseUrl}/registry/v1/trusted-agents`);
|
|
7
|
+
if (cursor)
|
|
8
|
+
url.searchParams.set('cursor', cursor);
|
|
9
|
+
url.searchParams.set('limit', '100');
|
|
10
|
+
const resp = await fetchFn(url.toString());
|
|
11
|
+
if (!resp.ok) {
|
|
12
|
+
throw new Error(`Registry fetch failed with status ${resp.status}`);
|
|
13
|
+
}
|
|
14
|
+
const data = await resp.json();
|
|
15
|
+
for (const agent of data.agents) {
|
|
16
|
+
registry.set(agent.credential_id, agent);
|
|
17
|
+
}
|
|
18
|
+
cursor = data.next_cursor;
|
|
19
|
+
} while (cursor);
|
|
20
|
+
return registry;
|
|
21
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "piree-merchant-middleware",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Express middleware that verifies Piree agent credentials against the public trusted-agents registry",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "dist/index.js",
|
|
7
|
+
"types": "dist/index.d.ts",
|
|
8
|
+
"exports": {
|
|
9
|
+
".": {
|
|
10
|
+
"import": "./dist/index.js",
|
|
11
|
+
"types": "./dist/index.d.ts"
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"scripts": {
|
|
15
|
+
"build": "tsc",
|
|
16
|
+
"test": "cross-env NODE_OPTIONS=--experimental-vm-modules jest",
|
|
17
|
+
"prepublishOnly": "npm run build"
|
|
18
|
+
},
|
|
19
|
+
"keywords": [
|
|
20
|
+
"piree",
|
|
21
|
+
"agent",
|
|
22
|
+
"middleware",
|
|
23
|
+
"express",
|
|
24
|
+
"trust"
|
|
25
|
+
],
|
|
26
|
+
"license": "MIT",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"node-fetch": "^3.3.2"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/express": "^5.0.0",
|
|
32
|
+
"@types/jest": "^29.5.14",
|
|
33
|
+
"@types/node": "^20.0.0",
|
|
34
|
+
"@types/supertest": "^7.2.0",
|
|
35
|
+
"cross-env": "^10.1.0",
|
|
36
|
+
"express": "^5.0.0",
|
|
37
|
+
"jest": "^30.4.0",
|
|
38
|
+
"supertest": "^7.2.2",
|
|
39
|
+
"ts-jest": "^29.4.11",
|
|
40
|
+
"typescript": "^5.4.0"
|
|
41
|
+
},
|
|
42
|
+
"jest": {
|
|
43
|
+
"preset": "ts-jest/presets/default-esm",
|
|
44
|
+
"testEnvironment": "node",
|
|
45
|
+
"extensionsToTreatAsEsm": [
|
|
46
|
+
".ts"
|
|
47
|
+
],
|
|
48
|
+
"moduleNameMapper": {
|
|
49
|
+
"^(\\.{1,2}/.*)\\.js$": "$1"
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { jest, describe, it, expect, beforeEach, afterEach } from '@jest/globals'
|
|
2
|
+
import express, { Response } from 'express'
|
|
3
|
+
import supertest from 'supertest'
|
|
4
|
+
import { pireeAgentVerifier } from '../middleware.js'
|
|
5
|
+
|
|
6
|
+
describe('pireeAgentVerifier middleware', () => {
|
|
7
|
+
function makeRegistryPage(agents: object[], nextCursor: string | null = null) {
|
|
8
|
+
const mockFetch = jest.fn<any>().mockResolvedValue({
|
|
9
|
+
ok: true,
|
|
10
|
+
json: jest.fn<any>().mockResolvedValue({
|
|
11
|
+
registry: 'piree.ai',
|
|
12
|
+
total: agents.length,
|
|
13
|
+
agents,
|
|
14
|
+
next_cursor: nextCursor,
|
|
15
|
+
}),
|
|
16
|
+
})
|
|
17
|
+
return mockFetch
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function makeAgent(credentialId: string, agentName = 'test-agent') {
|
|
21
|
+
return {
|
|
22
|
+
credential_id: credentialId,
|
|
23
|
+
agent_name: agentName,
|
|
24
|
+
org_did: 'did:web:test.com',
|
|
25
|
+
purpose: 'autonomous-commerce-agent',
|
|
26
|
+
scope_summary: { categories: ['grocery'], spend_limit_monthly: 500000 },
|
|
27
|
+
agent_card_url: `https://api.piree.ai/api/v1/credentials/${credentialId}/agent-card`,
|
|
28
|
+
valid_until: '2027-06-07T00:00:00Z',
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function buildApp(mockFetch: jest.Mock<any>, options: object) {
|
|
33
|
+
const app = express()
|
|
34
|
+
app.use(pireeAgentVerifier({ registryUrl: 'https://api.piree.ai', _fetchFn: mockFetch as any, ...options }))
|
|
35
|
+
app.get('/test', (req: any, res: Response) =>
|
|
36
|
+
res.json({ agent: req.pireeAgent?.agent_name ?? null })
|
|
37
|
+
)
|
|
38
|
+
return app
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
it('passes request and populates req.pireeAgent for known agent', async () => {
|
|
42
|
+
const CRED_ID = 'cred-123'
|
|
43
|
+
const mockFetch = makeRegistryPage([makeAgent(CRED_ID)])
|
|
44
|
+
const app = buildApp(mockFetch, { header: 'Piree-Agent-Id' })
|
|
45
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
46
|
+
|
|
47
|
+
const res = await supertest(app)
|
|
48
|
+
.get('/test')
|
|
49
|
+
.set('Piree-Agent-Id', CRED_ID)
|
|
50
|
+
|
|
51
|
+
expect(res.status).toBe(200)
|
|
52
|
+
expect(res.body.agent).toBe('test-agent')
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('returns 401 for unknown agent when onUnknownAgent=reject', async () => {
|
|
56
|
+
const mockFetch = makeRegistryPage([])
|
|
57
|
+
const app = buildApp(mockFetch, { onUnknownAgent: 'reject' })
|
|
58
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
59
|
+
|
|
60
|
+
const res = await supertest(app)
|
|
61
|
+
.get('/test')
|
|
62
|
+
.set('Piree-Agent-Id', 'unknown-cred-id')
|
|
63
|
+
|
|
64
|
+
expect(res.status).toBe(401)
|
|
65
|
+
expect(res.body.error).toBe('agent_not_trusted')
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('passes request with pireeAgent=null when onUnknownAgent=allow', async () => {
|
|
69
|
+
const mockFetch = makeRegistryPage([])
|
|
70
|
+
const app = buildApp(mockFetch, { onUnknownAgent: 'allow' })
|
|
71
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
72
|
+
|
|
73
|
+
const res = await supertest(app)
|
|
74
|
+
.get('/test')
|
|
75
|
+
.set('Piree-Agent-Id', 'unknown-cred')
|
|
76
|
+
|
|
77
|
+
expect(res.status).toBe(200)
|
|
78
|
+
expect(res.body.agent).toBeNull()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('passes request normally when no Piree-Agent-Id header present', async () => {
|
|
82
|
+
const mockFetch = makeRegistryPage([])
|
|
83
|
+
const app = buildApp(mockFetch, {})
|
|
84
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
85
|
+
|
|
86
|
+
const res = await supertest(app).get('/test')
|
|
87
|
+
expect(res.status).toBe(200)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('fails open when initial registry fetch fails', async () => {
|
|
91
|
+
const warnSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
|
|
92
|
+
const mockFetch = jest.fn<any>().mockRejectedValue(new Error('Network error'))
|
|
93
|
+
const app = buildApp(mockFetch, { onUnknownAgent: 'reject' })
|
|
94
|
+
await new Promise(resolve => setTimeout(resolve, 50))
|
|
95
|
+
|
|
96
|
+
const res = await supertest(app)
|
|
97
|
+
.get('/test')
|
|
98
|
+
.set('Piree-Agent-Id', 'any-cred')
|
|
99
|
+
|
|
100
|
+
// Registry failed to load → empty registry → unknown agent → 401 (onUnknownAgent=reject)
|
|
101
|
+
expect(res.status).toBe(401)
|
|
102
|
+
warnSpy.mockRestore()
|
|
103
|
+
})
|
|
104
|
+
})
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Request, Response, NextFunction } from 'express'
|
|
2
|
+
import fetch from 'node-fetch'
|
|
3
|
+
import { fetchFullRegistry, AgentRegistryEntry } from './registry.js'
|
|
4
|
+
|
|
5
|
+
type FetchFn = typeof fetch
|
|
6
|
+
|
|
7
|
+
export interface VerifierOptions {
|
|
8
|
+
registryUrl: string
|
|
9
|
+
header?: string
|
|
10
|
+
refreshIntervalMs?: number
|
|
11
|
+
onUnknownAgent?: 'reject' | 'allow' | 'log'
|
|
12
|
+
_fetchFn?: FetchFn // DI for testing — do not use in production
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function pireeAgentVerifier(options: VerifierOptions) {
|
|
16
|
+
const {
|
|
17
|
+
registryUrl,
|
|
18
|
+
header = 'Piree-Agent-Id',
|
|
19
|
+
refreshIntervalMs = 5 * 60 * 1000,
|
|
20
|
+
onUnknownAgent = 'reject',
|
|
21
|
+
_fetchFn = fetch,
|
|
22
|
+
} = options
|
|
23
|
+
|
|
24
|
+
let registry = new Map<string, AgentRegistryEntry>()
|
|
25
|
+
const headerLower = header.toLowerCase()
|
|
26
|
+
|
|
27
|
+
async function refresh(): Promise<void> {
|
|
28
|
+
try {
|
|
29
|
+
registry = await fetchFullRegistry(registryUrl, _fetchFn)
|
|
30
|
+
} catch (err) {
|
|
31
|
+
console.error('[piree-middleware] Registry refresh failed:', (err as Error).message)
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
refresh().then(() => {
|
|
36
|
+
setInterval(refresh, refreshIntervalMs).unref()
|
|
37
|
+
}).catch(err => {
|
|
38
|
+
console.error('[piree-middleware] Initial registry fetch failed:', (err as Error).message)
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
return function middleware(
|
|
42
|
+
req: Request & { pireeAgent?: AgentRegistryEntry | null },
|
|
43
|
+
res: Response,
|
|
44
|
+
next: NextFunction
|
|
45
|
+
): void {
|
|
46
|
+
const agentId = req.headers[headerLower] as string | undefined
|
|
47
|
+
|
|
48
|
+
if (!agentId) {
|
|
49
|
+
return next()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const agent = registry.get(agentId)
|
|
53
|
+
|
|
54
|
+
if (agent) {
|
|
55
|
+
req.pireeAgent = agent
|
|
56
|
+
return next()
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (onUnknownAgent === 'reject') {
|
|
60
|
+
res.status(401).json({ error: 'agent_not_trusted' })
|
|
61
|
+
return
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (onUnknownAgent === 'log') {
|
|
65
|
+
console.warn(`[piree-middleware] Unverified agent: ${agentId}`)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
req.pireeAgent = null
|
|
69
|
+
return next()
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import fetch from 'node-fetch'
|
|
2
|
+
import type { RequestInfo, RequestInit } from 'node-fetch'
|
|
3
|
+
|
|
4
|
+
export interface AgentRegistryEntry {
|
|
5
|
+
credential_id: string
|
|
6
|
+
agent_name: string
|
|
7
|
+
org_did: string
|
|
8
|
+
purpose: string
|
|
9
|
+
scope_summary: { categories: string[]; spend_limit_monthly: number }
|
|
10
|
+
agent_card_url: string
|
|
11
|
+
valid_until: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type FetchFn = (url: RequestInfo, init?: RequestInit) => ReturnType<typeof fetch>
|
|
15
|
+
|
|
16
|
+
export async function fetchFullRegistry(
|
|
17
|
+
baseUrl: string,
|
|
18
|
+
fetchFn: FetchFn = fetch
|
|
19
|
+
): Promise<Map<string, AgentRegistryEntry>> {
|
|
20
|
+
const registry = new Map<string, AgentRegistryEntry>()
|
|
21
|
+
let cursor: string | null = null
|
|
22
|
+
|
|
23
|
+
do {
|
|
24
|
+
const url = new URL(`${baseUrl}/registry/v1/trusted-agents`)
|
|
25
|
+
if (cursor) url.searchParams.set('cursor', cursor)
|
|
26
|
+
url.searchParams.set('limit', '100')
|
|
27
|
+
|
|
28
|
+
const resp = await fetchFn(url.toString())
|
|
29
|
+
if (!resp.ok) {
|
|
30
|
+
throw new Error(`Registry fetch failed with status ${resp.status}`)
|
|
31
|
+
}
|
|
32
|
+
const data = await resp.json() as {
|
|
33
|
+
agents: AgentRegistryEntry[]
|
|
34
|
+
next_cursor: string | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
for (const agent of data.agents) {
|
|
38
|
+
registry.set(agent.credential_id, agent)
|
|
39
|
+
}
|
|
40
|
+
cursor = data.next_cursor
|
|
41
|
+
} while (cursor)
|
|
42
|
+
|
|
43
|
+
return registry
|
|
44
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "NodeNext",
|
|
5
|
+
"moduleResolution": "NodeNext",
|
|
6
|
+
"outDir": "dist",
|
|
7
|
+
"declaration": true,
|
|
8
|
+
"strict": true,
|
|
9
|
+
"isolatedModules": true,
|
|
10
|
+
"esModuleInterop": true,
|
|
11
|
+
"skipLibCheck": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts"],
|
|
14
|
+
"exclude": ["src/**/__tests__/**"]
|
|
15
|
+
}
|