@easysolutions906/mcp-ofac 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/Procfile +1 -0
- package/package.json +32 -0
- package/scripts/build-data.js +217 -0
- package/src/data/meta.json +88 -0
- package/src/data/sdn.json +1 -0
- package/src/index.js +497 -0
- package/src/keys.js +194 -0
- package/src/match.js +424 -0
- package/src/stripe.js +91 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
5
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
6
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
7
|
+
import express from 'express';
|
|
8
|
+
import { randomUUID } from 'node:crypto';
|
|
9
|
+
import { z } from 'zod';
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
screenName,
|
|
13
|
+
searchEntries,
|
|
14
|
+
getEntity,
|
|
15
|
+
listPrograms,
|
|
16
|
+
buildStats,
|
|
17
|
+
} from './match.js';
|
|
18
|
+
|
|
19
|
+
import {
|
|
20
|
+
PLANS,
|
|
21
|
+
authMiddleware,
|
|
22
|
+
incrementUsage,
|
|
23
|
+
createKey,
|
|
24
|
+
revokeKey,
|
|
25
|
+
} from './keys.js';
|
|
26
|
+
|
|
27
|
+
import { createCheckoutSession, handleWebhook } from './stripe.js';
|
|
28
|
+
|
|
29
|
+
// --- Load data ---
|
|
30
|
+
|
|
31
|
+
const DATA_DIR = new URL('./data/', import.meta.url).pathname;
|
|
32
|
+
|
|
33
|
+
const loadData = async () => {
|
|
34
|
+
const [sdnRaw, metaRaw] = await Promise.all([
|
|
35
|
+
readFile(`${DATA_DIR}sdn.json`, 'utf-8'),
|
|
36
|
+
readFile(`${DATA_DIR}meta.json`, 'utf-8'),
|
|
37
|
+
]);
|
|
38
|
+
return {
|
|
39
|
+
entries: JSON.parse(sdnRaw),
|
|
40
|
+
meta: JSON.parse(metaRaw),
|
|
41
|
+
};
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const { entries, meta } = await loadData();
|
|
45
|
+
console.log(`Loaded ${entries.length} SDN entries (published ${meta.publishDate})`);
|
|
46
|
+
|
|
47
|
+
// --- Shared response envelope ---
|
|
48
|
+
|
|
49
|
+
const auditFields = () => ({
|
|
50
|
+
listVersion: meta.publishDate,
|
|
51
|
+
screenedAt: new Date().toISOString(),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// --- Express API ---
|
|
55
|
+
|
|
56
|
+
const buildExpressApp = () => {
|
|
57
|
+
const app = express();
|
|
58
|
+
app.use(express.json({ limit: '1mb' }));
|
|
59
|
+
app.use((_req, res, next) => {
|
|
60
|
+
res.set({
|
|
61
|
+
'X-Content-Type-Options': 'nosniff',
|
|
62
|
+
'X-Frame-Options': 'DENY',
|
|
63
|
+
'X-XSS-Protection': '1; mode=block',
|
|
64
|
+
});
|
|
65
|
+
next();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
// GET / — API info
|
|
69
|
+
app.get('/', (_req, res) => {
|
|
70
|
+
res.json({
|
|
71
|
+
name: 'OFAC Sanctions Screening API',
|
|
72
|
+
version: '1.0.0',
|
|
73
|
+
description: 'Screen names against the US Treasury OFAC SDN list with advanced fuzzy matching',
|
|
74
|
+
dataVersion: meta.publishDate,
|
|
75
|
+
totalEntries: meta.recordCount,
|
|
76
|
+
endpoints: {
|
|
77
|
+
'GET /': 'API info and endpoint list',
|
|
78
|
+
'GET /health': 'Health check',
|
|
79
|
+
'GET /data-info': 'Data build date, record counts, OFAC publish date',
|
|
80
|
+
'POST /screen': 'Screen a single name against the SDN list',
|
|
81
|
+
'POST /screen/batch': 'Screen multiple names (max 100)',
|
|
82
|
+
'GET /entity/:uid': 'Get full details of an SDN entry by UID',
|
|
83
|
+
'GET /search?q=keyword&type=Individual&program=SDGT&limit=25': 'Search/browse the SDN list',
|
|
84
|
+
'GET /programs': 'List all sanctions programs with entry counts',
|
|
85
|
+
'GET /stats': 'Statistics: entries by type, program, top countries',
|
|
86
|
+
},
|
|
87
|
+
});
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
// GET /health
|
|
91
|
+
app.get('/health', (_req, res) => {
|
|
92
|
+
res.json({ status: 'ok', timestamp: new Date().toISOString(), entries: entries.length });
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// GET /data-info
|
|
96
|
+
app.get('/data-info', (_req, res) => {
|
|
97
|
+
res.json({
|
|
98
|
+
publishDate: meta.publishDate,
|
|
99
|
+
buildDate: meta.buildDate,
|
|
100
|
+
recordCount: meta.recordCount,
|
|
101
|
+
typeCounts: meta.typeCounts,
|
|
102
|
+
aliasCount: meta.aliasCount,
|
|
103
|
+
addressCount: meta.addressCount,
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// POST /screen — single name screening (key-gated)
|
|
108
|
+
app.post('/screen', authMiddleware, (req, res) => {
|
|
109
|
+
const { name, type, dateOfBirth, country, threshold = 0.85, limit = 10 } = req.body || {};
|
|
110
|
+
|
|
111
|
+
if (!name || typeof name !== 'string' || !name.trim()) {
|
|
112
|
+
return res.status(400).json({ error: 'Request body must include a non-empty "name" string' });
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (threshold < 0 || threshold > 1) {
|
|
116
|
+
return res.status(400).json({ error: 'Threshold must be between 0 and 1' });
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const clampedLimit = Math.min(Math.max(parseInt(limit, 10) || 10, 1), 100);
|
|
120
|
+
|
|
121
|
+
const matches = screenName(name, entries, {
|
|
122
|
+
type: type || null,
|
|
123
|
+
dateOfBirth: dateOfBirth || null,
|
|
124
|
+
country: country || null,
|
|
125
|
+
threshold,
|
|
126
|
+
limit: clampedLimit,
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
incrementUsage(req.identifier, 1);
|
|
130
|
+
|
|
131
|
+
res.json({
|
|
132
|
+
query: { name, type: type || null, dateOfBirth: dateOfBirth || null, country: country || null },
|
|
133
|
+
threshold,
|
|
134
|
+
matchCount: matches.length,
|
|
135
|
+
matches,
|
|
136
|
+
plan: req.planName,
|
|
137
|
+
...auditFields(),
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
// POST /screen/batch — batch screening (key-gated)
|
|
142
|
+
app.post('/screen/batch', authMiddleware, (req, res) => {
|
|
143
|
+
const { names, threshold = 0.85, limit = 10 } = req.body || {};
|
|
144
|
+
|
|
145
|
+
if (!names || !Array.isArray(names) || names.length === 0) {
|
|
146
|
+
return res.status(400).json({ error: 'Request body must include a non-empty "names" array' });
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (names.length > req.plan.batchLimit) {
|
|
150
|
+
return res.status(400).json({
|
|
151
|
+
error: `Maximum ${req.plan.batchLimit} names per batch on ${req.planName} plan`,
|
|
152
|
+
limit: req.plan.batchLimit,
|
|
153
|
+
upgrade: req.planName === 'free' ? 'Add an API key to increase batch size' : null,
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const clampedLimit = Math.min(Math.max(parseInt(limit, 10) || 10, 1), 100);
|
|
158
|
+
|
|
159
|
+
const results = names.map((item) => {
|
|
160
|
+
const nameStr = typeof item === 'string' ? item : item?.name;
|
|
161
|
+
|
|
162
|
+
if (!nameStr || typeof nameStr !== 'string' || !nameStr.trim()) {
|
|
163
|
+
return { query: { name: nameStr || null }, error: 'Invalid name' };
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const matches = screenName(nameStr, entries, {
|
|
167
|
+
type: (typeof item === 'object' ? item.type : null) || null,
|
|
168
|
+
dateOfBirth: (typeof item === 'object' ? item.dateOfBirth : null) || null,
|
|
169
|
+
country: (typeof item === 'object' ? item.country : null) || null,
|
|
170
|
+
threshold,
|
|
171
|
+
limit: clampedLimit,
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
query: {
|
|
176
|
+
name: nameStr,
|
|
177
|
+
type: (typeof item === 'object' ? item.type : null) || null,
|
|
178
|
+
dateOfBirth: (typeof item === 'object' ? item.dateOfBirth : null) || null,
|
|
179
|
+
country: (typeof item === 'object' ? item.country : null) || null,
|
|
180
|
+
},
|
|
181
|
+
matchCount: matches.length,
|
|
182
|
+
matches,
|
|
183
|
+
};
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
incrementUsage(req.identifier, names.length);
|
|
187
|
+
|
|
188
|
+
res.json({
|
|
189
|
+
total: results.length,
|
|
190
|
+
threshold,
|
|
191
|
+
results,
|
|
192
|
+
plan: req.planName,
|
|
193
|
+
...auditFields(),
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
// GET /entity/:uid
|
|
198
|
+
app.get('/entity/:uid', (req, res) => {
|
|
199
|
+
const uid = parseInt(req.params.uid, 10);
|
|
200
|
+
|
|
201
|
+
if (isNaN(uid)) {
|
|
202
|
+
return res.status(400).json({ error: 'UID must be a number' });
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const entity = getEntity(entries, uid);
|
|
206
|
+
|
|
207
|
+
if (!entity) {
|
|
208
|
+
return res.status(404).json({ error: `No SDN entry found with UID ${uid}` });
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
res.json({ entity, ...auditFields() });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// GET /search
|
|
215
|
+
app.get('/search', (req, res) => {
|
|
216
|
+
const { q, type, program } = req.query;
|
|
217
|
+
const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 25, 1), 200);
|
|
218
|
+
const offset = Math.max(parseInt(req.query.offset, 10) || 0, 0);
|
|
219
|
+
|
|
220
|
+
const result = searchEntries(entries, { q, type, program, limit, offset });
|
|
221
|
+
|
|
222
|
+
res.json({ ...result, ...auditFields() });
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// GET /programs
|
|
226
|
+
app.get('/programs', (_req, res) => {
|
|
227
|
+
res.json({ ...listPrograms(meta), ...auditFields() });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
// GET /stats
|
|
231
|
+
app.get('/stats', (_req, res) => {
|
|
232
|
+
res.json({ ...buildStats(entries, meta), ...auditFields() });
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// --- Admin endpoints (require ADMIN_SECRET env var) ---
|
|
236
|
+
|
|
237
|
+
const adminAuth = (req, res, next) => {
|
|
238
|
+
const secret = process.env.ADMIN_SECRET;
|
|
239
|
+
if (!secret) { return res.status(503).json({ error: 'Admin not configured' }); }
|
|
240
|
+
if (req.headers['x-admin-secret'] !== secret) {
|
|
241
|
+
return res.status(401).json({ error: 'Invalid admin secret' });
|
|
242
|
+
}
|
|
243
|
+
next();
|
|
244
|
+
};
|
|
245
|
+
|
|
246
|
+
app.post('/admin/keys', adminAuth, (req, res) => {
|
|
247
|
+
const { plan = 'pro', email = null } = req.body || {};
|
|
248
|
+
if (!PLANS[plan]) {
|
|
249
|
+
return res.status(400).json({ error: `Invalid plan. Options: ${Object.keys(PLANS).join(', ')}` });
|
|
250
|
+
}
|
|
251
|
+
const result = createKey(plan, email);
|
|
252
|
+
res.json(result);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
app.delete('/admin/keys/:key', adminAuth, (req, res) => {
|
|
256
|
+
const revoked = revokeKey(req.params.key);
|
|
257
|
+
res.json({ revoked });
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
app.get('/admin/plans', adminAuth, (_req, res) => {
|
|
261
|
+
res.json(PLANS);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
// --- Checkout endpoint ---
|
|
265
|
+
|
|
266
|
+
app.post('/checkout', async (req, res) => {
|
|
267
|
+
const { plan, successUrl, cancelUrl } = req.body || {};
|
|
268
|
+
if (!plan || !PLANS[plan] || plan === 'free') {
|
|
269
|
+
return res.status(400).json({
|
|
270
|
+
error: `Invalid plan. Options: ${Object.keys(PLANS).filter((p) => p !== 'free').join(', ')}`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
const session = await createCheckoutSession(plan, successUrl, cancelUrl);
|
|
275
|
+
res.json(session);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
res.status(500).json({ error: err.message });
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// --- Stripe webhook ---
|
|
282
|
+
|
|
283
|
+
app.post('/webhook/stripe', express.raw({ type: 'application/json' }), (req, res) => {
|
|
284
|
+
try {
|
|
285
|
+
const result = handleWebhook(req.body.toString(), req.headers['stripe-signature']);
|
|
286
|
+
res.json({ received: true, result: result || null });
|
|
287
|
+
} catch (err) {
|
|
288
|
+
console.error('Stripe webhook error:', err.message);
|
|
289
|
+
res.status(400).json({ error: 'Webhook processing failed' });
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
return app;
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
// --- MCP Server ---
|
|
297
|
+
|
|
298
|
+
const buildMcpServer = () => {
|
|
299
|
+
const server = new McpServer({
|
|
300
|
+
name: 'ofac-sanctions',
|
|
301
|
+
version: '1.0.0',
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
server.tool(
|
|
305
|
+
'ofac_screen',
|
|
306
|
+
'Screen a name against the OFAC SDN (Specially Designated Nationals) sanctions list with fuzzy matching. Returns scored matches with match type classification (exact/strong/partial/weak). Essential for KYC/AML compliance checks.',
|
|
307
|
+
{
|
|
308
|
+
name: z.string().describe('Name to screen (person, entity, vessel, or aircraft)'),
|
|
309
|
+
type: z.enum(['Individual', 'Entity', 'Vessel', 'Aircraft']).optional().describe('Filter by SDN entry type'),
|
|
310
|
+
dateOfBirth: z.string().optional().describe('Date of birth to improve accuracy (e.g., "1970-01-15" or "1970")'),
|
|
311
|
+
country: z.string().optional().describe('Country to improve accuracy (e.g., "Iran", "Russia")'),
|
|
312
|
+
threshold: z.number().optional().describe('Minimum match score 0-1 (default 0.85)'),
|
|
313
|
+
limit: z.number().optional().describe('Maximum results to return (default 10)'),
|
|
314
|
+
},
|
|
315
|
+
async ({ name, type, dateOfBirth, country, threshold, limit }) => {
|
|
316
|
+
const matches = screenName(name, entries, {
|
|
317
|
+
type: type || null,
|
|
318
|
+
dateOfBirth: dateOfBirth || null,
|
|
319
|
+
country: country || null,
|
|
320
|
+
threshold: threshold ?? 0.85,
|
|
321
|
+
limit: limit ?? 10,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const result = {
|
|
325
|
+
query: { name, type: type || null, dateOfBirth: dateOfBirth || null, country: country || null },
|
|
326
|
+
threshold: threshold ?? 0.85,
|
|
327
|
+
matchCount: matches.length,
|
|
328
|
+
matches,
|
|
329
|
+
...auditFields(),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
333
|
+
},
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
server.tool(
|
|
337
|
+
'ofac_screen_batch',
|
|
338
|
+
'Screen multiple names against the OFAC SDN list in one call. Max 100 names. Each name can optionally include type, dateOfBirth, and country for improved accuracy.',
|
|
339
|
+
{
|
|
340
|
+
names: z.array(z.union([
|
|
341
|
+
z.string(),
|
|
342
|
+
z.object({
|
|
343
|
+
name: z.string(),
|
|
344
|
+
type: z.enum(['Individual', 'Entity', 'Vessel', 'Aircraft']).optional(),
|
|
345
|
+
dateOfBirth: z.string().optional(),
|
|
346
|
+
country: z.string().optional(),
|
|
347
|
+
}),
|
|
348
|
+
])).describe('Array of names (strings or objects with name/type/dateOfBirth/country)'),
|
|
349
|
+
threshold: z.number().optional().describe('Minimum match score 0-1 (default 0.85)'),
|
|
350
|
+
limit: z.number().optional().describe('Maximum results per name (default 10)'),
|
|
351
|
+
},
|
|
352
|
+
async ({ names, threshold, limit }) => {
|
|
353
|
+
const clampedLimit = Math.min(Math.max(limit ?? 10, 1), 100);
|
|
354
|
+
const th = threshold ?? 0.85;
|
|
355
|
+
|
|
356
|
+
const results = names.map((item) => {
|
|
357
|
+
const nameStr = typeof item === 'string' ? item : item?.name;
|
|
358
|
+
if (!nameStr) { return { query: { name: null }, error: 'Invalid name' }; }
|
|
359
|
+
|
|
360
|
+
const matches = screenName(nameStr, entries, {
|
|
361
|
+
type: (typeof item === 'object' ? item.type : null) || null,
|
|
362
|
+
dateOfBirth: (typeof item === 'object' ? item.dateOfBirth : null) || null,
|
|
363
|
+
country: (typeof item === 'object' ? item.country : null) || null,
|
|
364
|
+
threshold: th,
|
|
365
|
+
limit: clampedLimit,
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
return {
|
|
369
|
+
query: { name: nameStr },
|
|
370
|
+
matchCount: matches.length,
|
|
371
|
+
matches,
|
|
372
|
+
};
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
const result = { total: results.length, threshold: th, results, ...auditFields() };
|
|
376
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
377
|
+
},
|
|
378
|
+
);
|
|
379
|
+
|
|
380
|
+
server.tool(
|
|
381
|
+
'ofac_entity',
|
|
382
|
+
'Get full details of an SDN entry by its unique ID (UID). Returns all fields: name, aliases, addresses, IDs, programs, dates of birth, nationalities, vessel info, and remarks.',
|
|
383
|
+
{
|
|
384
|
+
uid: z.number().describe('The unique SDN entry UID'),
|
|
385
|
+
},
|
|
386
|
+
async ({ uid }) => {
|
|
387
|
+
const entity = getEntity(entries, uid);
|
|
388
|
+
if (!entity) {
|
|
389
|
+
return { content: [{ type: 'text', text: JSON.stringify({ error: `No SDN entry found with UID ${uid}` }) }] };
|
|
390
|
+
}
|
|
391
|
+
return { content: [{ type: 'text', text: JSON.stringify({ entity, ...auditFields() }, null, 2) }] };
|
|
392
|
+
},
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
server.tool(
|
|
396
|
+
'ofac_search',
|
|
397
|
+
'Search and browse the OFAC SDN list by keyword, entry type, or sanctions program. Supports pagination. Use for exploratory queries or browsing entries.',
|
|
398
|
+
{
|
|
399
|
+
q: z.string().optional().describe('Search keyword (searches names and aliases)'),
|
|
400
|
+
type: z.enum(['Individual', 'Entity', 'Vessel', 'Aircraft']).optional().describe('Filter by entry type'),
|
|
401
|
+
program: z.string().optional().describe('Filter by sanctions program (e.g., "SDGT", "IRAN", "CUBA")'),
|
|
402
|
+
limit: z.number().optional().describe('Results per page (default 25, max 200)'),
|
|
403
|
+
offset: z.number().optional().describe('Number of results to skip for pagination'),
|
|
404
|
+
},
|
|
405
|
+
async ({ q, type, program, limit, offset }) => {
|
|
406
|
+
const result = searchEntries(entries, {
|
|
407
|
+
q: q || '',
|
|
408
|
+
type: type || null,
|
|
409
|
+
program: program || null,
|
|
410
|
+
limit: Math.min(Math.max(limit ?? 25, 1), 200),
|
|
411
|
+
offset: Math.max(offset ?? 0, 0),
|
|
412
|
+
});
|
|
413
|
+
return { content: [{ type: 'text', text: JSON.stringify({ ...result, ...auditFields() }, null, 2) }] };
|
|
414
|
+
},
|
|
415
|
+
);
|
|
416
|
+
|
|
417
|
+
server.tool(
|
|
418
|
+
'ofac_stats',
|
|
419
|
+
'Get OFAC SDN list statistics: total entries, entries by type, entries by sanctions program, data version info, and top countries.',
|
|
420
|
+
{},
|
|
421
|
+
async () => {
|
|
422
|
+
const stats = buildStats(entries, meta);
|
|
423
|
+
return { content: [{ type: 'text', text: JSON.stringify(stats, null, 2) }] };
|
|
424
|
+
},
|
|
425
|
+
);
|
|
426
|
+
|
|
427
|
+
return server;
|
|
428
|
+
};
|
|
429
|
+
|
|
430
|
+
// --- Start ---
|
|
431
|
+
|
|
432
|
+
const main = async () => {
|
|
433
|
+
const port = process.env.PORT;
|
|
434
|
+
|
|
435
|
+
if (port) {
|
|
436
|
+
// HTTP mode: Express REST API + MCP streamable HTTP
|
|
437
|
+
const app = buildExpressApp();
|
|
438
|
+
const mcpServer = buildMcpServer();
|
|
439
|
+
const transports = {};
|
|
440
|
+
|
|
441
|
+
app.post('/mcp', async (req, res) => {
|
|
442
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
443
|
+
let transport = transports[sessionId];
|
|
444
|
+
|
|
445
|
+
if (!transport) {
|
|
446
|
+
transport = new StreamableHTTPServerTransport({
|
|
447
|
+
sessionIdGenerator: () => randomUUID(),
|
|
448
|
+
});
|
|
449
|
+
transport.onclose = () => {
|
|
450
|
+
if (transport.sessionId) {
|
|
451
|
+
delete transports[transport.sessionId];
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
await mcpServer.connect(transport);
|
|
455
|
+
transports[transport.sessionId] = transport;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
await transport.handleRequest(req, res, req.body);
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
app.get('/mcp', async (req, res) => {
|
|
462
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
463
|
+
const transport = transports[sessionId];
|
|
464
|
+
if (!transport) {
|
|
465
|
+
res.status(400).json({ error: 'No active session. Send a POST to /mcp first.' });
|
|
466
|
+
return;
|
|
467
|
+
}
|
|
468
|
+
await transport.handleRequest(req, res);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
app.delete('/mcp', async (req, res) => {
|
|
472
|
+
const sessionId = req.headers['mcp-session-id'];
|
|
473
|
+
const transport = transports[sessionId];
|
|
474
|
+
if (!transport) {
|
|
475
|
+
res.status(400).json({ error: 'No active session.' });
|
|
476
|
+
return;
|
|
477
|
+
}
|
|
478
|
+
await transport.handleRequest(req, res);
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
app.listen(parseInt(port, 10), () => {
|
|
482
|
+
console.log(`OFAC Sanctions Screening API running on port ${port}`);
|
|
483
|
+
console.log(`REST endpoints: http://localhost:${port}/`);
|
|
484
|
+
console.log(`MCP endpoint: http://localhost:${port}/mcp`);
|
|
485
|
+
});
|
|
486
|
+
} else {
|
|
487
|
+
// Stdio mode: MCP only
|
|
488
|
+
const mcpServer = buildMcpServer();
|
|
489
|
+
const transport = new StdioServerTransport();
|
|
490
|
+
await mcpServer.connect(transport);
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
|
|
494
|
+
main().catch((err) => {
|
|
495
|
+
console.error('Failed to start OFAC server:', err);
|
|
496
|
+
process.exit(1);
|
|
497
|
+
});
|
package/src/keys.js
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
|
2
|
+
import { join, dirname } from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import { randomUUID } from 'node:crypto';
|
|
5
|
+
|
|
6
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const KEYS_PATH = join(__dirname, 'data', 'keys.json');
|
|
8
|
+
|
|
9
|
+
// --- Plans ---
|
|
10
|
+
|
|
11
|
+
const PLANS = {
|
|
12
|
+
free: { name: 'Free', screensPerDay: 10, batchLimit: 5, ratePerMinute: 5 },
|
|
13
|
+
starter: { name: 'Starter', screensPerDay: 100, batchLimit: 25, ratePerMinute: 15 },
|
|
14
|
+
pro: { name: 'Pro', screensPerDay: 1000, batchLimit: 50, ratePerMinute: 60 },
|
|
15
|
+
business: { name: 'Business', screensPerDay: 5000, batchLimit: 100, ratePerMinute: 200 },
|
|
16
|
+
enterprise: { name: 'Enterprise', screensPerDay: 50000, batchLimit: 100, ratePerMinute: 500 },
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
// --- Key storage (JSON file) ---
|
|
20
|
+
|
|
21
|
+
const loadKeys = () => {
|
|
22
|
+
if (!existsSync(KEYS_PATH)) { return {}; }
|
|
23
|
+
return JSON.parse(readFileSync(KEYS_PATH, 'utf-8'));
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const saveKeys = (keys) => {
|
|
27
|
+
writeFileSync(KEYS_PATH, JSON.stringify(keys, null, 2) + '\n');
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
// --- Usage tracking (in-memory, resets daily) ---
|
|
31
|
+
|
|
32
|
+
const usage = new Map(); // key/ip -> { date: 'YYYY-MM-DD', screens: 0 }
|
|
33
|
+
|
|
34
|
+
const today = () => new Date().toISOString().slice(0, 10);
|
|
35
|
+
|
|
36
|
+
const getUsage = (identifier) => {
|
|
37
|
+
const current = usage.get(identifier);
|
|
38
|
+
const d = today();
|
|
39
|
+
if (!current || current.date !== d) {
|
|
40
|
+
usage.set(identifier, { date: d, screens: 0 });
|
|
41
|
+
return usage.get(identifier);
|
|
42
|
+
}
|
|
43
|
+
return current;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const incrementUsage = (identifier, count = 1) => {
|
|
47
|
+
const u = getUsage(identifier);
|
|
48
|
+
u.screens += count;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
// --- Rate limiting (sliding window per minute) ---
|
|
52
|
+
|
|
53
|
+
const rateBuckets = new Map(); // key/ip -> [timestamp, ...]
|
|
54
|
+
|
|
55
|
+
const checkRateLimit = (identifier, maxPerMinute) => {
|
|
56
|
+
const now = Date.now();
|
|
57
|
+
const window = 60000;
|
|
58
|
+
const bucket = rateBuckets.get(identifier) || [];
|
|
59
|
+
const recent = bucket.filter((t) => now - t < window);
|
|
60
|
+
rateBuckets.set(identifier, recent);
|
|
61
|
+
|
|
62
|
+
if (recent.length >= maxPerMinute) {
|
|
63
|
+
return { allowed: false, retryAfterMs: window - (now - recent[0]) };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
recent.push(now);
|
|
67
|
+
return { allowed: true };
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// --- Key management ---
|
|
71
|
+
|
|
72
|
+
const createKey = (plan = 'pro', email = null, stripeCustomerId = null) => {
|
|
73
|
+
const keys = loadKeys();
|
|
74
|
+
const apiKey = `ofac_${randomUUID().replace(/-/g, '')}`;
|
|
75
|
+
|
|
76
|
+
keys[apiKey] = {
|
|
77
|
+
plan,
|
|
78
|
+
email,
|
|
79
|
+
stripeCustomerId,
|
|
80
|
+
createdAt: new Date().toISOString(),
|
|
81
|
+
active: true,
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
saveKeys(keys);
|
|
85
|
+
return { apiKey, plan, ...PLANS[plan] };
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
const revokeKey = (apiKey) => {
|
|
89
|
+
const keys = loadKeys();
|
|
90
|
+
if (keys[apiKey]) {
|
|
91
|
+
keys[apiKey].active = false;
|
|
92
|
+
keys[apiKey].revokedAt = new Date().toISOString();
|
|
93
|
+
saveKeys(keys);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
return false;
|
|
97
|
+
};
|
|
98
|
+
|
|
99
|
+
const validateKey = (apiKey) => {
|
|
100
|
+
if (!apiKey) { return null; }
|
|
101
|
+
const keys = loadKeys();
|
|
102
|
+
const entry = keys[apiKey];
|
|
103
|
+
if (!entry || !entry.active) { return null; }
|
|
104
|
+
return { ...entry, ...PLANS[entry.plan] };
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
// --- Express middleware ---
|
|
108
|
+
|
|
109
|
+
const authMiddleware = (req, res, next) => {
|
|
110
|
+
const apiKey = req.headers['x-api-key'] || req.query.api_key;
|
|
111
|
+
const clientIp = req.headers['x-forwarded-for']?.split(',')[0]?.trim() || req.ip;
|
|
112
|
+
|
|
113
|
+
// Determine plan
|
|
114
|
+
const keyData = validateKey(apiKey);
|
|
115
|
+
const plan = keyData ? PLANS[keyData.plan] : PLANS.free;
|
|
116
|
+
const identifier = apiKey || `ip:${clientIp}`;
|
|
117
|
+
|
|
118
|
+
// Rate limit check
|
|
119
|
+
const rateCheck = checkRateLimit(identifier, plan.ratePerMinute);
|
|
120
|
+
if (!rateCheck.allowed) {
|
|
121
|
+
return res.status(429).json({
|
|
122
|
+
error: 'Rate limit exceeded',
|
|
123
|
+
retryAfterMs: rateCheck.retryAfterMs,
|
|
124
|
+
plan: keyData ? keyData.plan : 'free',
|
|
125
|
+
limit: `${plan.ratePerMinute}/minute`,
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Daily usage check
|
|
130
|
+
const u = getUsage(identifier);
|
|
131
|
+
if (u.screens >= plan.screensPerDay) {
|
|
132
|
+
return res.status(429).json({
|
|
133
|
+
error: 'Daily screening limit exceeded',
|
|
134
|
+
used: u.screens,
|
|
135
|
+
limit: plan.screensPerDay,
|
|
136
|
+
plan: keyData ? keyData.plan : 'free',
|
|
137
|
+
resetsAt: `${today()}T23:59:59Z`,
|
|
138
|
+
upgrade: apiKey ? 'Contact support to upgrade your plan' : 'Add an API key to increase your limit',
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Attach to request
|
|
143
|
+
req.plan = plan;
|
|
144
|
+
req.planName = keyData ? keyData.plan : 'free';
|
|
145
|
+
req.identifier = identifier;
|
|
146
|
+
req.apiKey = apiKey || null;
|
|
147
|
+
|
|
148
|
+
next();
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
// --- Stripe webhook handler ---
|
|
152
|
+
|
|
153
|
+
const handleStripeWebhook = (rawBody, signature) => {
|
|
154
|
+
// TODO: add Stripe signature verification when STRIPE_WEBHOOK_SECRET is configured
|
|
155
|
+
// const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);
|
|
156
|
+
// stripe.webhooks.constructEvent(rawBody, signature, process.env.STRIPE_WEBHOOK_SECRET);
|
|
157
|
+
const event = JSON.parse(rawBody);
|
|
158
|
+
|
|
159
|
+
const handlers = {
|
|
160
|
+
'checkout.session.completed': (session) => {
|
|
161
|
+
const email = session.customer_details?.email;
|
|
162
|
+
const plan = session.metadata?.plan || 'pro';
|
|
163
|
+
const stripeCustomerId = session.customer;
|
|
164
|
+
const result = createKey(plan, email, stripeCustomerId);
|
|
165
|
+
console.log(`New key created for ${email}: ${result.apiKey} (${plan})`);
|
|
166
|
+
// TODO: send email with API key via SendGrid/Resend/etc.
|
|
167
|
+
return result;
|
|
168
|
+
},
|
|
169
|
+
'customer.subscription.deleted': (subscription) => {
|
|
170
|
+
const keys = loadKeys();
|
|
171
|
+
const match = Object.entries(keys).find(
|
|
172
|
+
([, v]) => v.stripeCustomerId === subscription.customer && v.active,
|
|
173
|
+
);
|
|
174
|
+
if (match) {
|
|
175
|
+
revokeKey(match[0]);
|
|
176
|
+
console.log(`Key revoked for customer ${subscription.customer}`);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const handler = handlers[event.type];
|
|
182
|
+
if (handler) { return handler(event.data.object); }
|
|
183
|
+
return null;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export {
|
|
187
|
+
PLANS,
|
|
188
|
+
loadKeys,
|
|
189
|
+
createKey,
|
|
190
|
+
revokeKey,
|
|
191
|
+
validateKey,
|
|
192
|
+
authMiddleware,
|
|
193
|
+
incrementUsage,
|
|
194
|
+
};
|