@chatpanel/bridge 0.10.42 → 0.11.1

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/src/server.js CHANGED
@@ -67,7 +67,7 @@ import {
67
67
  // Hardcoded (not read from package.json) so it survives Bun's single-file
68
68
  // --compile, where package.json isn't on a readable FS. CI fails the publish if
69
69
  // this drifts from package.json, so the two can't silently diverge.
70
- const VERSION = '0.10.42';
70
+ const VERSION = '0.11.1';
71
71
  const HOST = process.env.CHATPANEL_BRIDGE_HOST || '127.0.0.1';
72
72
  const PORT = Number(process.env.CHATPANEL_BRIDGE_PORT) || 4319;
73
73
 
@@ -244,6 +244,57 @@ function ensureToken() {
244
244
  log('error', `could not initialise bridge token: ${e?.message || e}`);
245
245
  }
246
246
  }
247
+ // ---------------------------------------------------------------------------
248
+ // CHANNELS — a messaging surface (Telegram today) driving a local agent.
249
+ //
250
+ // Hosted HERE because a channel has to be running when nobody is looking: the point is to
251
+ // reach your machine from a phone with the browser closed, and the bridge is the only
252
+ // always-on local process a ChatPanel user already has. The alternative — a second daemon, or
253
+ // an npm install, or a service worker Chrome suspends — is another thing a non-technical
254
+ // person has to install and keep alive, which is the same as not shipping it.
255
+ //
256
+ // The bridge owns transport and auth; @chatpanel/channels (vendored to src/channels) owns the
257
+ // contract: verify a bot, hold its token 0600, enroll a phone by one-time code, cap it with
258
+ // `reach`, redact both ways. Loaded lazily so the redaction engine never touches a boot where
259
+ // no channel is configured.
260
+ let channelsSvc = null;
261
+ async function channelService() {
262
+ if (channelsSvc) return channelsSvc;
263
+ const { createChannelService } = await import('./channels/service.js');
264
+ channelsSvc = createChannelService({
265
+ home: join(os.homedir(), '.chatpanel'),
266
+ dataDir: join(os.homedir(), '.chatpanel', 'channels'),
267
+ // It talks to THIS bridge as a privileged local client — same port, same token it just
268
+ // read. No second address to configure and get wrong.
269
+ bridge: { baseUrl: `http://127.0.0.1:${PORT}`, token: AUTH_TOKEN },
270
+ logger: { log: (m) => log('info', m), warn: (m) => log('info', m), error: (m) => log('error', m) },
271
+ });
272
+ return channelsSvc;
273
+ }
274
+
275
+ async function handleChannels(req, res, action) {
276
+ try {
277
+ const svc = await channelService();
278
+ const body = req.method === 'POST' ? await readBody(req) : {};
279
+ if (action === 'status') return json(res, 200, await svc.status());
280
+ if (action === 'connect') {
281
+ // The token is verified with Telegram before it is written, so a typo fails HERE, in the
282
+ // settings screen, with a reason — not later as a silent poll loop nobody reads the logs of.
283
+ const r = await svc.connect(body);
284
+ return json(res, 200, { ok: true, ...r });
285
+ }
286
+ if (action === 'pair') return json(res, 200, await svc.pair());
287
+ if (action === 'unpair') return json(res, 200, await svc.unpair(String(body.actorId || '')));
288
+ if (action === 'settings') return json(res, 200, await svc.update(body));
289
+ if (action === 'disconnect') return json(res, 200, await svc.stop({ forget: !!body.forget }));
290
+ return json(res, 404, { error: 'unknown channel action' });
291
+ } catch (e) {
292
+ // A readable reason, because every one of these is something a person can fix: a bad token,
293
+ // a bot not created yet, no network.
294
+ return json(res, 400, { error: e?.message || String(e) });
295
+ }
296
+ }
297
+
247
298
  function tokenOk(req) {
248
299
  if (!AUTH_TOKEN) return false;
249
300
  const h = String(req.headers['authorization'] || '');
@@ -278,7 +329,26 @@ const PRIVILEGED_POST = new Set([
278
329
  // endpoint that touches a run is already guarded. An unauthenticated hole next to nine
279
330
  // guarded neighbours is a hole regardless of how little it grants.
280
331
  '/cancel',
332
+ // A channel is a way into this machine from the internet. Everything that configures one —
333
+ // and the code that enrolls a phone — is as privileged as /chat itself.
334
+ '/channels/connect',
335
+ '/channels/pair',
336
+ '/channels/unpair',
337
+ '/channels/settings',
338
+ '/channels/disconnect',
281
339
  ]);
340
+ // /channels is NOT here, for the same reason /skills is not, and it was a regression to add
341
+ // it: a privileged GET is unreachable from the extension. The panel holds `<all_urls>`, so
342
+ // its fetches bypass CORS altogether — no preflight fires — and the Fetch spec attaches
343
+ // `Origin` only to requests whose method is not GET or HEAD. That is the whole asymmetry:
344
+ // every privileged POST here works, and a privileged GET can only ever answer the settings
345
+ // page with "forbidden: this endpoint requires the ChatPanel extension or a valid bridge
346
+ // token", which is what shipped in 0.11.0.
347
+ //
348
+ // Nothing is opened up by removing it. `originAllowed` still refuses any web page (it is the
349
+ // check doing the work), so the only caller admitted is a local no-Origin process — which can
350
+ // already read ~/.chatpanel/channels/ off the disk. /debug stays privileged: it exposes
351
+ // configuration a local process cannot otherwise see.
282
352
  const PRIVILEGED_GET = new Set(['/debug']);
283
353
 
284
354
  // /skills* is NOT privileged, and that is a considered position rather than a
@@ -1123,6 +1193,10 @@ const server = createServer(async (req, res) => {
1123
1193
  if (req.method === 'GET') { res.writeHead(405); return res.end(); } // no server-initiated stream
1124
1194
  if (req.method === 'DELETE') { deleteSession(sid); res.writeHead(204); return res.end(); }
1125
1195
  }
1196
+ if (req.method === 'GET' && url.pathname === '/channels') return handleChannels(req, res, 'status');
1197
+ if (req.method === 'POST' && url.pathname.startsWith('/channels/')) {
1198
+ return handleChannels(req, res, url.pathname.slice('/channels/'.length));
1199
+ }
1126
1200
  if (req.method === 'POST' && url.pathname === '/cancel') return handleCancel(req, res);
1127
1201
  if (req.method === 'POST' && url.pathname === '/tool-result') return handleToolResult(req, res);
1128
1202
  if (req.method === 'POST' && url.pathname === '/mcp-local') return handleMcpLocal(req, res);
@@ -1237,6 +1311,17 @@ function startServer() {
1237
1311
  log('info', ` ${a.ok ? '✓' : '✕'} ${label}${a.ok ? '' : ' — ' + (a.reason || 'unavailable')}`);
1238
1312
  }
1239
1313
  log('info', 'Open the ChatPanel side panel; installed agents (Claude Code, Codex, Antigravity) appear automatically.');
1314
+ // A channel someone connected last week must come back by itself after a reboot — nobody
1315
+ // is at the keyboard to press start, which is the entire premise of driving this from a
1316
+ // phone. Nothing loads and nothing runs until a bot has actually been connected.
1317
+ channelService()
1318
+ .then((svc) => svc.startIfConfigured())
1319
+ .then((r) => {
1320
+ if (r?.skipped) return;
1321
+ if (r?.ok) log('info', 'channels: telegram connected — polling for messages');
1322
+ else log('error', `channels: telegram not started — ${r?.error || 'unknown error'}`);
1323
+ })
1324
+ .catch((e) => log('error', `channels: ${e?.message || e}`));
1240
1325
  });
1241
1326
  }
1242
1327