@chatpanel/gateway 0.6.31 → 0.6.33

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.
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.31",
3
+ "version": "0.6.33",
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": {
@@ -1,12 +1,12 @@
1
1
  // Decrypt a ChatPanel backup envelope — the gateway counterpart to the extension's
2
2
  // crypto-backup.js. Same wire format so the gateway can read the user's own daily
3
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.
4
+ // isn't running. WebCrypto (PBKDF2 + AES-GCM) + native zlib codecs; no dependencies.
5
5
  //
6
- // Envelope (v2): { type, version, kdf:{iterations,salt}, cipher:'AES-GCM',
7
- // compression:'gzip'|'none'|absent, iv, ct }. v1 (no `compression`) → plaintext.
6
+ // Envelope: { type, version, kdf:{iterations,salt}, cipher:'AES-GCM',
7
+ // compression:'brotli'|'gzip'|'none'|absent, iv, ct }.
8
8
 
9
- import { gunzipSync } from 'node:zlib';
9
+ import { brotliDecompressSync, gunzipSync } from 'node:zlib';
10
10
 
11
11
  const ENCRYPTED_TYPE = 'chatpanel-backup-encrypted';
12
12
  const b64 = (s) => Buffer.from(String(s || ''), 'base64');
@@ -38,7 +38,8 @@ export async function decryptBackupEnvelope(envelope, passphrase) {
38
38
  } catch {
39
39
  throw new Error('wrong passphrase, or the backup file is corrupted');
40
40
  }
41
- // v2 gzips before encrypting; v1 (no `compression` key) is plaintext JSON.
41
+ // v1 is plaintext, v2 gzip, v3 may use Brotli for a smaller portable file.
42
42
  if (envelope.compression === 'gzip') payload = new Uint8Array(gunzipSync(Buffer.from(payload)));
43
+ else if (envelope.compression === 'brotli') payload = new Uint8Array(brotliDecompressSync(Buffer.from(payload)));
43
44
  return JSON.parse(Buffer.from(payload).toString('utf8'));
44
45
  }
@@ -15,7 +15,10 @@ import os from 'node:os';
15
15
  import { decryptBackupEnvelope } from './backup-decrypt.js';
16
16
 
17
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$/;
18
+ // Current extension builds scope each weekday slot to a stable per-device id so
19
+ // several machines can share one Drive account without overwriting one another.
20
+ // Keep accepting the seven pre-0.19.4 legacy slots for recovery.
21
+ const BACKUP_RE = /^chatpanel-backup-(?:[a-z0-9]{12,16}-)?(?:Sun|Mon|Tue|Wed|Thu|Fri|Sat)\.encrypted\.json$/;
19
22
 
20
23
  // Newest chatpanel-backup-*.encrypted.json in the backups dir, or null.
21
24
  export function findLatestBackup(dir = BACKUP_DIR) {
@@ -33,6 +36,16 @@ export function findLatestBackup(dir = BACKUP_DIR) {
33
36
  }
34
37
  }
35
38
 
39
+ export function findBackups(dir = BACKUP_DIR) {
40
+ try {
41
+ return readdirSync(dir)
42
+ .filter((f) => BACKUP_RE.test(f))
43
+ .map((f) => ({ path: join(dir, f), mtime: statSync(join(dir, f)).mtimeMs }))
44
+ .sort((a, b) => a.mtime - b.mtime)
45
+ .map((x) => x.path);
46
+ } catch { return []; }
47
+ }
48
+
36
49
  // Decrypted backup data → warm records [{ id, text, title, type, date }].
37
50
  export function backupToRecords(data) {
38
51
  const out = [];
@@ -74,7 +87,7 @@ export function backupToRecords(data) {
74
87
  // Decrypt the latest (or given) backup and upsert its records into the store.
75
88
  // Returns { ok, ingested, size } or { ok:false, reason }. Never throws on a missing
76
89
  // file / passphrase; surfaces a decrypt failure as reason:'decrypt'.
77
- export async function ingestBackup(store, passphrase, { path } = {}) {
90
+ export async function ingestBackup(store, passphrase, { path = '' } = {}) {
78
91
  const file = path || findLatestBackup();
79
92
  if (!file || !existsSync(file)) return { ok: false, reason: 'no-backup' };
80
93
  if (!passphrase) return { ok: false, reason: 'no-passphrase' };
@@ -88,3 +101,23 @@ export async function ingestBackup(store, passphrase, { path } = {}) {
88
101
  store.bulk({ upserts: records });
89
102
  return { ok: true, file, ingested: records.length, size: store.size };
90
103
  }
104
+
105
+ export async function ingestBackups(store, passphrase, { dir = BACKUP_DIR } = {}) {
106
+ if (!passphrase) return { ok: false, reason: 'no-passphrase' };
107
+ const files = findBackups(dir);
108
+ if (!files.length) return { ok: false, reason: 'no-backup' };
109
+ const byId = new Map();
110
+ const accepted = [];
111
+ const errors = [];
112
+ for (const file of files) {
113
+ try {
114
+ const data = await decryptBackupEnvelope(JSON.parse(readFileSync(file, 'utf8')), passphrase);
115
+ for (const record of backupToRecords(data)) byId.set(record.id, record);
116
+ accepted.push(file);
117
+ } catch (e) { errors.push({ file, error: String(e?.message || e) }); }
118
+ }
119
+ if (!accepted.length) return { ok: false, reason: 'decrypt', error: errors[0]?.error || 'no backup matched the stored passphrase' };
120
+ const records = [...byId.values()];
121
+ store.bulk({ upserts: records });
122
+ return { ok: true, file: accepted.at(-1), files: accepted.length, skippedFiles: errors.length, ingested: records.length, size: store.size };
123
+ }
@@ -66,6 +66,17 @@ export function publicConfig(cfg, { proUnlocked = false } = {}) {
66
66
  };
67
67
  }
68
68
 
69
+ // Selecting a bundled NER model is an explicit request to use that detector, not
70
+ // merely to remember its name. Older configs can carry autostart:false; leaving it
71
+ // untouched makes the selected model work only until restart and then silently
72
+ // falls back to deterministic-only redaction.
73
+ export function applyNerModelSelection(cfg, id) {
74
+ cfg.ner = cfg.ner || { allowDownload: true, enableFullTier: true };
75
+ cfg.ner.model = id;
76
+ cfg.ner.autostart = true;
77
+ return cfg.ner;
78
+ }
79
+
69
80
  // Merge an editable patch into the live cfg. Only known fields; ignores the rest.
70
81
  export function applyConfigPatch(cfg, patch = {}) {
71
82
  if (patch.backend === 'bridge' || patch.backend === 'api') cfg.backend = patch.backend;
@@ -13,7 +13,7 @@
13
13
  // tier, not this one. The file on disk is useless without the local key.
14
14
 
15
15
  import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
16
- import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync } from 'node:fs';
16
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, chmodSync, unlinkSync } from 'node:fs';
17
17
  import { join, dirname } from 'node:path';
18
18
  import os from 'node:os';
19
19
  import { SearchIndex } from './search-index.js';
@@ -57,11 +57,16 @@ function decrypt(key, env) {
57
57
  // Kept separate from the records file: it's a credential, not corpus data, and the
58
58
  // gateway needs it before the store is even loaded (startup backup-ingest).
59
59
  export function saveBackupSecret(passphrase) {
60
+ if (!String(passphrase || '')) return clearBackupSecret();
60
61
  const env = encrypt(loadOrCreateKey(), Buffer.from(String(passphrase || ''), 'utf8'));
61
62
  mkdirSync(dirname(SECRET_PATH), { recursive: true });
62
63
  writeFileSync(SECRET_PATH, JSON.stringify(env), { mode: 0o600 });
63
64
  }
64
65
 
66
+ export function clearBackupSecret() {
67
+ try { if (existsSync(SECRET_PATH)) unlinkSync(SECRET_PATH); } catch { /* best effort */ }
68
+ }
69
+
65
70
  export function loadBackupSecret() {
66
71
  try {
67
72
  if (!existsSync(SECRET_PATH)) return '';
@@ -72,7 +77,7 @@ export function loadBackupSecret() {
72
77
  }
73
78
 
74
79
  export function hasBackupSecret() {
75
- return existsSync(SECRET_PATH);
80
+ return !!loadBackupSecret();
76
81
  }
77
82
 
78
83
  // Records-plus-index with lazy, debounced encrypted persistence.
package/src/server.js CHANGED
@@ -30,22 +30,22 @@ import { createRelaySession, getRelaySession, endRelaySession, pumpBridgeStream,
30
30
  import { shaperFor } from './shape.js';
31
31
  import { startNer } from './ner.js';
32
32
  import { installTimestampedConsole } from './log.js';
33
- import { saveBackupSecret, loadBackupSecret, hasBackupSecret } from './history-store.js';
33
+ import { saveBackupSecret, clearBackupSecret, loadBackupSecret, hasBackupSecret } from './history-store.js';
34
34
  import { createHistoryStore } from './sqlite-store.js';
35
- import { ingestBackup } from './backup-ingest.js';
35
+ import { ingestBackups } from './backup-ingest.js';
36
36
  import * as nerEngine from './ner-engine.js';
37
37
  import * as sttEngine from './stt-engine.js';
38
38
  import * as diarizeEngine from './diarize-engine.js';
39
39
  import { MODEL_CATALOG, isKnownModel, isValidCustomModelId } from './models.js';
40
40
  import { STT_MODEL_CATALOG, isKnownSttModel, isValidCustomSttId, DEFAULT_STT_MODEL, STT_DTYPES, isValidDtype } from './stt-models.js';
41
41
  import { resolvePro, checkQuota, consume, usage } from './freegate.js';
42
- import { publicConfig, applyConfigPatch, persistConfig, configPath } from './configstore.js';
42
+ import { publicConfig, applyConfigPatch, applyNerModelSelection, persistConfig, configPath } from './configstore.js';
43
43
  import { resolveDestination, aggregateModelsAsync } from './router.js';
44
44
  import * as openai from './openai.js';
45
45
  import * as responses from './responses.js';
46
46
  import * as anthropic from './anthropic.js';
47
47
 
48
- export const VERSION = '0.6.31';
48
+ export const VERSION = '0.6.33';
49
49
 
50
50
  // WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
51
51
  // store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
@@ -518,6 +518,10 @@ export function createGateway(cfg = loadConfig()) {
518
518
  if ((pathname === '/config' || pathname === '/logs') && !isAdminAuthorized(req)) {
519
519
  return sendJson(res, 403, { error: 'admin route: extension origin or gateway token required' });
520
520
  }
521
+ if ((pathname === '/v1/history/key' || pathname === '/v1/history/ingest-backup')
522
+ && req.method === 'POST' && !isAdminAuthorized(req)) {
523
+ return sendJson(res, 403, { error: { message: 'history key route: extension origin or gateway token required', type: 'forbidden' } });
524
+ }
521
525
 
522
526
  if (req.method === 'GET' && pathname === '/health') {
523
527
  // `stt` is ADDITIVE (Tesla rule): old clients ignore it, new clients use it
@@ -603,19 +607,18 @@ export function createGateway(cfg = loadConfig()) {
603
607
  if (pathname === '/v1/history/key' && req.method === 'POST') {
604
608
  try {
605
609
  const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
606
- saveBackupSecret(String(body.passphrase || ''));
607
- const result = body.passphrase ? await ingestBackup(historyStore, String(body.passphrase)) : { ok: true, ingested: 0 };
610
+ if (body.passphrase) saveBackupSecret(String(body.passphrase)); else clearBackupSecret();
611
+ const result = body.passphrase ? await ingestBackups(historyStore, String(body.passphrase)) : { ok: true, ingested: 0 };
608
612
  return sendJson(res, 200, { ok: true, hasKey: !!body.passphrase, ...result });
609
613
  } catch (e) {
610
614
  return sendJson(res, 400, { error: { message: `key handoff failed: ${e.message}`, type: 'key_error' } });
611
615
  }
612
616
  }
613
- // Trigger a backup-ingest now (uses the stored key). Optional { path } overrides
614
- // which backup file to read. Returns how many records were seeded.
617
+ // Trigger an encrypted archive refresh now using the stored key. Every rotating
618
+ // weekday snapshot that matches the key contributes records; newest values win.
615
619
  if (pathname === '/v1/history/ingest-backup' && req.method === 'POST') {
616
620
  try {
617
- const body = JSON.parse((await readBody(req, cfg.maxBodyBytes) || '{}').toString('utf8') || '{}') || {};
618
- const result = await ingestBackup(historyStore, loadBackupSecret(), { path: body.path });
621
+ const result = await ingestBackups(historyStore, loadBackupSecret());
619
622
  return sendJson(res, result.ok ? 200 : 409, result);
620
623
  } catch (e) {
621
624
  return sendJson(res, 400, { error: { message: `backup ingest failed: ${e.message}`, type: 'ingest_error' } });
@@ -684,7 +687,7 @@ export function createGateway(cfg = loadConfig()) {
684
687
  if (!id || !(isKnownModel(id) || isValidCustomModelId(id))) return sendJson(res, 400, { error: { message: 'unknown or invalid model id', type: 'bad_model' } });
685
688
  // Persist first so a restart keeps the choice, then (re)load. Don't block the
686
689
  // response on a possibly-long download — the client polls GET for progress.
687
- if (cfg.ner) cfg.ner.model = id; else cfg.ner = { autostart: true, model: id, allowDownload: true, enableFullTier: true };
690
+ applyNerModelSelection(cfg, id);
688
691
  try { persistConfig(cfg, configPath()); } catch { /* best effort */ }
689
692
  nerEngine.setModel(id, { onLog: (m) => console.log(m) }).then((ok) => {
690
693
  if (ok && cfg.ner?.enableFullTier && cfg.redaction.tier !== 'full') cfg.redaction.tier = 'full';
@@ -1019,11 +1022,11 @@ export function start(cfg = loadConfig()) {
1019
1022
  console.error(`⚠ SECURITY: gateway bound to NON-LOOPBACK host ${cfg.host}. It is reachable off-machine, and the loopback Host-header check is spoofable from the LAN. Admin routes still need the token/extension, but prefer binding 127.0.0.1 unless you intend LAN exposure on a trusted network.`);
1020
1023
  }
1021
1024
  });
1022
- // If the user handed off a backup key, refresh the warm store from the latest
1023
- // daily backup in the background — so the gateway stays current even when the
1025
+ // If the user handed off a backup key, refresh the warm store from the encrypted
1026
+ // rotating archive in the background — so the gateway stays current even when the
1024
1027
  // extension never runs. Best-effort; never blocks startup or crashes it.
1025
1028
  if (hasBackupSecret()) {
1026
- ingestBackup(historyStore, loadBackupSecret())
1029
+ ingestBackups(historyStore, loadBackupSecret())
1027
1030
  .then((r) => { if (r?.ok) console.log(` warm : seeded ${r.ingested} records from ${r.file}`); })
1028
1031
  .catch(() => {});
1029
1032
  }
@@ -15,7 +15,7 @@
15
15
  // via history-store.js's saveBackupSecret.
16
16
 
17
17
  import { join } from 'node:path';
18
- import { mkdirSync } from 'node:fs';
18
+ import { mkdirSync, chmodSync, existsSync } from 'node:fs';
19
19
  import os from 'node:os';
20
20
  import { HistoryStore } from './history-store.js';
21
21
 
@@ -67,11 +67,19 @@ export class SqliteHistoryStore {
67
67
  }
68
68
 
69
69
  async init() {
70
- if (this.path !== ':memory:') mkdirSync(DIR, { recursive: true });
70
+ if (this.path !== ':memory:') {
71
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
72
+ try { chmodSync(DIR, 0o700); } catch { /* non-POSIX */ }
73
+ }
71
74
  this.db = await openDb(this.path);
72
75
  this.db.exec('PRAGMA journal_mode=WAL');
73
76
  this.db.exec('CREATE TABLE IF NOT EXISTS records(id TEXT PRIMARY KEY, title TEXT, type TEXT, date INTEGER, chars INTEGER)');
74
77
  this.db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS fts USING fts5(id UNINDEXED, title, text, tokenize='unicode61')");
78
+ if (this.path !== ':memory:') {
79
+ for (const path of [this.path, this.path + '-wal', this.path + '-shm']) {
80
+ try { if (existsSync(path)) chmodSync(path, 0o600); } catch { /* best effort */ }
81
+ }
82
+ }
75
83
  return this;
76
84
  }
77
85