@chatpanel/gateway 0.6.76 → 0.6.77
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 +1 -1
- package/src/prefs-store.js +149 -0
- package/src/server.js +33 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@chatpanel/gateway",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.77",
|
|
4
4
|
"description": "Local privacy gateway \u2014 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,149 @@
|
|
|
1
|
+
// CLIENT PREFERENCES — the settings every ChatPanel client shares, held here because the
|
|
2
|
+
// gateway is the one address the extension and the desktop both have.
|
|
3
|
+
//
|
|
4
|
+
// A document of SECTIONS (the MCP server list, the skills, the search engines — see
|
|
5
|
+
// @chatpanel/events/client-prefs.js), each stamped with when it was last written. A client
|
|
6
|
+
// pushes the sections it changed with its own stamps; the store keeps the newer stamp per
|
|
7
|
+
// section and tells the pusher which of its sections lost, so it can take the other side's
|
|
8
|
+
// copy. Per-section last-writer-wins: a section is one screen a person edits, and merging two
|
|
9
|
+
// edits of one list key by key would make a list neither of them made.
|
|
10
|
+
//
|
|
11
|
+
// ENCRYPTED AT REST with the same device key as memory and history — an MCP server entry can
|
|
12
|
+
// carry an Authorization header, and this file must not be the plaintext copy of it.
|
|
13
|
+
|
|
14
|
+
import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync } from 'node:fs';
|
|
15
|
+
import { join, dirname } from 'node:path';
|
|
16
|
+
import os from 'node:os';
|
|
17
|
+
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto';
|
|
18
|
+
|
|
19
|
+
const DIR = join(os.homedir(), '.chatpanel');
|
|
20
|
+
const STORE_PATH = process.env.CHATPANEL_PREFS_STORE || join(DIR, 'prefs-store.enc');
|
|
21
|
+
const KEY_PATH = process.env.CHATPANEL_HISTORY_KEY || join(DIR, 'history-key');
|
|
22
|
+
|
|
23
|
+
/** A section id is a short word; anything else is refused before it is stored. */
|
|
24
|
+
const SECTION_ID_RE = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
|
|
25
|
+
/** One section's JSON, serialized — a list of a thousand MCP servers is not a preference. */
|
|
26
|
+
const MAX_SECTION_BYTES = 512 * 1024;
|
|
27
|
+
|
|
28
|
+
function loadOrCreateKey() {
|
|
29
|
+
try {
|
|
30
|
+
if (existsSync(KEY_PATH)) return Buffer.from(readFileSync(KEY_PATH, 'utf8').trim(), 'base64');
|
|
31
|
+
} catch { /* regenerate below */ }
|
|
32
|
+
const key = randomBytes(32);
|
|
33
|
+
mkdirSync(dirname(KEY_PATH), { recursive: true, mode: 0o700 });
|
|
34
|
+
writeFileSync(KEY_PATH, key.toString('base64'), { mode: 0o600 });
|
|
35
|
+
return key;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function encrypt(key, buf) {
|
|
39
|
+
const iv = randomBytes(12);
|
|
40
|
+
const cipher = createCipheriv('aes-256-gcm', key, iv);
|
|
41
|
+
const ct = Buffer.concat([cipher.update(buf), cipher.final()]);
|
|
42
|
+
return { v: 1, iv: iv.toString('base64'), tag: cipher.getAuthTag().toString('base64'), ct: ct.toString('base64') };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function decrypt(key, env) {
|
|
46
|
+
const d = createDecipheriv('aes-256-gcm', key, Buffer.from(env.iv, 'base64'));
|
|
47
|
+
d.setAuthTag(Buffer.from(env.tag, 'base64'));
|
|
48
|
+
return Buffer.concat([d.update(Buffer.from(env.ct, 'base64')), d.final()]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export class PrefsStore {
|
|
52
|
+
constructor({ storePath = STORE_PATH } = {}) {
|
|
53
|
+
this.path = storePath;
|
|
54
|
+
this._key = null;
|
|
55
|
+
this.sections = {}; // id -> { value, updatedAt, by }
|
|
56
|
+
this.revision = 0; // bumps on every accepted write, so a client can ask "anything new?"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
load() {
|
|
60
|
+
this._key = loadOrCreateKey();
|
|
61
|
+
try {
|
|
62
|
+
if (existsSync(this.path)) {
|
|
63
|
+
const env = JSON.parse(readFileSync(this.path, 'utf8'));
|
|
64
|
+
const doc = JSON.parse(decrypt(this._key, env).toString('utf8'));
|
|
65
|
+
this.sections = doc?.sections && typeof doc.sections === 'object' ? doc.sections : {};
|
|
66
|
+
this.revision = Number(doc?.revision) || 0;
|
|
67
|
+
}
|
|
68
|
+
} catch {
|
|
69
|
+
// An unreadable store is an empty one, never a crash: the clients still hold their own
|
|
70
|
+
// copies and will push them back on the next change.
|
|
71
|
+
this.sections = {};
|
|
72
|
+
this.revision = 0;
|
|
73
|
+
}
|
|
74
|
+
return this;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
save() {
|
|
78
|
+
mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
|
|
79
|
+
const env = encrypt(this._key, Buffer.from(JSON.stringify({ v: 1, revision: this.revision, sections: this.sections }), 'utf8'));
|
|
80
|
+
// Write beside, then rename: a crash mid-write leaves the old file, not half of a new one.
|
|
81
|
+
const tmp = `${this.path}.${process.pid}.tmp`;
|
|
82
|
+
writeFileSync(tmp, JSON.stringify(env), { mode: 0o600 });
|
|
83
|
+
renameSync(tmp, this.path);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Every section, or one. Values are the caller's to keep — they are copies. */
|
|
87
|
+
get(id = '') {
|
|
88
|
+
if (id) {
|
|
89
|
+
const s = this.sections[id];
|
|
90
|
+
return s ? { [id]: { value: clone(s.value), updatedAt: s.updatedAt, by: s.by || '' } } : {};
|
|
91
|
+
}
|
|
92
|
+
const out = {};
|
|
93
|
+
for (const [k, s] of Object.entries(this.sections)) out[k] = { value: clone(s.value), updatedAt: s.updatedAt, by: s.by || '' };
|
|
94
|
+
return out;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Just the stamps — enough for a client to decide whether it needs the values. */
|
|
98
|
+
stamps() {
|
|
99
|
+
const out = {};
|
|
100
|
+
for (const [k, s] of Object.entries(this.sections)) out[k] = s.updatedAt;
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Merge a client's stamped sections in. A section is taken when its stamp is newer than
|
|
106
|
+
* the one held (or nothing is held); otherwise the client is told it lost and gets the
|
|
107
|
+
* held copy back. Returns `{ applied, kept, sections }` where `sections` holds the current
|
|
108
|
+
* copies of everything the client sent — the client writes `kept` ones over its own.
|
|
109
|
+
*/
|
|
110
|
+
put(incoming, { by = '' } = {}) {
|
|
111
|
+
const applied = [];
|
|
112
|
+
const kept = [];
|
|
113
|
+
const sections = {};
|
|
114
|
+
for (const [id, entry] of Object.entries(incoming || {})) {
|
|
115
|
+
if (!SECTION_ID_RE.test(id)) continue;
|
|
116
|
+
const updatedAt = Number(entry?.updatedAt) || 0;
|
|
117
|
+
if (!updatedAt || entry?.value === undefined) continue;
|
|
118
|
+
let bytes;
|
|
119
|
+
try { bytes = Buffer.byteLength(JSON.stringify(entry.value), 'utf8'); } catch { continue; }
|
|
120
|
+
if (bytes > MAX_SECTION_BYTES) continue;
|
|
121
|
+
const held = this.sections[id];
|
|
122
|
+
if (!held || updatedAt > held.updatedAt) {
|
|
123
|
+
this.sections[id] = { value: clone(entry.value), updatedAt, by: String(by || '').slice(0, 40) };
|
|
124
|
+
applied.push(id);
|
|
125
|
+
} else {
|
|
126
|
+
kept.push(id);
|
|
127
|
+
}
|
|
128
|
+
const cur = this.sections[id];
|
|
129
|
+
sections[id] = { value: clone(cur.value), updatedAt: cur.updatedAt, by: cur.by || '' };
|
|
130
|
+
}
|
|
131
|
+
if (applied.length) { this.revision += 1; this.save(); }
|
|
132
|
+
return { applied, kept, sections, revision: this.revision };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Forget one section entirely — the user removed the feature's settings, not just emptied them. */
|
|
136
|
+
remove(id) {
|
|
137
|
+
if (!this.sections[id]) return false;
|
|
138
|
+
delete this.sections[id];
|
|
139
|
+
this.revision += 1;
|
|
140
|
+
this.save();
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const clone = (v) => (v === undefined ? undefined : JSON.parse(JSON.stringify(v)));
|
|
146
|
+
|
|
147
|
+
export function createPrefsStore(opts) {
|
|
148
|
+
return new PrefsStore(opts).load();
|
|
149
|
+
}
|
package/src/server.js
CHANGED
|
@@ -33,6 +33,7 @@ import { startNer, ensureNer } from './ner.js';
|
|
|
33
33
|
import { installTimestampedConsole } from './log.js';
|
|
34
34
|
import { saveBackupSecret, clearBackupSecret, loadBackupSecret, hasBackupSecret } from './history-store.js';
|
|
35
35
|
import { createMemoryStore } from './memory-store.js';
|
|
36
|
+
import { createPrefsStore } from './prefs-store.js';
|
|
36
37
|
import { createHistoryStore } from './sqlite-store.js';
|
|
37
38
|
import { ingestBackups } from './backup-ingest.js';
|
|
38
39
|
import * as nerEngine from './ner-engine.js';
|
|
@@ -56,7 +57,7 @@ import * as openai from './openai.js';
|
|
|
56
57
|
import * as responses from './responses.js';
|
|
57
58
|
import * as anthropic from './anthropic.js';
|
|
58
59
|
|
|
59
|
-
export const VERSION = '0.6.
|
|
60
|
+
export const VERSION = '0.6.77';
|
|
60
61
|
|
|
61
62
|
// WARM search tier — SQLite + FTS5 record store (falls back to an encrypted-JSON
|
|
62
63
|
// store if SQLite can't load), fed by the extension's ingest sync + backup-ingest.
|
|
@@ -64,6 +65,7 @@ export const VERSION = '0.6.76';
|
|
|
64
65
|
// See docs/architecture-data-tiers.
|
|
65
66
|
const historyStore = await createHistoryStore();
|
|
66
67
|
const memoryStore = await createMemoryStore();
|
|
68
|
+
const prefsStore = createPrefsStore();
|
|
67
69
|
|
|
68
70
|
// OBSERVABILITY — a ring of "which agent read what, when", persisted across restarts (the
|
|
69
71
|
// gateway updates often; an empty panel after each restart reads as "nothing is set up").
|
|
@@ -128,7 +130,7 @@ function originAllowed(origin, cfg) {
|
|
|
128
130
|
|
|
129
131
|
function setCors(res, origin) {
|
|
130
132
|
res.setHeader('Access-Control-Allow-Origin', origin || '*');
|
|
131
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
|
|
133
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
|
|
132
134
|
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-ChatPanel-Token');
|
|
133
135
|
res.setHeader('Vary', 'Origin');
|
|
134
136
|
}
|
|
@@ -693,6 +695,12 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
693
695
|
&& req.method === 'POST' && !isAdminAuthorized(req)) {
|
|
694
696
|
return sendJson(res, 403, { error: { message: 'memory write — extension origin or gateway token required', type: 'forbidden' } });
|
|
695
697
|
}
|
|
698
|
+
// Client preferences travel between the extension and the desktop through here, and an
|
|
699
|
+
// MCP server entry can carry an Authorization header — so READS are gated too, unlike
|
|
700
|
+
// history and memory. A drive-by page must not learn what tools the user connected.
|
|
701
|
+
if (pathname === '/v1/prefs' && !isAdminAuthorized(req)) {
|
|
702
|
+
return sendJson(res, 403, { error: { message: 'prefs: extension origin or gateway token required', type: 'forbidden' } });
|
|
703
|
+
}
|
|
696
704
|
// The access log is who-read-what — sensitive, and writable only by the local MCP
|
|
697
705
|
// process (which sends the gateway token). Extension Origin or token for both the
|
|
698
706
|
// read (dashboard) and the report (MCP child); a drive-by page has neither.
|
|
@@ -764,6 +772,29 @@ export function createGateway(cfg = loadConfig()) {
|
|
|
764
772
|
return sendJson(res, 400, { error: { message: `ingest failed: ${e.message}`, type: 'ingest_error' } });
|
|
765
773
|
}
|
|
766
774
|
}
|
|
775
|
+
// --- CLIENT PREFERENCES — the settings every client shares (prefs-store.js).
|
|
776
|
+
// GET /v1/prefs[?section=id][&stamps=1] → { ok, revision, sections: { id: { value, updatedAt, by } } }
|
|
777
|
+
// POST /v1/prefs { sections: { id: { value, updatedAt } }, by } → { ok, revision, applied, kept, sections }
|
|
778
|
+
// DELETE /v1/prefs?section=id → { ok, removed }
|
|
779
|
+
if (pathname === '/v1/prefs' && req.method === 'GET') {
|
|
780
|
+
const section = String(url.searchParams.get('section') || '');
|
|
781
|
+
if (url.searchParams.get('stamps')) return sendJson(res, 200, { ok: true, revision: prefsStore.revision, stamps: prefsStore.stamps() });
|
|
782
|
+
return sendJson(res, 200, { ok: true, revision: prefsStore.revision, sections: prefsStore.get(section) });
|
|
783
|
+
}
|
|
784
|
+
if (pathname === '/v1/prefs' && (req.method === 'POST' || req.method === 'PUT')) {
|
|
785
|
+
try {
|
|
786
|
+
const body = JSON.parse((await readBody(req, cfg.maxBodyBytes)).toString('utf8')) || {};
|
|
787
|
+
const out = prefsStore.put(body.sections || {}, { by: body.by || '' });
|
|
788
|
+
return sendJson(res, 200, { ok: true, ...out });
|
|
789
|
+
} catch (e) {
|
|
790
|
+
return sendJson(res, 400, { error: { message: `prefs write failed: ${e.message}`, type: 'prefs_error' } });
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
if (pathname === '/v1/prefs' && req.method === 'DELETE') {
|
|
794
|
+
const section = String(url.searchParams.get('section') || '');
|
|
795
|
+
return sendJson(res, 200, { ok: true, removed: section ? prefsStore.remove(section) : false });
|
|
796
|
+
}
|
|
797
|
+
|
|
767
798
|
// --- MEMORY. Small, durable facts about the user, reachable by every local agent.
|
|
768
799
|
// GET /v1/memory/list → { memories }
|
|
769
800
|
// POST /v1/memory/recall { text, scopes } → { memories, block }
|