@chatpanel/gateway 0.6.12 → 0.6.14

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.12",
3
+ "version": "0.6.14",
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": {
@@ -0,0 +1,44 @@
1
+ // Decrypt a ChatPanel backup envelope — the gateway counterpart to the extension's
2
+ // crypto-backup.js. Same wire format so the gateway can read the user's own daily
3
+ // encrypted backups (with the passphrase they hand off) even when the extension
4
+ // isn't running. WebCrypto (PBKDF2 + AES-GCM) + zlib gunzip; no dependencies.
5
+ //
6
+ // Envelope (v2): { type, version, kdf:{iterations,salt}, cipher:'AES-GCM',
7
+ // compression:'gzip'|'none'|absent, iv, ct }. v1 (no `compression`) → plaintext.
8
+
9
+ import { gunzipSync } from 'node:zlib';
10
+
11
+ const ENCRYPTED_TYPE = 'chatpanel-backup-encrypted';
12
+ const b64 = (s) => Buffer.from(String(s || ''), 'base64');
13
+
14
+ async function deriveKey(passphrase, salt, iterations) {
15
+ const base = await crypto.subtle.importKey('raw', new TextEncoder().encode(passphrase), 'PBKDF2', false, ['deriveKey']);
16
+ return crypto.subtle.deriveKey(
17
+ { name: 'PBKDF2', salt, iterations, hash: 'SHA-256' },
18
+ base,
19
+ { name: 'AES-GCM', length: 256 },
20
+ false,
21
+ ['decrypt'],
22
+ );
23
+ }
24
+
25
+ export function isEncryptedBackup(obj) {
26
+ return !!obj && typeof obj === 'object' && obj.type === ENCRYPTED_TYPE;
27
+ }
28
+
29
+ // Envelope + passphrase → the original backup data object. Throws a friendly error
30
+ // on a wrong passphrase or a tampered file (AES-GCM auth-tag mismatch catches both).
31
+ export async function decryptBackupEnvelope(envelope, passphrase) {
32
+ if (!isEncryptedBackup(envelope)) throw new Error('not an encrypted ChatPanel backup');
33
+ if (!passphrase) throw new Error('a passphrase is required to decrypt this backup');
34
+ const key = await deriveKey(passphrase, b64(envelope.kdf?.salt), envelope.kdf?.iterations || 250000);
35
+ let payload;
36
+ try {
37
+ payload = new Uint8Array(await crypto.subtle.decrypt({ name: 'AES-GCM', iv: b64(envelope.iv) }, key, b64(envelope.ct)));
38
+ } catch {
39
+ throw new Error('wrong passphrase, or the backup file is corrupted');
40
+ }
41
+ // v2 gzips before encrypting; v1 (no `compression` key) is plaintext JSON.
42
+ if (envelope.compression === 'gzip') payload = new Uint8Array(gunzipSync(Buffer.from(payload)));
43
+ return JSON.parse(Buffer.from(payload).toString('utf8'));
44
+ }
@@ -0,0 +1,76 @@
1
+ // Backup-ingest: seed the warm store from the user's own daily encrypted backup so
2
+ // the gateway holds the full corpus even when the extension isn't running. Finds the
3
+ // newest backup file, decrypts it with the handed-off passphrase, and turns it into
4
+ // warm records.
5
+ //
6
+ // The record extraction here is a SIMPLIFIED mirror of the extension's rich
7
+ // conversationSource/meetingSource — same ids (chat:<id> / meeting:<id>), so when the
8
+ // extension IS running its live-sync upserts its exact records right over these. This
9
+ // path only needs "searchable text + title + date + id"; it is the fallback, not the
10
+ // source of truth.
11
+
12
+ import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
13
+ import { join } from 'node:path';
14
+ import os from 'node:os';
15
+ import { decryptBackupEnvelope } from './backup-decrypt.js';
16
+
17
+ const BACKUP_DIR = process.env.CHATPANEL_BACKUP_DIR || join(os.homedir(), 'Downloads', 'ChatPanel Backups');
18
+ const BACKUP_RE = /^chatpanel-backup-[A-Za-z]+\.encrypted\.json$/;
19
+
20
+ // Newest chatpanel-backup-*.encrypted.json in the backups dir, or null.
21
+ export function findLatestBackup(dir = BACKUP_DIR) {
22
+ try {
23
+ let best = null;
24
+ for (const f of readdirSync(dir)) {
25
+ if (!BACKUP_RE.test(f)) continue;
26
+ const path = join(dir, f);
27
+ const mtime = statSync(path).mtimeMs;
28
+ if (!best || mtime > best.mtime) best = { path, mtime };
29
+ }
30
+ return best?.path || null;
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ // Decrypted backup data → warm records [{ id, text, title, type, date }].
37
+ export function backupToRecords(data) {
38
+ const out = [];
39
+ for (const c of data?.conversations || []) {
40
+ if (!c?.id) continue;
41
+ const title = c.title || 'Chat';
42
+ const date = c.updatedAt || c.createdAt || 0;
43
+ const body = (c.messages || [])
44
+ .filter((m) => m && m.content)
45
+ .map((m) => `${m.role || 'user'}: ${m.content}`)
46
+ .join('\n');
47
+ out.push({ id: `chat:${c.id}`, type: 'chat', title, date, text: `CHAT: ${title}\n${body}`.trim() });
48
+ }
49
+ for (const m of data?.meetings || []) {
50
+ const id = m?.id;
51
+ if (!id) continue;
52
+ const title = m.title || 'Meeting';
53
+ const date = m.startedAt || 0;
54
+ const segs = (m.segments || []).map((s) => `${s.speaker || ''}: ${s.text || ''}`).join('\n');
55
+ out.push({ id: `meeting:${id}`, type: 'meeting', title, date, text: `MEETING: ${title}\n${segs}`.trim() });
56
+ }
57
+ return out;
58
+ }
59
+
60
+ // Decrypt the latest (or given) backup and upsert its records into the store.
61
+ // Returns { ok, ingested, size } or { ok:false, reason }. Never throws on a missing
62
+ // file / passphrase; surfaces a decrypt failure as reason:'decrypt'.
63
+ export async function ingestBackup(store, passphrase, { path } = {}) {
64
+ const file = path || findLatestBackup();
65
+ if (!file || !existsSync(file)) return { ok: false, reason: 'no-backup' };
66
+ if (!passphrase) return { ok: false, reason: 'no-passphrase' };
67
+ let data;
68
+ try {
69
+ data = await decryptBackupEnvelope(JSON.parse(readFileSync(file, 'utf8')), passphrase);
70
+ } catch (e) {
71
+ return { ok: false, reason: 'decrypt', error: String(e?.message || e) };
72
+ }
73
+ const records = backupToRecords(data);
74
+ store.bulk({ upserts: records });
75
+ return { ok: true, file, ingested: records.length, size: store.size };
76
+ }
@@ -21,6 +21,7 @@ import { SearchIndex } from './search-index.js';
21
21
  const DIR = join(os.homedir(), '.chatpanel');
22
22
  const STORE_PATH = process.env.CHATPANEL_HISTORY_STORE || join(DIR, 'history-store.enc');
23
23
  const KEY_PATH = process.env.CHATPANEL_HISTORY_KEY || join(DIR, 'history-key');
24
+ const SECRET_PATH = process.env.CHATPANEL_HISTORY_SECRET || join(DIR, 'history-secret.enc');
24
25
 
25
26
  function loadOrCreateKey() {
26
27
  try {
@@ -52,6 +53,28 @@ function decrypt(key, env) {
52
53
  return Buffer.concat([decipher.update(Buffer.from(env.ct, 'base64')), decipher.final()]);
53
54
  }
54
55
 
56
+ // The handed-off backup passphrase, encrypted at rest with the same local key.
57
+ // Kept separate from the records file: it's a credential, not corpus data, and the
58
+ // gateway needs it before the store is even loaded (startup backup-ingest).
59
+ export function saveBackupSecret(passphrase) {
60
+ const env = encrypt(loadOrCreateKey(), Buffer.from(String(passphrase || ''), 'utf8'));
61
+ mkdirSync(dirname(SECRET_PATH), { recursive: true });
62
+ writeFileSync(SECRET_PATH, JSON.stringify(env), { mode: 0o600 });
63
+ }
64
+
65
+ export function loadBackupSecret() {
66
+ try {
67
+ if (!existsSync(SECRET_PATH)) return '';
68
+ return decrypt(loadOrCreateKey(), JSON.parse(readFileSync(SECRET_PATH, 'utf8'))).toString('utf8');
69
+ } catch {
70
+ return '';
71
+ }
72
+ }
73
+
74
+ export function hasBackupSecret() {
75
+ return existsSync(SECRET_PATH);
76
+ }
77
+
55
78
  // Records-plus-index with lazy, debounced encrypted persistence.
56
79
  export class HistoryStore {
57
80
  constructor({ storePath = STORE_PATH, persistMs = 2000 } = {}) {
package/src/server.js CHANGED
@@ -28,7 +28,9 @@ import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream,
28
28
  import { shaperFor } from './shape.js';
29
29
  import { startNer } from './ner.js';
30
30
  import { installTimestampedConsole } from './log.js';
31
- import { HistoryStore } from './history-store.js';
31
+ import { saveBackupSecret, loadBackupSecret, hasBackupSecret } from './history-store.js';
32
+ import { createHistoryStore } from './sqlite-store.js';
33
+ import { ingestBackup } from './backup-ingest.js';
32
34
  import * as nerEngine from './ner-engine.js';
33
35
  import { MODEL_CATALOG, isKnownModel } from './models.js';
34
36
  import { resolvePro, checkQuota, consume, usage } from './freegate.js';
@@ -38,12 +40,13 @@ import * as openai from './openai.js';
38
40
  import * as responses from './responses.js';
39
41
  import * as anthropic from './anthropic.js';
40
42
 
41
- export const VERSION = '0.6.12';
43
+ export const VERSION = '0.6.14';
42
44
 
43
- // WARM search tier — one record store + BM25 index per gateway process, fed by the
44
- // extension's ingest sync. Encrypted at rest under ~/.chatpanel, loaded on start so a
45
- // restart doesn't need a full re-ingest (no cold start). See docs/architecture-data-tiers.
46
- const historyStore = new HistoryStore().load();
45
+ // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
46
+ // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
47
+ // Persistent + memory-mapped, so a restart needs no re-ingest (no cold start).
48
+ // See docs/architecture-data-tiers.
49
+ const historyStore = await createHistoryStore();
47
50
 
48
51
  const KNOWN_AGENTS = new Set(['codex', 'claude', 'opencode', 'pi', 'kiro', 'antigravity']);
49
52
 
@@ -551,6 +554,34 @@ export function createGateway(cfg = loadConfig()) {
551
554
  if (!record) return sendJson(res, 404, { error: { message: 'no such record', type: 'not_found' } });
552
555
  return sendJson(res, 200, { ok: true, record });
553
556
  }
557
+ // Key-handoff (loopback only): store the user's backup passphrase so the gateway
558
+ // can decrypt their daily backups unattended. Encrypted at rest with the local
559
+ // key. { passphrase } → stores it and does one immediate ingest. { passphrase:'' }
560
+ // forgets it. GET → whether a key is held (never returns the key itself).
561
+ if (pathname === '/v1/history/key' && req.method === 'GET') {
562
+ return sendJson(res, 200, { ok: true, hasKey: hasBackupSecret() });
563
+ }
564
+ if (pathname === '/v1/history/key' && req.method === 'POST') {
565
+ try {
566
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
567
+ saveBackupSecret(String(body.passphrase || ''));
568
+ const result = body.passphrase ? await ingestBackup(historyStore, String(body.passphrase)) : { ok: true, ingested: 0 };
569
+ return sendJson(res, 200, { ok: true, hasKey: !!body.passphrase, ...result });
570
+ } catch (e) {
571
+ return sendJson(res, 400, { error: { message: `key handoff failed: ${e.message}`, type: 'key_error' } });
572
+ }
573
+ }
574
+ // Trigger a backup-ingest now (uses the stored key). Optional { path } overrides
575
+ // which backup file to read. Returns how many records were seeded.
576
+ if (pathname === '/v1/history/ingest-backup' && req.method === 'POST') {
577
+ try {
578
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes) || '{}').toString('utf8') || '{}') || {};
579
+ const result = await ingestBackup(historyStore, loadBackupSecret(), { path: body.path });
580
+ return sendJson(res, result.ok ? 200 : 409, result);
581
+ } catch (e) {
582
+ return sendJson(res, 400, { error: { message: `backup ingest failed: ${e.message}`, type: 'ingest_error' } });
583
+ }
584
+ }
554
585
 
555
586
  // The detector, on the gateway's own port (no second port). GET → health;
556
587
  // POST {text} → {entities}. The bundled engine runs IN-PROCESS; a user's own
@@ -782,6 +813,14 @@ export function start(cfg = loadConfig()) {
782
813
  console.log(` redaction: ${cfg.redaction.tier}` + (cfg.redaction.detection?.backend && cfg.redaction.detection.backend !== 'off'
783
814
  ? ` + ${cfg.redaction.detection.backend} detector` : (cfg.ner?.autostart ? ' (+ NER starting…)' : '')));
784
815
  });
816
+ // If the user handed off a backup key, refresh the warm store from the latest
817
+ // daily backup in the background — so the gateway stays current even when the
818
+ // extension never runs. Best-effort; never blocks startup or crashes it.
819
+ if (hasBackupSecret()) {
820
+ ingestBackup(historyStore, loadBackupSecret())
821
+ .then((r) => { if (r?.ok) console.log(` warm : seeded ${r.ingested} records from ${r.file}`); })
822
+ .catch(() => {});
823
+ }
785
824
  const shutdown = () => { ner?.stop(); entitlement.stop(); server.close(() => process.exit(0)); };
786
825
  process.on('SIGINT', shutdown);
787
826
  process.on('SIGTERM', shutdown);
@@ -0,0 +1,143 @@
1
+ // WARM store — SQLite + FTS5 backend (the scale engine behind the same interface
2
+ // as history-store.js's HistoryStore). SQLite is memory-mapped, so a year of
3
+ // chats/meetings is searchable without loading the whole corpus into RAM, with
4
+ // battle-tested BM25 full-text search and O(1) record lookups.
5
+ //
6
+ // Dual runtime: the npm gateway runs on Node (node:sqlite, built in since 22); the
7
+ // standalone binary is compiled with Bun (bun:sqlite). Both ship FTS5. The
8
+ // specifier is computed so neither bundler tries to resolve the other runtime's
9
+ // module. createHistoryStore() falls back to the encrypted-JSON HistoryStore if
10
+ // SQLite can't load at all, so this can never break a gateway.
11
+ //
12
+ // AT REST: a local .db file (0600) under ~/.chatpanel, protected by OS disk
13
+ // encryption — the on-device warm tier. Zero-knowledge encryption is the COLD/cloud
14
+ // tier's job, not this one. The backup passphrase (a credential) stays encrypted
15
+ // via history-store.js's saveBackupSecret.
16
+
17
+ import { join } from 'node:path';
18
+ import { mkdirSync } from 'node:fs';
19
+ import os from 'node:os';
20
+ import { HistoryStore } from './history-store.js';
21
+
22
+ const DIR = join(os.homedir(), '.chatpanel');
23
+ const DB_PATH = process.env.CHATPANEL_HISTORY_DB || join(DIR, 'history.db');
24
+
25
+ // Silence node:sqlite's one-time "experimental" warning for a clean CLI; pass
26
+ // every other warning through untouched.
27
+ if (typeof Bun === 'undefined') {
28
+ const emit = process.emitWarning.bind(process);
29
+ process.emitWarning = (w, ...a) => (typeof w === 'string' && w.includes('SQLite is an experimental') ? undefined : emit(w, ...a));
30
+ }
31
+
32
+ // Normalize node:sqlite (DatabaseSync) and bun:sqlite (Database) to one tiny API.
33
+ async function openDb(path) {
34
+ if (typeof Bun !== 'undefined') {
35
+ const { Database } = await import('bun:sqlite');
36
+ const db = new Database(path, { create: true });
37
+ return {
38
+ exec: (sql) => db.run(sql),
39
+ run: (sql, p = []) => db.prepare(sql).run(...p),
40
+ all: (sql, p = []) => db.query(sql).all(...p),
41
+ get: (sql, p = []) => db.query(sql).get(...p),
42
+ };
43
+ }
44
+ const spec = 'node' + ':sqlite'; // computed so the Bun bundler won't touch it
45
+ const { DatabaseSync } = await import(spec);
46
+ const db = new DatabaseSync(path);
47
+ return {
48
+ exec: (sql) => db.exec(sql),
49
+ run: (sql, p = []) => db.prepare(sql).run(...p),
50
+ all: (sql, p = []) => db.prepare(sql).all(...p),
51
+ get: (sql, p = []) => db.prepare(sql).get(...p),
52
+ };
53
+ }
54
+
55
+ // query text → a safe FTS5 MATCH string. Each term is quoted (so FTS5 operators in
56
+ // user text can't inject), joined with OR for recall — bm25 handles the ranking.
57
+ function ftsMatch(query) {
58
+ const terms = String(query || '').toLowerCase().match(/[a-z0-9][a-z0-9'_+-]*/g);
59
+ if (!terms || !terms.length) return null;
60
+ return terms.map((t) => `"${t.replace(/"/g, '""')}"`).join(' OR ');
61
+ }
62
+
63
+ export class SqliteHistoryStore {
64
+ constructor({ path = DB_PATH } = {}) {
65
+ this.path = path;
66
+ this.db = null;
67
+ }
68
+
69
+ async init() {
70
+ if (this.path !== ':memory:') mkdirSync(DIR, { recursive: true });
71
+ this.db = await openDb(this.path);
72
+ this.db.exec('PRAGMA journal_mode=WAL');
73
+ this.db.exec('CREATE TABLE IF NOT EXISTS records(id TEXT PRIMARY KEY, title TEXT, type TEXT, date INTEGER, chars INTEGER)');
74
+ this.db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(id UNINDEXED, title, text, tokenize='unicode61')");
75
+ return this;
76
+ }
77
+
78
+ // Kept for interface-compatibility with HistoryStore (SQLite is already loaded).
79
+ load() {
80
+ return this;
81
+ }
82
+
83
+ get size() {
84
+ return this.db.get('SELECT COUNT(*) c FROM records')?.c || 0;
85
+ }
86
+
87
+ bulk({ upserts = [], removes = [] } = {}) {
88
+ this.db.exec('BEGIN');
89
+ try {
90
+ for (const id of removes) {
91
+ this.db.run('DELETE FROM records WHERE id = ?', [id]);
92
+ this.db.run('DELETE FROM fts WHERE id = ?', [id]);
93
+ }
94
+ for (const d of upserts) {
95
+ if (!d || !d.id) continue;
96
+ const text = String(d.text || '');
97
+ this.db.run('DELETE FROM fts WHERE id = ?', [d.id]); // FTS5 has no UPSERT on UNINDEXED id
98
+ this.db.run('INSERT INTO fts(id, title, text) VALUES(?, ?, ?)', [d.id, d.title || '', text]);
99
+ this.db.run('INSERT OR REPLACE INTO records(id, title, type, date, chars) VALUES(?, ?, ?, ?, ?)', [d.id, d.title || '', d.type || '', d.date || 0, text.length]);
100
+ }
101
+ this.db.exec('COMMIT');
102
+ } catch (e) {
103
+ this.db.exec('ROLLBACK');
104
+ throw e;
105
+ }
106
+ return this.size;
107
+ }
108
+
109
+ // [{ id, score, title, type, date }] — score higher = better (bm25 is negated).
110
+ search(query, { limit = 10 } = {}) {
111
+ const match = ftsMatch(query);
112
+ if (!match) return [];
113
+ const rows = this.db.all(
114
+ 'SELECT r.id id, r.title title, r.type type, r.date date, bm25(fts) b FROM fts JOIN records r ON r.id = fts.id WHERE fts MATCH ? ORDER BY b LIMIT ?',
115
+ [match, limit],
116
+ );
117
+ return rows.map((r) => ({ id: r.id, score: -r.b, title: r.title, type: r.type, date: r.date }));
118
+ }
119
+
120
+ list({ limit = 50, offset = 0 } = {}) {
121
+ const total = this.db.get('SELECT COUNT(*) c FROM records')?.c || 0;
122
+ const items = this.db.all('SELECT id, title, type, date, chars FROM records ORDER BY date DESC LIMIT ? OFFSET ?', [limit, offset]);
123
+ return { total, items };
124
+ }
125
+
126
+ get(id) {
127
+ const meta = this.db.get('SELECT id, title, type, date FROM records WHERE id = ?', [id]);
128
+ if (!meta) return null;
129
+ const body = this.db.get('SELECT text FROM fts WHERE id = ?', [id]);
130
+ return { ...meta, text: body?.text || '' };
131
+ }
132
+ }
133
+
134
+ // Pick the best available warm engine. SQLite when it loads; otherwise the
135
+ // encrypted-JSON HistoryStore — so a gateway is never left without a warm store.
136
+ export async function createHistoryStore(opts = {}) {
137
+ try {
138
+ return await new SqliteHistoryStore(opts).init();
139
+ } catch (e) {
140
+ console.log(`[warm] SQLite unavailable (${e.message}); using the encrypted file store`);
141
+ return new HistoryStore(opts).load();
142
+ }
143
+ }