@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 +2 -2
- package/src/config.js +7 -5
- package/src/configstore.js +7 -3
- package/src/entitlement-refresh.js +94 -0
- package/src/freegate.js +34 -22
- package/src/redact.js +26 -9
- package/src/server.js +32 -11
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
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.
|
|
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 =
|
|
38
|
-
// redaction (
|
|
39
|
-
//
|
|
40
|
-
//
|
|
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
|
-
|
|
46
|
+
used: 0,
|
|
45
47
|
},
|
|
46
48
|
},
|
|
47
49
|
|
package/src/configstore.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
106
|
-
|
|
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):
|
|
4
|
-
//
|
|
5
|
-
// ChatPanel entitlement token — the same
|
|
6
|
-
// bridge use) unlocks
|
|
7
|
-
//
|
|
8
|
-
//
|
|
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
|
-
//
|
|
22
|
+
// Lifetime free usage — for the gateway's /status (the extension's monitoring).
|
|
16
23
|
export function usage(cfg) {
|
|
17
|
-
|
|
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
|
-
//
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
//
|
|
35
|
-
//
|
|
36
|
-
export function
|
|
37
|
-
if (isPro) return
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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,
|
|
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
|
-
//
|
|
17
|
-
//
|
|
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
|
-
//
|
|
24
|
-
//
|
|
25
|
-
|
|
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,
|
|
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,
|
|
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.
|
|
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
|
-
|
|
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:
|
|
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 =
|
|
646
|
+
const allow = checkQuota(cfg, isPro);
|
|
640
647
|
if (!allow.allowed) {
|
|
641
648
|
return sendJson(res, 402, { error: {
|
|
642
|
-
message: `ChatPanel Gateway free
|
|
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
|
-
|
|
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;
|