@chatpanel/gateway 0.6.31 → 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.
- package/bin/chatpanel-gateway.js +0 -0
- package/package.json +1 -1
- package/src/backup-decrypt.js +6 -5
- package/src/backup-ingest.js +35 -2
- package/src/history-store.js +7 -2
- package/src/server.js +15 -12
- package/src/sqlite-store.js +10 -2
package/bin/chatpanel-gateway.js
CHANGED
|
File without changes
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
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": {
|
package/src/backup-decrypt.js
CHANGED
|
@@ -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
|
|
4
|
+
// isn't running. WebCrypto (PBKDF2 + AES-GCM) + native zlib codecs; no dependencies.
|
|
5
5
|
//
|
|
6
|
-
// Envelope
|
|
7
|
-
// compression:'gzip'|'none'|absent, iv, ct }.
|
|
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
|
|
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
|
}
|
package/src/backup-ingest.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/src/history-store.js
CHANGED
|
@@ -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
|
|
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 {
|
|
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.
|
|
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.
|
|
@@ -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
|
|
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
|
|
614
|
-
//
|
|
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
|
|
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' } });
|
|
@@ -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
|
|
1023
|
-
//
|
|
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
|
-
|
|
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
|
}
|
package/src/sqlite-store.js
CHANGED
|
@@ -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:')
|
|
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
|
|