@chatpanel/gateway 0.6.12 → 0.6.13

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.13",
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,8 @@ 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 { HistoryStore, saveBackupSecret, loadBackupSecret, hasBackupSecret } from './history-store.js';
32
+ import { ingestBackup } from './backup-ingest.js';
32
33
  import * as nerEngine from './ner-engine.js';
33
34
  import { MODEL_CATALOG, isKnownModel } from './models.js';
34
35
  import { resolvePro, checkQuota, consume, usage } from './freegate.js';
@@ -38,7 +39,7 @@ import * as openai from './openai.js';
38
39
  import * as responses from './responses.js';
39
40
  import * as anthropic from './anthropic.js';
40
41
 
41
- export const VERSION = '0.6.12';
42
+ export const VERSION = '0.6.13';
42
43
 
43
44
  // WARM search tier — one record store + BM25 index per gateway process, fed by the
44
45
  // extension's ingest sync. Encrypted at rest under ~/.chatpanel, loaded on start so a
@@ -551,6 +552,34 @@ export function createGateway(cfg = loadConfig()) {
551
552
  if (!record) return sendJson(res, 404, { error: { message: 'no such record', type: 'not_found' } });
552
553
  return sendJson(res, 200, { ok: true, record });
553
554
  }
555
+ // Key-handoff (loopback only): store the user's backup passphrase so the gateway
556
+ // can decrypt their daily backups unattended. Encrypted at rest with the local
557
+ // key. { passphrase } → stores it and does one immediate ingest. { passphrase:'' }
558
+ // forgets it. GET → whether a key is held (never returns the key itself).
559
+ if (pathname === '/v1/history/key' && req.method === 'GET') {
560
+ return sendJson(res, 200, { ok: true, hasKey: hasBackupSecret() });
561
+ }
562
+ if (pathname === '/v1/history/key' && req.method === 'POST') {
563
+ try {
564
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
565
+ saveBackupSecret(String(body.passphrase || ''));
566
+ const result = body.passphrase ? await ingestBackup(historyStore, String(body.passphrase)) : { ok: true, ingested: 0 };
567
+ return sendJson(res, 200, { ok: true, hasKey: !!body.passphrase, ...result });
568
+ } catch (e) {
569
+ return sendJson(res, 400, { error: { message: `key handoff failed: ${e.message}`, type: 'key_error' } });
570
+ }
571
+ }
572
+ // Trigger a backup-ingest now (uses the stored key). Optional { path } overrides
573
+ // which backup file to read. Returns how many records were seeded.
574
+ if (pathname === '/v1/history/ingest-backup' && req.method === 'POST') {
575
+ try {
576
+ const body = JSON.parse((await readBody(req, cfg.maxBodyBytes) || '{}').toString('utf8') || '{}') || {};
577
+ const result = await ingestBackup(historyStore, loadBackupSecret(), { path: body.path });
578
+ return sendJson(res, result.ok ? 200 : 409, result);
579
+ } catch (e) {
580
+ return sendJson(res, 400, { error: { message: `backup ingest failed: ${e.message}`, type: 'ingest_error' } });
581
+ }
582
+ }
554
583
 
555
584
  // The detector, on the gateway's own port (no second port). GET → health;
556
585
  // POST {text} → {entities}. The bundled engine runs IN-PROCESS; a user's own
@@ -782,6 +811,14 @@ export function start(cfg = loadConfig()) {
782
811
  console.log(` redaction: ${cfg.redaction.tier}` + (cfg.redaction.detection?.backend && cfg.redaction.detection.backend !== 'off'
783
812
  ? ` + ${cfg.redaction.detection.backend} detector` : (cfg.ner?.autostart ? ' (+ NER starting…)' : '')));
784
813
  });
814
+ // If the user handed off a backup key, refresh the warm store from the latest
815
+ // daily backup in the background — so the gateway stays current even when the
816
+ // extension never runs. Best-effort; never blocks startup or crashes it.
817
+ if (hasBackupSecret()) {
818
+ ingestBackup(historyStore, loadBackupSecret())
819
+ .then((r) => { if (r?.ok) console.log(` warm : seeded ${r.ingested} records from ${r.file}`); })
820
+ .catch(() => {});
821
+ }
785
822
  const shutdown = () => { ner?.stop(); entitlement.stop(); server.close(() => process.exit(0)); };
786
823
  process.on('SIGINT', shutdown);
787
824
  process.on('SIGTERM', shutdown);