@opencard-dev/core 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.
Files changed (53) hide show
  1. package/dist/db.d.ts +145 -0
  2. package/dist/db.d.ts.map +1 -0
  3. package/dist/db.js +373 -0
  4. package/dist/db.js.map +1 -0
  5. package/dist/errors.d.ts +72 -0
  6. package/dist/errors.d.ts.map +1 -0
  7. package/dist/errors.js +124 -0
  8. package/dist/errors.js.map +1 -0
  9. package/dist/index.d.ts +34 -0
  10. package/dist/index.d.ts.map +1 -0
  11. package/dist/index.js +67 -0
  12. package/dist/index.js.map +1 -0
  13. package/dist/logger.d.ts +53 -0
  14. package/dist/logger.d.ts.map +1 -0
  15. package/dist/logger.js +109 -0
  16. package/dist/logger.js.map +1 -0
  17. package/dist/rules-engine.d.ts +159 -0
  18. package/dist/rules-engine.d.ts.map +1 -0
  19. package/dist/rules-engine.js +375 -0
  20. package/dist/rules-engine.js.map +1 -0
  21. package/dist/rules-store.d.ts +187 -0
  22. package/dist/rules-store.d.ts.map +1 -0
  23. package/dist/rules-store.js +291 -0
  24. package/dist/rules-store.js.map +1 -0
  25. package/dist/rules-validation.d.ts +54 -0
  26. package/dist/rules-validation.d.ts.map +1 -0
  27. package/dist/rules-validation.js +110 -0
  28. package/dist/rules-validation.js.map +1 -0
  29. package/dist/stripe-client.d.ts +154 -0
  30. package/dist/stripe-client.d.ts.map +1 -0
  31. package/dist/stripe-client.js +444 -0
  32. package/dist/stripe-client.js.map +1 -0
  33. package/dist/test-utils.d.ts +55 -0
  34. package/dist/test-utils.d.ts.map +1 -0
  35. package/dist/test-utils.js +91 -0
  36. package/dist/test-utils.js.map +1 -0
  37. package/dist/tracker.d.ts +130 -0
  38. package/dist/tracker.d.ts.map +1 -0
  39. package/dist/tracker.js +196 -0
  40. package/dist/tracker.js.map +1 -0
  41. package/dist/transaction-reconciler.d.ts +30 -0
  42. package/dist/transaction-reconciler.d.ts.map +1 -0
  43. package/dist/transaction-reconciler.js +131 -0
  44. package/dist/transaction-reconciler.js.map +1 -0
  45. package/dist/types.d.ts +194 -0
  46. package/dist/types.d.ts.map +1 -0
  47. package/dist/types.js +7 -0
  48. package/dist/types.js.map +1 -0
  49. package/dist/webhooks.d.ts +121 -0
  50. package/dist/webhooks.d.ts.map +1 -0
  51. package/dist/webhooks.js +307 -0
  52. package/dist/webhooks.js.map +1 -0
  53. package/package.json +46 -0
@@ -0,0 +1,375 @@
1
+ "use strict";
2
+ /**
3
+ * Rules Engine
4
+ * ============
5
+ * Two responsibilities:
6
+ *
7
+ * 1. SpendRules — a fluent builder that makes it easy to construct SpendRule
8
+ * objects without remembering field names. Also ships pre-built templates
9
+ * for common agent configurations.
10
+ *
11
+ * 2. evaluateAuthorization — takes a live Stripe authorization request plus
12
+ * a card's rules and returns an approve/decline decision. This is called
13
+ * from the webhook handler in real time (within the ~2s Stripe window).
14
+ *
15
+ * All amounts are in cents throughout. Stripe uses cents; we match that to
16
+ * avoid any float-to-dollar conversion bugs.
17
+ *
18
+ * ─── Design philosophy ───────────────────────────────────────────────────────
19
+ * The rules engine enforces two core hard controls:
20
+ * 1. Spend limits (daily, monthly, per-transaction)
21
+ * 2. Category locks (allowed/blocked merchant categories)
22
+ *
23
+ * Confidence scoring — where agents self-report how sure they are about a
24
+ * purchase — was deliberately removed. Self-reported confidence is unreliable:
25
+ * an agent with no safety awareness will report 1.0; a cautious agent might
26
+ * report 0.7 for a perfectly reasonable purchase. It adds noise without
27
+ * adding real safety. Hard limits are the guardrail. Category locks add
28
+ * specificity. That's the model.
29
+ */
30
+ Object.defineProperty(exports, "__esModule", { value: true });
31
+ exports.SpendRules = void 0;
32
+ exports.evaluateRule = evaluateRule;
33
+ exports.evaluateAuthorization = evaluateAuthorization;
34
+ const logger_1 = require("./logger");
35
+ const logger = (0, logger_1.getLogger)('rules-engine');
36
+ class SpendRules {
37
+ rule = {};
38
+ /**
39
+ * Create a new SpendRules builder
40
+ */
41
+ constructor() {
42
+ this.rule = {};
43
+ }
44
+ /**
45
+ * Load a pre-built template
46
+ */
47
+ static template(template) {
48
+ const builder = new SpendRules();
49
+ switch (template) {
50
+ case 'balanced':
51
+ // Reasonable defaults for a general-purpose agent card.
52
+ // Hard daily limit + per-transaction cap, decline on violation.
53
+ return builder
54
+ .dailyLimit(50000) // $500/day
55
+ .maxPerTx(10000) // $100/tx
56
+ .onFailure('decline')
57
+ .name('Balanced')
58
+ .description('General-purpose card with a $500/day cap and $100 per-transaction limit');
59
+ case 'category_locked':
60
+ // Restricted categories with low per-transaction limit
61
+ return builder
62
+ .category(['grocery', 'transit', 'fuel'])
63
+ .maxPerTx(2000) // $20/tx max
64
+ .dailyLimit(10000) // $100/day
65
+ .onFailure('decline')
66
+ .name('Category Locked')
67
+ .description('Restricted to specific categories, low transaction limit');
68
+ case 'daily_limit':
69
+ // Simple daily budget
70
+ return builder
71
+ .dailyLimit(10000) // $100/day
72
+ .onFailure('decline')
73
+ .name('Daily Limit')
74
+ .description('Hard daily spending cap of $100');
75
+ case 'approval_gated':
76
+ // High transactions require human approval.
77
+ // Note: 'alert' onFailure behaves as 'decline' today (see onFailure docs).
78
+ // Use the opencard_request_approval MCP tool for actual human-in-the-loop approval.
79
+ return builder
80
+ .approvalThreshold(5000) // $50+
81
+ .requiresApproval(true)
82
+ .onFailure('decline')
83
+ .name('Approval Gated')
84
+ .description('Transactions over $50 require human approval');
85
+ default:
86
+ const _exhaustive = template;
87
+ return _exhaustive;
88
+ }
89
+ }
90
+ /**
91
+ * Set allowed merchant categories
92
+ * Only transactions in these categories will be approved
93
+ */
94
+ category(categories) {
95
+ this.rule.allowedCategories = categories;
96
+ return this;
97
+ }
98
+ /**
99
+ * Set blocked merchant categories
100
+ * Transactions in these categories will be declined
101
+ */
102
+ blockCategory(categories) {
103
+ this.rule.blockedCategories = categories;
104
+ return this;
105
+ }
106
+ /**
107
+ * Set maximum amount per transaction (in cents)
108
+ */
109
+ maxPerTx(cents) {
110
+ if (cents < 0) {
111
+ throw new Error('Max per transaction must be >= 0');
112
+ }
113
+ this.rule.maxPerTransaction = cents;
114
+ return this;
115
+ }
116
+ /**
117
+ * Set daily spending limit (in cents)
118
+ */
119
+ dailyLimit(cents) {
120
+ if (cents < 0) {
121
+ throw new Error('Daily limit must be >= 0');
122
+ }
123
+ this.rule.dailyLimit = cents;
124
+ this.rule.spendingInterval = 'daily';
125
+ return this;
126
+ }
127
+ /**
128
+ * Set monthly spending limit (in cents)
129
+ */
130
+ monthlyLimit(cents) {
131
+ if (cents < 0) {
132
+ throw new Error('Monthly limit must be >= 0');
133
+ }
134
+ this.rule.monthlyLimit = cents;
135
+ this.rule.spendingInterval = 'monthly';
136
+ return this;
137
+ }
138
+ /**
139
+ * Set per-authorization limit (in cents)
140
+ */
141
+ perAuthLimit(cents) {
142
+ if (cents < 0) {
143
+ throw new Error('Per-auth limit must be >= 0');
144
+ }
145
+ this.rule.maxPerTransaction = cents;
146
+ this.rule.spendingInterval = 'per_authorization';
147
+ return this;
148
+ }
149
+ /**
150
+ * Set whether transactions above the approval threshold require human sign-off.
151
+ * Usually you don't call this directly — `approvalThreshold()` sets it automatically.
152
+ * But you can call it explicitly if you need to toggle approval requirements
153
+ * independently of the threshold value.
154
+ */
155
+ requiresApproval(required) {
156
+ this.rule.requiresApproval = required;
157
+ return this;
158
+ }
159
+ /**
160
+ * Set approval threshold (in cents)
161
+ * Transactions above this amount require human approval.
162
+ * Also sets requiresApproval = true automatically.
163
+ */
164
+ approvalThreshold(cents) {
165
+ if (cents < 0) {
166
+ throw new Error('Approval threshold must be >= 0');
167
+ }
168
+ this.rule.approvalThreshold = cents;
169
+ this.rule.requiresApproval = true;
170
+ return this;
171
+ }
172
+ /**
173
+ * Set failure handling behavior.
174
+ *
175
+ * Currently only 'decline' is fully implemented in the authorization flow.
176
+ * 'alert' and 'pause' are accepted for forward compatibility but behave
177
+ * the same as 'decline' today — the charge is declined. These modes will
178
+ * be wired to notification and card-pausing logic in a future release.
179
+ */
180
+ onFailure(action) {
181
+ this.rule.onFailure = action;
182
+ return this;
183
+ }
184
+ /**
185
+ * Set human-readable name for this rule set
186
+ */
187
+ name(name) {
188
+ this.rule.name = name;
189
+ return this;
190
+ }
191
+ /**
192
+ * Set human-readable description for this rule set
193
+ */
194
+ description(desc) {
195
+ this.rule.description = desc;
196
+ return this;
197
+ }
198
+ /**
199
+ * Build and return the final SpendRule object
200
+ */
201
+ build() {
202
+ if (this.rule.onFailure === 'alert' || this.rule.onFailure === 'pause') {
203
+ logger.warn(`Alert/pause onFailure modes are not yet implemented. Rule will default to decline behavior: ${JSON.stringify(this.rule)}`);
204
+ }
205
+ return { ...this.rule };
206
+ }
207
+ /**
208
+ * Get a string representation of the rules
209
+ */
210
+ toString() {
211
+ const parts = [];
212
+ if (this.rule.allowedCategories?.length) {
213
+ parts.push(`categories: [${this.rule.allowedCategories.join(', ')}]`);
214
+ }
215
+ if (this.rule.maxPerTransaction !== undefined) {
216
+ const dollars = (this.rule.maxPerTransaction / 100).toFixed(2);
217
+ parts.push(`max per tx: $${dollars}`);
218
+ }
219
+ if (this.rule.dailyLimit !== undefined) {
220
+ const dollars = (this.rule.dailyLimit / 100).toFixed(2);
221
+ parts.push(`daily limit: $${dollars}`);
222
+ }
223
+ if (this.rule.monthlyLimit !== undefined) {
224
+ const dollars = (this.rule.monthlyLimit / 100).toFixed(2);
225
+ parts.push(`monthly limit: $${dollars}`);
226
+ }
227
+ if (this.rule.requiresApproval && this.rule.approvalThreshold) {
228
+ const dollars = (this.rule.approvalThreshold / 100).toFixed(2);
229
+ parts.push(`approval required >$${dollars}`);
230
+ }
231
+ if (this.rule.onFailure) {
232
+ parts.push(`on violation: ${this.rule.onFailure}`);
233
+ }
234
+ return `SpendRules(${parts.join(', ')})`;
235
+ }
236
+ }
237
+ exports.SpendRules = SpendRules;
238
+ /**
239
+ * Evaluate a transaction against a rule set
240
+ * Returns { allowed: boolean, reason?: string }
241
+ */
242
+ function evaluateRule(rule, transaction) {
243
+ // Check allowed categories
244
+ if (rule.allowedCategories?.length &&
245
+ transaction.category &&
246
+ !rule.allowedCategories.includes(transaction.category)) {
247
+ return {
248
+ allowed: false,
249
+ reason: `Category '${transaction.category}' not in allowed list`,
250
+ };
251
+ }
252
+ // Check blocked categories
253
+ if (rule.blockedCategories?.length &&
254
+ transaction.category &&
255
+ rule.blockedCategories.includes(transaction.category)) {
256
+ return {
257
+ allowed: false,
258
+ reason: `Category '${transaction.category}' is blocked`,
259
+ };
260
+ }
261
+ // Check max per transaction
262
+ if (rule.maxPerTransaction !== undefined &&
263
+ transaction.amount > rule.maxPerTransaction) {
264
+ const txDollars = (transaction.amount / 100).toFixed(2);
265
+ const maxDollars = (rule.maxPerTransaction / 100).toFixed(2);
266
+ return {
267
+ allowed: false,
268
+ reason: `Transaction amount $${txDollars} exceeds per-tx limit $${maxDollars}`,
269
+ };
270
+ }
271
+ // If all checks pass
272
+ return { allowed: true };
273
+ }
274
+ /**
275
+ * evaluateAuthorization
276
+ * ──────────────────────
277
+ * The core decision function. Given a live Stripe authorization request and
278
+ * the card's spending rules, determines whether to approve or decline.
279
+ *
280
+ * Rules are checked in order of cheapest/fastest first:
281
+ * 1. Merchant category (allowed list, then blocked list)
282
+ * 2. Per-transaction amount limit
283
+ * 3. Daily budget (current spend + this tx > limit?)
284
+ * 4. Monthly budget
285
+ *
286
+ * The first failing check short-circuits and returns a decline with a reason
287
+ * string that explains exactly which rule tripped.
288
+ *
289
+ * @param auth - The Stripe Issuing.Authorization from the webhook event
290
+ * @param rules - The SpendRule configured for this card
291
+ * @param context - Current spend totals from the tracker
292
+ * @returns AuthorizationDecision with approved flag and human-readable reason
293
+ */
294
+ function evaluateAuthorization(auth, rules, context) {
295
+ // ── Extract key fields from the Stripe authorization object ─────────────
296
+ //
297
+ // auth.amount — the amount in cents the merchant is requesting
298
+ // auth.merchant_data.category — MCC (merchant category code) as a string
299
+ // e.g. "grocery_stores", "airlines", "restaurants"
300
+ const amount = auth.amount; // cents
301
+ const category = auth.merchant_data?.category || '';
302
+ // ── Check 1: Allowed categories ─────────────────────────────────────────
303
+ // If the rule specifies an allowlist, the merchant's category MUST be on it.
304
+ // This is useful for cards meant for a specific purpose (e.g. a card that
305
+ // should only be used for SaaS subscriptions).
306
+ if (rules.allowedCategories && rules.allowedCategories.length > 0) {
307
+ if (category && !rules.allowedCategories.includes(category)) {
308
+ return {
309
+ approved: false,
310
+ reason: `Merchant category '${category}' is not in the allowed list: [${rules.allowedCategories.join(', ')}]`,
311
+ };
312
+ }
313
+ }
314
+ // ── Check 2: Blocked categories ─────────────────────────────────────────
315
+ // If the rule specifies a blocklist, ANY match immediately declines.
316
+ // Blocked list takes precedence even if the category is also in the allowed
317
+ // list (though that would be a misconfigured rule).
318
+ if (rules.blockedCategories && rules.blockedCategories.length > 0) {
319
+ if (category && rules.blockedCategories.includes(category)) {
320
+ return {
321
+ approved: false,
322
+ reason: `Merchant category '${category}' is explicitly blocked`,
323
+ };
324
+ }
325
+ }
326
+ // ── Check 3: Per-transaction limit ──────────────────────────────────────
327
+ // The simplest budget check: if this single transaction is too large,
328
+ // decline it regardless of running totals.
329
+ if (rules.maxPerTransaction !== undefined && amount > rules.maxPerTransaction) {
330
+ const txDollars = (amount / 100).toFixed(2);
331
+ const limitDollars = (rules.maxPerTransaction / 100).toFixed(2);
332
+ return {
333
+ approved: false,
334
+ reason: `Transaction $${txDollars} exceeds per-transaction limit of $${limitDollars}`,
335
+ };
336
+ }
337
+ // ── Check 4: Daily budget ────────────────────────────────────────────────
338
+ // Would adding this transaction push today's total over the daily limit?
339
+ // We use the PROSPECTIVE total (spent + this amount) not the current total,
340
+ // so a card at $95/day with a $100 limit can't approve a $10 transaction.
341
+ if (rules.dailyLimit !== undefined) {
342
+ const prospectiveDaily = context.dailySpend + amount;
343
+ if (prospectiveDaily > rules.dailyLimit) {
344
+ const spentDollars = (context.dailySpend / 100).toFixed(2);
345
+ const txDollars = (amount / 100).toFixed(2);
346
+ const limitDollars = (rules.dailyLimit / 100).toFixed(2);
347
+ return {
348
+ approved: false,
349
+ reason: `Daily limit exceeded: already spent $${spentDollars} + $${txDollars} > daily limit $${limitDollars}`,
350
+ };
351
+ }
352
+ }
353
+ // ── Check 5: Monthly budget ─────────────────────────────────────────────
354
+ // Same as daily, but for the calendar month.
355
+ if (rules.monthlyLimit !== undefined) {
356
+ const prospectiveMonthly = context.monthlySpend + amount;
357
+ if (prospectiveMonthly > rules.monthlyLimit) {
358
+ const spentDollars = (context.monthlySpend / 100).toFixed(2);
359
+ const txDollars = (amount / 100).toFixed(2);
360
+ const limitDollars = (rules.monthlyLimit / 100).toFixed(2);
361
+ return {
362
+ approved: false,
363
+ reason: `Monthly limit exceeded: already spent $${spentDollars} + $${txDollars} > monthly limit $${limitDollars}`,
364
+ };
365
+ }
366
+ }
367
+ // ── All checks passed ────────────────────────────────────────────────────
368
+ // If we got here, the transaction satisfies every rule. Approve it.
369
+ const dollars = (amount / 100).toFixed(2);
370
+ return {
371
+ approved: true,
372
+ reason: `All rules passed — approving $${dollars} at ${auth.merchant_data?.name || 'unknown merchant'}`,
373
+ };
374
+ }
375
+ //# sourceMappingURL=rules-engine.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rules-engine.js","sourceRoot":"","sources":["../src/rules-engine.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,187 @@
1
+ /**
2
+ * Rules Store
3
+ * ===========
4
+ * Persistent storage for SpendRule objects, backed by a local JSON file.
5
+ *
6
+ * ─── Why not keep rules in Stripe metadata? ─────────────────────────────────
7
+ * Storing rules in Stripe card metadata (as we did in Phase 1) is a security
8
+ * problem: anyone with Stripe dashboard access can edit the rules directly,
9
+ * bypassing any access controls we might add. By moving rules to our own store,
10
+ * we control who can read or modify them — and Stripe metadata only holds a
11
+ * reference ID (opencard_rule_id), not the rules themselves.
12
+ *
13
+ * ─── Why a JSON file? ────────────────────────────────────────────────────────
14
+ * Phase 1 is all about getting something working and safe without adding
15
+ * operational complexity. A JSON file:
16
+ * - Survives process restarts (unlike in-memory)
17
+ * - Requires no database setup or migration
18
+ * - Is easy to inspect, back up, and version-control
19
+ * - Is fast enough for Phase 1 workloads (hundreds of rules, not millions)
20
+ *
21
+ * Phase 2 will replace this with a proper database when we need multi-instance
22
+ * support or higher write throughput.
23
+ *
24
+ * ─── File format ─────────────────────────────────────────────────────────────
25
+ * The file is a JSON object: { [ruleId]: SpendRule }
26
+ * Example:
27
+ * {
28
+ * "rule_01HX9K2Z3A4B5C6D7E8F9G0H1J": { "dailyLimit": 10000, "onFailure": "decline" },
29
+ * "rule_01HX9K2Z3A4B5C6D7E8F9G0H1K": { "monthlyLimit": 50000 }
30
+ * }
31
+ *
32
+ * ─── ID format ───────────────────────────────────────────────────────────────
33
+ * IDs use the format `rule_<timestamp_hex><random_hex>` — lexicographically
34
+ * sortable (newer rules sort after older ones) and globally unique without
35
+ * a central counter. We implement this without external dependencies using
36
+ * Node.js's built-in crypto module.
37
+ *
38
+ * ─── Thread safety ───────────────────────────────────────────────────────────
39
+ * We serialize all writes through a simple async queue (each write awaits the
40
+ * previous one). This isn't needed for single-threaded Node.js, but it makes
41
+ * the behavior explicit and protects against future async-write bugs.
42
+ */
43
+ import { SpendRule } from './types';
44
+ export declare class RulesStore {
45
+ /**
46
+ * Path to the JSON file where rules are stored.
47
+ * Configurable via OPENCARD_RULES_PATH env var.
48
+ * Defaults to ./opencard-rules.json relative to the process working directory.
49
+ */
50
+ private readonly filePath;
51
+ /**
52
+ * Internal write queue: we chain writes so concurrent callers don't
53
+ * corrupt the file by reading stale contents and overwriting each other.
54
+ */
55
+ private writeQueue;
56
+ constructor(filePath?: string);
57
+ /**
58
+ * Generates a unique, sortable rule ID.
59
+ *
60
+ * Format: `rule_<timestamp_ms_hex><random_8_hex_chars>`
61
+ * Example: `rule_018f1a2b3c4d5e6f7a8b9c0d`
62
+ *
63
+ * - The timestamp prefix makes IDs sort chronologically (newest last in
64
+ * a lexicographic sort), which is useful when listing rules.
65
+ * - The random suffix prevents collisions even if two rules are created
66
+ * in the same millisecond.
67
+ * - No external library needed — uses Node.js built-in crypto.
68
+ */
69
+ private generateId;
70
+ /**
71
+ * Reads the rules file from disk and parses it.
72
+ *
73
+ * If the file doesn't exist yet, returns an empty object (not an error) —
74
+ * the file will be created on the first write.
75
+ *
76
+ * If the file exists but contains invalid JSON, throws an error. This
77
+ * shouldn't happen in normal operation (we always write valid JSON), but
78
+ * could happen if the file was manually edited.
79
+ */
80
+ private readFile;
81
+ /**
82
+ * Writes the entire rules object to disk, atomically.
83
+ *
84
+ * We write to a temp file first, then rename it into place. This avoids
85
+ * leaving a half-written file if the process crashes mid-write.
86
+ *
87
+ * All writes are serialized through the writeQueue to prevent concurrent
88
+ * writes from racing each other.
89
+ */
90
+ private writeFile;
91
+ /**
92
+ * Creates a new rule in the store.
93
+ *
94
+ * Validates the rule before writing — throws if validation fails.
95
+ * This is the right place to reject bad rules so they never make it
96
+ * into the store and can never affect a live transaction.
97
+ *
98
+ * @param rule - The spend rule to store
99
+ * @returns The generated rule ID (e.g. "rule_018f1a2b3c4d5e6f7a8b9c0d")
100
+ *
101
+ * @throws Error if the rule fails validation
102
+ *
103
+ * @example
104
+ * const ruleId = await rulesStore.createRule({
105
+ * dailyLimit: 10000, // $100/day
106
+ * onFailure: 'decline',
107
+ * });
108
+ * // ruleId → "rule_018f1a2b3c4d5e6f7a8b9c0d"
109
+ */
110
+ createRule(rule: SpendRule): Promise<string>;
111
+ /**
112
+ * Looks up a rule by its ID.
113
+ *
114
+ * Returns null (not an error) if the rule doesn't exist. This is important
115
+ * in the webhook path: if a card references a rule ID that's been deleted,
116
+ * the webhook handler should fall back to default-deny, not crash.
117
+ *
118
+ * @param ruleId - The rule ID to look up
119
+ * @returns The SpendRule, or null if not found
120
+ *
121
+ * @example
122
+ * const rule = await rulesStore.getRule('rule_018f1a2b...');
123
+ * if (rule === null) {
124
+ * // rule was deleted or ID is wrong — use default-deny
125
+ * }
126
+ */
127
+ getRule(ruleId: string): Promise<SpendRule | null>;
128
+ /**
129
+ * Updates an existing rule.
130
+ *
131
+ * Validates the new rule before writing. The update replaces the entire
132
+ * rule — it's not a partial merge. Pass the full updated rule object.
133
+ *
134
+ * @throws Error if the rule doesn't exist (can't update what isn't there)
135
+ * @throws Error if the new rule fails validation
136
+ *
137
+ * @example
138
+ * await rulesStore.updateRule('rule_018f1a2b...', {
139
+ * dailyLimit: 20000, // raised from $100 to $200
140
+ * onFailure: 'decline',
141
+ * });
142
+ */
143
+ updateRule(ruleId: string, rule: SpendRule): Promise<void>;
144
+ /**
145
+ * Deletes a rule from the store.
146
+ *
147
+ * Note: this does NOT update any cards that reference this rule.
148
+ * Cards referencing a deleted rule ID will fall back to default-deny
149
+ * in the webhook handler (by design — it's better to be safe).
150
+ *
151
+ * If the rule doesn't exist, this is a no-op (not an error).
152
+ *
153
+ * @param ruleId - The rule ID to delete
154
+ */
155
+ deleteRule(ruleId: string): Promise<void>;
156
+ /**
157
+ * Lists all rules in the store.
158
+ *
159
+ * Returns them sorted by ID (which is chronological since IDs are
160
+ * timestamp-prefixed). Newest rules appear last.
161
+ *
162
+ * @returns Array of { id, rule } objects, sorted by creation order
163
+ *
164
+ * @example
165
+ * const rules = await rulesStore.listRules();
166
+ * for (const { id, rule } of rules) {
167
+ * console.log(`${id}: ${rule.name ?? 'unnamed'}`);
168
+ * }
169
+ */
170
+ listRules(): Promise<{
171
+ id: string;
172
+ rule: SpendRule;
173
+ }[]>;
174
+ }
175
+ /**
176
+ * Module-level singleton instance of RulesStore.
177
+ *
178
+ * Export and use this everywhere, just like the `tracker` singleton.
179
+ * This ensures all parts of the system share the same file path and
180
+ * write queue — critical for avoiding concurrent write corruption.
181
+ *
182
+ * @example
183
+ * import { rulesStore } from '@opencard-dev/core';
184
+ * const ruleId = await rulesStore.createRule({ dailyLimit: 10000 });
185
+ */
186
+ export declare const rulesStore: RulesStore;
187
+ //# sourceMappingURL=rules-store.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rules-store.d.ts","sourceRoot":"","sources":["../src/rules-store.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GAyCG;AAKH,OAAO,EAAE,SAAS,EAAE,MAAM,SAAS,CAAC;AAcpC,qBAAa,UAAU;IACrB;;;;OAIG;IACH,OAAO,CAAC,QAAQ,CAAC,QAAQ,CAAS;IAElC;;;OAGG;IACH,OAAO,CAAC,UAAU,CAAoC;gBAE1C,QAAQ,CAAC,EAAE,MAAM;IAU7B;;;;;;;;;;;OAWG;IACH,OAAO,CAAC,UAAU;IAQlB;;;;;;;;;OASG;YACW,QAAQ;IAgBtB;;;;;;;;OAQG;YACW,SAAS;IA+BvB;;;;;;;;;;;;;;;;;;OAkBG;IACG,UAAU,CAAC,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,MAAM,CAAC;IA0BlD;;;;;;;;;;;;;;;OAeG;IACG,OAAO,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,GAAG,IAAI,CAAC;IAKxD;;;;;;;;;;;;;;OAcG;IACG,UAAU,CAAC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,SAAS,GAAG,OAAO,CAAC,IAAI,CAAC;IAuBhE;;;;;;;;;;OAUG;IACG,UAAU,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,IAAI,CAAC;IAe/C;;;;;;;;;;;;;OAaG;IACG,SAAS,IAAI,OAAO,CAAC;QAAE,EAAE,EAAE,MAAM,CAAC;QAAC,IAAI,EAAE,SAAS,CAAA;KAAE,EAAE,CAAC;CAO9D;AAID;;;;;;;;;;GAUG;AACH,eAAO,MAAM,UAAU,YAAmB,CAAC"}