@chatpanel/gateway 0.6.30 → 0.6.32

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.30",
3
+ "version": "0.6.32",
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
+ }
@@ -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,9 +30,9 @@ 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';
@@ -45,7 +45,7 @@ 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.30';
48
+ export const VERSION = '0.6.32';
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.
@@ -499,6 +499,18 @@ export function createGateway(cfg = loadConfig()) {
499
499
  if (req.headers.origin) setCors(res, req.headers.origin);
500
500
  if (req.method === 'OPTIONS') { res.writeHead(204); return res.end(); }
501
501
 
502
+ // Admin-token handshake. The extension authenticates admin routes by its
503
+ // chrome-extension:// Origin, but Chrome OMITS Origin on GET requests to a host the
504
+ // extension has permission for — so config READS (GET /config) would fail. A POST
505
+ // still carries the Origin, so the extension POSTs here (authorized by Origin) to get
506
+ // the token, then sends it as `Authorization: Bearer` on the GET admin routes. A
507
+ // drive-by web page can't reach this: its Origin isn't chrome-extension:// (Origin
508
+ // check) and it has no token. Additive route — old extensions ignore it.
509
+ if (pathname === '/admin/token' && req.method === 'POST') {
510
+ if (!isAdminAuthorized(req)) return sendJson(res, 403, { error: 'admin: extension origin or gateway token required' });
511
+ return sendJson(res, 200, { token: ensureGatewayToken() });
512
+ }
513
+
502
514
  // M2: ADMIN routes reconfigure the gateway (POST /config) or expose its in-memory
503
515
  // logs (GET /logs). Unlike the /v1 data plane (open to any local client — the
504
516
  // product), these must not be reachable by a no-Origin local process or a drive-by
@@ -506,6 +518,10 @@ export function createGateway(cfg = loadConfig()) {
506
518
  if ((pathname === '/config' || pathname === '/logs') && !isAdminAuthorized(req)) {
507
519
  return sendJson(res, 403, { error: 'admin route: extension origin or gateway token required' });
508
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
+ }
509
525
 
510
526
  if (req.method === 'GET' && pathname === '/health') {
511
527
  // `stt` is ADDITIVE (Tesla rule): old clients ignore it, new clients use it
@@ -591,19 +607,18 @@ export function createGateway(cfg = loadConfig()) {
591
607
  if (pathname === '/v1/history/key' && req.method === 'POST') {
592
608
  try {
593
609
  const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
594
- saveBackupSecret(String(body.passphrase || ''));
595
- 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 };
596
612
  return sendJson(res, 200, { ok: true, hasKey: !!body.passphrase, ...result });
597
613
  } catch (e) {
598
614
  return sendJson(res, 400, { error: { message: `key handoff failed: ${e.message}`, type: 'key_error' } });
599
615
  }
600
616
  }
601
- // Trigger a backup-ingest now (uses the stored key). Optional { path } overrides
602
- // 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.
603
619
  if (pathname === '/v1/history/ingest-backup' && req.method === 'POST') {
604
620
  try {
605
- const body = JSON.parse((await readBody(req, cfg.maxBodyBytes) || '{}').toString('utf8') || '{}') || {};
606
- const result = await ingestBackup(historyStore, loadBackupSecret(), { path: body.path });
621
+ const result = await ingestBackups(historyStore, loadBackupSecret());
607
622
  return sendJson(res, result.ok ? 200 : 409, result);
608
623
  } catch (e) {
609
624
  return sendJson(res, 400, { error: { message: `backup ingest failed: ${e.message}`, type: 'ingest_error' } });
@@ -1007,11 +1022,11 @@ export function start(cfg = loadConfig()) {
1007
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.`);
1008
1023
  }
1009
1024
  });
1010
- // If the user handed off a backup key, refresh the warm store from the latest
1011
- // 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
1012
1027
  // extension never runs. Best-effort; never blocks startup or crashes it.
1013
1028
  if (hasBackupSecret()) {
1014
- ingestBackup(historyStore, loadBackupSecret())
1029
+ ingestBackups(historyStore, loadBackupSecret())
1015
1030
  .then((r) => { if (r?.ok) console.log(` warm : seeded ${r.ingested} records from ${r.file}`); })
1016
1031
  .catch(() => {});
1017
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