@opencard-dev/mcp-server 0.1.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/tools.js ADDED
@@ -0,0 +1,1199 @@
1
+ "use strict";
2
+ /**
3
+ * OpenCard MCP Tool Definitions and Handlers
4
+ * ==================================== * Defines the MCP tool schemas and wires each tool to the real
5
+ * @opencard/core SDK so AI agents can manage virtual cards via MCP.
6
+ *
7
+ * ─── Architecture ────────────────────────────────────────────────────────────
8
+ *
9
+ * Each tool follows this pattern:
10
+ * 1. Extract + validate input args
11
+ * 2. Initialize StripeClient with the API key from env
12
+ * 3. Call the appropriate SDK method
13
+ * 4. Return a structured result or error object (never throw)
14
+ *
15
+ * ─── Error handling ──────────────────────────────────────────────────────────
16
+ *
17
+ * All handlers catch errors and return `{ status: 'error', message }` rather
18
+ * than throwing. This prevents the MCP server from crashing on Stripe API
19
+ * errors, network failures, or bad input. The caller decides what to do with
20
+ * the error.
21
+ *
22
+ * ─── Stripe key ──────────────────────────────────────────────────────────────
23
+ *
24
+ * The StripeClient reads STRIPE_SECRET_KEY from the environment by default.
25
+ * Callers can also pass it explicitly via the `stripe_secret_key` arg (useful
26
+ * when the MCP server is shared across multiple callers with different keys).
27
+ */
28
+ var __importDefault = (this && this.__importDefault) || function (mod) {
29
+ return (mod && mod.__esModule) ? mod : { "default": mod };
30
+ };
31
+ Object.defineProperty(exports, "__esModule", { value: true });
32
+ exports.openCardTools = void 0;
33
+ exports.createToolHandler = createToolHandler;
34
+ const core_1 = require("@opencard-dev/core");
35
+ const stripe_1 = __importDefault(require("stripe"));
36
+ // ─── Tool schemas ─────────────────────────────────────────────────────────────
37
+ /**
38
+ * MCP Tool definition for OpenCard.
39
+ * These schemas are consumed by MCP-compatible agents to discover available tools.
40
+ */
41
+ exports.openCardTools = [
42
+ {
43
+ name: 'opencard_create_card',
44
+ description: 'Use when setting up a new agent that needs to make purchases. Creates a virtual Stripe Issuing card with spending rules attached. Requires an existing cardholder ID — if you don\'t have one, tell the human operator to create one via the Stripe dashboard or CLI first. This hits the Stripe API.',
45
+ inputSchema: {
46
+ type: 'object',
47
+ properties: {
48
+ agent_name: {
49
+ type: 'string',
50
+ description: 'Name of the agent this card is for (e.g., research-bot, shopping-agent)',
51
+ },
52
+ cardholder_id: {
53
+ type: 'string',
54
+ description: 'Stripe cardholder ID (must exist)',
55
+ },
56
+ rule_template: {
57
+ type: 'string',
58
+ enum: ['balanced', 'category_locked', 'daily_limit', 'approval_gated'],
59
+ description: 'Pre-built rule template to apply',
60
+ },
61
+ daily_limit_cents: {
62
+ type: 'number',
63
+ description: 'Daily spending limit in CENTS (e.g., 2500 = $25.00, 100000 = $1,000.00). NOT dollars.',
64
+ },
65
+ max_per_transaction_cents: {
66
+ type: 'number',
67
+ description: 'Per-transaction limit in CENTS (e.g., 2500 = $25.00). NOT dollars.',
68
+ },
69
+ description: {
70
+ type: 'string',
71
+ description: "What this card is for (e.g., 'Engineering SaaS subscriptions — Vercel, AWS, Anthropic'). Helps agents pick the right card for each purchase.",
72
+ },
73
+ metadata: {
74
+ type: 'object',
75
+ description: 'Custom metadata (tags, project ID, etc)',
76
+ },
77
+ },
78
+ required: ['agent_name', 'cardholder_id'],
79
+ },
80
+ },
81
+ {
82
+ name: 'opencard_get_balance',
83
+ description: 'Get spending totals and remaining budget for a card. Shows today\'s spend, monthly spend, and remaining amounts based on the card\'s rules. Call this to check if there\'s enough budget before making a purchase.',
84
+ inputSchema: {
85
+ type: 'object',
86
+ properties: {
87
+ card_id: {
88
+ type: 'string',
89
+ description: 'Stripe card ID',
90
+ },
91
+ },
92
+ required: ['card_id'],
93
+ },
94
+ },
95
+ {
96
+ name: 'opencard_get_transactions',
97
+ description: 'Use for expense reporting, debugging declined charges, or reviewing what was purchased. Returns transaction history for a specific card. Supports filtering by status. For real-time spend totals, use get_balance instead.',
98
+ inputSchema: {
99
+ type: 'object',
100
+ properties: {
101
+ card_id: {
102
+ type: 'string',
103
+ description: 'Stripe card ID',
104
+ },
105
+ limit: {
106
+ type: 'number',
107
+ description: 'Max transactions to return (default 50)',
108
+ },
109
+ status: {
110
+ type: 'string',
111
+ enum: ['approved', 'declined', 'pending', 'captured', 'reversed'],
112
+ description: 'Filter by transaction status',
113
+ },
114
+ },
115
+ required: ['card_id'],
116
+ },
117
+ },
118
+ {
119
+ name: 'opencard_pause_card',
120
+ description: 'Temporarily freeze a card when something is wrong — overspending, suspicious activity, or you need to investigate. All new charges are declined immediately. The card is NOT canceled — use resume_card to re-enable it later.',
121
+ inputSchema: {
122
+ type: 'object',
123
+ properties: {
124
+ card_id: {
125
+ type: 'string',
126
+ description: 'Stripe card ID',
127
+ },
128
+ reason: {
129
+ type: 'string',
130
+ description: 'Optional reason for pausing',
131
+ },
132
+ },
133
+ required: ['card_id'],
134
+ },
135
+ },
136
+ {
137
+ name: 'opencard_resume_card',
138
+ description: 'Re-enable a card that was paused. Only works on paused (inactive) cards, not canceled ones. Call this after resolving whatever caused the pause.',
139
+ inputSchema: {
140
+ type: 'object',
141
+ properties: {
142
+ card_id: {
143
+ type: 'string',
144
+ description: 'Stripe card ID',
145
+ },
146
+ },
147
+ required: ['card_id'],
148
+ },
149
+ },
150
+ {
151
+ name: 'opencard_set_limits',
152
+ description: 'Update spending limits on a card when budget needs change. Sets hard limits enforced by Stripe directly (these work even if the OpenCard webhook server is down). Currently only "decline" mode is active (alert/pause modes are planned). Amounts are in CENTS.',
153
+ inputSchema: {
154
+ type: 'object',
155
+ properties: {
156
+ card_id: {
157
+ type: 'string',
158
+ description: 'Stripe card ID',
159
+ },
160
+ daily_limit_cents: {
161
+ type: 'number',
162
+ description: 'Daily limit in CENTS (e.g., 2500 = $25.00, 100000 = $1,000.00). NOT dollars.',
163
+ },
164
+ monthly_limit_cents: {
165
+ type: 'number',
166
+ description: 'Monthly limit in CENTS (e.g., 2500 = $25.00, 100000 = $1,000.00). NOT dollars.',
167
+ },
168
+ per_tx_limit_cents: {
169
+ type: 'number',
170
+ description: 'Per-transaction limit in CENTS (e.g., 2500 = $25.00). NOT dollars.',
171
+ },
172
+ },
173
+ required: ['card_id'],
174
+ },
175
+ },
176
+ {
177
+ name: 'opencard_get_card_details',
178
+ description: 'Get full details for a specific card when you need more than what list_cards provides — metadata, spending controls, Stripe-level config. For just checking budget, use get_balance instead.',
179
+ inputSchema: {
180
+ type: 'object',
181
+ properties: {
182
+ card_id: {
183
+ type: 'string',
184
+ description: 'Stripe card ID',
185
+ },
186
+ },
187
+ required: ['card_id'],
188
+ },
189
+ },
190
+ {
191
+ name: 'opencard_list_cards',
192
+ description: 'Call this FIRST when you need to make a purchase or check spending — find the right card before doing anything else. Returns all cards with their rules, current spend, and remaining budget. Don\'t hardcode card IDs; always discover them here.',
193
+ inputSchema: {
194
+ type: 'object',
195
+ properties: {
196
+ status: {
197
+ type: 'string',
198
+ enum: ['active', 'inactive', 'canceled'],
199
+ description: 'Filter by card status (default: active)',
200
+ },
201
+ limit: {
202
+ type: 'number',
203
+ description: 'Max cards to return (default 20, max 100)',
204
+ },
205
+ },
206
+ required: [],
207
+ },
208
+ },
209
+ {
210
+ name: 'opencard_check_rules',
211
+ description: 'Call this before attempting any purchase to verify it will be approved. Dry-runs the charge against the card\'s rules locally — no real charge, no Stripe API call, instant response. Returns detailed breakdown of which checks passed or failed. Amounts are in CENTS (2500 = $25.00). NOTE: rule_id is required when multiple rules exist in the store; optional only if a single rule is configured.',
212
+ inputSchema: {
213
+ type: 'object',
214
+ properties: {
215
+ card_id: {
216
+ type: 'string',
217
+ description: 'Stripe card ID to check against',
218
+ },
219
+ amount_cents: {
220
+ type: 'number',
221
+ description: 'Amount in CENTS (e.g., 2500 = $25.00, 100000 = $1,000.00). NOT dollars.',
222
+ },
223
+ merchant_category: {
224
+ type: 'string',
225
+ description: 'MCC category code (e.g., "software", "restaurants")',
226
+ },
227
+ merchant_name: {
228
+ type: 'string',
229
+ description: 'Merchant name (for logging/context only)',
230
+ },
231
+ rule_id: {
232
+ type: 'string',
233
+ description: 'OpenCard rule ID. Required when multiple rules exist in the store. Optional only if a single rule is configured.',
234
+ },
235
+ },
236
+ required: ['card_id', 'amount_cents'],
237
+ },
238
+ },
239
+ {
240
+ name: 'opencard_request_approval',
241
+ description: 'Request human approval when you need to make a purchase that exceeds your normal limits or is unusual. Blocks until the human approves, denies, or the request times out (default 5 minutes). You MUST provide a reason — the human sees it. Approval gives you PERMISSION only — it does not execute the purchase. Amounts are in CENTS.',
242
+ inputSchema: {
243
+ type: 'object',
244
+ properties: {
245
+ card_id: {
246
+ type: 'string',
247
+ description: 'Card to charge',
248
+ },
249
+ amount_cents: {
250
+ type: 'number',
251
+ description: 'Requested amount in CENTS (e.g., 2500 = $25.00, 100000 = $1,000.00). NOT dollars.',
252
+ },
253
+ merchant_name: {
254
+ type: 'string',
255
+ description: 'Where the money is going',
256
+ },
257
+ merchant_category: {
258
+ type: 'string',
259
+ description: 'MCC category (optional)',
260
+ },
261
+ reason: {
262
+ type: 'string',
263
+ description: 'Why the agent needs this purchase (shown to human)',
264
+ },
265
+ timeout_seconds: {
266
+ type: 'number',
267
+ description: 'How long to wait for approval in seconds (default: 300)',
268
+ },
269
+ },
270
+ required: ['card_id', 'amount_cents', 'merchant_name', 'reason'],
271
+ },
272
+ },
273
+ ];
274
+ // ─── Tool handlers ────────────────────────────────────────────────────────────
275
+ /**
276
+ * Routes an MCP tool call to the appropriate handler and returns the result.
277
+ *
278
+ * All handlers are async and catch their own errors — this function will
279
+ * always return a result object, never throw. Unknown tool names return an
280
+ * error result rather than crashing.
281
+ *
282
+ * @param toolName - The MCP tool name (e.g. 'opencard_create_card')
283
+ * @param args - The tool's input arguments from the MCP request
284
+ * @returns A result object. Structure varies per tool; always includes `status`.
285
+ */
286
+ async function createToolHandler(toolName, args) {
287
+ switch (toolName) {
288
+ case 'opencard_create_card':
289
+ return handleCreateCard(args);
290
+ case 'opencard_get_balance':
291
+ return handleGetBalance(args);
292
+ case 'opencard_get_transactions':
293
+ return handleGetTransactions(args);
294
+ case 'opencard_pause_card':
295
+ return handlePauseCard(args);
296
+ case 'opencard_resume_card':
297
+ return handleResumeCard(args);
298
+ case 'opencard_set_limits':
299
+ return handleSetLimits(args);
300
+ case 'opencard_get_card_details':
301
+ return handleGetCardDetails(args);
302
+ case 'opencard_check_rules':
303
+ return handleCheckRules(args);
304
+ case 'opencard_list_cards':
305
+ return handleListCards(args);
306
+ case 'opencard_request_approval':
307
+ return handleRequestApproval(args);
308
+ default:
309
+ return {
310
+ status: 'error',
311
+ message: `Unknown tool: ${toolName}`,
312
+ };
313
+ }
314
+ }
315
+ // ─── Individual handlers ──────────────────────────────────────────────────────
316
+ /**
317
+ * opencard_create_card
318
+ * Creates a new virtual card under the specified cardholder.
319
+ * Optionally applies a spend-rule template and custom limit overrides.
320
+ */
321
+ async function handleCreateCard(args) {
322
+ try {
323
+ const agentName = args.agent_name;
324
+ const cardholderId = args.cardholder_id;
325
+ if (!agentName || !cardholderId) {
326
+ return { status: 'error', message: 'agent_name and cardholder_id are required' };
327
+ }
328
+ // Build spend rules from optional template + overrides
329
+ let rulesBuilder = args.rule_template
330
+ ? core_1.SpendRules.template(args.rule_template)
331
+ : new core_1.SpendRules();
332
+ if (typeof args.daily_limit_cents === 'number') {
333
+ rulesBuilder = rulesBuilder.dailyLimit(args.daily_limit_cents);
334
+ }
335
+ if (typeof args.max_per_transaction_cents === 'number') {
336
+ rulesBuilder = rulesBuilder.maxPerTx(args.max_per_transaction_cents);
337
+ }
338
+ const rules = rulesBuilder.build();
339
+ const client = new core_1.StripeClient();
340
+ const card = await client.createCard(cardholderId, {
341
+ agentName,
342
+ rules,
343
+ description: args.description || undefined,
344
+ metadata: args.metadata || {},
345
+ });
346
+ return {
347
+ status: 'success',
348
+ card: {
349
+ id: card.id,
350
+ last4: card.last4,
351
+ expiry: card.expiry,
352
+ status: card.status,
353
+ agentName: card.agentName,
354
+ description: card.description,
355
+ rules: card.rules,
356
+ createdAt: card.createdAt,
357
+ },
358
+ };
359
+ }
360
+ catch (err) {
361
+ return { status: 'error', message: String(err) };
362
+ }
363
+ }
364
+ /**
365
+ * Formats a timestamp as a human-readable relative time string (e.g. "2 hours ago").
366
+ */
367
+ function formatRelativeTime(dateStr) {
368
+ if (!dateStr)
369
+ return 'unknown';
370
+ const date = new Date(dateStr);
371
+ const now = new Date();
372
+ const diffMs = now.getTime() - date.getTime();
373
+ const diffMins = Math.floor(diffMs / 60000);
374
+ if (diffMins < 1)
375
+ return 'just now';
376
+ if (diffMins < 60)
377
+ return `${diffMins} minute${diffMins === 1 ? '' : 's'} ago`;
378
+ const diffHours = Math.floor(diffMins / 60);
379
+ if (diffHours < 24)
380
+ return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`;
381
+ const diffDays = Math.floor(diffHours / 24);
382
+ return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
383
+ }
384
+ /**
385
+ * opencard_get_balance
386
+ * Returns per-card spend totals from SQLite (authorizations + transactions),
387
+ * enriched with rule limits from RulesStore. Falls back to in-memory tracker
388
+ * if SQLite is unavailable.
389
+ *
390
+ * R1: Query SQLite for daily/monthly authorizations and all-time transactions
391
+ * R2: New response shape with spend, limits, remaining, recent_transactions
392
+ * R3: Fallback to in-memory tracker if SQLite unavailable
393
+ * R4: Look up rule limits from RulesStore via card metadata opencard_rule_id
394
+ * R5: No more account-level available/reserved/spent fields
395
+ * R6: Updated tool description (see schema above)
396
+ */
397
+ async function handleGetBalance(args) {
398
+ try {
399
+ const cardId = args.card_id;
400
+ if (!cardId) {
401
+ return { status: 'error', message: 'card_id is required' };
402
+ }
403
+ // ── R4: Fetch card metadata to get rule ID and limits ────────────────
404
+ const secretKey = process.env.STRIPE_SECRET_KEY;
405
+ let ruleLimits = {
406
+ daily_limit_cents: null,
407
+ monthly_limit_cents: null,
408
+ max_per_transaction_cents: null,
409
+ };
410
+ if (secretKey) {
411
+ try {
412
+ const stripe = new stripe_1.default(secretKey, { apiVersion: '2023-10-16' });
413
+ const stripeCard = await stripe.issuing.cards.retrieve(cardId);
414
+ const metadata = (stripeCard.metadata ?? {});
415
+ const ruleId = metadata.opencard_rule_id ?? null;
416
+ if (ruleId) {
417
+ const rule = await core_1.rulesStore.getRule(ruleId);
418
+ if (rule) {
419
+ ruleLimits = {
420
+ daily_limit_cents: rule.dailyLimit ?? null,
421
+ monthly_limit_cents: rule.monthlyLimit ?? null,
422
+ max_per_transaction_cents: rule.maxPerTransaction ?? null,
423
+ };
424
+ }
425
+ }
426
+ }
427
+ catch {
428
+ // Card fetch or rule lookup failed — proceed with null limits
429
+ }
430
+ }
431
+ // ── R1: Query SQLite for spend totals ────────────────────────────────
432
+ let db = null;
433
+ try {
434
+ db = (0, core_1.initDatabase)();
435
+ }
436
+ catch {
437
+ // SQLite unavailable — will fall back to in-memory tracker (R3)
438
+ }
439
+ if (db) {
440
+ // SQLite is available — query authorizations and transactions
441
+ try {
442
+ const rawDb = db.getDb();
443
+ // Spend from authorizations table with status='approved' for real-time budget enforcement.
444
+ // We use authorizations (not transactions) because:
445
+ // - authorizations are real-time and status reflects rule enforcement decisions
446
+ // - transactions are a subset of authorizations that were captured by Stripe
447
+ // - using the same table ensures budget enforcement and reporting show the same spend totals
448
+ // - this matches CLI status and MCP list_cards for consistency
449
+ // Daily spend: approved authorizations from midnight UTC today
450
+ const dailyResult = rawDb
451
+ .prepare("SELECT COALESCE(SUM(amount), 0) as total FROM authorizations WHERE card_id = ? AND status = 'approved' AND created_at >= date('now')")
452
+ .get(cardId);
453
+ // Monthly spend: approved authorizations from 1st of current month UTC
454
+ const monthlyResult = rawDb
455
+ .prepare("SELECT COALESCE(SUM(amount), 0) as total FROM authorizations WHERE card_id = ? AND status = 'approved' AND created_at >= strftime('%Y-%m-01', 'now')")
456
+ .get(cardId);
457
+ // All-time spend: captured transactions
458
+ const allTimeResult = rawDb
459
+ .prepare("SELECT COALESCE(SUM(amount), 0) as total FROM transactions WHERE card_id = ? AND status = 'captured'")
460
+ .get(cardId);
461
+ // Recent transactions (last 5 captured, newest first)
462
+ const recentRows = rawDb
463
+ .prepare("SELECT amount, merchant_name, merchant_category, stripe_created_at FROM transactions WHERE card_id = ? AND status = 'captured' ORDER BY stripe_created_at DESC LIMIT 5")
464
+ .all(cardId);
465
+ const todayCents = dailyResult.total;
466
+ const thisMonthCents = monthlyResult.total;
467
+ const allTimeCents = allTimeResult.total;
468
+ const recentTransactions = recentRows.map((row) => ({
469
+ amount_cents: row.amount,
470
+ merchant: row.merchant_name ?? 'Unknown',
471
+ category: row.merchant_category ?? 'unknown',
472
+ when: formatRelativeTime(row.stripe_created_at),
473
+ }));
474
+ const result = {
475
+ status: 'success',
476
+ card_id: cardId,
477
+ spend: {
478
+ today_cents: todayCents,
479
+ this_month_cents: thisMonthCents,
480
+ all_time_cents: allTimeCents,
481
+ },
482
+ limits: {
483
+ daily_limit_cents: ruleLimits.daily_limit_cents,
484
+ monthly_limit_cents: ruleLimits.monthly_limit_cents,
485
+ max_per_transaction_cents: ruleLimits.max_per_transaction_cents,
486
+ },
487
+ remaining: {
488
+ today_cents: ruleLimits.daily_limit_cents !== null
489
+ ? Math.max(0, ruleLimits.daily_limit_cents - todayCents)
490
+ : null,
491
+ this_month_cents: ruleLimits.monthly_limit_cents !== null
492
+ ? Math.max(0, ruleLimits.monthly_limit_cents - thisMonthCents)
493
+ : null,
494
+ },
495
+ recent_transactions: recentTransactions,
496
+ data_source: 'sqlite',
497
+ };
498
+ db.close();
499
+ return result;
500
+ }
501
+ catch (dbErr) {
502
+ // Query failed — fall through to in-memory fallback
503
+ try {
504
+ db.close();
505
+ }
506
+ catch { /* ignore */ }
507
+ db = null;
508
+ }
509
+ }
510
+ // ── R3: Fallback to in-memory tracker ────────────────────────────────
511
+ const trackerInstance = new core_1.TransactionTracker();
512
+ const todayCents = trackerInstance.getDailySpend(cardId);
513
+ const thisMonthCents = trackerInstance.getMonthlySpend(cardId);
514
+ return {
515
+ status: 'success',
516
+ card_id: cardId,
517
+ spend: {
518
+ today_cents: todayCents,
519
+ this_month_cents: thisMonthCents,
520
+ all_time_cents: null, // Not available in in-memory tracker
521
+ },
522
+ limits: {
523
+ daily_limit_cents: ruleLimits.daily_limit_cents,
524
+ monthly_limit_cents: ruleLimits.monthly_limit_cents,
525
+ max_per_transaction_cents: ruleLimits.max_per_transaction_cents,
526
+ },
527
+ remaining: {
528
+ today_cents: ruleLimits.daily_limit_cents !== null
529
+ ? Math.max(0, ruleLimits.daily_limit_cents - todayCents)
530
+ : null,
531
+ this_month_cents: ruleLimits.monthly_limit_cents !== null
532
+ ? Math.max(0, ruleLimits.monthly_limit_cents - thisMonthCents)
533
+ : null,
534
+ },
535
+ recent_transactions: [],
536
+ data_source: 'in_memory',
537
+ warning: 'SQLite database unavailable. Spend data is from in-memory tracker only and will reset on server restart.',
538
+ };
539
+ }
540
+ catch (err) {
541
+ return { status: 'error', message: String(err) };
542
+ }
543
+ }
544
+ /**
545
+ * opencard_get_transactions
546
+ * Retrieves the transaction history for a card from Stripe Issuing.
547
+ * Note: These are captured/settled transactions, not pending authorizations.
548
+ * Pending authorizations are handled via webhooks in real time.
549
+ */
550
+ async function handleGetTransactions(args) {
551
+ try {
552
+ const cardId = args.card_id;
553
+ if (!cardId) {
554
+ return { status: 'error', message: 'card_id is required' };
555
+ }
556
+ const client = new core_1.StripeClient();
557
+ const transactions = await client.getTransactions(cardId, {
558
+ limit: typeof args.limit === 'number' ? args.limit : 50,
559
+ });
560
+ // Optionally filter by status if the caller asked for a specific status
561
+ const statusFilter = args.status;
562
+ const filtered = statusFilter
563
+ ? transactions.filter((tx) => tx.status === statusFilter)
564
+ : transactions;
565
+ return {
566
+ status: 'success',
567
+ card_id: cardId,
568
+ count: filtered.length,
569
+ transactions: filtered.map((tx) => ({
570
+ id: tx.id,
571
+ amount: tx.amount,
572
+ currency: tx.currency,
573
+ merchant_name: tx.merchant.name,
574
+ merchant_category: tx.merchant.category,
575
+ status: tx.status,
576
+ created_at: tx.createdAt,
577
+ })),
578
+ };
579
+ }
580
+ catch (err) {
581
+ return { status: 'error', message: String(err) };
582
+ }
583
+ }
584
+ /**
585
+ * opencard_pause_card
586
+ * Sets the card status to 'inactive', blocking all new charges.
587
+ * The card can be re-enabled via opencard_resume_card.
588
+ * Useful for temporarily suspending an agent that has exceeded its budget.
589
+ */
590
+ async function handlePauseCard(args) {
591
+ try {
592
+ const cardId = args.card_id;
593
+ if (!cardId) {
594
+ return { status: 'error', message: 'card_id is required' };
595
+ }
596
+ const client = new core_1.StripeClient();
597
+ const card = await client.pauseCard(cardId);
598
+ return {
599
+ status: 'success',
600
+ card_id: card.id,
601
+ new_status: card.status,
602
+ reason: args.reason ?? null,
603
+ message: `Card ${card.id} paused. All new charges will be declined until resumed.`,
604
+ };
605
+ }
606
+ catch (err) {
607
+ return { status: 'error', message: String(err) };
608
+ }
609
+ }
610
+ /**
611
+ * opencard_resume_card
612
+ * Sets the card status back to 'active', re-enabling new charges.
613
+ * Only works on cards that were paused (inactive), not canceled ones.
614
+ */
615
+ async function handleResumeCard(args) {
616
+ try {
617
+ const cardId = args.card_id;
618
+ if (!cardId) {
619
+ return { status: 'error', message: 'card_id is required' };
620
+ }
621
+ const client = new core_1.StripeClient();
622
+ const card = await client.resumeCard(cardId);
623
+ return {
624
+ status: 'success',
625
+ card_id: card.id,
626
+ new_status: card.status,
627
+ message: `Card ${card.id} resumed. Charges are now accepted again.`,
628
+ };
629
+ }
630
+ catch (err) {
631
+ return { status: 'error', message: String(err) };
632
+ }
633
+ }
634
+ /**
635
+ * opencard_set_limits
636
+ * Updates Stripe-native spending limits on the card.
637
+ * These limits are enforced by Stripe regardless of whether the OpenCard
638
+ * webhook server is running — they're the hard floor under our soft rules.
639
+ *
640
+ * Accepts any combination of daily, monthly, and per-transaction limits.
641
+ * At least one limit must be specified.
642
+ */
643
+ async function handleSetLimits(args) {
644
+ try {
645
+ const cardId = args.card_id;
646
+ if (!cardId) {
647
+ return { status: 'error', message: 'card_id is required' };
648
+ }
649
+ // Build the spending limits array from whichever limits were provided.
650
+ // Stripe requires at least one limit; we return an error if none given.
651
+ const limits = [];
652
+ if (typeof args.daily_limit_cents === 'number') {
653
+ limits.push({ amount: args.daily_limit_cents, intervalType: 'daily', intervalCurrencyUnit: 'usd' });
654
+ }
655
+ if (typeof args.monthly_limit_cents === 'number') {
656
+ limits.push({ amount: args.monthly_limit_cents, intervalType: 'monthly', intervalCurrencyUnit: 'usd' });
657
+ }
658
+ if (typeof args.per_tx_limit_cents === 'number') {
659
+ limits.push({ amount: args.per_tx_limit_cents, intervalType: 'per_authorization', intervalCurrencyUnit: 'usd' });
660
+ }
661
+ if (limits.length === 0) {
662
+ return {
663
+ status: 'error',
664
+ message: 'At least one of daily_limit_cents, monthly_limit_cents, or per_tx_limit_cents must be specified',
665
+ };
666
+ }
667
+ const client = new core_1.StripeClient();
668
+ const card = await client.setSpendingLimits(cardId, limits);
669
+ return {
670
+ status: 'success',
671
+ card_id: card.id,
672
+ limits_applied: limits,
673
+ spending_controls: card.spendingControls,
674
+ message: `Spending limits updated on card ${card.id}.`,
675
+ };
676
+ }
677
+ catch (err) {
678
+ return { status: 'error', message: String(err) };
679
+ }
680
+ }
681
+ /**
682
+ * opencard_get_card_details
683
+ * Retrieves full card details from Stripe including status, expiry,
684
+ * agent name, applied rules, and spending controls.
685
+ */
686
+ async function handleGetCardDetails(args) {
687
+ try {
688
+ const cardId = args.card_id;
689
+ if (!cardId) {
690
+ return { status: 'error', message: 'card_id is required' };
691
+ }
692
+ const client = new core_1.StripeClient();
693
+ const card = await client.getCard(cardId);
694
+ return {
695
+ status: 'success',
696
+ card: {
697
+ id: card.id,
698
+ last4: card.last4,
699
+ brand: card.brand,
700
+ expiry: card.expiry,
701
+ status: card.status,
702
+ cardholder_id: card.cardholderId,
703
+ agent_name: card.agentName,
704
+ description: card.description,
705
+ rules: card.rules,
706
+ spending_controls: card.spendingControls,
707
+ metadata: card.metadata,
708
+ created_at: card.createdAt,
709
+ },
710
+ };
711
+ }
712
+ catch (err) {
713
+ return { status: 'error', message: String(err) };
714
+ }
715
+ }
716
+ /**
717
+
718
+ * opencard_list_cards
719
+ * Lists all cards from Stripe Issuing, enriched with rule details and
720
+ * best-effort spend data from SQLite. Designed for agent discovery — call
721
+ * this first to find the right card ID before any other operation.
722
+ *
723
+ * Spend data is pulled from the local SQLite DB (populated by the webhook
724
+ * server). If the DB is unavailable (hasn't been set up yet), spend is
725
+ * returned as null — the card list still works fine without it.
726
+ *
727
+ * Rule data is pulled from RulesStore using the opencard_rule_id stored in
728
+ * each card's Stripe metadata. If no rule is assigned, rule is null.
729
+ */
730
+ async function handleListCards(args) {
731
+ try {
732
+ const statusFilter = args.status ?? 'active';
733
+ const limit = typeof args.limit === 'number' ? Math.min(args.limit, 100) : 20;
734
+ // ── Fetch cards from Stripe ────────────────────────────────────────────
735
+ const secretKey = process.env.STRIPE_SECRET_KEY;
736
+ if (!secretKey) {
737
+ return { status: 'error', message: 'STRIPE_SECRET_KEY must be set' };
738
+ }
739
+ const stripe = new stripe_1.default(secretKey, { apiVersion: '2023-10-16' });
740
+ const stripeCards = await stripe.issuing.cards.list({
741
+ status: statusFilter,
742
+ limit,
743
+ });
744
+ // ── Best-effort SQLite spend lookup ───────────────────────────────────
745
+ // Open DB once for all cards; if it throws, spend will be null for all.
746
+ let db = null;
747
+ try {
748
+ db = (0, core_1.initDatabase)();
749
+ }
750
+ catch {
751
+ // DB unavailable — spend will be null for all cards (per R3)
752
+ }
753
+ // Helper: compute today_cents and this_month_cents for a card from SQLite.
754
+ // Returns null if DB is not available.
755
+ // Uses authorizations table with status='approved' for consistency with get_balance.
756
+ // We use authorizations (not transactions) because:
757
+ // - authorizations are real-time and status reflects rule enforcement decisions
758
+ // - transactions are a subset of authorizations that were captured by Stripe
759
+ // - using the same table ensures budget enforcement and reporting show the same spend totals
760
+ function getSpendFromDb(cardId) {
761
+ if (!db)
762
+ return null;
763
+ try {
764
+ const rawDb = db.getDb();
765
+ // Daily spend: approved authorizations from midnight UTC today
766
+ const todayResult = rawDb
767
+ .prepare("SELECT COALESCE(SUM(amount), 0) as total FROM authorizations WHERE card_id = ? AND status = 'approved' AND created_at >= date('now')")
768
+ .get(cardId);
769
+ // Monthly spend: approved authorizations from 1st of current month UTC
770
+ const monthResult = rawDb
771
+ .prepare("SELECT COALESCE(SUM(amount), 0) as total FROM authorizations WHERE card_id = ? AND status = 'approved' AND created_at >= strftime('%Y-%m-01', 'now')")
772
+ .get(cardId);
773
+ return {
774
+ today_cents: todayResult.total,
775
+ this_month_cents: monthResult.total,
776
+ };
777
+ }
778
+ catch {
779
+ return null;
780
+ }
781
+ }
782
+ // ── Build enriched card list ───────────────────────────────────────────
783
+ const cards = await Promise.all(stripeCards.data.map(async (stripeCard) => {
784
+ const metadata = (stripeCard.metadata ?? {});
785
+ const agentName = metadata.agentName ?? metadata.agent_name ?? null;
786
+ // Rule lookup: read opencard_rule_id from metadata, fetch from RulesStore
787
+ const ruleId = metadata.opencard_rule_id ?? null;
788
+ let rule = null;
789
+ if (ruleId) {
790
+ try {
791
+ const spendRule = await core_1.rulesStore.getRule(ruleId);
792
+ if (spendRule) {
793
+ rule = {
794
+ id: ruleId,
795
+ name: spendRule.name,
796
+ daily_limit_cents: spendRule.dailyLimit,
797
+ monthly_limit_cents: spendRule.monthlyLimit,
798
+ max_per_transaction_cents: spendRule.maxPerTransaction,
799
+ };
800
+ }
801
+ }
802
+ catch {
803
+ // Rule store unavailable — return null for rule gracefully
804
+ }
805
+ }
806
+ // Spend from SQLite (best-effort, null if DB not available)
807
+ const spend = getSpendFromDb(stripeCard.id);
808
+ const description = metadata.opencard_description ?? null;
809
+ return {
810
+ id: stripeCard.id,
811
+ last4: stripeCard.last4,
812
+ name: agentName,
813
+ description,
814
+ status: stripeCard.status,
815
+ rule,
816
+ spend,
817
+ created_at: new Date(stripeCard.created * 1000).toISOString(),
818
+ };
819
+ }));
820
+ // Close DB when done
821
+ if (db) {
822
+ try {
823
+ db.close();
824
+ }
825
+ catch { /* ignore close errors */ }
826
+ }
827
+ return {
828
+ status: 'success',
829
+ count: cards.length,
830
+ cards,
831
+ };
832
+ }
833
+ catch (err) {
834
+ return { status: 'error', message: String(err) };
835
+ }
836
+ }
837
+ /**
838
+ * opencard_check_rules
839
+ * Dry-runs a purchase against the card's current rules without making
840
+ * any real charge or Stripe API calls. Uses:
841
+ * 1. RulesStore to look up the card's rule (via card metadata opencard_rule_id)
842
+ * 2. SQLite to get current daily/monthly spend (same queries as CLI status)
843
+ * 3. evaluateAuthorization from the rules engine for the decision
844
+ *
845
+ * Entirely local — zero Stripe API calls.
846
+ */
847
+ async function handleCheckRules(args) {
848
+ try {
849
+ const cardId = args.card_id;
850
+ const amountCents = args.amount_cents;
851
+ if (!cardId) {
852
+ return { status: 'error', message: 'card_id is required' };
853
+ }
854
+ if (typeof amountCents !== 'number' || amountCents < 0) {
855
+ return { status: 'error', message: 'amount_cents must be a non-negative number' };
856
+ }
857
+ const merchantCategory = args.merchant_category ?? '';
858
+ const merchantName = args.merchant_name ?? 'unknown merchant';
859
+ // ── Step 1: Look up the rule for this card ────────────────────────────────
860
+ // We need the card's opencard_rule_id to find the rule.
861
+ // Per the brief, we use getRulesForCard logic — but we don't have a
862
+ // Stripe.Issuing.Card object here without an API call. Instead, we accept
863
+ // either a rule_id passed directly, or we check the rules store.
864
+ // Per R3: no Stripe API calls. We'll use the rulesStore directly with
865
+ // the card_id as the rule lookup key — the card metadata mapping is only
866
+ // available via Stripe. We look up the first rule that lists this card_id,
867
+ // or fall back to reading card metadata from a local cache.
868
+ //
869
+ // Practical approach: try to load rule by treating card_id as a potential
870
+ // rule_id lookup key, or query RulesStore.listRules() to find one
871
+ // matching this card. If not found, return default-deny.
872
+ //
873
+ // For maximum usefulness without a Stripe call, we support:
874
+ // - A card_rule_id arg (optional) to skip the card lookup
875
+ // - Fallback: search rules store for a rule with card_id matching this card
876
+ // - Final fallback: no rule found → default-deny with explanation
877
+ let rule = null;
878
+ let ruleName = 'unknown';
879
+ let ruleId = null;
880
+ // Check if caller provided an explicit rule ID override
881
+ const explicitRuleId = args.rule_id;
882
+ if (explicitRuleId) {
883
+ rule = await core_1.rulesStore.getRule(explicitRuleId);
884
+ if (rule) {
885
+ ruleId = explicitRuleId;
886
+ ruleName = rule.name ?? explicitRuleId;
887
+ }
888
+ }
889
+ if (!rule) {
890
+ // Search the rules store for a rule associated with this card_id
891
+ const allRules = await core_1.rulesStore.listRules();
892
+ // Rules don't store card_id in the JSON file — they're referenced via
893
+ // Stripe card metadata. Without a Stripe call we can't resolve
894
+ // card → rule_id. Instead, if there's exactly one rule, use it as a
895
+ // best-effort heuristic (useful in dev/single-card setups).
896
+ // In production with multiple cards, the agent should pass rule_id explicitly.
897
+ if (allRules.length === 0) {
898
+ // No rules at all
899
+ return {
900
+ status: 'error',
901
+ message: 'No rules found in the store. Create a rule first using opencard_set_limits or rules store API.',
902
+ };
903
+ }
904
+ else if (allRules.length === 1) {
905
+ rule = allRules[0].rule;
906
+ ruleId = allRules[0].id;
907
+ ruleName = rule.name ?? ruleId;
908
+ }
909
+ else {
910
+ // Multiple rules exist and no rule_id was provided
911
+ const ruleList = allRules
912
+ .map((r) => `${r.id} (${r.rule.name || 'unnamed'})`)
913
+ .join(', ');
914
+ return {
915
+ status: 'error',
916
+ message: `Multiple rules found in the store and rule_id was not provided. Pass rule_id explicitly. Available rules: ${ruleList}`,
917
+ };
918
+ }
919
+ }
920
+ if (!rule) {
921
+ return {
922
+ status: 'success',
923
+ would_approve: false,
924
+ reason: 'Could not resolve which rule applies to this card. This usually means: (1) the card has no opencard_rule_id in its Stripe metadata, or (2) there are multiple rules in the store and we can\'t pick one without a Stripe API call. Fix: pass rule_id explicitly, or assign a rule to the card via opencard_rule_id metadata.',
925
+ rule_name: null,
926
+ rule_id: null,
927
+ checks: {},
928
+ };
929
+ }
930
+ // ── Step 2: Get current daily/monthly spend from SQLite ───────────────────
931
+ let dailySpend = 0;
932
+ let monthlySpend = 0;
933
+ let spendDataNote = null;
934
+ try {
935
+ const db = new core_1.OpenCardDatabase();
936
+ const rawDb = db.getDb();
937
+ const now = new Date();
938
+ const todayMidnight = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()));
939
+ const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));
940
+ const todayStr = todayMidnight.toISOString().replace('T', ' ').slice(0, 19);
941
+ const monthStr = monthStart.toISOString().replace('T', ' ').slice(0, 19);
942
+ const todayRow = rawDb.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM authorizations WHERE card_id = ? AND status = 'approved' AND created_at >= ?`).get(cardId, todayStr);
943
+ const monthRow = rawDb.prepare(`SELECT COALESCE(SUM(amount), 0) as total FROM authorizations WHERE card_id = ? AND status = 'approved' AND created_at >= ?`).get(cardId, monthStr);
944
+ dailySpend = todayRow.total;
945
+ monthlySpend = monthRow.total;
946
+ db.close();
947
+ }
948
+ catch {
949
+ spendDataNote = 'unavailable — webhook server not running or DB not initialized';
950
+ // Continue with zeroed spend — run the check without spend context
951
+ }
952
+ // ── Step 3: Build a mock Stripe authorization object ──────────────────────
953
+ const mockAuth = {
954
+ id: `dry_run_${Date.now()}`,
955
+ object: 'issuing.authorization',
956
+ amount: amountCents,
957
+ approved: false,
958
+ currency: 'usd',
959
+ merchant_data: {
960
+ category: merchantCategory,
961
+ name: merchantName,
962
+ city: null,
963
+ country: null,
964
+ network_id: '',
965
+ postal_code: null,
966
+ state: null,
967
+ },
968
+ card: { id: cardId, metadata: {} },
969
+ metadata: {},
970
+ pending_request: null,
971
+ request_history: [],
972
+ status: 'pending',
973
+ created: Math.floor(Date.now() / 1000),
974
+ livemode: false,
975
+ network_data: null,
976
+ transactions: [],
977
+ verification_data: {
978
+ address_line1_check: 'not_provided',
979
+ address_postal_code_check: 'not_provided',
980
+ cvc_check: 'not_provided',
981
+ expiry_check: 'match',
982
+ },
983
+ wallet: null,
984
+ amount_details: null,
985
+ balance_transactions: [],
986
+ cardholder: null,
987
+ fleet: null,
988
+ fuel: null,
989
+ merchant_amount: amountCents,
990
+ merchant_currency: 'usd',
991
+ token: null,
992
+ };
993
+ // ── Step 4: Evaluate against the rules engine ─────────────────────────────
994
+ const decision = (0, core_1.evaluateAuthorization)(mockAuth, rule, {
995
+ dailySpend,
996
+ monthlySpend,
997
+ });
998
+ // ── Step 5: Build detailed checks breakdown ────────────────────────────────
999
+ const checks = {};
1000
+ if (rule.maxPerTransaction !== undefined) {
1001
+ checks.per_transaction = {
1002
+ limit: rule.maxPerTransaction,
1003
+ amount: amountCents,
1004
+ passed: amountCents <= rule.maxPerTransaction,
1005
+ };
1006
+ }
1007
+ if (rule.dailyLimit !== undefined) {
1008
+ checks.daily = {
1009
+ limit: rule.dailyLimit,
1010
+ spent_today: dailySpend,
1011
+ after_purchase: dailySpend + amountCents,
1012
+ passed: dailySpend + amountCents <= rule.dailyLimit,
1013
+ ...(spendDataNote ? { spend_data: spendDataNote } : {}),
1014
+ };
1015
+ }
1016
+ if (rule.monthlyLimit !== undefined) {
1017
+ checks.monthly = {
1018
+ limit: rule.monthlyLimit,
1019
+ spent_this_month: monthlySpend,
1020
+ after_purchase: monthlySpend + amountCents,
1021
+ passed: monthlySpend + amountCents <= rule.monthlyLimit,
1022
+ ...(spendDataNote ? { spend_data: spendDataNote } : {}),
1023
+ };
1024
+ }
1025
+ if (rule.allowedCategories && rule.allowedCategories.length > 0) {
1026
+ checks.category = {
1027
+ allowed: rule.allowedCategories,
1028
+ requested: merchantCategory || null,
1029
+ passed: !merchantCategory || rule.allowedCategories.includes(merchantCategory),
1030
+ };
1031
+ }
1032
+ else if (rule.blockedCategories && rule.blockedCategories.length > 0) {
1033
+ checks.category = {
1034
+ blocked: rule.blockedCategories,
1035
+ requested: merchantCategory || null,
1036
+ passed: !merchantCategory || !rule.blockedCategories.includes(merchantCategory),
1037
+ };
1038
+ }
1039
+ // ── Step 6: Include card description so agent can confirm right card ──────
1040
+ // Try to read description from Stripe metadata. Best-effort — if Stripe
1041
+ // call fails (no key set, test env, etc.), description is null.
1042
+ let cardDescription = null;
1043
+ const secretKeyForDesc = process.env.STRIPE_SECRET_KEY;
1044
+ if (secretKeyForDesc) {
1045
+ try {
1046
+ const stripe = new stripe_1.default(secretKeyForDesc, { apiVersion: '2023-10-16' });
1047
+ const stripeCard = await stripe.issuing.cards.retrieve(cardId);
1048
+ cardDescription = stripeCard.metadata?.opencard_description ?? null;
1049
+ }
1050
+ catch {
1051
+ // Not critical — proceed without description
1052
+ }
1053
+ }
1054
+ return {
1055
+ status: 'success',
1056
+ would_approve: decision.approved,
1057
+ reason: decision.reason,
1058
+ rule_name: ruleName,
1059
+ rule_id: ruleId,
1060
+ card_description: cardDescription,
1061
+ checks,
1062
+ ...(spendDataNote && Object.keys(checks).length === 0 ? { spend_data: spendDataNote } : {}),
1063
+ };
1064
+ }
1065
+ catch (err) {
1066
+ return { status: 'error', message: String(err) };
1067
+ }
1068
+ }
1069
+ /**
1070
+ * opencard_request_approval
1071
+ * Human-in-the-loop spending approval. Creates an approval request in SQLite,
1072
+ * notifies the operator (via stderr + optional webhook), then polls every 2s
1073
+ * until the human approves/denies or the timeout expires.
1074
+ *
1075
+ * Approval does NOT auto-execute the purchase — it returns permission only.
1076
+ * The agent then decides whether to proceed.
1077
+ */
1078
+ async function handleRequestApproval(args) {
1079
+ try {
1080
+ const cardId = args.card_id;
1081
+ const amountCents = args.amount_cents;
1082
+ const merchantName = args.merchant_name;
1083
+ const reason = args.reason;
1084
+ const merchantCategory = args.merchant_category ?? null;
1085
+ const timeoutSeconds = typeof args.timeout_seconds === 'number' ? args.timeout_seconds : 300;
1086
+ if (!cardId)
1087
+ return { status: 'error', message: 'card_id is required' };
1088
+ if (typeof amountCents !== 'number' || amountCents < 0)
1089
+ return { status: 'error', message: 'amount_cents must be a non-negative number' };
1090
+ if (!merchantName)
1091
+ return { status: 'error', message: 'merchant_name is required' };
1092
+ if (!reason)
1093
+ return { status: 'error', message: 'reason is required' };
1094
+ // ── Create the approval request in SQLite ─────────────────────────────
1095
+ const db = (0, core_1.initDatabase)();
1096
+ const request = db.createApprovalRequest({
1097
+ card_id: cardId,
1098
+ amount: amountCents,
1099
+ merchant_name: merchantName,
1100
+ merchant_category: merchantCategory,
1101
+ reason,
1102
+ timeout_seconds: timeoutSeconds,
1103
+ });
1104
+ const requestId = request.id;
1105
+ const amountDollars = `$${(amountCents / 100).toFixed(2)}`;
1106
+ // ── Notify operator ───────────────────────────────────────────────────
1107
+ const notifyMsg = [
1108
+ ``,
1109
+ `╔══════════════════════════════════════════════════════╗`,
1110
+ `║ 🔔 APPROVAL REQUEST — ${requestId}`,
1111
+ `╠══════════════════════════════════════════════════════╣`,
1112
+ `║ Amount: ${amountDollars}`,
1113
+ `║ Merchant: ${merchantName}${merchantCategory ? ` (${merchantCategory})` : ''}`,
1114
+ `║ Card: ${cardId}`,
1115
+ `║ Reason: ${reason}`,
1116
+ `║ Timeout: ${timeoutSeconds}s`,
1117
+ `╠══════════════════════════════════════════════════════╣`,
1118
+ `║ opencard approvals approve ${requestId}`,
1119
+ `║ opencard approvals deny ${requestId} --note "reason"`,
1120
+ `╚══════════════════════════════════════════════════════╝`,
1121
+ ``,
1122
+ ].join('\n');
1123
+ process.stderr.write(notifyMsg + '\n');
1124
+ // Fire webhook notification (non-blocking, don't fail if it errors)
1125
+ const webhookUrl = process.env.OPENCARD_APPROVAL_WEBHOOK_URL;
1126
+ if (webhookUrl) {
1127
+ const payload = {
1128
+ event: 'approval_request.created',
1129
+ request_id: requestId,
1130
+ card_id: cardId,
1131
+ amount_cents: amountCents,
1132
+ merchant_name: merchantName,
1133
+ merchant_category: merchantCategory,
1134
+ reason,
1135
+ timeout_seconds: timeoutSeconds,
1136
+ created_at: request.created_at,
1137
+ expires_at: request.expires_at,
1138
+ };
1139
+ fetch(webhookUrl, {
1140
+ method: 'POST',
1141
+ headers: { 'Content-Type': 'application/json' },
1142
+ body: JSON.stringify(payload),
1143
+ }).catch(err => {
1144
+ process.stderr.write(`[OpenCard] Approval webhook notification failed: ${err}\n`);
1145
+ });
1146
+ }
1147
+ // ── Poll until decided or expired ─────────────────────────────────────
1148
+ const pollIntervalMs = 2000;
1149
+ const deadlineMs = Date.now() + timeoutSeconds * 1000;
1150
+ while (Date.now() < deadlineMs) {
1151
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
1152
+ db.expireStaleApprovalRequests();
1153
+ const current = db.getApprovalRequest(requestId);
1154
+ if (!current) {
1155
+ db.close();
1156
+ return { status: 'error', message: `Approval request ${requestId} disappeared` };
1157
+ }
1158
+ if (current.status === 'approved') {
1159
+ db.close();
1160
+ return {
1161
+ status: 'approved',
1162
+ request_id: requestId,
1163
+ approved_by: current.decided_by,
1164
+ note: current.decision_note ?? null,
1165
+ approved_at: current.decided_at,
1166
+ };
1167
+ }
1168
+ if (current.status === 'denied') {
1169
+ db.close();
1170
+ return {
1171
+ status: 'denied',
1172
+ request_id: requestId,
1173
+ denied_by: current.decided_by,
1174
+ note: current.decision_note ?? null,
1175
+ denied_at: current.decided_at,
1176
+ };
1177
+ }
1178
+ if (current.status === 'expired') {
1179
+ db.close();
1180
+ return {
1181
+ status: 'expired',
1182
+ request_id: requestId,
1183
+ message: `No response within ${timeoutSeconds} seconds`,
1184
+ };
1185
+ }
1186
+ }
1187
+ db.expireStaleApprovalRequests();
1188
+ db.close();
1189
+ return {
1190
+ status: 'expired',
1191
+ request_id: requestId,
1192
+ message: `No response within ${timeoutSeconds} seconds`,
1193
+ };
1194
+ }
1195
+ catch (err) {
1196
+ return { status: 'error', message: String(err) };
1197
+ }
1198
+ }
1199
+ //# sourceMappingURL=tools.js.map