@chatpanel/gateway 0.6.5 → 0.6.6

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.6",
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.9",
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,24 @@
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 } 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();
20
23
  const texts = segments.map((s) => s.get()).filter((t) => typeof t === 'string' && t);
21
24
  if (texts.length === 0) return { vault, count: 0 };
22
25
 
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);
26
+ // Use the configured tier as-is (no free downgrade the quota is the free gate),
27
+ // but keep the dictionary capped for free via the shared chatpanel-pii gate.
28
+ const tier = redactionCfg.tier === 'full' ? 'full' : 'basic';
27
29
  const dictionary = gatedDictionary(redactionCfg, isPro);
28
30
 
29
31
  // Detection source: a USER-configured external detector takes precedence; else
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.6';
39
40
 
40
41
  const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
41
42
 
@@ -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, {
@@ -634,12 +638,14 @@ export function createGateway(cfg = loadConfig()) {
634
638
  body.tools = narrowSpecs(body.tools, latestUserText(body, r.kind), { cap, keep, name: toolName, description: toolDesc });
635
639
  narrowedTools = before - body.tools.length;
636
640
  }
637
- // Free/Pro gate: meter the request and pick the effective tier.
641
+ // Free/Pro gate: free users get the REAL thing (full-tier redaction), but
642
+ // only for a fixed lifetime allowance — checked here, consumed below once a
643
+ // redaction actually happens. Over the cap → 402 upsell.
638
644
  isPro = await resolvePro(cfg.pro?.entitlementToken);
639
- const allow = meter(cfg, isPro);
645
+ const allow = checkQuota(cfg, isPro);
640
646
  if (!allow.allowed) {
641
647
  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).`,
648
+ 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
649
  type: 'free_limit_reached',
644
650
  } });
645
651
  }
@@ -647,10 +653,19 @@ export function createGateway(cfg = loadConfig()) {
647
653
  const ac = new AbortController();
648
654
  req.on('close', () => ac.abort());
649
655
  const rd0 = trace ? trace.clock() : 0;
656
+ // Redact at the configured tier for everyone (free users get genuine
657
+ // name/org redaction within their allowance, not a downgraded preview);
658
+ // the custom dictionary stays capped for free (isPro decides that inside).
650
659
  const { vault: v, count } = await redactSegments(segs, cfg.redaction, { signal: ac.signal, isPro });
651
660
  if (trace) trace.lap('redact', rd0);
652
661
  vault = v;
653
662
  redactedCount = count;
663
+ // Burn one lifetime free credit only when we actually redacted something,
664
+ // then persist so the count survives a restart. (No-op / no write for Pro.)
665
+ if (!isPro && count > 0) {
666
+ consume(cfg, isPro);
667
+ try { persistConfig(cfg, configPath()); } catch { /* best effort — usage is advisory */ }
668
+ }
654
669
  // When tools are armed, tell the model placeholders are auto-restored for
655
670
  // tools (so privacy-aware models USE them instead of refusing). Injected
656
671
  // AFTER redaction so the note isn't itself redacted. Covers BOTH the API
@@ -689,13 +704,17 @@ export function createGateway(cfg = loadConfig()) {
689
704
  export function start(cfg = loadConfig()) {
690
705
  const server = createGateway(cfg);
691
706
  const ner = startNer(cfg); // may mutate cfg.redaction when it comes up
707
+ // Re-validate the stored Pro entitlement online on an interval, so a refunded /
708
+ // revoked subscription drops the gateway to Free instead of riding the offline
709
+ // token to its exp (see entitlement-refresh.js).
710
+ const entitlement = startEntitlementRefresh(cfg);
692
711
  server.listen(cfg.port, cfg.host, () => {
693
712
  console.log(`ChatPanel Privacy Gateway v${VERSION} on http://${cfg.host}:${cfg.port}`);
694
713
  console.log(` backend : ${cfg.backend}` + (cfg.backend === 'bridge' ? ` (agent: ${cfg.bridge.agent}, via ${cfg.bridge.url})` : ''));
695
714
  console.log(` redaction: ${cfg.redaction.tier}` + (cfg.redaction.detection?.backend && cfg.redaction.detection.backend !== 'off'
696
715
  ? ` + ${cfg.redaction.detection.backend} detector` : (cfg.ner?.autostart ? ' (+ NER starting…)' : '')));
697
716
  });
698
- const shutdown = () => { ner?.stop(); server.close(() => process.exit(0)); };
717
+ const shutdown = () => { ner?.stop(); entitlement.stop(); server.close(() => process.exit(0)); };
699
718
  process.on('SIGINT', shutdown);
700
719
  process.on('SIGTERM', shutdown);
701
720
  return server;