@chatpanel/gateway 0.6.4 → 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/README.md CHANGED
@@ -43,9 +43,14 @@ You need the [ChatPanel bridge](https://github.com/chatpanel/chatpanel-bridge)
43
43
  running and logged into codex/claude (the same bridge the extension uses).
44
44
 
45
45
  ```bash
46
+ # Standalone binary — no Node.js required:
47
+ curl -fsSL https://dl.chatpanel.net/gateway/install.sh | bash # macOS / Linux
48
+ # Windows (PowerShell): irm https://dl.chatpanel.net/gateway/install.ps1 | iex
49
+
50
+ # Or via npm (needs Node):
46
51
  npm install -g @chatpanel/gateway
47
52
  chatpanel-gateway
48
- # → ChatPanel Privacy Gateway v0.1.0 on http://127.0.0.1:4320
53
+ # → ChatPanel Privacy Gateway on http://127.0.0.1:4320
49
54
  # backend : bridge (agent: codex, via http://127.0.0.1:4319)
50
55
  ```
51
56
 
@@ -68,16 +73,42 @@ before codex sees it, and the reply is restored before opencode renders it. The
68
73
  request's `model` (`codex`/`claude`/`opencode`/`pi`) picks which agent the bridge
69
74
  drives; otherwise the configured default (`codex`) is used.
70
75
 
71
- ### Using the api backend instead (local models / BYO keys)
76
+ ### Quick start (api backend no bridge)
72
77
 
73
- Set `backend: "api"` (see config) and the gateway forwards redacted traffic to a
74
- real provider endpoint, passing your `Authorization` / `x-api-key` through:
78
+ If all you want is the gateway as a **redacting proxy in front of an API model**,
79
+ you do **not** need the bridge. Set `backend: "api"` and (optionally) point the
80
+ gateway's upstream at your provider — in `~/.chatpanel/gateway.config.json`:
81
+
82
+ ```json
83
+ {
84
+ "backend": "api",
85
+ "upstreams": {
86
+ "openai": { "baseUrl": "https://api.openai.com" },
87
+ "anthropic": { "baseUrl": "https://api.anthropic.com" }
88
+ }
89
+ }
90
+ ```
91
+
92
+ Then point your **client** at the gateway and send your **own** API key — the
93
+ gateway redacts, forwards to the provider with your key (it stores none), and
94
+ restores the reply:
75
95
 
76
96
  ```bash
77
- export OPENAI_BASE_URL=http://127.0.0.1:4320/v1 # OpenAI / codex / aider / cursor
97
+ # In your CLIENT's environment (NOT the gateway's — see the footgun below):
98
+ export OPENAI_BASE_URL=http://127.0.0.1:4320/v1 # OpenAI-compatible: codex / aider / cursor / SDKs
78
99
  export ANTHROPIC_BASE_URL=http://127.0.0.1:4320 # Claude Code / Anthropic SDK
79
100
  ```
80
101
 
102
+ To target a non-OpenAI provider (a local model, OpenRouter, Azure, …) change the
103
+ **gateway's** `upstreams.*.baseUrl` in the config above — that's where the gateway
104
+ forwards to. Flow: `client → gateway (redact) → provider (your key) → gateway (restore) → client`.
105
+
106
+ > ⚠️ **Footgun:** `OPENAI_BASE_URL` means two different things — for your *client*
107
+ > it's "where the gateway is", for the *gateway* it's "where my upstream is". Don't
108
+ > set `OPENAI_BASE_URL=…:4320` in the **gateway's own** environment, or it forwards
109
+ > to itself (the loop guard returns 508). Set the gateway's upstream in the config
110
+ > file; use the env var only in the client's shell.
111
+
81
112
  ## Name/org redaction is built in (in-process NER, no Python)
82
113
 
83
114
  Deterministic redaction (emails, phones, cards, SSNs, API keys, IPs) needs no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.4",
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
 
@@ -96,6 +98,14 @@ const DEFAULTS = {
96
98
 
97
99
  // Log one line per request (method, tokens redacted) without any raw values.
98
100
  logRequests: true,
101
+
102
+ // Optional per-request redaction breakdown attached to each log entry (shown
103
+ // expandable in the extension). Memory-only — never persisted to disk with the
104
+ // captured values; only the MODE is saved.
105
+ // 'off' — counts only (default; the privacy-safe baseline)
106
+ // 'types' — entity types + placeholder tokens (e.g. PERSON_1), no real values
107
+ // 'values' — real → placeholder mapping (the actual PII; opt-in, debugging)
108
+ logDetail: 'off',
99
109
  };
100
110
 
101
111
  function deepMerge(base, over) {
@@ -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).
@@ -22,7 +23,7 @@ export function persistConfig(cfg, path = configPath()) {
22
23
  destinations: cfg.destinations,
23
24
  bridge: cfg.bridge, upstreams: cfg.upstreams, redaction: cfg.redaction,
24
25
  ner: cfg.ner, allowedOrigins: cfg.allowedOrigins, maxBodyBytes: cfg.maxBodyBytes,
25
- pro: cfg.pro, logRequests: cfg.logRequests, tools: cfg.tools,
26
+ pro: cfg.pro, logRequests: cfg.logRequests, logDetail: cfg.logDetail, tools: cfg.tools,
26
27
  };
27
28
  writeFileSync(path, JSON.stringify(out, null, 2));
28
29
  }
@@ -45,8 +46,11 @@ 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,
53
+ logDetail: ['types', 'values'].includes(cfg.logDetail) ? cfg.logDetail : 'off',
50
54
  tools: {
51
55
  autoNarrow: cfg.tools?.autoNarrow !== false,
52
56
  maxPerTurn: Number(cfg.tools?.maxPerTurn) > 0 ? Number(cfg.tools.maxPerTurn) : 8,
@@ -101,10 +105,12 @@ export function applyConfigPatch(cfg, patch = {}) {
101
105
  if (Array.isArray(patch.allowedOrigins)) cfg.allowedOrigins = patch.allowedOrigins;
102
106
  if (patch.pro && typeof patch.pro === 'object') {
103
107
  if (typeof patch.pro.entitlementToken === 'string') cfg.pro.entitlementToken = patch.pro.entitlementToken;
104
- const cap = patch.pro.free?.maxRequestsPerDay;
105
- 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.
106
111
  }
107
112
  if (typeof patch.logRequests === 'boolean') cfg.logRequests = patch.logRequests;
113
+ if (['off', 'types', 'values'].includes(patch.logDetail)) cfg.logDetail = patch.logDetail;
108
114
  if (patch.tools && typeof patch.tools === 'object') {
109
115
  cfg.tools = cfg.tools || {};
110
116
  if ('autoNarrow' in patch.tools) cfg.tools.autoNarrow = !!patch.tools.autoNarrow;
@@ -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.4';
39
+ export const VERSION = '0.6.6';
39
40
 
40
41
  const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
41
42
 
@@ -100,12 +101,62 @@ function setCors(res, origin) {
100
101
 
101
102
  const STARTED_AT = Date.now();
102
103
 
103
- // In-memory ring of recent request SUMMARIES for the extension's monitoring view.
104
- // Counts only — never any prompt/response text or values.
105
- const recentRequests = [];
106
- function recordRequest(entry) {
107
- recentRequests.push(entry);
108
- if (recentRequests.length > 50) recentRequests.shift();
104
+ // Render a timings map as a compact one-liner for the console log.
105
+ function fmtTimings(t) {
106
+ if (!t) return '';
107
+ const label = { redact: 'redact', upstream: 'model', stream: 'stream', restore: 'restore', total: 'total' };
108
+ return Object.keys(t).map((k) => `${label[k] || k} ${t[k]}ms`).join(' · ');
109
+ }
110
+
111
+ // Per-request timing + summary. Created ONLY when logging is on — when it's off
112
+ // the handler passes a null trace and every `trace?.…` call short-circuits, so we
113
+ // don't even read the clock: logging then adds zero latency. The committed entry
114
+ // is flushed to the in-memory ring via setImmediate, i.e. AFTER the response has
115
+ // been handed back, so recording (and the console line) never sits on the request
116
+ // path. `timings` are wall-clock ms per stage of the flow:
117
+ // redact prompt → harness[redact] → model input
118
+ // upstream model input → model output. Non-stream: full call. Stream: time to
119
+ // first token (model/connection latency). Shown as "model".
120
+ // stream first token → last token (generation), streaming responses only
121
+ // restore model output → harness[restore] → user response (non-stream; for
122
+ // streams restore is inline per chunk, so it's folded into stream)
123
+ // total end-to-end through the gateway
124
+ function mkTrace(sink) {
125
+ const start = performance.now();
126
+ const timings = {};
127
+ let done = false;
128
+ const mark = (name, ms) => { timings[name] = Math.round(ms * 10) / 10; };
129
+ return {
130
+ meta: {}, timings, mark,
131
+ // Stamp the duration of an already-completed stage (`t0` from clock()).
132
+ lap(name, t0) { mark(name, performance.now() - t0); },
133
+ clock() { return performance.now(); },
134
+ commit() {
135
+ if (done) return; done = true;
136
+ mark('total', performance.now() - start);
137
+ const entry = /** @type {any} */ ({ ...this.meta, timings });
138
+ setImmediate(() => {
139
+ sink(entry);
140
+ console.log(`[gateway] model=${entry.model || '-'} → ${entry.dest ? `${entry.dest}(${entry.type})` : 'none'} · redacted ${entry.redacted || 0}${entry.narrowed ? ` · narrowed -${entry.narrowed}` : ''} · ${fmtTimings(timings)}`);
141
+ });
142
+ },
143
+ };
144
+ }
145
+
146
+ // Build the optional per-request redaction breakdown from the request's vault.
147
+ // 'types' → [{ token:'PERSON_1', type:'PERSON' }] (no real values)
148
+ // 'values' → [{ token:'PERSON_1', type:'PERSON', value:'…' }] (the real PII; opt-in)
149
+ // Lives only in the in-memory ring — never written to disk by persistConfig.
150
+ function redactionDetail(vault, mode) {
151
+ if (!vault || !vault.byToken || (mode !== 'types' && mode !== 'values')) return undefined;
152
+ const out = [];
153
+ for (const [token, value] of vault.byToken) {
154
+ const m = /^\[\[([A-Z][A-Z0-9]*)_\d+\]\]$/.exec(token);
155
+ const t = m ? m[1] : 'PII';
156
+ const bare = token.replace(/^\[\[|\]\]$/g, '');
157
+ out.push(mode === 'values' ? { token: bare, type: t, value } : { token: bare, type: t });
158
+ }
159
+ return out;
109
160
  }
110
161
 
111
162
  // Classify a request: which protocol kind + adapter, whether it's a redactable
@@ -213,27 +264,38 @@ function forwardHeaders(headers, base) {
213
264
  // ---- backend: bridge -------------------------------------------------------
214
265
 
215
266
  // Stream the bridge SSE through the OpenAI shaper, parking on a tool call.
216
- async function pumpRelay(res, s, shaper) {
267
+ // `trace` (when present) times the agent turn: 'upstream' = time to first token,
268
+ // 'stream' = first token → park/done. The turn ends at a tool-call (parked, the
269
+ // client runs the tool and POSTs back, resuming a fresh trace) or at onDone.
270
+ async function pumpRelay(res, s, shaper, trace) {
217
271
  const restorer = makeTokenRestorer(s.vault);
272
+ const t0 = trace ? trace.clock() : 0;
273
+ let sStart = t0;
274
+ let first = true;
275
+ const tick = () => { if (trace && first) { trace.lap('upstream', t0); sStart = trace.clock(); first = false; } };
218
276
  await pumpBridgeStream(s, {
219
- onText: (text) => { const r = restorer.push(text); if (r) res.write(shaper.sseDelta(r)); },
277
+ onText: (text) => { tick(); const r = restorer.push(text); if (r) res.write(shaper.sseDelta(r)); },
220
278
  onToolRequest: ({ name, restoredArgs, toolId }) => {
279
+ tick();
221
280
  const tail = restorer.flush(); if (tail) res.write(shaper.sseDelta(tail));
222
281
  res.write(shaper.sseToolCalls([{ id: toolId, name, arguments: JSON.stringify(restoredArgs) }]));
223
282
  res.write(shaper.sseToolFinish());
224
- res.end(); // park: turn ends with tool_calls; the session stays alive for the follow-up
283
+ if (trace) trace.lap('stream', sStart);
284
+ res.end(); trace?.commit(); // park: turn ends with tool_calls; the session stays alive for the follow-up
225
285
  },
226
- onDone: () => { const tail = restorer.flush(); if (tail) res.write(shaper.sseDelta(tail)); res.write(shaper.sseTail()); res.end(); endRelaySession(s.id); },
227
- onError: (e) => { res.write(`data: ${JSON.stringify({ error: { message: e.message, type: 'bridge_error' } })}\n\n`); res.end(); endRelaySession(s.id); },
286
+ onDone: () => { const tail = restorer.flush(); if (tail) res.write(shaper.sseDelta(tail)); res.write(shaper.sseTail()); if (trace) trace.lap('stream', sStart); res.end(); endRelaySession(s.id); trace?.commit(); },
287
+ onError: (e) => { res.write(`data: ${JSON.stringify({ error: { message: e.message, type: 'bridge_error' } })}\n\n`); res.end(); endRelaySession(s.id); trace?.commit(); },
228
288
  });
229
289
  }
230
290
 
231
291
  // New tool-enabled turn: open the bridge with the client's tools as MCP specs.
232
- async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg, isPro, tools, harness = null) {
292
+ async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg, isPro, tools, harness = null, trace = null) {
233
293
  const { messages, system } = adapter.toTurn(body);
234
294
  const token = readBridgeToken(cfg.bridge.token);
235
295
  const shaper = shaperFor(kind, body?.model || agent);
236
- 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: [] };
237
299
  const s = createRelaySession({ vault, redactOpts, bridgeUrl: cfg.bridge.url, token, harness });
238
300
  const ttl = setTimeout(() => endRelaySession(s.id), 135_000); // bridge tool-call timeout is 120s
239
301
  // The placeholder note is already in `system` (injected into the body after
@@ -241,40 +303,48 @@ async function startRelay(req, res, { kind, adapter, agent }, body, vault, cfg,
241
303
  let resp;
242
304
  try {
243
305
  resp = await openBridgeChat({ bridgeUrl: cfg.bridge.url, agent, token, messages, system, specs: toolsToSpecs(tools), options: {}, signal: undefined });
244
- } catch (e) { clearTimeout(ttl); endRelaySession(s.id); return sendJson(res, 502, { error: { message: `bridge: ${e.message}`, type: 'bridge_error' } }); }
306
+ } catch (e) { clearTimeout(ttl); endRelaySession(s.id); trace?.commit(); return sendJson(res, 502, { error: { message: `bridge: ${e.message}`, type: 'bridge_error' } }); }
245
307
  s.reader = resp.body.getReader();
246
308
  res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
247
309
  res.write(shaper.sseHead());
248
- return pumpRelay(res, s, shaper);
310
+ return pumpRelay(res, s, shaper, trace);
249
311
  }
250
312
 
251
313
  // Follow-up turn carrying a tool result: feed it to the parked agent + resume.
252
- async function resumeRelay(res, s, toolContent, model) {
253
- try { await deliverToolResult(s, toolContent); }
254
- catch (e) { endRelaySession(s.id); return sendJson(res, 502, { error: { message: `tool-result: ${e.message}`, type: 'bridge_error' } }); }
314
+ // The relay redacts the tool result with ITS vault (the main handler skips
315
+ // redaction for a relay-resume), so time that here as the 'redact' leg.
316
+ async function resumeRelay(res, s, toolContent, model, trace = null) {
317
+ try {
318
+ const rd0 = trace ? trace.clock() : 0;
319
+ await deliverToolResult(s, toolContent);
320
+ if (trace) trace.lap('redact', rd0);
321
+ } catch (e) { endRelaySession(s.id); trace?.commit(); return sendJson(res, 502, { error: { message: `tool-result: ${e.message}`, type: 'bridge_error' } }); }
255
322
  const shaper = shaperFor('openai', model || 'codex');
256
323
  res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
257
324
  res.write(shaper.sseHead());
258
- return pumpRelay(res, s, shaper);
325
+ return pumpRelay(res, s, shaper, trace);
259
326
  }
260
327
 
261
- async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride, harness }, body, vault, cfg, isPro) {
328
+ async function handleBridge(req, res, { kind, adapter, redactable, pathname, agentOverride, harness, trace }, body, vault, cfg, isPro) {
262
329
  if (!redactable) {
330
+ trace?.commit();
263
331
  return sendJson(res, 404, { error: `endpoint ${pathname} not supported by the bridge backend` });
264
332
  }
265
333
 
266
334
  // Tool relay (OpenAI protocol + agent destinations). A follow-up request carries
267
335
  // a tool result for a parked session; a new request with `tools` starts one.
336
+ // The relay streams its own multi-turn flow and commits the trace when its turn
337
+ // ends (parked on a tool call, or done).
268
338
  if (kind === 'openai') {
269
339
  const toolResult = adapter.extractLatestToolResult(body);
270
340
  if (toolResult) {
271
341
  const parsed = parseToolCallId(toolResult.tool_call_id);
272
342
  const s = parsed && getRelaySession(parsed.gwId);
273
- if (s) return resumeRelay(res, s, toolResult.content, body?.model);
343
+ if (s) return resumeRelay(res, s, toolResult.content, body?.model, trace);
274
344
  }
275
345
  const tools = adapter.extractTools(body);
276
346
  if (tools.length && body?.stream === true) {
277
- return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg) }, body, vault, cfg, isPro, tools, harness);
347
+ return startRelay(req, res, { kind, adapter, agent: agentOverride || pickAgent(body?.model, cfg) }, body, vault, cfg, isPro, tools, harness, trace);
278
348
  }
279
349
  }
280
350
 
@@ -291,10 +361,17 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
291
361
  if (!wantStream) {
292
362
  try {
293
363
  let full = '';
364
+ const up0 = trace ? trace.clock() : 0;
294
365
  await streamBridgeChat(turn, (t) => { full += t; });
366
+ if (trace) trace.lap('upstream', up0);
367
+ const rs0 = trace ? trace.clock() : 0;
368
+ const out = shaper.full(restoreText(full, vault));
369
+ if (trace) trace.lap('restore', rs0);
295
370
  res.writeHead(200, { 'content-type': shaper.contentType });
296
- return res.end(shaper.full(restoreText(full, vault)));
371
+ res.end(out);
372
+ return trace?.commit();
297
373
  } catch (e) {
374
+ trace?.commit();
298
375
  return sendJson(res, 502, { error: { message: `bridge backend failed: ${e.message}`, type: 'bridge_error' } });
299
376
  }
300
377
  }
@@ -302,8 +379,12 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
302
379
  res.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache', connection: 'keep-alive' });
303
380
  res.write(shaper.sseHead());
304
381
  const restorer = makeTokenRestorer(vault);
382
+ const up0 = trace ? trace.clock() : 0;
383
+ let sStart = up0;
305
384
  try {
385
+ let first = true;
306
386
  await streamBridgeChat(turn, (chunk) => {
387
+ if (trace && first) { trace.lap('upstream', up0); sStart = trace.clock(); first = false; } // time-to-first-token
307
388
  const restored = restorer.push(chunk);
308
389
  if (restored) res.write(shaper.sseDelta(restored));
309
390
  });
@@ -313,13 +394,16 @@ async function handleBridge(req, res, { kind, adapter, redactable, pathname, age
313
394
  } catch (e) {
314
395
  res.write(`data: ${JSON.stringify({ error: { message: e.message, type: 'bridge_error' } })}\n\n`);
315
396
  }
397
+ if (trace) trace.lap('stream', sStart);
316
398
  res.end();
399
+ trace?.commit();
317
400
  }
318
401
 
319
402
  // ---- backend: api ----------------------------------------------------------
320
403
 
321
- async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness }, outBody, vault) {
404
+ async function handleApi(req, res, { adapter, kind, pathname, search, base, destKey, destProtocol, harness, trace }, outBody, vault) {
322
405
  let upstream;
406
+ const up0 = trace ? trace.clock() : 0;
323
407
  try {
324
408
  const headers = forwardHeaders(req.headers, base);
325
409
  // If the destination carries its own key (imported from a configured API),
@@ -334,6 +418,7 @@ async function handleApi(req, res, { adapter, kind, pathname, search, base, dest
334
418
  body: ['GET', 'HEAD'].includes(req.method) ? undefined : outBody,
335
419
  });
336
420
  } catch (e) {
421
+ trace?.commit();
337
422
  return sendJson(res, 502, { error: `upstream fetch failed: ${e.message}` });
338
423
  }
339
424
 
@@ -342,29 +427,47 @@ async function handleApi(req, res, { adapter, kind, pathname, search, base, dest
342
427
  upstream.headers.forEach((v, k) => { if (!HOP_BY_HOP.has(k.toLowerCase())) resHeaders[k] = v; });
343
428
 
344
429
  if (ct.includes('text/event-stream') && upstream.body) {
430
+ if (trace) trace.lap('upstream', up0); // model latency to response headers
431
+ const sStart = trace ? trace.clock() : 0;
345
432
  res.writeHead(upstream.status, resHeaders);
346
433
  // OpenAI streaming: restore tool-call args via the harness (real, or kept
347
434
  // redacted for remote MCP under redactRemote) while keeping visible text
348
- // pseudonymized. Other protocols: generic restore.
349
- if (kind === 'openai') return pipeRestoredOpenAIStream(upstream.body, res, vault, harness);
350
- return pipeRestoredStream(upstream.body, res, vault);
435
+ // pseudonymized. Other protocols: generic restore. The 'stream' leg spans
436
+ // the body; commit once it finishes (total then covers the whole response).
437
+ const piped = kind === 'openai'
438
+ ? pipeRestoredOpenAIStream(upstream.body, res, vault, harness)
439
+ : pipeRestoredStream(upstream.body, res, vault);
440
+ return Promise.resolve(piped).finally(() => { if (trace) trace.lap('stream', sStart); trace?.commit(); });
351
441
  }
352
442
 
353
443
  const buf = Buffer.from(await upstream.arrayBuffer());
444
+ if (trace) trace.lap('upstream', up0);
354
445
  if (vault && ct.includes('application/json')) {
355
446
  try {
447
+ const rs0 = trace ? trace.clock() : 0;
356
448
  const json = adapter.restoreResponse(JSON.parse(buf.toString('utf8')), vault, harness);
449
+ if (trace) trace.lap('restore', rs0);
357
450
  res.writeHead(upstream.status, { ...resHeaders, 'content-type': 'application/json' });
358
- return res.end(Buffer.from(JSON.stringify(json), 'utf8'));
451
+ res.end(Buffer.from(JSON.stringify(json), 'utf8'));
452
+ return trace?.commit();
359
453
  } catch { /* fall through */ }
360
454
  }
361
455
  res.writeHead(upstream.status, resHeaders);
362
456
  res.end(buf);
457
+ trace?.commit();
363
458
  }
364
459
 
365
460
  // ---- server ----------------------------------------------------------------
366
461
 
367
462
  export function createGateway(cfg = loadConfig()) {
463
+ // Per-gateway ring of recent request summaries for the extension's monitoring
464
+ // view (newest last). Counts + optional redaction detail + per-stage timings —
465
+ // see mkTrace. Lives only here, never persisted to disk.
466
+ const recentRequests = [];
467
+ const recordRequest = (entry) => {
468
+ recentRequests.push(entry);
469
+ if (recentRequests.length > 50) recentRequests.shift();
470
+ };
368
471
  return createServer(async (req, res) => {
369
472
  const url = new URL(req.url, 'http://127.0.0.1');
370
473
  const pathname = url.pathname;
@@ -382,6 +485,7 @@ export function createGateway(cfg = loadConfig()) {
382
485
 
383
486
  // --- Config API (the extension's "Gateway" tab is a client of these) ---
384
487
  if (pathname === '/status' && req.method === 'GET') {
488
+ maybeRevalidate(cfg); // throttled, fire-and-forget: reflect a refund/revoke quickly
385
489
  const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
386
490
  const health = await probeNerHealth(cfg); // live GET /health on the detector
387
491
  return sendJson(res, 200, {
@@ -461,7 +565,7 @@ export function createGateway(cfg = loadConfig()) {
461
565
  }
462
566
  }
463
567
  if (pathname === '/logs' && req.method === 'GET') {
464
- return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only
568
+ return sendJson(res, 200, { entries: [...recentRequests].reverse() }); // newest first; counts only, unless logDetail enriches each entry
465
569
  }
466
570
  if (pathname === '/config' && req.method === 'GET') {
467
571
  const proUnlocked = await resolvePro(cfg.pro?.entitlementToken);
@@ -511,6 +615,9 @@ export function createGateway(cfg = loadConfig()) {
511
615
  let redactedCount = 0;
512
616
  let narrowedTools = 0;
513
617
  let isPro = true;
618
+ // Off the hot path: only build a trace when logging is on, so it adds nothing
619
+ // when off (no clock reads, no record, no console line).
620
+ const trace = (cfg.logRequests && r.redactable && req.method === 'POST') ? mkTrace(recordRequest) : null;
514
621
  if (r.redactable && req.method === 'POST' && raw.length) {
515
622
  try { body = JSON.parse(raw.toString('utf8')); } catch { body = null; }
516
623
  if (body && isRelayResume(body, r.kind)) {
@@ -531,21 +638,34 @@ export function createGateway(cfg = loadConfig()) {
531
638
  body.tools = narrowSpecs(body.tools, latestUserText(body, r.kind), { cap, keep, name: toolName, description: toolDesc });
532
639
  narrowedTools = before - body.tools.length;
533
640
  }
534
- // 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.
535
644
  isPro = await resolvePro(cfg.pro?.entitlementToken);
536
- const allow = meter(cfg, isPro);
645
+ const allow = checkQuota(cfg, isPro);
537
646
  if (!allow.allowed) {
538
647
  return sendJson(res, 402, { error: {
539
- 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).`,
540
649
  type: 'free_limit_reached',
541
650
  } });
542
651
  }
543
652
  const segs = r.adapter.collectSegments(body, cfg.redaction);
544
653
  const ac = new AbortController();
545
654
  req.on('close', () => ac.abort());
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).
546
659
  const { vault: v, count } = await redactSegments(segs, cfg.redaction, { signal: ac.signal, isPro });
660
+ if (trace) trace.lap('redact', rd0);
547
661
  vault = v;
548
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
+ }
549
669
  // When tools are armed, tell the model placeholders are auto-restored for
550
670
  // tools (so privacy-aware models USE them instead of refusing). Injected
551
671
  // AFTER redaction so the note isn't itself redacted. Covers BOTH the API
@@ -566,31 +686,35 @@ export function createGateway(cfg = loadConfig()) {
566
686
  // Route by the requested model → a destination (agent via the bridge, or an
567
687
  // API we forward to). Falls back to the legacy backend when none configured.
568
688
  const dest = resolveDestination(body?.model, cfg, r.kind);
569
- if (cfg.logRequests && r.redactable) {
570
- recordRequest({ t: Date.now(), model: body?.model || null, dest: dest ? dest.id : null, type: dest ? dest.type : null, redacted: redactedCount, narrowed: narrowedTools });
571
- console.log(`[gateway] ${req.method} ${pathname} · model=${body?.model || '-'} → ${dest ? `${dest.id}(${dest.type})` : 'none'} · redacted ${redactedCount}${narrowedTools ? ` · narrowed -${narrowedTools} tools` : ''}`);
689
+ if (trace) {
690
+ 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) };
572
691
  }
573
692
  if (dest && dest.type === 'api') {
574
- if (!dest.baseUrl) return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` });
693
+ if (!dest.baseUrl) { trace?.commit(); return sendJson(res, 502, { error: `destination "${dest.id}" has no baseUrl` }); }
575
694
  if (isSelfUrl(dest.baseUrl, cfg)) {
695
+ trace?.commit();
576
696
  return sendJson(res, 508, { error: { message: `destination "${dest.id}" points back at the gateway (${dest.baseUrl}) — refusing to forward (would loop).`, type: 'loop_detected' } });
577
697
  }
578
- return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol, harness }, outBody, vault);
698
+ return handleApi(req, res, { ...r, pathname, search: url.search, base: dest.baseUrl, destKey: dest.apiKey, destProtocol: dest.protocol, harness, trace }, outBody, vault);
579
699
  }
580
- return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent, harness }, body, vault, cfg, isPro);
700
+ return handleBridge(req, res, { ...r, pathname, agentOverride: dest?.agent, harness, trace }, body, vault, cfg, isPro);
581
701
  });
582
702
  }
583
703
 
584
704
  export function start(cfg = loadConfig()) {
585
705
  const server = createGateway(cfg);
586
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);
587
711
  server.listen(cfg.port, cfg.host, () => {
588
712
  console.log(`ChatPanel Privacy Gateway v${VERSION} on http://${cfg.host}:${cfg.port}`);
589
713
  console.log(` backend : ${cfg.backend}` + (cfg.backend === 'bridge' ? ` (agent: ${cfg.bridge.agent}, via ${cfg.bridge.url})` : ''));
590
714
  console.log(` redaction: ${cfg.redaction.tier}` + (cfg.redaction.detection?.backend && cfg.redaction.detection.backend !== 'off'
591
715
  ? ` + ${cfg.redaction.detection.backend} detector` : (cfg.ner?.autostart ? ' (+ NER starting…)' : '')));
592
716
  });
593
- const shutdown = () => { ner?.stop(); server.close(() => process.exit(0)); };
717
+ const shutdown = () => { ner?.stop(); entitlement.stop(); server.close(() => process.exit(0)); };
594
718
  process.on('SIGINT', shutdown);
595
719
  process.on('SIGTERM', shutdown);
596
720
  return server;