@chatpanel/gateway 0.6.5 → 0.6.8

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.5",
3
+ "version": "0.6.8",
4
4
  "description": "Local privacy gateway — redacts PII out of OpenAI/Anthropic API traffic before it reaches a model, then restores it in the reply. Point opencode, codex, aider, Claude Code, etc. at it.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,7 +27,7 @@
27
27
  "node": ">=18"
28
28
  },
29
29
  "dependencies": {
30
- "@chatpanel/pii": "^0.2.6",
30
+ "@chatpanel/pii": "^0.2.10",
31
31
  "@huggingface/transformers": "^4.2.0",
32
32
  "onnxruntime-web": "1.26.0-dev.20260416-b7804b056c"
33
33
  },
package/src/config.js CHANGED
@@ -34,14 +34,16 @@ const DEFAULTS = {
34
34
  allowedOrigins: [],
35
35
  maxBodyBytes: 26214400,
36
36
 
37
- // Monetization: the gateway is free to try, paid to rely on. Free = deterministic
38
- // redaction (basic tier) + a daily request cap. Paste a ChatPanel Pro entitlement
39
- // token (the same offline-signed token the extension/bridge use) to unlock
40
- // full-tier redaction (NER names/orgs + full dictionary) and unlimited usage.
37
+ // Monetization: the gateway is free to try, paid to rely on. Free = full-tier
38
+ // redaction (the real thing NER names/orgs + dictionary) for a FIXED LIFETIME
39
+ // allowance (freegate.FREE_TOTAL_CAP redactions), then it stops. Paste a
40
+ // ChatPanel Pro entitlement token (the same offline-signed token the extension/
41
+ // bridge use) to unlock unlimited usage. `free.used` is the running lifetime
42
+ // count (server-authoritative; persisted here so it survives restarts).
41
43
  pro: {
42
44
  entitlementToken: '',
43
45
  free: {
44
- maxRequestsPerDay: 25,
46
+ used: 0,
45
47
  },
46
48
  },
47
49
 
@@ -5,6 +5,7 @@
5
5
  import { writeFileSync, mkdirSync } from 'node:fs';
6
6
  import { join, dirname } from 'node:path';
7
7
  import os from 'node:os';
8
+ import { usage } from './freegate.js';
8
9
 
9
10
  // Default to a writable per-user location, NOT process.cwd(): when the gateway
10
11
  // runs as a login service its cwd is "/" (read-only → EROFS on save).
@@ -45,7 +46,9 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
45
46
  },
46
47
  ner: cfg.ner,
47
48
  allowedOrigins: Array.isArray(cfg.allowedOrigins) ? cfg.allowedOrigins : [],
48
- pro: { unlocked: proUnlocked, hasToken: !!cfg.pro?.entitlementToken, free: cfg.pro?.free },
49
+ // free = lifetime trial usage ({ used, cap, remaining }) read-only; the cap
50
+ // is fixed and the count is server-authoritative (never settable from the UI).
51
+ pro: { unlocked: proUnlocked, hasToken: !!cfg.pro?.entitlementToken, free: usage(cfg) },
49
52
  logRequests: !!cfg.logRequests,
50
53
  logDetail: ['types', 'values'].includes(cfg.logDetail) ? cfg.logDetail : 'off',
51
54
  tools: {
@@ -102,8 +105,9 @@ export function applyConfigPatch(cfg, patch = {}) {
102
105
  if (Array.isArray(patch.allowedOrigins)) cfg.allowedOrigins = patch.allowedOrigins;
103
106
  if (patch.pro && typeof patch.pro === 'object') {
104
107
  if (typeof patch.pro.entitlementToken === 'string') cfg.pro.entitlementToken = patch.pro.entitlementToken;
105
- const cap = patch.pro.free?.maxRequestsPerDay;
106
- if (Number.isFinite(cap) && cap >= 0) cfg.pro.free.maxRequestsPerDay = cap;
108
+ // NOTE: the free trial is a FIXED lifetime cap (freegate.FREE_TOTAL_CAP) and
109
+ // its `used` count is server-authoritative neither is editable here, so a
110
+ // client can't raise the cap or reset its own trial.
107
111
  }
108
112
  if (typeof patch.logRequests === 'boolean') cfg.logRequests = patch.logRequests;
109
113
  if (['off', 'types', 'values'].includes(patch.logDetail)) cfg.logDetail = patch.logDetail;
@@ -0,0 +1,94 @@
1
+ // Online entitlement re-validation — closes the refund/revoke abuse window.
2
+ //
3
+ // The gateway stores ONE offline-signed entitlement token (pushed once from the
4
+ // extension via POST /config). Verified purely offline (entitlement.js), that token
5
+ // keeps unlocking Pro until its `exp` — up to 7 days — EVEN AFTER the subscription
6
+ // is refunded, cancelled, or the seat is revoked. The extension/bridge avoid this
7
+ // by re-polling the license worker; the gateway didn't, so a refund left unlimited
8
+ // redaction open for the rest of the token's life.
9
+ //
10
+ // So we also re-check ONLINE on an interval: poll the worker's /entitlement for the
11
+ // token's install_id and either
12
+ // • REFRESH the stored token (still entitled) — so it never lapses while paid, and
13
+ // • CLEAR it (worker says valid:false → refunded/cancelled/revoked) — dropping the
14
+ // gateway to Free immediately instead of riding the offline exp.
15
+ // Network/worker errors NEVER revoke (fail-open for paying users); the offline `exp`
16
+ // still bounds the worst case. Bounds post-refund Pro to <= CHECK_INTERVAL_MS.
17
+
18
+ import { persistConfig, configPath } from './configstore.js';
19
+
20
+ // Same worker the extension/bridge use. Overridable for self-hosted/test.
21
+ const API_BASE = (process.env.CHATPANEL_API_BASE || 'https://api.chatpanel.net').replace(/\/+$/, '');
22
+ const CHECK_INTERVAL_MS = 60 * 60 * 1000; // 1h — the autonomous post-refund Pro window
23
+ const FIRST_CHECK_DELAY_MS = 30 * 1000; // let the server settle before first poll
24
+ const MIN_RECHECK_MS = 2 * 60 * 1000; // throttle on-demand (/status) re-checks
25
+
26
+ let lastCheckAt = 0;
27
+
28
+ // The token payload carries { typ, plan, install_id, sub, exp }; we only need the
29
+ // install_id to ask the worker whether that seat is still entitled.
30
+ function installIdFromToken(token) {
31
+ try {
32
+ const head = String(token).split('.')[0];
33
+ const json = Buffer.from(head.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8');
34
+ const p = JSON.parse(json);
35
+ return typeof p.install_id === 'string' && p.install_id ? p.install_id : null;
36
+ } catch {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ async function revalidate(cfg) {
42
+ lastCheckAt = Date.now();
43
+ const token = cfg.pro?.entitlementToken;
44
+ if (!token) return;
45
+ const installId = installIdFromToken(token);
46
+ if (!installId) return; // legacy/opaque token — leave the offline exp to bound it
47
+
48
+ let data;
49
+ try {
50
+ const r = await fetch(`${API_BASE}/entitlement?install_id=${encodeURIComponent(installId)}`, {
51
+ signal: AbortSignal.timeout(8000),
52
+ });
53
+ if (!r.ok) return; // worker hiccup → fail-open, retry next interval
54
+ data = await r.json();
55
+ } catch {
56
+ return; // offline / network error → fail-open (never revoke a paying user)
57
+ }
58
+
59
+ if (data && data.valid && typeof data.token === 'string' && data.token) {
60
+ // Still entitled — adopt the freshly-signed token so Pro never lapses while paid.
61
+ if (data.token !== token) {
62
+ cfg.pro.entitlementToken = data.token;
63
+ try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
64
+ }
65
+ } else if (data && data.valid === false) {
66
+ // Refunded / cancelled / seat revoked → drop Pro NOW (don't wait out the exp).
67
+ cfg.pro.entitlementToken = '';
68
+ try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
69
+ console.log('[gateway] entitlement no longer valid — Pro deactivated (refund/revoke/seat lost).');
70
+ }
71
+ }
72
+
73
+ // On-demand, throttled re-check — fire-and-forget from a hot path (the extension's
74
+ // /status poll) so deactivation shows up within ~minutes of opening the gateway
75
+ // tab, not just on the hourly tick. Never awaited; safe to call often.
76
+ export function maybeRevalidate(cfg) {
77
+ if (process.env.CHATPANEL_NO_REVALIDATE) return;
78
+ if (!cfg.pro?.entitlementToken) return;
79
+ if (Date.now() - lastCheckAt < MIN_RECHECK_MS) return;
80
+ lastCheckAt = Date.now(); // claim the slot before the async hop (avoid stampede)
81
+ revalidate(cfg).catch(() => {});
82
+ }
83
+
84
+ // Start the periodic re-check. Timers are unref'd so they never keep the process
85
+ // alive on their own. Returns a handle with stop() for clean shutdown.
86
+ export function startEntitlementRefresh(cfg) {
87
+ if (process.env.CHATPANEL_NO_REVALIDATE) return { stop() {} };
88
+ const tick = () => { revalidate(cfg).catch(() => {}); };
89
+ const first = setTimeout(tick, FIRST_CHECK_DELAY_MS);
90
+ const iv = setInterval(tick, CHECK_INTERVAL_MS);
91
+ if (typeof first.unref === 'function') first.unref();
92
+ if (typeof iv.unref === 'function') iv.unref();
93
+ return { stop() { clearTimeout(first); clearInterval(iv); } };
94
+ }
package/src/freegate.js CHANGED
@@ -1,20 +1,28 @@
1
1
  // Free vs Pro for the gateway runtime — the "taste" gate.
2
2
  //
3
- // Free (no entitlement token): deterministic redaction only (basic tier) + a
4
- // capped number of metered requests per day, so anyone can try it. Pro (a valid
5
- // ChatPanel entitlement token — the same offline-signed token the extension and
6
- // bridge use) unlocks full-tier redaction (names/orgs via NER + full dictionary)
7
- // and unlimited usage. The cryptographic check (entitlement.js) means a forked UI
8
- // can't unlock it — only the configured/paid token does.
3
+ // Free (no entitlement token): full-tier redaction works, but only for a fixed
4
+ // LIFETIME number of redactions (FREE_TOTAL_CAP) a real trial of the genuine
5
+ // thing, then you buy. Pro (a valid ChatPanel entitlement token — the same
6
+ // offline-signed token the extension and bridge use) unlocks UNLIMITED redaction.
7
+ // The cryptographic check (entitlement.js) means a forked UI can't unlock it —
8
+ // only the configured/paid token does.
9
+ //
10
+ // The cap is LIFETIME (overall), not per-day, and it is NOT user-editable — so a
11
+ // free user gets exactly FREE_TOTAL_CAP genuine redactions, full stop. The count
12
+ // persists in cfg.pro.free.used (written via configstore.persistConfig), so it
13
+ // survives restarts.
9
14
 
10
15
  import { isProEntitled } from './entitlement.js';
11
16
 
17
+ // The lifetime free allowance. Fixed — deliberately NOT configurable.
18
+ export const FREE_TOTAL_CAP = 100;
19
+
12
20
  const proCache = { token: null, val: false };
13
- const counts = { day: '', n: 0 };
14
21
 
15
- // Today's metered usage — for the gateway's /status (the extension's monitoring).
22
+ // Lifetime free usage — for the gateway's /status (the extension's monitoring).
16
23
  export function usage(cfg) {
17
- return { day: counts.day, used: counts.n, cap: cfg.pro?.free?.maxRequestsPerDay ?? 25 };
24
+ const used = Number(cfg.pro?.free?.used) || 0;
25
+ return { used, cap: FREE_TOTAL_CAP, remaining: Math.max(0, FREE_TOTAL_CAP - used) };
18
26
  }
19
27
 
20
28
  export async function resolvePro(token) {
@@ -26,19 +34,23 @@ export async function resolvePro(token) {
26
34
  return val;
27
35
  }
28
36
 
29
- // UTC day bucket. (Runtime Date is available here, unlike workflow scripts.)
30
- function dayKey() {
31
- return new Date().toISOString().slice(0, 10);
37
+ // May this request still redact? Pro = always. Free = allowed until the lifetime
38
+ // cap is reached, then refused so the client gets a clear upsell. This only
39
+ // CHECKS — the count is advanced by consume() AFTER a redaction actually happens,
40
+ // so requests with nothing to redact don't burn the allowance.
41
+ export function checkQuota(cfg, isPro) {
42
+ if (isPro) return { allowed: true, remaining: Infinity, isPro: true };
43
+ const used = Number(cfg.pro?.free?.used) || 0;
44
+ if (used >= FREE_TOTAL_CAP) return { allowed: false, remaining: 0, used, cap: FREE_TOTAL_CAP, isPro: false };
45
+ return { allowed: true, remaining: FREE_TOTAL_CAP - used, used, cap: FREE_TOTAL_CAP, isPro: false };
32
46
  }
33
47
 
34
- // Meter one redactable request. Pro = always allowed. Free = allowed until the
35
- // daily cap, then refused so the client gets a clear upsell.
36
- export function meter(cfg, isPro) {
37
- if (isPro) return { allowed: true, remaining: Infinity, isPro: true };
38
- const cap = cfg.pro?.free?.maxRequestsPerDay ?? 25;
39
- const d = dayKey();
40
- if (counts.day !== d) { counts.day = d; counts.n = 0; }
41
- if (counts.n >= cap) return { allowed: false, remaining: 0, cap, isPro: false };
42
- counts.n += 1;
43
- return { allowed: true, remaining: cap - counts.n, cap, isPro: false };
48
+ // Record one consumed free redaction (lifetime). No-op for Pro. Mutates cfg so
49
+ // the caller can persist it. Returns the new used count.
50
+ export function consume(cfg, isPro) {
51
+ if (isPro) return Infinity;
52
+ cfg.pro = cfg.pro || {};
53
+ cfg.pro.free = cfg.pro.free || {};
54
+ cfg.pro.free.used = (Number(cfg.pro.free.used) || 0) + 1;
55
+ return cfg.pro.free.used;
44
56
  }
package/src/redact.js CHANGED
@@ -8,22 +8,39 @@
8
8
  // mapping is self-consistent within the request. (Same reasoning as the
9
9
  // extension's pii-pipeline.)
10
10
 
11
- import { createVault, redactText, detectEntities, effectiveTier, gatedDictionary } from '@chatpanel/pii';
11
+ import { createVault, redactText, detectEntities, gatedDictionary, sanitizeUnicode } from '@chatpanel/pii';
12
12
  import * as engine from './ner-engine.js';
13
13
 
14
14
  // tier: 'basic' | 'full'. For 'full' we run the local detector over the combined
15
15
  // text to harvest names/orgs, then redact every segment against that entity set.
16
- // `isPro` applies the SAME free/Pro gating as the extension (shared package):
17
- // free deterministic 'basic' tier + a capped dictionary; Pro full tier.
16
+ //
17
+ // Free vs Pro on the gateway: the free trial is limited by a REQUEST QUOTA
18
+ // (freegate.js), not by downgrading quality — so free users get the REAL tier
19
+ // (names/orgs via NER) within their allowance. The custom dictionary, though, is
20
+ // still a Pro power feature: gatedDictionary caps it to FREE_DICT_LIMIT for free.
18
21
  export async function redactSegments(segments, redactionCfg, { signal, isPro = true } = {}) {
19
22
  const vault = createVault();
23
+
24
+ // De-steganography FIRST (before detection). Invisible/format Unicode is a triple
25
+ // threat at this boundary: it can split a value so the detector misses it and the
26
+ // model reassembles real PII (redaction bypass), smuggle a hidden instruction via
27
+ // Tag chars (ASCII smuggling), or carry a fingerprint/watermark a client injected.
28
+ // We strip it in place so detection sees clean text and the forwarded request is
29
+ // clean too. Counted (not silently dropped) so the server can report it.
30
+ let sanitized = 0;
31
+ for (const seg of segments) {
32
+ const before = seg.get();
33
+ if (typeof before !== 'string' || !before) continue;
34
+ const { clean, removed } = sanitizeUnicode(before);
35
+ if (removed) { seg.set(clean); sanitized += removed; }
36
+ }
37
+
20
38
  const texts = segments.map((s) => s.get()).filter((t) => typeof t === 'string' && t);
21
- if (texts.length === 0) return { vault, count: 0 };
39
+ if (texts.length === 0) return { vault, count: 0, sanitized };
22
40
 
23
- // effectiveTier downgrades 'full'→'basic' for free; gatedDictionary trims to the
24
- // free limit. This reuses chatpanel-pii's gating so the gateway and extension
25
- // enforce free/Pro identically.
26
- const tier = effectiveTier({ tier: redactionCfg.tier }, isPro);
41
+ // Use the configured tier as-is (no free downgrade the quota is the free gate),
42
+ // but keep the dictionary capped for free via the shared chatpanel-pii gate.
43
+ const tier = redactionCfg.tier === 'full' ? 'full' : 'basic';
27
44
  const dictionary = gatedDictionary(redactionCfg, isPro);
28
45
 
29
46
  // Detection source: a USER-configured external detector takes precedence; else
@@ -62,7 +79,7 @@ export async function redactSegments(segments, redactionCfg, { signal, isPro = t
62
79
  if (after !== before) count++;
63
80
  seg.set(after);
64
81
  }
65
- return { vault, count };
82
+ return { vault, count, sanitized };
66
83
  }
67
84
 
68
85
  // A `segment` is a tiny getter/setter over wherever the text lives in the parsed
package/src/server.js CHANGED
@@ -19,23 +19,24 @@
19
19
 
20
20
  import { createServer } from 'node:http';
21
21
  import { loadConfig } from './config.js';
22
+ import { startEntitlementRefresh, maybeRevalidate } from './entitlement-refresh.js';
22
23
  import { redactSegments } from './redact.js';
23
24
  import { pipeRestoredStream, pipeRestoredOpenAIStream, makeTokenRestorer } from './stream.js';
24
- import { restoreText, effectiveTier, gatedDictionary, narrowSpecs, makeToolHarness, placeholderToolNote } from '@chatpanel/pii';
25
+ import { restoreText, gatedDictionary, narrowSpecs, makeToolHarness, placeholderToolNote } from '@chatpanel/pii';
25
26
  import { streamBridgeChat, readBridgeToken, openBridgeChat } from './bridge.js';
26
27
  import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream, deliverToolResult, toolsToSpecs, parseToolCallId } from './toolrelay.js';
27
28
  import { shaperFor } from './shape.js';
28
29
  import { startNer } from './ner.js';
29
30
  import * as nerEngine from './ner-engine.js';
30
31
  import { MODEL_CATALOG, isKnownModel } from './models.js';
31
- import { resolvePro, meter, usage } from './freegate.js';
32
+ import { resolvePro, checkQuota, consume, usage } from './freegate.js';
32
33
  import { publicConfig, applyConfigPatch, persistConfig, configPath } from './configstore.js';
33
34
  import { resolveDestination, aggregateModelsAsync } from './router.js';
34
35
  import * as openai from './openai.js';
35
36
  import * as responses from './responses.js';
36
37
  import * as anthropic from './anthropic.js';
37
38
 
38
- export const VERSION = '0.6.5';
39
+ export const VERSION = '0.6.8';
39
40
 
40
41
  const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
41
42
 
@@ -136,7 +137,7 @@ function mkTrace(sink) {
136
137
  const entry = /** @type {any} */ ({ ...this.meta, timings });
137
138
  setImmediate(() => {
138
139
  sink(entry);
139
- console.log(`[gateway] model=${entry.model || '-'} → ${entry.dest ? `${entry.dest}(${entry.type})` : 'none'} · redacted ${entry.redacted || 0}${entry.narrowed ? ` · narrowed -${entry.narrowed}` : ''} · ${fmtTimings(timings)}`);
140
+ console.log(`[gateway] model=${entry.model || '-'} → ${entry.dest ? `${entry.dest}(${entry.type})` : 'none'} · redacted ${entry.redacted || 0}${entry.sanitized ? ` · scrubbed ${entry.sanitized} hidden` : ''}${entry.narrowed ? ` · narrowed -${entry.narrowed}` : ''} · ${fmtTimings(timings)}`);
140
141
  });
141
142
  },
142
143
  };
@@ -292,7 +293,9 @@ async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg,
292
293
  const { messages, system } = adapter.toTurn(body);
293
294
  const token = readBridgeToken(cfg.bridge.token);
294
295
  const shaper = shaperFor(kind, body?.model || agent);
295
- const redactOpts = { tier: effectiveTier({ tier: cfg.redaction.tier }, isPro), dictionary: gatedDictionary(cfg.redaction, isPro), entities: [] };
296
+ // Full tier for everyone here (the free allowance is enforced by the quota gate
297
+ // in the main handler), but the custom dictionary stays capped for free.
298
+ const redactOpts = { tier: cfg.redaction.tier === 'full' ? 'full' : 'basic', dictionary: gatedDictionary(cfg.redaction, isPro), entities: [] };
296
299
  const s = createRelaySession({ vault, redactOpts, bridgeUrl: cfg.bridge.url, token, harness });
297
300
  const ttl = setTimeout(() => endRelaySession(s.id), 135_000); // bridge tool-call timeout is 120s
298
301
  // The placeholder note is already in `system` (injected into the body after
@@ -482,6 +485,7 @@ export function createGateway(cfg = loadConfig()) {
482
485
 
483
486
  // --- Config API (the extension's "Gateway" tab is a client of these) ---
484
487
  if (pathname === '/status' && req.method === 'GET') {
488
+ maybeRevalidate(cfg); // throttled, fire-and-forget: reflect a refund/revoke quickly
485
489
  const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
486
490
  const health = await probeNerHealth(cfg); // live GET /health on the detector
487
491
  return sendJson(res, 200, {
@@ -609,6 +613,7 @@ export function createGateway(cfg = loadConfig()) {
609
613
  let body = null;
610
614
  let outBody = raw;
611
615
  let redactedCount = 0;
616
+ let sanitizedCount = 0;
612
617
  let narrowedTools = 0;
613
618
  let isPro = true;
614
619
  // Off the hot path: only build a trace when logging is on, so it adds nothing
@@ -634,12 +639,14 @@ export function createGateway(cfg = loadConfig()) {
634
639
  body.tools = narrowSpecs(body.tools, latestUserText(body, r.kind), { cap, keep, name: toolName, description: toolDesc });
635
640
  narrowedTools = before - body.tools.length;
636
641
  }
637
- // Free/Pro gate: meter the request and pick the effective tier.
642
+ // Free/Pro gate: free users get the REAL thing (full-tier redaction), but
643
+ // only for a fixed lifetime allowance — checked here, consumed below once a
644
+ // redaction actually happens. Over the cap → 402 upsell.
638
645
  isPro = await resolvePro(cfg.pro?.entitlementToken);
639
- const allow = meter(cfg, isPro);
646
+ const allow = checkQuota(cfg, isPro);
640
647
  if (!allow.allowed) {
641
648
  return sendJson(res, 402, { error: {
642
- message: `ChatPanel Gateway free limit reached (${allow.cap}/day). Add a ChatPanel Pro entitlement token to unlock unlimited usage + full-tier redaction (names/orgs).`,
649
+ message: `ChatPanel Gateway free trial used up (${allow.cap} redactions). Add a ChatPanel Pro entitlement token to unlock unlimited full-tier redaction (names/orgs).`,
643
650
  type: 'free_limit_reached',
644
651
  } });
645
652
  }
@@ -647,10 +654,20 @@ export function createGateway(cfg = loadConfig()) {
647
654
  const ac = new AbortController();
648
655
  req.on('close', () => ac.abort());
649
656
  const rd0 = trace ? trace.clock() : 0;
650
- const { vault: v, count } = await redactSegments(segs, cfg.redaction, { signal: ac.signal, isPro });
657
+ // Redact at the configured tier for everyone (free users get genuine
658
+ // name/org redaction within their allowance, not a downgraded preview);
659
+ // the custom dictionary stays capped for free (isPro decides that inside).
660
+ const { vault: v, count, sanitized } = await redactSegments(segs, cfg.redaction, { signal: ac.signal, isPro });
651
661
  if (trace) trace.lap('redact', rd0);
652
662
  vault = v;
653
663
  redactedCount = count;
664
+ sanitizedCount = sanitized || 0;
665
+ // Burn one lifetime free credit only when we actually redacted something,
666
+ // then persist so the count survives a restart. (No-op / no write for Pro.)
667
+ if (!isPro && count > 0) {
668
+ consume(cfg, isPro);
669
+ try { persistConfig(cfg, configPath()); } catch { /* best effort — usage is advisory */ }
670
+ }
654
671
  // When tools are armed, tell the model placeholders are auto-restored for
655
672
  // tools (so privacy-aware models USE them instead of refusing). Injected
656
673
  // AFTER redaction so the note isn't itself redacted. Covers BOTH the API
@@ -672,7 +689,7 @@ export function createGateway(cfg = loadConfig()) {
672
689
  // API we forward to). Falls back to the legacy backend when none configured.
673
690
  const dest = resolveDestination(body?.model, cfg, r.kind);
674
691
  if (trace) {
675
- trace.meta = { t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount, narrowed: narrowedTools, detail: redactionDetail(vault, cfg.logDetail) };
692
+ trace.meta = { t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount, sanitized: sanitizedCount, narrowed: narrowedTools, detail: redactionDetail(vault, cfg.logDetail) };
676
693
  }
677
694
  if (dest && dest.type === 'api') {
678
695
  if (!dest.baseUrl) { trace?.commit(); return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` }); }
@@ -689,13 +706,17 @@ export function createGateway(cfg = loadConfig()) {
689
706
  export function start(cfg = loadConfig()) {
690
707
  const server = createGateway(cfg);
691
708
  const ner = startNer(cfg); // may mutate cfg.redaction when it comes up
709
+ // Re-validate the stored Pro entitlement online on an interval, so a refunded /
710
+ // revoked subscription drops the gateway to Free instead of riding the offline
711
+ // token to its exp (see entitlement-refresh.js).
712
+ const entitlement = startEntitlementRefresh(cfg);
692
713
  server.listen(cfg.port, cfg.host, () => {
693
714
  console.log(`ChatPanel Privacy Gateway v${VERSION} on http://${cfg.host}:${cfg.port}`);
694
715
  console.log(` backend : ${cfg.backend}` + (cfg.backend === 'bridge' ? ` (agent: ${cfg.bridge.agent}, via ${cfg.bridge.url})` : ''));
695
716
  console.log(` redaction: ${cfg.redaction.tier}` + (cfg.redaction.detection?.backend && cfg.redaction.detection.backend !== 'off'
696
717
  ? ` + ${cfg.redaction.detection.backend} detector` : (cfg.ner?.autostart ? ' (+ NER starting…)' : '')));
697
718
  });
698
- const shutdown = () => { ner?.stop(); server.close(() => process.exit(0)); };
719
+ const shutdown = () => { ner?.stop(); entitlement.stop(); server.close(() => process.exit(0)); };
699
720
  process.on('SIGINT', shutdown);
700
721
  process.on('SIGTERM', shutdown);
701
722
  return server;