@fidacy/mcp 0.1.10 → 0.1.12

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/CHANGELOG.md CHANGED
@@ -85,3 +85,30 @@ semantic versioning.
85
85
  - Onboarding: first-run `~/.fidacy/config.json` now writes the mandate template
86
86
  explicitly (payees/categories/currency/caps) — adding a trusted payee is a
87
87
  visible one-line edit; friendlier first-boot message (no more "dev mode" scare).
88
+
89
+ ## 0.1.11 — 2026-07-02
90
+
91
+ - Upgrade micro-triggers, elegant by doctrine: fire only at value-proven moments
92
+ (first block, first duplicate-invoice/BEC catch, 10th gated action, one week
93
+ protected), each ONCE per install ever (persisted in config.json) — the free
94
+ tier stays fully usable in silence. The 0.1.10 every-DENY nudge is replaced.
95
+ - verify_mandate/get_audit_proof gain one informational closing line each;
96
+ assess_action's missing-key error now offers the free-key path.
97
+ - config.json gains created_at + nudge/decision state (preserved on read).
98
+
99
+ ## 0.1.12 — 2026-07-02
100
+
101
+ - **The BEC guarantee now survives restarts.** The local free-tier core kept the
102
+ duplicate-invoice set and the spent counter in memory only, so a re-presented
103
+ invoice was ALLOWED again after every process restart — exactly the short-lived
104
+ shell case (`npx` per session). The core now rehydrates both from the persisted
105
+ audit log at boot: prior ALLOW records rebuild the invoice claims (a paid
106
+ invoice stays paid, regardless of age) and re-sum `spent` within the current
107
+ mandate window and currency. One O(records) pass over the already-loaded chain.
108
+ - Audit records for ALLOWs now carry `amount`, `currency`, and the canonical
109
+ `invoiceRef` (rehydration fields). Additive: old logs stay valid, chain hashes
110
+ unchanged; pre-0.1.12 records simply carry no rehydratable state.
111
+ - Torn-tail recovery: a crash mid-append used to quarantine the WHOLE audit log
112
+ on next boot (losing all dedup state). A torn trailing line is now recognized,
113
+ the intact prefix is salvaged, and the tail truncated. Mid-file damage still
114
+ quarantines the file as before.
package/dist/core.js CHANGED
@@ -33,66 +33,9 @@ function sha256(input) {
33
33
 
34
34
  // ../firewall/dist/audit-store.js
35
35
  import fs from "node:fs";
36
- var FileAuditStore = class {
37
- path;
38
- chain = [];
39
- constructor(path) {
40
- this.path = path;
41
- this.load();
42
- }
43
- load() {
44
- if (!fs.existsSync(this.path))
45
- return;
46
- try {
47
- const raw = fs.readFileSync(this.path, "utf8");
48
- const parsed = [];
49
- for (const line of raw.split("\n")) {
50
- if (!line.trim())
51
- continue;
52
- parsed.push(JSON.parse(line));
53
- }
54
- this.chain = parsed;
55
- if (!this.intact())
56
- throw new Error("audit chain integrity broken");
57
- } catch (err) {
58
- this.chain = [];
59
- try {
60
- const quarantine = `${this.path}.corrupt-${Date.now()}`;
61
- fs.renameSync(this.path, quarantine);
62
- console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered (${err.message}); quarantined to ${quarantine}, starting a fresh chain.`);
63
- } catch {
64
- console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered and could not be quarantined; starting a fresh in-memory chain.`);
65
- }
66
- }
67
- }
68
- head() {
69
- return this.chain.length ? this.chain[this.chain.length - 1].hash : "GENESIS";
70
- }
71
- append(decision) {
72
- const prevHash = this.head();
73
- const seq = this.chain.length;
74
- const ts = decision.ts;
75
- const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
76
- const hash = sha256(`${prevHash}|${digest}|${seq}|${ts}`);
77
- const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts };
78
- fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
79
- this.chain.push(record2);
80
- return record2;
81
- }
82
- find(decisionId) {
83
- return this.chain.find((r) => r.decisionId === decisionId);
84
- }
85
- intact() {
86
- let prev = "GENESIS";
87
- for (const r of this.chain) {
88
- const expected = sha256(`${prev}|${r.digest}|${r.seq}|${r.ts}`);
89
- if (expected !== r.hash || r.prevHash !== prev)
90
- return false;
91
- prev = r.hash;
92
- }
93
- return true;
94
- }
95
- };
36
+
37
+ // ../firewall/dist/core.js
38
+ import { randomUUID } from "node:crypto";
96
39
 
97
40
  // ../firewall/dist/evaluate.js
98
41
  function fold(s) {
@@ -169,7 +112,6 @@ function evaluate(mandate, req, spentSoFar) {
169
112
  }
170
113
 
171
114
  // ../firewall/dist/core.js
172
- import { randomUUID } from "node:crypto";
173
115
  function canonInvoice(ref) {
174
116
  if (typeof ref !== "string")
175
117
  return "";
@@ -217,6 +159,35 @@ var DevFidacyCore = class {
217
159
  this.mandate = opts.mandate;
218
160
  this.store = new FileAuditStore(opts.auditLogPath ?? "./fidacy-audit.log");
219
161
  this.onDecision = opts.onDecision;
162
+ this.rehydrate();
163
+ }
164
+ /**
165
+ * Rebuild the in-memory decision state from the persisted audit log, so a process
166
+ * restart cannot re-open a claimed invoice (BEC dedup) or reset the maxTotal
167
+ * counter. One O(records) pass at boot over the already-loaded chain:
168
+ * - claimedInvoices: every ALLOW that carried an invoiceRef, regardless of age —
169
+ * a paid invoice stays paid.
170
+ * - spent: ALLOW amounts whose timestamp falls inside the CURRENT mandate window
171
+ * and whose currency matches the mandate (the cap is defined per window/currency).
172
+ * Records from older versions lack the rehydration fields and are skipped.
173
+ */
174
+ rehydrate() {
175
+ const notBefore = Date.parse(this.mandate.window.notBefore);
176
+ const notAfter = Date.parse(this.mandate.window.notAfter);
177
+ for (const r of this.store.records()) {
178
+ if (r.status !== "ALLOW")
179
+ continue;
180
+ if (r.invoiceRef)
181
+ this.claimedInvoices.add(`${r.subject}|${r.invoiceRef}`);
182
+ if (typeof r.amount !== "number" || !Number.isFinite(r.amount))
183
+ continue;
184
+ if (r.currency !== this.mandate.allow.currency)
185
+ continue;
186
+ const t = Date.parse(r.ts);
187
+ if (!Number.isFinite(t) || t < notBefore || t > notAfter)
188
+ continue;
189
+ this.spent += r.amount;
190
+ }
220
191
  }
221
192
  async getMandate() {
222
193
  return this.mandate;
@@ -303,6 +274,96 @@ var HttpFidacyCore = class {
303
274
  }
304
275
  };
305
276
 
277
+ // ../firewall/dist/audit-store.js
278
+ var FileAuditStore = class {
279
+ path;
280
+ chain = [];
281
+ constructor(path) {
282
+ this.path = path;
283
+ this.load();
284
+ }
285
+ load() {
286
+ if (!fs.existsSync(this.path))
287
+ return;
288
+ try {
289
+ const raw = fs.readFileSync(this.path, "utf8");
290
+ const lines = raw.split("\n");
291
+ const parsed = [];
292
+ let torn = false;
293
+ for (let i = 0; i < lines.length; i++) {
294
+ if (!lines[i].trim())
295
+ continue;
296
+ try {
297
+ parsed.push(JSON.parse(lines[i]));
298
+ } catch {
299
+ if (lines.slice(i + 1).some((l) => l.trim()))
300
+ throw new Error(`unparseable record at line ${i + 1}`);
301
+ torn = true;
302
+ break;
303
+ }
304
+ }
305
+ this.chain = parsed;
306
+ if (!this.intact())
307
+ throw new Error("audit chain integrity broken");
308
+ if (torn) {
309
+ const clean = parsed.map((r) => JSON.stringify(r)).join("\n") + (parsed.length ? "\n" : "");
310
+ fs.writeFileSync(this.path, clean);
311
+ console.error(`[fidacy] audit log at ${this.path} had a torn trailing line (crash during append?); salvaged ${parsed.length} intact records and truncated the tail.`);
312
+ }
313
+ } catch (err) {
314
+ this.chain = [];
315
+ try {
316
+ const quarantine = `${this.path}.corrupt-${Date.now()}`;
317
+ fs.renameSync(this.path, quarantine);
318
+ console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered (${err.message}); quarantined to ${quarantine}, starting a fresh chain.`);
319
+ } catch {
320
+ console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered and could not be quarantined; starting a fresh in-memory chain.`);
321
+ }
322
+ }
323
+ }
324
+ head() {
325
+ return this.chain.length ? this.chain[this.chain.length - 1].hash : "GENESIS";
326
+ }
327
+ append(decision) {
328
+ const prevHash = this.head();
329
+ const seq = this.chain.length;
330
+ const ts = decision.ts;
331
+ const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
332
+ const hash = sha256(`${prevHash}|${digest}|${seq}|${ts}`);
333
+ const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts };
334
+ if (decision.status === "ALLOW") {
335
+ const req = decision.request;
336
+ if (typeof req?.amount === "number" && Number.isFinite(req.amount))
337
+ record2.amount = req.amount;
338
+ if (typeof req?.currency === "string")
339
+ record2.currency = req.currency;
340
+ const invoice = canonInvoice(req?.invoiceRef);
341
+ if (invoice)
342
+ record2.invoiceRef = invoice;
343
+ }
344
+ fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
345
+ this.chain.push(record2);
346
+ return record2;
347
+ }
348
+ find(decisionId) {
349
+ return this.chain.find((r) => r.decisionId === decisionId);
350
+ }
351
+ /** The full loaded chain, oldest first. Read-only; used to rehydrate core state at boot. */
352
+ records() {
353
+ return this.chain;
354
+ }
355
+ intact() {
356
+ let prev = "GENESIS";
357
+ for (const r of this.chain) {
358
+ const expected = sha256(`${prev}|${r.digest}|${r.seq}|${r.ts}`);
359
+ if (expected !== r.hash || r.prevHash !== prev)
360
+ return false;
361
+ prev = r.hash;
362
+ }
363
+ return true;
364
+ }
365
+ };
366
+
306
367
  // src/config.ts
307
368
  import { homedir } from "node:os";
308
369
  import { join } from "node:path";
@@ -337,7 +398,12 @@ function readConfig() {
337
398
  anon_id: raw.anon_id,
338
399
  tier: raw.tier === "paid" ? "paid" : "free",
339
400
  api_key: typeof raw.api_key === "string" ? raw.api_key : null,
340
- mandate: raw.mandate
401
+ mandate: raw.mandate,
402
+ // Install-state passthrough: dropping these on a read→write cycle would
403
+ // reset every once-per-install nudge into an every-time nag.
404
+ created_at: typeof raw.created_at === "string" ? raw.created_at : void 0,
405
+ nudges: raw.nudges && typeof raw.nudges === "object" ? raw.nudges : void 0,
406
+ decisions_count: typeof raw.decisions_count === "number" ? raw.decisions_count : void 0
341
407
  };
342
408
  } catch {
343
409
  return null;
@@ -357,7 +423,7 @@ function resolveMandateRules(cfg) {
357
423
  }
358
424
 
359
425
  // src/telemetry.ts
360
- var CLIENT_VERSION = true ? "0.1.10" : "dev";
426
+ var CLIENT_VERSION = true ? "0.1.12" : "dev";
361
427
  var currentShell = "mcp";
362
428
  function telemetryEnabled() {
363
429
  const v = (process.env.FIDACY_DISABLE_TELEMETRY ?? "").trim().toLowerCase();
package/dist/index.js CHANGED
@@ -40,66 +40,9 @@ function sha256(input) {
40
40
 
41
41
  // ../firewall/dist/audit-store.js
42
42
  import fs from "node:fs";
43
- var FileAuditStore = class {
44
- path;
45
- chain = [];
46
- constructor(path) {
47
- this.path = path;
48
- this.load();
49
- }
50
- load() {
51
- if (!fs.existsSync(this.path))
52
- return;
53
- try {
54
- const raw = fs.readFileSync(this.path, "utf8");
55
- const parsed = [];
56
- for (const line of raw.split("\n")) {
57
- if (!line.trim())
58
- continue;
59
- parsed.push(JSON.parse(line));
60
- }
61
- this.chain = parsed;
62
- if (!this.intact())
63
- throw new Error("audit chain integrity broken");
64
- } catch (err) {
65
- this.chain = [];
66
- try {
67
- const quarantine = `${this.path}.corrupt-${Date.now()}`;
68
- fs.renameSync(this.path, quarantine);
69
- console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered (${err.message}); quarantined to ${quarantine}, starting a fresh chain.`);
70
- } catch {
71
- console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered and could not be quarantined; starting a fresh in-memory chain.`);
72
- }
73
- }
74
- }
75
- head() {
76
- return this.chain.length ? this.chain[this.chain.length - 1].hash : "GENESIS";
77
- }
78
- append(decision) {
79
- const prevHash = this.head();
80
- const seq = this.chain.length;
81
- const ts = decision.ts;
82
- const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
83
- const hash = sha256(`${prevHash}|${digest}|${seq}|${ts}`);
84
- const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts };
85
- fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
86
- this.chain.push(record2);
87
- return record2;
88
- }
89
- find(decisionId) {
90
- return this.chain.find((r) => r.decisionId === decisionId);
91
- }
92
- intact() {
93
- let prev = "GENESIS";
94
- for (const r of this.chain) {
95
- const expected = sha256(`${prev}|${r.digest}|${r.seq}|${r.ts}`);
96
- if (expected !== r.hash || r.prevHash !== prev)
97
- return false;
98
- prev = r.hash;
99
- }
100
- return true;
101
- }
102
- };
43
+
44
+ // ../firewall/dist/core.js
45
+ import { randomUUID } from "node:crypto";
103
46
 
104
47
  // ../firewall/dist/evaluate.js
105
48
  function fold(s) {
@@ -176,7 +119,6 @@ function evaluate(mandate, req, spentSoFar) {
176
119
  }
177
120
 
178
121
  // ../firewall/dist/core.js
179
- import { randomUUID } from "node:crypto";
180
122
  function canonInvoice(ref) {
181
123
  if (typeof ref !== "string")
182
124
  return "";
@@ -224,6 +166,35 @@ var DevFidacyCore = class {
224
166
  this.mandate = opts.mandate;
225
167
  this.store = new FileAuditStore(opts.auditLogPath ?? "./fidacy-audit.log");
226
168
  this.onDecision = opts.onDecision;
169
+ this.rehydrate();
170
+ }
171
+ /**
172
+ * Rebuild the in-memory decision state from the persisted audit log, so a process
173
+ * restart cannot re-open a claimed invoice (BEC dedup) or reset the maxTotal
174
+ * counter. One O(records) pass at boot over the already-loaded chain:
175
+ * - claimedInvoices: every ALLOW that carried an invoiceRef, regardless of age —
176
+ * a paid invoice stays paid.
177
+ * - spent: ALLOW amounts whose timestamp falls inside the CURRENT mandate window
178
+ * and whose currency matches the mandate (the cap is defined per window/currency).
179
+ * Records from older versions lack the rehydration fields and are skipped.
180
+ */
181
+ rehydrate() {
182
+ const notBefore = Date.parse(this.mandate.window.notBefore);
183
+ const notAfter = Date.parse(this.mandate.window.notAfter);
184
+ for (const r of this.store.records()) {
185
+ if (r.status !== "ALLOW")
186
+ continue;
187
+ if (r.invoiceRef)
188
+ this.claimedInvoices.add(`${r.subject}|${r.invoiceRef}`);
189
+ if (typeof r.amount !== "number" || !Number.isFinite(r.amount))
190
+ continue;
191
+ if (r.currency !== this.mandate.allow.currency)
192
+ continue;
193
+ const t = Date.parse(r.ts);
194
+ if (!Number.isFinite(t) || t < notBefore || t > notAfter)
195
+ continue;
196
+ this.spent += r.amount;
197
+ }
227
198
  }
228
199
  async getMandate() {
229
200
  return this.mandate;
@@ -310,6 +281,96 @@ var HttpFidacyCore = class {
310
281
  }
311
282
  };
312
283
 
284
+ // ../firewall/dist/audit-store.js
285
+ var FileAuditStore = class {
286
+ path;
287
+ chain = [];
288
+ constructor(path) {
289
+ this.path = path;
290
+ this.load();
291
+ }
292
+ load() {
293
+ if (!fs.existsSync(this.path))
294
+ return;
295
+ try {
296
+ const raw = fs.readFileSync(this.path, "utf8");
297
+ const lines = raw.split("\n");
298
+ const parsed = [];
299
+ let torn = false;
300
+ for (let i = 0; i < lines.length; i++) {
301
+ if (!lines[i].trim())
302
+ continue;
303
+ try {
304
+ parsed.push(JSON.parse(lines[i]));
305
+ } catch {
306
+ if (lines.slice(i + 1).some((l) => l.trim()))
307
+ throw new Error(`unparseable record at line ${i + 1}`);
308
+ torn = true;
309
+ break;
310
+ }
311
+ }
312
+ this.chain = parsed;
313
+ if (!this.intact())
314
+ throw new Error("audit chain integrity broken");
315
+ if (torn) {
316
+ const clean = parsed.map((r) => JSON.stringify(r)).join("\n") + (parsed.length ? "\n" : "");
317
+ fs.writeFileSync(this.path, clean);
318
+ console.error(`[fidacy] audit log at ${this.path} had a torn trailing line (crash during append?); salvaged ${parsed.length} intact records and truncated the tail.`);
319
+ }
320
+ } catch (err) {
321
+ this.chain = [];
322
+ try {
323
+ const quarantine = `${this.path}.corrupt-${Date.now()}`;
324
+ fs.renameSync(this.path, quarantine);
325
+ console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered (${err.message}); quarantined to ${quarantine}, starting a fresh chain.`);
326
+ } catch {
327
+ console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered and could not be quarantined; starting a fresh in-memory chain.`);
328
+ }
329
+ }
330
+ }
331
+ head() {
332
+ return this.chain.length ? this.chain[this.chain.length - 1].hash : "GENESIS";
333
+ }
334
+ append(decision) {
335
+ const prevHash = this.head();
336
+ const seq = this.chain.length;
337
+ const ts = decision.ts;
338
+ const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
339
+ const hash = sha256(`${prevHash}|${digest}|${seq}|${ts}`);
340
+ const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts };
341
+ if (decision.status === "ALLOW") {
342
+ const req = decision.request;
343
+ if (typeof req?.amount === "number" && Number.isFinite(req.amount))
344
+ record2.amount = req.amount;
345
+ if (typeof req?.currency === "string")
346
+ record2.currency = req.currency;
347
+ const invoice = canonInvoice(req?.invoiceRef);
348
+ if (invoice)
349
+ record2.invoiceRef = invoice;
350
+ }
351
+ fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
352
+ this.chain.push(record2);
353
+ return record2;
354
+ }
355
+ find(decisionId) {
356
+ return this.chain.find((r) => r.decisionId === decisionId);
357
+ }
358
+ /** The full loaded chain, oldest first. Read-only; used to rehydrate core state at boot. */
359
+ records() {
360
+ return this.chain;
361
+ }
362
+ intact() {
363
+ let prev = "GENESIS";
364
+ for (const r of this.chain) {
365
+ const expected = sha256(`${prev}|${r.digest}|${r.seq}|${r.ts}`);
366
+ if (expected !== r.hash || r.prevHash !== prev)
367
+ return false;
368
+ prev = r.hash;
369
+ }
370
+ return true;
371
+ }
372
+ };
373
+
313
374
  // src/config.ts
314
375
  import { randomUUID as randomUUID2 } from "node:crypto";
315
376
  import { homedir } from "node:os";
@@ -345,7 +406,12 @@ function readConfig() {
345
406
  anon_id: raw.anon_id,
346
407
  tier: raw.tier === "paid" ? "paid" : "free",
347
408
  api_key: typeof raw.api_key === "string" ? raw.api_key : null,
348
- mandate: raw.mandate
409
+ mandate: raw.mandate,
410
+ // Install-state passthrough: dropping these on a read→write cycle would
411
+ // reset every once-per-install nudge into an every-time nag.
412
+ created_at: typeof raw.created_at === "string" ? raw.created_at : void 0,
413
+ nudges: raw.nudges && typeof raw.nudges === "object" ? raw.nudges : void 0,
414
+ decisions_count: typeof raw.decisions_count === "number" ? raw.decisions_count : void 0
349
415
  };
350
416
  } catch {
351
417
  return null;
@@ -363,7 +429,8 @@ function ensureState() {
363
429
  anon_id: randomUUID2(),
364
430
  tier: "free",
365
431
  api_key: null,
366
- mandate: { payees: [], categories: ["*"], currency: "USD", perTxMax: 2500, maxTotal: 1e4 }
432
+ mandate: { payees: [], categories: ["*"], currency: "USD", perTxMax: 2500, maxTotal: 1e4 },
433
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
367
434
  };
368
435
  try {
369
436
  writeConfig(config);
@@ -385,7 +452,7 @@ function resolveMandateRules(cfg) {
385
452
  }
386
453
 
387
454
  // src/telemetry.ts
388
- var CLIENT_VERSION = true ? "0.1.10" : "dev";
455
+ var CLIENT_VERSION = true ? "0.1.12" : "dev";
389
456
  var currentShell = "mcp";
390
457
  function telemetryEnabled() {
391
458
  const v = (process.env.FIDACY_DISABLE_TELEMETRY ?? "").trim().toLowerCase();
@@ -609,7 +676,7 @@ async function postOnce(fetchImpl, url, headers, payload, timeoutMs) {
609
676
  }
610
677
 
611
678
  // src/provision.ts
612
- var CLIENT_VERSION2 = true ? "0.1.10" : "dev";
679
+ var CLIENT_VERSION2 = true ? "0.1.12" : "dev";
613
680
  function provisionEnabled() {
614
681
  const v = (process.env.FIDACY_DISABLE_PROVISION ?? "").trim().toLowerCase();
615
682
  return !(v === "1" || v === "true" || v === "yes");
@@ -656,6 +723,55 @@ function requestUpgrade() {
656
723
  return { url: upgradeUrl(), anonId: anonId2 };
657
724
  }
658
725
 
726
+ // src/nudges.ts
727
+ function nudgeOnce(key) {
728
+ const cfg = readConfig();
729
+ if (!cfg) return false;
730
+ if (cfg.nudges?.[key]) return false;
731
+ cfg.nudges = { ...cfg.nudges ?? {}, [key]: true };
732
+ try {
733
+ writeConfig(cfg);
734
+ } catch {
735
+ return false;
736
+ }
737
+ return true;
738
+ }
739
+ function bumpDecisionCount() {
740
+ const cfg = readConfig();
741
+ if (!cfg) return 0;
742
+ const n = (cfg.decisions_count ?? 0) + 1;
743
+ cfg.decisions_count = n;
744
+ try {
745
+ writeConfig(cfg);
746
+ } catch {
747
+ }
748
+ return n;
749
+ }
750
+ function installAgeDays() {
751
+ const t = Date.parse(readConfig()?.created_at ?? "");
752
+ if (Number.isNaN(t)) return 0;
753
+ return Math.floor((Date.now() - t) / 864e5);
754
+ }
755
+ function decisionNudge(status, violatedRule, upgradeToolName) {
756
+ const count = bumpDecisionCount();
757
+ if (status === "DENY" && violatedRule?.startsWith("duplicate_invoice") && nudgeOnce("bec_catch")) {
758
+ return `That was a duplicate-invoice (BEC) catch \u2014 the #1 payment fraud. On a free account every block like this is server-signed and anchored to Bitcoin: court-grade evidence. Call the ${upgradeToolName} tool to start.`;
759
+ }
760
+ if (status === "DENY" && nudgeOnce("first_deny")) {
761
+ return `Fidacy just blocked this for you \u2014 for server-signed, anchored proof of every block (free account, local history migrates), call the ${upgradeToolName} tool.`;
762
+ }
763
+ if (count === 10 && nudgeOnce("milestone_10")) {
764
+ return `That's 10 actions gated on this install. For a signed, Bitcoin-anchored history of all of them (free account), call the ${upgradeToolName} tool.`;
765
+ }
766
+ return null;
767
+ }
768
+ function weekOneBootNudge(upgradeToolName) {
769
+ if (installAgeDays() >= 7 && nudgeOnce("week_1")) {
770
+ return ` One week protected \u2014 anchor your history to Bitcoin with a free account: the ${upgradeToolName} tool starts it.`;
771
+ }
772
+ return null;
773
+ }
774
+
659
775
  // src/index.ts
660
776
  var state = ensureState();
661
777
  var core = makeCore();
@@ -689,8 +805,10 @@ server.registerTool(
689
805
  const req = args;
690
806
  const d = await core.decide(req, subject);
691
807
  const out = { status: d.status, decisionId: d.decisionId, grant: d.grant, violatedRule: d.violatedRule };
692
- const human = d.status === "ALLOW" ? `ALLOW (decision ${d.decisionId})${req.invoiceRef ? ` for invoice ${req.invoiceRef}` : ""}. To settle, call execute_payment with the SAME payee, amount, currency, and idempotencyKey, and set "grant" to EXACTLY this signed value:
693
- ${d.grant}` : `DENY (decision ${d.decisionId}). Rule violated: ${d.violatedRule}. No grant issued, this payment cannot proceed. The denial itself is recorded in the tamper-evident, hash-chained audit: call get_audit_proof with decisionId ${d.decisionId} for the proof of what was blocked. Fidacy just blocked this for you \u2014 for server-signed, anchored proof of every block (free account, local history migrates), call the upgrade tool.`;
808
+ const base = d.status === "ALLOW" ? `ALLOW (decision ${d.decisionId})${req.invoiceRef ? ` for invoice ${req.invoiceRef}` : ""}. To settle, call execute_payment with the SAME payee, amount, currency, and idempotencyKey, and set "grant" to EXACTLY this signed value:
809
+ ${d.grant}` : `DENY (decision ${d.decisionId}). Rule violated: ${d.violatedRule}. No grant issued, this payment cannot proceed. The denial itself is recorded in the tamper-evident, hash-chained audit: call get_audit_proof with decisionId ${d.decisionId} for the proof of what was blocked.`;
810
+ const nudge = decisionNudge(d.status, d.violatedRule, "upgrade");
811
+ const human = nudge ? `${base} ${nudge}` : base;
694
812
  return { content: [{ type: "text", text: human }], structuredContent: out };
695
813
  }
696
814
  );
@@ -705,7 +823,8 @@ server.registerTool(
705
823
  async () => {
706
824
  const m = await core.getMandate(subject);
707
825
  const payload = { mandate: m, fidacyPublicKey: core.publicKey() };
708
- return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], structuredContent: payload };
826
+ const text = JSON.stringify(payload, null, 2) + "\n\nThis mandate is local (free tier). Hosted mandates add team governance and server-signed verdicts \u2014 the upgrade tool starts a free account.";
827
+ return { content: [{ type: "text", text }], structuredContent: payload };
709
828
  }
710
829
  );
711
830
  server.registerTool(
@@ -719,7 +838,8 @@ server.registerTool(
719
838
  async ({ decisionId }) => {
720
839
  const proof = await core.getProof(decisionId);
721
840
  if (!proof) return { content: [{ type: "text", text: `No proof found for ${decisionId}` }], isError: true };
722
- return { content: [{ type: "text", text: JSON.stringify(proof, null, 2) }], structuredContent: proof };
841
+ const text = JSON.stringify(proof, null, 2) + "\n\nThis proof lives on this machine. On a free hosted account the chain is anchored to Bitcoin \u2014 verifiable by anyone, forever, even against Fidacy (the upgrade tool starts it).";
842
+ return { content: [{ type: "text", text }], structuredContent: proof };
723
843
  }
724
844
  );
725
845
  server.registerTool(
@@ -745,7 +865,7 @@ server.registerTool(
745
865
  content: [
746
866
  {
747
867
  type: "text",
748
- text: "assess_action requires FIDACY_ENGINE_API_KEY (an fky_live_/fky_test_ key with assess:write). Set it to enable signed verdicts."
868
+ text: "assess_action needs an engine key \u2014 free in about a minute: call the upgrade tool (your usage history migrates), or set FIDACY_ENGINE_API_KEY if you already have one."
749
869
  }
750
870
  ],
751
871
  isError: true
@@ -805,7 +925,8 @@ async function main() {
805
925
  void autoProvision();
806
926
  const verdictHint = process.env.FIDACY_ENGINE_API_KEY ? "" : " Set FIDACY_ENGINE_API_KEY to enable verdicts (assess_action).";
807
927
  const tierHint = state.firstRun ? " First run: free local tier active (deny-unknown payee + cap). Add trusted payees in ~/.fidacy/config.json." : ` Tier: ${state.config.tier} (local-first).`;
808
- console.error("[fidacy] @fidacy/mcp ready on stdio: signed verdict + payment firewall." + tierHint + verdictHint);
928
+ const weekNudge = weekOneBootNudge("upgrade") ?? "";
929
+ console.error("[fidacy] @fidacy/mcp ready on stdio: signed verdict + payment firewall." + tierHint + verdictHint + weekNudge);
809
930
  }
810
931
  main().catch((e) => {
811
932
  console.error("[fidacy] fatal:", e);
package/dist/lib.js CHANGED
@@ -74,151 +74,9 @@ function verifyGrant(publicKeyPem2, grant, expected) {
74
74
 
75
75
  // ../firewall/dist/audit-store.js
76
76
  import fs from "node:fs";
77
- var FileAuditStore = class {
78
- path;
79
- chain = [];
80
- constructor(path) {
81
- this.path = path;
82
- this.load();
83
- }
84
- load() {
85
- if (!fs.existsSync(this.path))
86
- return;
87
- try {
88
- const raw = fs.readFileSync(this.path, "utf8");
89
- const parsed = [];
90
- for (const line of raw.split("\n")) {
91
- if (!line.trim())
92
- continue;
93
- parsed.push(JSON.parse(line));
94
- }
95
- this.chain = parsed;
96
- if (!this.intact())
97
- throw new Error("audit chain integrity broken");
98
- } catch (err) {
99
- this.chain = [];
100
- try {
101
- const quarantine = `${this.path}.corrupt-${Date.now()}`;
102
- fs.renameSync(this.path, quarantine);
103
- console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered (${err.message}); quarantined to ${quarantine}, starting a fresh chain.`);
104
- } catch {
105
- console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered and could not be quarantined; starting a fresh in-memory chain.`);
106
- }
107
- }
108
- }
109
- head() {
110
- return this.chain.length ? this.chain[this.chain.length - 1].hash : "GENESIS";
111
- }
112
- append(decision) {
113
- const prevHash = this.head();
114
- const seq = this.chain.length;
115
- const ts = decision.ts;
116
- const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
117
- const hash = sha256(`${prevHash}|${digest}|${seq}|${ts}`);
118
- const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts };
119
- fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
120
- this.chain.push(record2);
121
- return record2;
122
- }
123
- find(decisionId) {
124
- return this.chain.find((r) => r.decisionId === decisionId);
125
- }
126
- intact() {
127
- let prev = "GENESIS";
128
- for (const r of this.chain) {
129
- const expected = sha256(`${prev}|${r.digest}|${r.seq}|${r.ts}`);
130
- if (expected !== r.hash || r.prevHash !== prev)
131
- return false;
132
- prev = r.hash;
133
- }
134
- return true;
135
- }
136
- };
137
77
 
138
- // ../firewall/dist/executor.js
139
- import { createPublicKey } from "node:crypto";
78
+ // ../firewall/dist/core.js
140
79
  import { randomUUID } from "node:crypto";
141
- var ReferenceRail = class {
142
- settlements = [];
143
- async execute(req) {
144
- const railRef = "ref_" + randomUUID();
145
- this.settlements.push({ ...req, railRef, at: (/* @__PURE__ */ new Date()).toISOString() });
146
- return { railRef };
147
- }
148
- };
149
- function httpRedeem(coreUrl, apiKey) {
150
- const base = String(coreUrl ?? "").replace(/\/+$/, "");
151
- const u = new URL(base);
152
- if (u.protocol !== "https:" && !(u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1"))) {
153
- throw new Error("core URL must be https:// (the API key is sent to it)");
154
- }
155
- return async (grant) => {
156
- const res = await fetch(`${base}/v1/grant/redeem`, {
157
- method: "POST",
158
- headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
159
- body: JSON.stringify({ grant })
160
- });
161
- if (res.status === 200)
162
- return true;
163
- if (res.status === 409)
164
- return false;
165
- throw new Error(`redeem -> ${res.status}`);
166
- };
167
- }
168
- var GrantEnforcingExecutor = class {
169
- rail;
170
- used = /* @__PURE__ */ new Set();
171
- pub;
172
- redeem;
173
- constructor(publicKeyPem2, rail, opts) {
174
- this.rail = rail;
175
- this.pub = createPublicKey(publicKeyPem2);
176
- this.redeem = opts?.redeem;
177
- }
178
- async execute(req, grant) {
179
- if (!grant)
180
- return { status: "REFUSED", reason: "missing_grant" };
181
- const dot = grant.indexOf(".");
182
- if (dot < 0)
183
- return { status: "REFUSED", reason: "malformed_grant" };
184
- const body = grant.slice(0, dot);
185
- const sig = grant.slice(dot + 1);
186
- if (!verify(this.pub, body, sig))
187
- return { status: "REFUSED", reason: "invalid_signature" };
188
- let p;
189
- try {
190
- p = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
191
- } catch {
192
- return { status: "REFUSED", reason: "undecodable_grant" };
193
- }
194
- if (Date.now() > p.exp)
195
- return { status: "REFUSED", reason: "grant_expired" };
196
- if (this.used.has(p.decisionId))
197
- return { status: "REFUSED", reason: "grant_replayed" };
198
- if (p.payee !== req.payee)
199
- return { status: "REFUSED", reason: "payee_mismatch" };
200
- if (p.amount !== req.amount)
201
- return { status: "REFUSED", reason: "amount_mismatch" };
202
- if (p.currency !== req.currency)
203
- return { status: "REFUSED", reason: "currency_mismatch" };
204
- if (req.invoiceRef != null && req.invoiceRef !== (p.invoiceRef ?? null)) {
205
- return { status: "REFUSED", reason: "invoice_mismatch" };
206
- }
207
- if (this.redeem) {
208
- let first;
209
- try {
210
- first = await this.redeem(grant);
211
- } catch {
212
- return { status: "REFUSED", reason: "redeem_unavailable" };
213
- }
214
- if (!first)
215
- return { status: "REFUSED", reason: "grant_replayed" };
216
- }
217
- this.used.add(p.decisionId);
218
- const { railRef } = await this.rail.execute(req);
219
- return { status: "EXECUTED", railRef, decisionId: p.decisionId };
220
- }
221
- };
222
80
 
223
81
  // ../firewall/dist/evaluate.js
224
82
  function fold(s) {
@@ -295,7 +153,6 @@ function evaluate(mandate, req, spentSoFar) {
295
153
  }
296
154
 
297
155
  // ../firewall/dist/core.js
298
- import { randomUUID as randomUUID2 } from "node:crypto";
299
156
  function canonInvoice(ref) {
300
157
  if (typeof ref !== "string")
301
158
  return "";
@@ -343,12 +200,41 @@ var DevFidacyCore = class {
343
200
  this.mandate = opts.mandate;
344
201
  this.store = new FileAuditStore(opts.auditLogPath ?? "./fidacy-audit.log");
345
202
  this.onDecision = opts.onDecision;
203
+ this.rehydrate();
204
+ }
205
+ /**
206
+ * Rebuild the in-memory decision state from the persisted audit log, so a process
207
+ * restart cannot re-open a claimed invoice (BEC dedup) or reset the maxTotal
208
+ * counter. One O(records) pass at boot over the already-loaded chain:
209
+ * - claimedInvoices: every ALLOW that carried an invoiceRef, regardless of age —
210
+ * a paid invoice stays paid.
211
+ * - spent: ALLOW amounts whose timestamp falls inside the CURRENT mandate window
212
+ * and whose currency matches the mandate (the cap is defined per window/currency).
213
+ * Records from older versions lack the rehydration fields and are skipped.
214
+ */
215
+ rehydrate() {
216
+ const notBefore = Date.parse(this.mandate.window.notBefore);
217
+ const notAfter = Date.parse(this.mandate.window.notAfter);
218
+ for (const r of this.store.records()) {
219
+ if (r.status !== "ALLOW")
220
+ continue;
221
+ if (r.invoiceRef)
222
+ this.claimedInvoices.add(`${r.subject}|${r.invoiceRef}`);
223
+ if (typeof r.amount !== "number" || !Number.isFinite(r.amount))
224
+ continue;
225
+ if (r.currency !== this.mandate.allow.currency)
226
+ continue;
227
+ const t = Date.parse(r.ts);
228
+ if (!Number.isFinite(t) || t < notBefore || t > notAfter)
229
+ continue;
230
+ this.spent += r.amount;
231
+ }
346
232
  }
347
233
  async getMandate() {
348
234
  return this.mandate;
349
235
  }
350
236
  async decide(req, subject) {
351
- const decisionId = randomUUID2();
237
+ const decisionId = randomUUID();
352
238
  const ts = (/* @__PURE__ */ new Date()).toISOString();
353
239
  const invalid = validateRequest(req);
354
240
  if (invalid) {
@@ -429,6 +315,181 @@ var HttpFidacyCore = class {
429
315
  }
430
316
  };
431
317
 
318
+ // ../firewall/dist/audit-store.js
319
+ var FileAuditStore = class {
320
+ path;
321
+ chain = [];
322
+ constructor(path) {
323
+ this.path = path;
324
+ this.load();
325
+ }
326
+ load() {
327
+ if (!fs.existsSync(this.path))
328
+ return;
329
+ try {
330
+ const raw = fs.readFileSync(this.path, "utf8");
331
+ const lines = raw.split("\n");
332
+ const parsed = [];
333
+ let torn = false;
334
+ for (let i = 0; i < lines.length; i++) {
335
+ if (!lines[i].trim())
336
+ continue;
337
+ try {
338
+ parsed.push(JSON.parse(lines[i]));
339
+ } catch {
340
+ if (lines.slice(i + 1).some((l) => l.trim()))
341
+ throw new Error(`unparseable record at line ${i + 1}`);
342
+ torn = true;
343
+ break;
344
+ }
345
+ }
346
+ this.chain = parsed;
347
+ if (!this.intact())
348
+ throw new Error("audit chain integrity broken");
349
+ if (torn) {
350
+ const clean = parsed.map((r) => JSON.stringify(r)).join("\n") + (parsed.length ? "\n" : "");
351
+ fs.writeFileSync(this.path, clean);
352
+ console.error(`[fidacy] audit log at ${this.path} had a torn trailing line (crash during append?); salvaged ${parsed.length} intact records and truncated the tail.`);
353
+ }
354
+ } catch (err) {
355
+ this.chain = [];
356
+ try {
357
+ const quarantine = `${this.path}.corrupt-${Date.now()}`;
358
+ fs.renameSync(this.path, quarantine);
359
+ console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered (${err.message}); quarantined to ${quarantine}, starting a fresh chain.`);
360
+ } catch {
361
+ console.error(`[fidacy] audit log at ${this.path} is unreadable/tampered and could not be quarantined; starting a fresh in-memory chain.`);
362
+ }
363
+ }
364
+ }
365
+ head() {
366
+ return this.chain.length ? this.chain[this.chain.length - 1].hash : "GENESIS";
367
+ }
368
+ append(decision) {
369
+ const prevHash = this.head();
370
+ const seq = this.chain.length;
371
+ const ts = decision.ts;
372
+ const digest = sha256(stableStringify({ decisionId: decision.decisionId, status: decision.status, request: decision.request, violatedRule: decision.violatedRule ?? null }));
373
+ const hash = sha256(`${prevHash}|${digest}|${seq}|${ts}`);
374
+ const record2 = { seq, decisionId: decision.decisionId, status: decision.status, subject: decision.subject, digest, prevHash, hash, ts };
375
+ if (decision.status === "ALLOW") {
376
+ const req = decision.request;
377
+ if (typeof req?.amount === "number" && Number.isFinite(req.amount))
378
+ record2.amount = req.amount;
379
+ if (typeof req?.currency === "string")
380
+ record2.currency = req.currency;
381
+ const invoice = canonInvoice(req?.invoiceRef);
382
+ if (invoice)
383
+ record2.invoiceRef = invoice;
384
+ }
385
+ fs.appendFileSync(this.path, JSON.stringify(record2) + "\n");
386
+ this.chain.push(record2);
387
+ return record2;
388
+ }
389
+ find(decisionId) {
390
+ return this.chain.find((r) => r.decisionId === decisionId);
391
+ }
392
+ /** The full loaded chain, oldest first. Read-only; used to rehydrate core state at boot. */
393
+ records() {
394
+ return this.chain;
395
+ }
396
+ intact() {
397
+ let prev = "GENESIS";
398
+ for (const r of this.chain) {
399
+ const expected = sha256(`${prev}|${r.digest}|${r.seq}|${r.ts}`);
400
+ if (expected !== r.hash || r.prevHash !== prev)
401
+ return false;
402
+ prev = r.hash;
403
+ }
404
+ return true;
405
+ }
406
+ };
407
+
408
+ // ../firewall/dist/executor.js
409
+ import { createPublicKey } from "node:crypto";
410
+ import { randomUUID as randomUUID2 } from "node:crypto";
411
+ var ReferenceRail = class {
412
+ settlements = [];
413
+ async execute(req) {
414
+ const railRef = "ref_" + randomUUID2();
415
+ this.settlements.push({ ...req, railRef, at: (/* @__PURE__ */ new Date()).toISOString() });
416
+ return { railRef };
417
+ }
418
+ };
419
+ function httpRedeem(coreUrl, apiKey) {
420
+ const base = String(coreUrl ?? "").replace(/\/+$/, "");
421
+ const u = new URL(base);
422
+ if (u.protocol !== "https:" && !(u.protocol === "http:" && (u.hostname === "localhost" || u.hostname === "127.0.0.1"))) {
423
+ throw new Error("core URL must be https:// (the API key is sent to it)");
424
+ }
425
+ return async (grant) => {
426
+ const res = await fetch(`${base}/v1/grant/redeem`, {
427
+ method: "POST",
428
+ headers: { "content-type": "application/json", authorization: `Bearer ${apiKey}` },
429
+ body: JSON.stringify({ grant })
430
+ });
431
+ if (res.status === 200)
432
+ return true;
433
+ if (res.status === 409)
434
+ return false;
435
+ throw new Error(`redeem -> ${res.status}`);
436
+ };
437
+ }
438
+ var GrantEnforcingExecutor = class {
439
+ rail;
440
+ used = /* @__PURE__ */ new Set();
441
+ pub;
442
+ redeem;
443
+ constructor(publicKeyPem2, rail, opts) {
444
+ this.rail = rail;
445
+ this.pub = createPublicKey(publicKeyPem2);
446
+ this.redeem = opts?.redeem;
447
+ }
448
+ async execute(req, grant) {
449
+ if (!grant)
450
+ return { status: "REFUSED", reason: "missing_grant" };
451
+ const dot = grant.indexOf(".");
452
+ if (dot < 0)
453
+ return { status: "REFUSED", reason: "malformed_grant" };
454
+ const body = grant.slice(0, dot);
455
+ const sig = grant.slice(dot + 1);
456
+ if (!verify(this.pub, body, sig))
457
+ return { status: "REFUSED", reason: "invalid_signature" };
458
+ let p;
459
+ try {
460
+ p = JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
461
+ } catch {
462
+ return { status: "REFUSED", reason: "undecodable_grant" };
463
+ }
464
+ if (Date.now() > p.exp)
465
+ return { status: "REFUSED", reason: "grant_expired" };
466
+ if (this.used.has(p.decisionId))
467
+ return { status: "REFUSED", reason: "grant_replayed" };
468
+ if (p.payee !== req.payee)
469
+ return { status: "REFUSED", reason: "payee_mismatch" };
470
+ if (p.amount !== req.amount)
471
+ return { status: "REFUSED", reason: "amount_mismatch" };
472
+ if (p.currency !== req.currency)
473
+ return { status: "REFUSED", reason: "currency_mismatch" };
474
+ if (req.invoiceRef != null && req.invoiceRef !== (p.invoiceRef ?? null)) {
475
+ return { status: "REFUSED", reason: "invoice_mismatch" };
476
+ }
477
+ if (this.redeem) {
478
+ let first;
479
+ try {
480
+ first = await this.redeem(grant);
481
+ } catch {
482
+ return { status: "REFUSED", reason: "redeem_unavailable" };
483
+ }
484
+ if (!first)
485
+ return { status: "REFUSED", reason: "grant_replayed" };
486
+ }
487
+ this.used.add(p.decisionId);
488
+ const { railRef } = await this.rail.execute(req);
489
+ return { status: "EXECUTED", railRef, decisionId: p.decisionId };
490
+ }
491
+ };
492
+
432
493
  // src/config.ts
433
494
  import { randomUUID as randomUUID3 } from "node:crypto";
434
495
  import { homedir } from "node:os";
@@ -464,7 +525,12 @@ function readConfig() {
464
525
  anon_id: raw.anon_id,
465
526
  tier: raw.tier === "paid" ? "paid" : "free",
466
527
  api_key: typeof raw.api_key === "string" ? raw.api_key : null,
467
- mandate: raw.mandate
528
+ mandate: raw.mandate,
529
+ // Install-state passthrough: dropping these on a read→write cycle would
530
+ // reset every once-per-install nudge into an every-time nag.
531
+ created_at: typeof raw.created_at === "string" ? raw.created_at : void 0,
532
+ nudges: raw.nudges && typeof raw.nudges === "object" ? raw.nudges : void 0,
533
+ decisions_count: typeof raw.decisions_count === "number" ? raw.decisions_count : void 0
468
534
  };
469
535
  } catch {
470
536
  return null;
@@ -482,7 +548,8 @@ function ensureState() {
482
548
  anon_id: randomUUID3(),
483
549
  tier: "free",
484
550
  api_key: null,
485
- mandate: { payees: [], categories: ["*"], currency: "USD", perTxMax: 2500, maxTotal: 1e4 }
551
+ mandate: { payees: [], categories: ["*"], currency: "USD", perTxMax: 2500, maxTotal: 1e4 },
552
+ created_at: (/* @__PURE__ */ new Date()).toISOString()
486
553
  };
487
554
  try {
488
555
  writeConfig(config);
@@ -504,7 +571,7 @@ function resolveMandateRules(cfg) {
504
571
  }
505
572
 
506
573
  // src/telemetry.ts
507
- var CLIENT_VERSION = true ? "0.1.10" : "dev";
574
+ var CLIENT_VERSION = true ? "0.1.12" : "dev";
508
575
  var currentShell = "mcp";
509
576
  function setTelemetryShell(shell) {
510
577
  currentShell = shell;
@@ -627,7 +694,7 @@ function requestUpgrade() {
627
694
  }
628
695
 
629
696
  // src/provision.ts
630
- var CLIENT_VERSION2 = true ? "0.1.10" : "dev";
697
+ var CLIENT_VERSION2 = true ? "0.1.12" : "dev";
631
698
  function provisionEnabled() {
632
699
  const v = (process.env.FIDACY_DISABLE_PROVISION ?? "").trim().toLowerCase();
633
700
  return !(v === "1" || v === "true" || v === "yes");
@@ -661,6 +728,55 @@ async function autoProvision() {
661
728
  return "failed";
662
729
  }
663
730
  }
731
+
732
+ // src/nudges.ts
733
+ function nudgeOnce(key) {
734
+ const cfg = readConfig();
735
+ if (!cfg) return false;
736
+ if (cfg.nudges?.[key]) return false;
737
+ cfg.nudges = { ...cfg.nudges ?? {}, [key]: true };
738
+ try {
739
+ writeConfig(cfg);
740
+ } catch {
741
+ return false;
742
+ }
743
+ return true;
744
+ }
745
+ function bumpDecisionCount() {
746
+ const cfg = readConfig();
747
+ if (!cfg) return 0;
748
+ const n = (cfg.decisions_count ?? 0) + 1;
749
+ cfg.decisions_count = n;
750
+ try {
751
+ writeConfig(cfg);
752
+ } catch {
753
+ }
754
+ return n;
755
+ }
756
+ function installAgeDays() {
757
+ const t = Date.parse(readConfig()?.created_at ?? "");
758
+ if (Number.isNaN(t)) return 0;
759
+ return Math.floor((Date.now() - t) / 864e5);
760
+ }
761
+ function decisionNudge(status, violatedRule, upgradeToolName) {
762
+ const count = bumpDecisionCount();
763
+ if (status === "DENY" && violatedRule?.startsWith("duplicate_invoice") && nudgeOnce("bec_catch")) {
764
+ return `That was a duplicate-invoice (BEC) catch \u2014 the #1 payment fraud. On a free account every block like this is server-signed and anchored to Bitcoin: court-grade evidence. Call the ${upgradeToolName} tool to start.`;
765
+ }
766
+ if (status === "DENY" && nudgeOnce("first_deny")) {
767
+ return `Fidacy just blocked this for you \u2014 for server-signed, anchored proof of every block (free account, local history migrates), call the ${upgradeToolName} tool.`;
768
+ }
769
+ if (count === 10 && nudgeOnce("milestone_10")) {
770
+ return `That's 10 actions gated on this install. For a signed, Bitcoin-anchored history of all of them (free account), call the ${upgradeToolName} tool.`;
771
+ }
772
+ return null;
773
+ }
774
+ function weekOneBootNudge(upgradeToolName) {
775
+ if (installAgeDays() >= 7 && nudgeOnce("week_1")) {
776
+ return ` One week protected \u2014 anchor your history to Bitcoin with a free account: the ${upgradeToolName} tool starts it.`;
777
+ }
778
+ return null;
779
+ }
664
780
  export {
665
781
  DevFidacyCore,
666
782
  FileAuditStore,
@@ -669,12 +785,15 @@ export {
669
785
  ReferenceRail,
670
786
  autoProvision,
671
787
  canonInvoice,
788
+ decisionNudge,
672
789
  ensureState,
673
790
  evaluate,
674
791
  httpRedeem,
792
+ installAgeDays,
675
793
  loadOrGenerateKeyPair,
676
794
  lookalikePayee,
677
795
  makeCore,
796
+ nudgeOnce,
678
797
  publicKeyPem,
679
798
  readConfig,
680
799
  recordAgentActive,
@@ -688,5 +807,6 @@ export {
688
807
  upgradeUrl,
689
808
  validateRequest,
690
809
  verify,
691
- verifyGrant
810
+ verifyGrant,
811
+ weekOneBootNudge
692
812
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fidacy/mcp",
3
- "version": "0.1.10",
3
+ "version": "0.1.12",
4
4
  "description": "Fidacy action firewall for AI agents. Mandate-gated payment authorization as an MCP server.",
5
5
  "license": "Apache-2.0",
6
6
  "homepage": "https://fidacy.com",