@bakapiano/ccsm 0.17.11 → 0.18.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/server.js CHANGED
@@ -16,6 +16,8 @@ const {
16
16
  const webTerminal = require('./lib/webTerminal');
17
17
  const persistedSessions = require('./lib/persistedSessions');
18
18
  const folders = require('./lib/folders');
19
+ const tunnel = require('./lib/tunnel');
20
+ const devices = require('./lib/devices');
19
21
  // Upstream CLI session-id capture used to live in lib/cliSessionWatcher
20
22
  // (poll the CLI's transcript dir, match by cwd). It's gone now — for
21
23
  // CLIs that expose a "set the UUID for a new session" flag (claude +
@@ -44,6 +46,7 @@ async function gracefulShutdown(reason) {
44
46
  }
45
47
  } catch {}
46
48
  try { webTerminal.killAll(); } catch {}
49
+ try { tunnel.stop(); } catch {}
47
50
  process.exit(0);
48
51
  }
49
52
 
@@ -63,13 +66,87 @@ app.use((req, res, next) => {
63
66
  if (origin && ALLOWED_ORIGINS.has(origin)) {
64
67
  res.setHeader('Access-Control-Allow-Origin', origin);
65
68
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
66
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
69
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Device-Id');
67
70
  res.setHeader('Vary', 'Origin');
68
71
  }
69
72
  if (req.method === 'OPTIONS') return res.sendStatus(204);
70
73
  next();
71
74
  });
72
75
 
76
+ // Remote-access token guard. Once a token is set (via the Remote page
77
+ // → POST /api/tunnel/start), any /api/* request that wasn't direct
78
+ // loopback must present the token either as `Authorization: Bearer
79
+ // <token>` or `?token=<token>`.
80
+ // "Direct loopback" = the Host header is loopback-shaped AND no
81
+ // proxy injected an X-Forwarded-* header. devtunnel rewrites Host
82
+ // to `localhost:7788` (it's reverse-proxying via a local socket) but
83
+ // adds `x-forwarded-host` / `x-forwarded-for`; cloudflared does the
84
+ // same with `cf-connecting-ip` etc. Either header's mere presence
85
+ // flips us into "treat as remote" mode regardless of Host. Real
86
+ // browsers on the host machine set neither.
87
+ // /api/health is exempt so tunnel URL probes keep working without
88
+ // leaking the token.
89
+ function isDirectLoopback(req) {
90
+ if (req.headers['x-forwarded-host']) return false;
91
+ if (req.headers['x-forwarded-for']) return false;
92
+ if (req.headers['cf-connecting-ip']) return false;
93
+ const host = String(req.headers.host || '').toLowerCase();
94
+ return /^(localhost|127\.0\.0\.1|\[::1\])(:\d+)?$/.test(host);
95
+ }
96
+ // Device-approval gate.
97
+ //
98
+ // Two-stage trust model:
99
+ // 1. NEW device wants in → must hit /api/devices/me with a valid
100
+ // token. That's the only place new pending records get created
101
+ // (see the handler below). Without the token, a stranger can't
102
+ // flood the host's pending queue with random device ids.
103
+ // 2. ALREADY-known device → its UUID is the credential. The host
104
+ // Approved it once; subsequent calls go through with just the
105
+ // X-Device-Id header (no token needed). Rotating the host token
106
+ // doesn't break existing approved devices — they keep working
107
+ // until the host explicitly Revokes them.
108
+ //
109
+ // This middleware reads-only: it never inserts. Unknown ids → 401 to
110
+ // nudge the caller to re-arrive via the share URL (which lands them
111
+ // on /api/devices/me with the embedded token and registers them).
112
+ const DEVICE_EXEMPT_PATHS = new Set(['/api/health', '/api/devices/me']);
113
+ async function deviceGate(req, res, next) {
114
+ if (DEVICE_EXEMPT_PATHS.has(req.path)) return next();
115
+ if (!req.path.startsWith('/api/')) return next();
116
+ if (isDirectLoopback(req)) return next();
117
+ const id = String(req.headers['x-device-id'] || (req.query && req.query.device) || '');
118
+ if (!id) return res.status(400).json({ error: 'device id required' });
119
+ const d = await devices.get(id);
120
+ if (!d) return res.status(401).json({ error: 'unknown device · open the share URL to register' });
121
+ // Bump lastSeen via record() — it short-circuits the write when the
122
+ // last flush was recent (see MIN_FLUSH_MS in lib/devices.js).
123
+ try { await devices.record(id, {
124
+ userAgent: req.headers['user-agent'] || '',
125
+ ip: String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim(),
126
+ }); } catch { /* lastSeen bump is best-effort */ }
127
+ if (d.status === 'approved') return next();
128
+ return res.status(403).json({
129
+ error: d.status === 'rejected' ? 'device rejected by host' : 'pending host approval',
130
+ pending: d.status === 'pending',
131
+ rejected: d.status === 'rejected',
132
+ deviceId: d.id,
133
+ });
134
+ }
135
+ app.use(deviceGate);
136
+
137
+ // Final admin lock — all device management + tunnel-mutating routes are
138
+ // loopback-only. The remote browser already only sees /api/health and
139
+ // /api/devices/me through the gates above; this stops a remote from
140
+ // trying to enumerate or manipulate the approval list even if they
141
+ // somehow got past everything.
142
+ const HOST_ONLY_PREFIXES = ['/api/devices', '/api/tunnel'];
143
+ app.use((req, res, next) => {
144
+ if (!HOST_ONLY_PREFIXES.some((p) => req.path === p || req.path.startsWith(p + '/'))) return next();
145
+ if (req.path === '/api/devices/me') return next();
146
+ if (isDirectLoopback(req)) return next();
147
+ res.status(403).json({ error: 'host-only endpoint' });
148
+ });
149
+
73
150
  // Dev mode = running from a checkout (not from an npm-install location).
74
151
  // Used to gate two things: (a) serving static frontend from local public/
75
152
  // so a contributor can iterate without pushing to GH Pages; (b) hot-reload
@@ -78,8 +155,20 @@ app.use((req, res, next) => {
78
155
  // frontend lives at https://bakapiano.github.io/ccsm/ (router → per-version).
79
156
  const IS_DEV = !__dirname.includes(`${path.sep}node_modules${path.sep}`) && process.env.CCSM_NO_DEV !== '1';
80
157
 
81
- if (IS_DEV) {
82
- app.use(express.static(path.join(__dirname, 'public')));
158
+ // Always serve public/ when it exists alongside server.js. In a
159
+ // checkout this is the live frontend used during dev. In an npm
160
+ // install this lets a tunneled session (Remote page) reach the
161
+ // frontend at the tunnel URL — the GH Pages hosted frontend is
162
+ // unreachable to a phone on cellular, but the locally-bundled
163
+ // public/ shipped in the package IS, via the tunnel. Same files
164
+ // either way; just no version router in front.
165
+ {
166
+ const publicDir = path.join(__dirname, 'public');
167
+ try {
168
+ if (require('node:fs').statSync(publicDir).isDirectory()) {
169
+ app.use(express.static(publicDir));
170
+ }
171
+ } catch { /* not bundled · API-only mode */ }
83
172
  }
84
173
 
85
174
  const reloadClients = new Set();
@@ -939,6 +1028,108 @@ app.post('/api/shutdown', (_req, res) => {
939
1028
  setImmediate(() => gracefulShutdown('/api/shutdown'));
940
1029
  });
941
1030
 
1031
+ // ---- remote / tunnel ----
1032
+ //
1033
+ // Lifecycle: the Remote page POSTs /start with { provider, token } —
1034
+ // we save the token (used by the middleware above for auth) and spawn
1035
+ // the chosen tunnel CLI. URL appears asynchronously in the CLI's
1036
+ // stdout; lib/tunnel parses it. /status returns the latest snapshot
1037
+ // for the page to poll.
1038
+ app.get('/api/tunnel/status', (_req, res) => {
1039
+ res.json(tunnel.status());
1040
+ });
1041
+ app.post('/api/tunnel/start', asyncH(async (req, res) => {
1042
+ const { provider, token } = req.body || {};
1043
+ if (!token || String(token).length < 8) {
1044
+ return res.status(400).json({ error: 'token required (≥ 8 chars)' });
1045
+ }
1046
+ tunnel.setToken(token);
1047
+ try {
1048
+ const result = await tunnel.start({ provider, port: currentPort });
1049
+ res.json(result);
1050
+ } catch (e) {
1051
+ res.status(400).json({ error: e.message, providers: tunnel.probe() });
1052
+ }
1053
+ }));
1054
+ app.post('/api/tunnel/stop', (_req, res) => {
1055
+ const stopped = tunnel.stop();
1056
+ res.json({ stopped, ...tunnel.status() });
1057
+ });
1058
+ app.post('/api/tunnel/token', (req, res) => {
1059
+ // Bare token update without touching the running tunnel.
1060
+ // POST { token: '' } to clear and disable remote auth.
1061
+ const t = (req.body && req.body.token) || '';
1062
+ tunnel.setToken(t);
1063
+ res.json(tunnel.status());
1064
+ });
1065
+ app.post('/api/tunnel/install', asyncH(async (req, res) => {
1066
+ const { provider } = req.body || {};
1067
+ try {
1068
+ const r = tunnel.installViaWinget(provider);
1069
+ res.json({ ok: true, ...r });
1070
+ } catch (e) {
1071
+ res.status(400).json({ error: e.message });
1072
+ }
1073
+ }));
1074
+
1075
+ // ---- devices ----
1076
+ //
1077
+ // /api/devices/me is callable from the remote browser BEFORE approval —
1078
+ // it's how the PendingApprovalOverlay polls for the host's decision.
1079
+ // Everything else is locked to loopback by the gate above.
1080
+ app.get('/api/devices/me', asyncH(async (req, res) => {
1081
+ const id = String(req.headers['x-device-id'] || (req.query && req.query.device) || '');
1082
+ if (!id) return res.status(400).json({ error: 'device id required' });
1083
+ // Token check applies HERE — this is the only endpoint where new
1084
+ // device records are created (record() inserts pending on first
1085
+ // sight). Demanding the token at registration time stops random
1086
+ // tunnel-URL scanners from filling the host's pending queue with
1087
+ // garbage entries. Already-known devices can re-poll without the
1088
+ // token (the existing record is returned as-is).
1089
+ const existing = await devices.get(id);
1090
+ if (!existing) {
1091
+ const tok = tunnel.getToken();
1092
+ if (tok && !isDirectLoopback(req)) {
1093
+ const auth = req.headers.authorization || '';
1094
+ const qTok = req.query && req.query.token;
1095
+ if (auth !== `Bearer ${tok}` && qTok !== tok) {
1096
+ return res.status(401).json({ error: 'token required to register a new device' });
1097
+ }
1098
+ }
1099
+ }
1100
+ const ua = req.headers['user-agent'] || '';
1101
+ const ip = String(req.headers['x-forwarded-for'] || req.socket.remoteAddress || '').split(',')[0].trim();
1102
+ const d = await devices.record(id, { userAgent: ua, ip });
1103
+ res.json(d);
1104
+ }));
1105
+ app.get('/api/devices', asyncH(async (_req, res) => {
1106
+ res.json({ devices: await devices.list() });
1107
+ }));
1108
+ app.post('/api/devices/:id/approve', asyncH(async (req, res) => {
1109
+ const d = await devices.approve(req.params.id, req.body && req.body.label);
1110
+ if (!d) return res.status(404).json({ error: 'device not found' });
1111
+ res.json(d);
1112
+ }));
1113
+ app.post('/api/devices/:id/reject', asyncH(async (req, res) => {
1114
+ const d = await devices.reject(req.params.id);
1115
+ if (!d) return res.status(404).json({ error: 'device not found' });
1116
+ res.json(d);
1117
+ }));
1118
+ app.post('/api/devices/:id/revoke', asyncH(async (req, res) => {
1119
+ const d = await devices.revoke(req.params.id);
1120
+ if (!d) return res.status(404).json({ error: 'device not found' });
1121
+ res.json(d);
1122
+ }));
1123
+ app.put('/api/devices/:id', asyncH(async (req, res) => {
1124
+ const d = await devices.rename(req.params.id, (req.body && req.body.label) || '');
1125
+ if (!d) return res.status(404).json({ error: 'device not found' });
1126
+ res.json(d);
1127
+ }));
1128
+ app.delete('/api/devices/:id', asyncH(async (req, res) => {
1129
+ const removed = await devices.remove(req.params.id);
1130
+ res.json({ removed });
1131
+ }));
1132
+
942
1133
  // Restart: in production, spawn the restart-helper detached then
943
1134
  // gracefulShutdown — the helper waits for the port to free and respawns
944
1135
  // `ccsm.cmd` (with CCSM_NO_BROWSER so we don't pop a new window — the
@@ -1348,11 +1539,26 @@ function openInBrowser(url) {
1348
1539
  try { ({ WebSocketServer } = require('ws')); } catch {}
1349
1540
  if (WebSocketServer) {
1350
1541
  const wss = new WebSocketServer({ noServer: true });
1351
- server.on('upgrade', (req, socket, head) => {
1352
- const origin = req.headers.origin;
1353
- if (origin && !ALLOWED_ORIGINS.has(origin) && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
1354
- socket.destroy();
1355
- return;
1542
+ server.on('upgrade', async (req, socket, head) => {
1543
+ const direct = isDirectLoopback(req);
1544
+ // Non-loopback WS: device id alone gates entry. The host
1545
+ // explicitly Approved this device id earlier — that approval
1546
+ // IS the credential. No token check here (matches the device
1547
+ // gate above: token is only for /api/devices/me registration).
1548
+ if (!direct) {
1549
+ try {
1550
+ const u = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
1551
+ const devId = u.searchParams.get('device');
1552
+ if (!devId) { socket.destroy(); return; }
1553
+ const d = await devices.get(devId);
1554
+ if (!d || d.status !== 'approved') { socket.destroy(); return; }
1555
+ } catch { socket.destroy(); return; }
1556
+ } else {
1557
+ const origin = req.headers.origin;
1558
+ if (origin && !ALLOWED_ORIGINS.has(origin) && !/^https?:\/\/(localhost|127\.0\.0\.1)(:\d+)?$/i.test(origin)) {
1559
+ socket.destroy();
1560
+ return;
1561
+ }
1356
1562
  }
1357
1563
  const m = req.url && req.url.match(/^\/ws\/terminal\/([^\/?#]+)/);
1358
1564
  if (!m) { socket.destroy(); return; }