@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chatpanel/gateway",
3
- "version": "0.6.76",
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.76';
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 }