@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/src/match.js ADDED
@@ -0,0 +1,424 @@
1
+ import { doubleMetaphone } from 'double-metaphone';
2
+
3
+ // --- Normalize ---
4
+
5
+ const normalize = (str) => {
6
+ if (!str) { return ''; }
7
+ return str
8
+ .toLowerCase()
9
+ .normalize('NFD')
10
+ .replace(/[\u0300-\u036f]/g, '')
11
+ .replace(/[^a-z0-9\s]/g, ' ')
12
+ .replace(/\b(al|el|bin|ibn|abd|abu)\b/g, '')
13
+ .replace(/\s+/g, ' ')
14
+ .trim();
15
+ };
16
+
17
+ // --- Jaro-Winkler (from scratch) ---
18
+
19
+ const jaro = (s1, s2) => {
20
+ if (s1 === s2) { return 1.0; }
21
+ if (!s1.length || !s2.length) { return 0.0; }
22
+
23
+ const matchWindow = Math.max(Math.floor(Math.max(s1.length, s2.length) / 2) - 1, 0);
24
+ const s1Matches = new Array(s1.length).fill(false);
25
+ const s2Matches = new Array(s2.length).fill(false);
26
+
27
+ let matches = 0;
28
+ let transpositions = 0;
29
+
30
+ for (let i = 0; i < s1.length; i++) {
31
+ const start = Math.max(0, i - matchWindow);
32
+ const end = Math.min(i + matchWindow + 1, s2.length);
33
+
34
+ for (let j = start; j < end; j++) {
35
+ if (s2Matches[j] || s1[i] !== s2[j]) { continue; }
36
+ s1Matches[i] = true;
37
+ s2Matches[j] = true;
38
+ matches++;
39
+ break;
40
+ }
41
+ }
42
+
43
+ if (matches === 0) { return 0.0; }
44
+
45
+ let k = 0;
46
+ for (let i = 0; i < s1.length; i++) {
47
+ if (!s1Matches[i]) { continue; }
48
+ while (!s2Matches[k]) { k++; }
49
+ if (s1[i] !== s2[k]) { transpositions++; }
50
+ k++;
51
+ }
52
+
53
+ return (
54
+ (matches / s1.length + matches / s2.length + (matches - transpositions / 2) / matches) / 3
55
+ );
56
+ };
57
+
58
+ const jaroWinkler = (s1, s2, prefixScale = 0.1) => {
59
+ const jaroScore = jaro(s1, s2);
60
+ let prefixLen = 0;
61
+ const maxPrefix = Math.min(4, s1.length, s2.length);
62
+
63
+ for (let i = 0; i < maxPrefix; i++) {
64
+ if (s1[i] === s2[i]) {
65
+ prefixLen++;
66
+ } else {
67
+ break;
68
+ }
69
+ }
70
+
71
+ return jaroScore + prefixLen * prefixScale * (1 - jaroScore);
72
+ };
73
+
74
+ // --- Token-set matching ---
75
+
76
+ const tokenSetSimilarity = (tokens1, tokens2) => {
77
+ if (!tokens1.length || !tokens2.length) { return 0; }
78
+
79
+ const set1 = new Set(tokens1);
80
+ const set2 = new Set(tokens2);
81
+
82
+ const intersection = [...set1].filter((t) => set2.has(t)).length;
83
+ const union = new Set([...set1, ...set2]).size;
84
+
85
+ if (union === 0) { return 0; }
86
+
87
+ // Also compute best pairwise token Jaro-Winkler for partial matches
88
+ let pairwiseSum = 0;
89
+ let pairCount = 0;
90
+
91
+ tokens1.forEach((t1) => {
92
+ let bestMatch = 0;
93
+ tokens2.forEach((t2) => {
94
+ const score = jaroWinkler(t1, t2);
95
+ if (score > bestMatch) { bestMatch = score; }
96
+ });
97
+ pairwiseSum += bestMatch;
98
+ pairCount++;
99
+ });
100
+
101
+ const pairwiseAvg = pairCount > 0 ? pairwiseSum / pairCount : 0;
102
+ const jaccard = intersection / union;
103
+
104
+ // Blend exact token overlap with fuzzy pairwise
105
+ return jaccard * 0.4 + pairwiseAvg * 0.6;
106
+ };
107
+
108
+ // --- Phonetic matching ---
109
+
110
+ const phoneticMatch = (codes1, codes2) => {
111
+ if (!codes1.length || !codes2.length) { return false; }
112
+ const set2 = new Set(codes2);
113
+ return codes1.some((c) => set2.has(c));
114
+ };
115
+
116
+ const computePhonetic = (name) => {
117
+ const tokens = normalize(name).split(/\s+/).filter(Boolean);
118
+ return tokens.flatMap((t) => {
119
+ const [primary, secondary] = doubleMetaphone(t);
120
+ return [primary, secondary].filter(Boolean);
121
+ });
122
+ };
123
+
124
+ // --- Exact substring ---
125
+
126
+ const exactSubstring = (query, target) => {
127
+ if (!query || !target) { return false; }
128
+ return target.includes(query) || query.includes(target);
129
+ };
130
+
131
+ // --- Composite score for one name comparison ---
132
+
133
+ const WEIGHTS = {
134
+ jaroWinkler: 0.40,
135
+ tokenSet: 0.30,
136
+ phonetic: 0.20,
137
+ exactSubstring: 0.10,
138
+ };
139
+
140
+ const scoreName = (queryNorm, queryTokens, queryCodes, targetNorm, targetTokens, targetCodes) => {
141
+ // Exact match short-circuit
142
+ if (queryNorm === targetNorm && queryNorm.length > 0) {
143
+ return {
144
+ score: 1.0,
145
+ details: { jaroWinkler: 1.0, tokenSet: 1.0, phonetic: true, exactSubstring: true },
146
+ };
147
+ }
148
+
149
+ const jwScore = jaroWinkler(queryNorm, targetNorm);
150
+ const tsScore = tokenSetSimilarity(queryTokens, targetTokens);
151
+ const isPhonetic = phoneticMatch(queryCodes, targetCodes);
152
+ const isSubstring = exactSubstring(queryNorm, targetNorm);
153
+
154
+ // Boost when any query token exactly matches a target token (catches "PUTIN" vs "Vladimir PUTIN")
155
+ const hasExactToken = queryTokens.some((t) => targetTokens.includes(t) && t.length >= 3);
156
+ const exactTokenBoost = hasExactToken ? 0.15 : 0;
157
+
158
+ const score = Math.min(1.0,
159
+ jwScore * WEIGHTS.jaroWinkler +
160
+ tsScore * WEIGHTS.tokenSet +
161
+ (isPhonetic ? 1.0 : 0.0) * WEIGHTS.phonetic +
162
+ (isSubstring ? 1.0 : 0.0) * WEIGHTS.exactSubstring +
163
+ exactTokenBoost);
164
+
165
+ return {
166
+ score,
167
+ details: {
168
+ jaroWinkler: Math.round(jwScore * 100) / 100,
169
+ tokenSet: Math.round(tsScore * 100) / 100,
170
+ phonetic: isPhonetic,
171
+ exactSubstring: isSubstring,
172
+ },
173
+ };
174
+ };
175
+
176
+ // --- DOB boost/penalty ---
177
+
178
+ const parseDateLoose = (str) => {
179
+ if (!str) { return null; }
180
+ const s = String(str).trim();
181
+ // "DD Mon YYYY", "YYYY", "YYYY-MM-DD" etc.
182
+ const d = new Date(s);
183
+ if (!isNaN(d.getTime())) { return d; }
184
+ // Year only
185
+ const yearMatch = s.match(/^\d{4}$/);
186
+ if (yearMatch) { return { year: parseInt(s, 10) }; }
187
+ return null;
188
+ };
189
+
190
+ const dobBoost = (queryDob, entryDobs) => {
191
+ if (!queryDob || !entryDobs || !entryDobs.length) { return 0; }
192
+
193
+ const qDate = parseDateLoose(queryDob);
194
+ if (!qDate) { return 0; }
195
+
196
+ const qYear = qDate instanceof Date ? qDate.getFullYear() : qDate.year;
197
+
198
+ for (const d of entryDobs) {
199
+ const eDate = parseDateLoose(d.dateOfBirth);
200
+ if (!eDate) { continue; }
201
+ const eYear = eDate instanceof Date ? eDate.getFullYear() : eDate.year;
202
+
203
+ // Exact date match
204
+ if (qDate instanceof Date && eDate instanceof Date) {
205
+ if (qDate.toISOString().slice(0, 10) === eDate.toISOString().slice(0, 10)) {
206
+ return 0.10; // boost 10%
207
+ }
208
+ }
209
+ // Same year
210
+ if (qYear === eYear) { return 0.05; }
211
+ // Close year
212
+ if (Math.abs(qYear - eYear) <= 2) { return 0.02; }
213
+ }
214
+
215
+ return -0.05; // penalty if DOB provided but doesn't match
216
+ };
217
+
218
+ // --- Country boost/penalty ---
219
+
220
+ const countryBoost = (queryCountry, entry) => {
221
+ if (!queryCountry) { return 0; }
222
+
223
+ const qc = queryCountry.toLowerCase().trim();
224
+ const entryCountries = new Set();
225
+
226
+ (entry.nationalities || []).forEach((n) => {
227
+ if (n.country) { entryCountries.add(n.country.toLowerCase()); }
228
+ });
229
+ (entry.citizenships || []).forEach((c) => {
230
+ if (c.country) { entryCountries.add(c.country.toLowerCase()); }
231
+ });
232
+ (entry.addresses || []).forEach((a) => {
233
+ if (a.country) { entryCountries.add(a.country.toLowerCase()); }
234
+ });
235
+
236
+ if (entryCountries.size === 0) { return 0; }
237
+
238
+ for (const c of entryCountries) {
239
+ if (c === qc || c.includes(qc) || qc.includes(c)) { return 0.05; }
240
+ }
241
+
242
+ return -0.03;
243
+ };
244
+
245
+ // --- Match type classification ---
246
+
247
+ const classifyMatch = (score) => {
248
+ if (score >= 0.95) { return 'exact'; }
249
+ if (score >= 0.85) { return 'strong'; }
250
+ if (score >= 0.70) { return 'partial'; }
251
+ return 'weak';
252
+ };
253
+
254
+ // --- Main screen function ---
255
+
256
+ const screenName = (query, entries, options = {}) => {
257
+ const {
258
+ type = null,
259
+ dateOfBirth = null,
260
+ country = null,
261
+ threshold = 0.85,
262
+ limit = 10,
263
+ } = options;
264
+
265
+ const queryNorm = normalize(query);
266
+ const queryTokens = queryNorm.split(/\s+/).filter(Boolean);
267
+ const queryCodes = computePhonetic(query);
268
+
269
+ if (!queryNorm) { return []; }
270
+
271
+ // Pre-filter by type if specified
272
+ const candidates = type
273
+ ? entries.filter((e) => e.sdnType.toLowerCase() === type.toLowerCase())
274
+ : entries;
275
+
276
+ const results = [];
277
+
278
+ candidates.forEach((entry) => {
279
+ const { search } = entry;
280
+
281
+ // Score against primary name
282
+ const primary = scoreName(
283
+ queryNorm, queryTokens, queryCodes,
284
+ search.normalizedName, search.nameTokens, search.phonetic,
285
+ );
286
+
287
+ let bestScore = primary.score;
288
+ let bestDetails = primary.details;
289
+ let matchedOn = 'primary_name';
290
+ let matchedName = entry.name;
291
+
292
+ // Score against each alias
293
+ search.aliases.forEach((alias) => {
294
+ const aliasResult = scoreName(
295
+ queryNorm, queryTokens, queryCodes,
296
+ alias.normalized, alias.tokens, alias.phonetic,
297
+ );
298
+
299
+ if (aliasResult.score > bestScore) {
300
+ bestScore = aliasResult.score;
301
+ bestDetails = aliasResult.details;
302
+ matchedOn = 'alias';
303
+ matchedName = alias.name;
304
+ }
305
+ });
306
+
307
+ // Apply DOB and country adjustments
308
+ bestScore += dobBoost(dateOfBirth, entry.datesOfBirth);
309
+ bestScore += countryBoost(country, entry);
310
+
311
+ // Clamp to [0, 1]
312
+ bestScore = Math.max(0, Math.min(1, bestScore));
313
+
314
+ if (bestScore >= threshold) {
315
+ // Strip search index from returned entity
316
+ const { search: _search, ...entity } = entry;
317
+
318
+ results.push({
319
+ entity,
320
+ score: Math.round(bestScore * 1000) / 1000,
321
+ matchType: classifyMatch(bestScore),
322
+ matchedOn,
323
+ matchedName,
324
+ matchDetails: bestDetails,
325
+ });
326
+ }
327
+ });
328
+
329
+ // Sort by score descending, take top N
330
+ results.sort((a, b) => b.score - a.score);
331
+ return results.slice(0, limit);
332
+ };
333
+
334
+ // --- Search/browse function ---
335
+
336
+ const searchEntries = (entries, options = {}) => {
337
+ const { q = '', type = null, program = null, limit = 25, offset = 0 } = options;
338
+
339
+ let filtered = entries;
340
+
341
+ if (type) {
342
+ filtered = filtered.filter((e) => e.sdnType.toLowerCase() === type.toLowerCase());
343
+ }
344
+
345
+ if (program) {
346
+ const progUpper = program.toUpperCase();
347
+ filtered = filtered.filter((e) => e.programs.some((p) => p.toUpperCase() === progUpper));
348
+ }
349
+
350
+ if (q) {
351
+ const queryNorm = normalize(q);
352
+ filtered = filtered.filter((e) => {
353
+ if (e.search.normalizedName.includes(queryNorm)) { return true; }
354
+ return e.search.aliases.some((a) => a.normalized.includes(queryNorm));
355
+ });
356
+ }
357
+
358
+ const total = filtered.length;
359
+ const results = filtered.slice(offset, offset + limit).map((e) => {
360
+ const { search: _search, ...entity } = e;
361
+ return entity;
362
+ });
363
+
364
+ return { total, offset, limit, results };
365
+ };
366
+
367
+ // --- Get entity by UID ---
368
+
369
+ const getEntity = (entries, uid) => {
370
+ const entry = entries.find((e) => e.uid === uid);
371
+ if (!entry) { return null; }
372
+ const { search: _search, ...entity } = entry;
373
+ return entity;
374
+ };
375
+
376
+ // --- Programs list ---
377
+
378
+ const listPrograms = (meta) => {
379
+ const programs = Object.entries(meta.programCounts)
380
+ .map(([name, count]) => ({ name, count }))
381
+ .sort((a, b) => b.count - a.count);
382
+ return { total: programs.length, programs };
383
+ };
384
+
385
+ // --- Stats ---
386
+
387
+ const buildStats = (entries, meta) => {
388
+ // Top countries by address
389
+ const countryCounts = {};
390
+ entries.forEach((e) => {
391
+ e.addresses.forEach((a) => {
392
+ if (a.country) {
393
+ countryCounts[a.country] = (countryCounts[a.country] || 0) + 1;
394
+ }
395
+ });
396
+ });
397
+
398
+ const topCountries = Object.entries(countryCounts)
399
+ .map(([country, count]) => ({ country, count }))
400
+ .sort((a, b) => b.count - a.count)
401
+ .slice(0, 25);
402
+
403
+ return {
404
+ publishDate: meta.publishDate,
405
+ buildDate: meta.buildDate,
406
+ totalEntries: meta.recordCount,
407
+ typeCounts: meta.typeCounts,
408
+ programCounts: meta.programCounts,
409
+ totalAliases: meta.aliasCount,
410
+ totalAddresses: meta.addressCount,
411
+ topCountries,
412
+ };
413
+ };
414
+
415
+ export {
416
+ screenName,
417
+ searchEntries,
418
+ getEntity,
419
+ listPrograms,
420
+ buildStats,
421
+ normalize,
422
+ jaroWinkler,
423
+ classifyMatch,
424
+ };
package/src/stripe.js ADDED
@@ -0,0 +1,91 @@
1
+ import Stripe from 'stripe';
2
+ import { createKey, revokeKey, loadKeys } from './keys.js';
3
+
4
+ const PRICE_TO_PLAN = {
5
+ 'price_1TBIexGsl682g3KoUHEuvqD7': 'starter',
6
+ 'price_1TBIfJGsl682g3KoAx498iww': 'pro',
7
+ 'price_1TBIfcGsl682g3Ko3hl50Z4O': 'business',
8
+ 'price_1TBIg0Gsl682g3KoxvKYGNYK': 'enterprise',
9
+ };
10
+
11
+ const PLAN_TO_PRICE = Object.fromEntries(
12
+ Object.entries(PRICE_TO_PLAN).map(([k, v]) => [v, k]),
13
+ );
14
+
15
+ const getStripe = () => {
16
+ const key = process.env.STRIPE_SECRET_KEY;
17
+ if (!key) { return null; }
18
+ return new Stripe(key);
19
+ };
20
+
21
+ // Create a Stripe Checkout session for a plan
22
+ const createCheckoutSession = async (plan, successUrl, cancelUrl) => {
23
+ const stripe = getStripe();
24
+ if (!stripe) { throw new Error('Stripe not configured'); }
25
+
26
+ const priceId = PLAN_TO_PRICE[plan];
27
+ if (!priceId) { throw new Error(`Invalid plan: ${plan}`); }
28
+
29
+ const session = await stripe.checkout.sessions.create({
30
+ mode: 'subscription',
31
+ payment_method_types: ['card'],
32
+ line_items: [{ price: priceId, quantity: 1 }],
33
+ success_url: successUrl || 'https://example.com/success?session_id={CHECKOUT_SESSION_ID}',
34
+ cancel_url: cancelUrl || 'https://example.com/cancel',
35
+ metadata: { plan },
36
+ });
37
+
38
+ return { url: session.url, sessionId: session.id };
39
+ };
40
+
41
+ // Handle Stripe webhook events
42
+ const handleWebhook = (rawBody, signature) => {
43
+ const stripe = getStripe();
44
+ const webhookSecret = process.env.STRIPE_WEBHOOK_SECRET;
45
+
46
+ let event;
47
+ if (stripe && webhookSecret && signature) {
48
+ event = stripe.webhooks.constructEvent(rawBody, signature, webhookSecret);
49
+ } else {
50
+ event = JSON.parse(rawBody);
51
+ }
52
+
53
+ const handlers = {
54
+ 'checkout.session.completed': (session) => {
55
+ const email = session.customer_details?.email;
56
+ const plan = session.metadata?.plan || 'pro';
57
+ const stripeCustomerId = session.customer;
58
+ const result = createKey(plan, email, stripeCustomerId);
59
+ console.log(`[stripe] Key created for ${email} (${plan}): ${result.apiKey}`);
60
+ return { apiKey: result.apiKey, plan, email };
61
+ },
62
+
63
+ 'customer.subscription.updated': (subscription) => {
64
+ const priceId = subscription.items?.data?.[0]?.price?.id;
65
+ const newPlan = PRICE_TO_PLAN[priceId];
66
+ if (newPlan) {
67
+ console.log(`[stripe] Subscription updated to ${newPlan} for ${subscription.customer}`);
68
+ }
69
+ return { action: 'subscription_updated', plan: newPlan };
70
+ },
71
+
72
+ 'customer.subscription.deleted': (subscription) => {
73
+ const keys = loadKeys();
74
+ const match = Object.entries(keys).find(
75
+ ([, v]) => v.stripeCustomerId === subscription.customer && v.active,
76
+ );
77
+ if (match) {
78
+ revokeKey(match[0]);
79
+ console.log(`[stripe] Key revoked for customer ${subscription.customer}`);
80
+ return { action: 'key_revoked', customer: subscription.customer };
81
+ }
82
+ return { action: 'no_matching_key' };
83
+ },
84
+ };
85
+
86
+ const handler = handlers[event.type];
87
+ if (handler) { return handler(event.data.object); }
88
+ return { action: 'ignored', type: event.type };
89
+ };
90
+
91
+ export { createCheckoutSession, handleWebhook, PLAN_TO_PRICE, PRICE_TO_PLAN };