mobygate 0.7.2 → 0.8.0

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/lib/platform.js CHANGED
@@ -110,6 +110,80 @@ export function writeMacServerPlist({ installPath, nodeBin, port, logsDir }) {
110
110
  return plistPath;
111
111
  }
112
112
 
113
+ /**
114
+ * Generate the macOS auth-refresh plist with the user's actual paths
115
+ * baked in. Earlier we shipped a static plist template and sed-replaced
116
+ * Farhan's hardcoded paths inside it — anyone who installed without an
117
+ * EXACT path match (different username, different fnm version, etc.)
118
+ * ended up with a plist pointing at /Users/farhan/... and the cron
119
+ * silently failed forever. This generator mirrors writeMacServerPlist
120
+ * and uses the same nodeBin / installPath / logsDir resolution so the
121
+ * resulting plist is portable across any user's machine.
122
+ */
123
+ export function writeMacAuthRefreshPlist({ installPath, nodeBin, logsDir, intervalHours = 4 }) {
124
+ if (!IS_MAC) throw new Error('writeMacAuthRefreshPlist called on non-macOS');
125
+ if (!existsSync(LAUNCH_AGENTS_DIR)) mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true });
126
+ const plistPath = join(LAUNCH_AGENTS_DIR, `${AUTH_LABEL}.plist`);
127
+ const intervalSec = Math.max(60, parseInt(intervalHours, 10) * 3600);
128
+ const pathChain = [
129
+ dirname(nodeBin),
130
+ '/usr/local/bin', '/usr/bin', '/bin', '/opt/homebrew/bin',
131
+ join(homedir(), '.local/bin'),
132
+ ].join(':');
133
+ const xml = `<?xml version="1.0" encoding="UTF-8"?>
134
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
135
+ <!--
136
+ Generated by \`mobygate init\` on ${new Date().toISOString()}.
137
+ Proactive Claude Max OAuth refresh cron.
138
+ - Runs scripts/auth-refresh.js every ${intervalHours}h via launchd
139
+ - Anthropic OAuth tokens last ~8h, so ${intervalHours}h cadence keeps
140
+ us inside the valid window even if one run fails
141
+
142
+ Install: launchctl load ~/Library/LaunchAgents/${AUTH_LABEL}.plist
143
+ Uninstall: launchctl unload ~/Library/LaunchAgents/${AUTH_LABEL}.plist
144
+ -->
145
+ <plist version="1.0">
146
+ <dict>
147
+ <key>Label</key>
148
+ <string>${AUTH_LABEL}</string>
149
+
150
+ <key>ProgramArguments</key>
151
+ <array>
152
+ <string>${nodeBin}</string>
153
+ <string>scripts/auth-refresh.js</string>
154
+ </array>
155
+
156
+ <key>WorkingDirectory</key>
157
+ <string>${installPath}</string>
158
+
159
+ <key>EnvironmentVariables</key>
160
+ <dict>
161
+ <key>PATH</key>
162
+ <string>${pathChain}</string>
163
+ <key>HOME</key>
164
+ <string>${homedir()}</string>
165
+ </dict>
166
+
167
+ <key>StartInterval</key>
168
+ <integer>${intervalSec}</integer>
169
+
170
+ <key>RunAtLoad</key>
171
+ <true/>
172
+
173
+ <key>StandardOutPath</key>
174
+ <string>${logsDir}/auth-refresh.log</string>
175
+ <key>StandardErrorPath</key>
176
+ <string>${logsDir}/auth-refresh.err.log</string>
177
+
178
+ <key>KeepAlive</key>
179
+ <false/>
180
+ </dict>
181
+ </plist>
182
+ `;
183
+ writeFileSync(plistPath, xml);
184
+ return plistPath;
185
+ }
186
+
113
187
  /**
114
188
  * Install (copy + load) a plist. Returns {installed: true, path}.
115
189
  * Safe to call when already loaded — we unload first.
package/lib/updater.js CHANGED
@@ -26,6 +26,12 @@ import { readFileSync, writeFileSync, existsSync, mkdirSync, openSync } from 'fs
26
26
  import { join, sep, dirname } from 'path';
27
27
  import { fileURLToPath } from 'url';
28
28
  import { LOGS_DIR } from './config.js';
29
+ // Single-source service labels from platform.js — earlier we duplicated
30
+ // these constants here and they drifted (WIN_SERVER_TASK was 'ai.mobygate.server'
31
+ // while platform.js registered 'mobygate-server'), so the dashboard's
32
+ // "Update now" silently no-op'd on Windows because the schtasks /End in the
33
+ // update chain failed and short-circuited the rest via &&.
34
+ import { WIN_LABELS, LINUX_UNITS } from './platform.js';
29
35
 
30
36
  const __filename = fileURLToPath(import.meta.url);
31
37
  const REPO_ROOT = dirname(dirname(__filename)); // lib/updater.js → repo root
@@ -35,8 +41,7 @@ const IS_MAC = process.platform === 'darwin';
35
41
  const IS_LINUX = process.platform === 'linux';
36
42
 
37
43
  const SERVER_LABEL = 'ai.mobygate.server';
38
- const WIN_SERVER_TASK = 'ai.mobygate.server';
39
- const LINUX_SERVER_UNIT = 'mobygate-server.service';
44
+ const AUTH_LABEL = 'ai.mobygate.auth-refresh';
40
45
 
41
46
  const UPDATE_LOG = join(LOGS_DIR, 'update.log');
42
47
  const UPDATE_MARKER = join(LOGS_DIR, 'update.state.json');
@@ -174,14 +179,17 @@ function writeUpdateState(patch) {
174
179
  function buildUpdateCommand({ mode, repoRoot, logPath }) {
175
180
  if (IS_WIN) {
176
181
  // cmd.exe — `>>` for append, `2>&1` to merge. Each step on its own
177
- // line so failures short-circuit via `||`.
182
+ // line so failures short-circuit via `&&`. The auth-refresh task is
183
+ // also stopped because it's a separate scheduled task that imports
184
+ // mobygate code; if it fires mid-install it grabs file handles in
185
+ // node_modules\mobygate and we hit EBUSY just like the server task.
186
+ // Note: trailing 2>nul on End calls so "task not found" doesn't
187
+ // short-circuit the chain — the start steps will surface real errors.
178
188
  const steps = [];
179
189
  steps.push(`echo [mobygate-update] start at %DATE% %TIME%`);
180
- // Stop FIRST so npm can replace files without EBUSY. /F forces close
181
- // even if the process is mid-request; the SDK session map writes are
182
- // synchronous and the SIGTERM handler flushes before exit.
183
- steps.push(`echo [mobygate-update] stopping service`);
184
- steps.push(`schtasks /End /TN "${WIN_SERVER_TASK}"`);
190
+ steps.push(`echo [mobygate-update] stopping services`);
191
+ steps.push(`(schtasks /End /TN "${WIN_LABELS.server}" 2>nul) | rem`);
192
+ steps.push(`(schtasks /End /TN "${WIN_LABELS.auth}" 2>nul) | rem`);
185
193
  if (mode === 'npm') {
186
194
  steps.push(`npm install -g mobygate@latest`);
187
195
  } else if (mode === 'git') {
@@ -189,22 +197,29 @@ function buildUpdateCommand({ mode, repoRoot, logPath }) {
189
197
  steps.push(`git pull --ff-only`);
190
198
  steps.push(`npm install`);
191
199
  }
192
- steps.push(`echo [mobygate-update] restarting service`);
193
- steps.push(`schtasks /Run /TN "${WIN_SERVER_TASK}"`);
200
+ steps.push(`echo [mobygate-update] starting services on new build`);
201
+ steps.push(`schtasks /Run /TN "${WIN_LABELS.server}"`);
202
+ steps.push(`(schtasks /Run /TN "${WIN_LABELS.auth}" 2>nul) | rem`);
194
203
  steps.push(`echo [mobygate-update] done`);
195
204
  // Join with && so any failure stops the chain. Final redirect to log.
196
205
  const inner = steps.map((s) => `(${s})`).join(' && ');
197
206
  return { shell: 'cmd', cmd: `${inner} >> "${logPath}" 2>&1` };
198
207
  }
199
- // POSIX: sh -c, bail-on-first-failure via set -e. Stop service first
200
- // for the same reason symmetry, cleaner restart, no harm.
208
+ // POSIX: sh -c, bail-on-first-failure via set -e. Same dual-task stop
209
+ // applies auth-refresh runs on its own launchd plist / systemd timer
210
+ // and would lock files mid-install if not stopped. `|| true` because
211
+ // a not-loaded service shouldn't kill the chain.
201
212
  const parts = [`set -e`, `echo "[mobygate-update] start $(date)"`];
202
- parts.push(`echo "[mobygate-update] stopping service"`);
213
+ parts.push(`echo "[mobygate-update] stopping services"`);
203
214
  if (IS_MAC) {
204
- const plist = join(process.env.HOME || '~', 'Library', 'LaunchAgents', `${SERVER_LABEL}.plist`);
205
- parts.push(`launchctl unload "${plist}" 2>/dev/null || true`);
215
+ const serverPlist = join(process.env.HOME || '~', 'Library', 'LaunchAgents', `${SERVER_LABEL}.plist`);
216
+ const authPlist = join(process.env.HOME || '~', 'Library', 'LaunchAgents', `${AUTH_LABEL}.plist`);
217
+ parts.push(`launchctl unload "${serverPlist}" 2>/dev/null || true`);
218
+ parts.push(`launchctl unload "${authPlist}" 2>/dev/null || true`);
206
219
  } else if (IS_LINUX) {
207
- parts.push(`systemctl --user stop ${LINUX_SERVER_UNIT} 2>/dev/null || true`);
220
+ parts.push(`systemctl --user stop ${LINUX_UNITS.server} 2>/dev/null || true`);
221
+ if (LINUX_UNITS.timer) parts.push(`systemctl --user stop ${LINUX_UNITS.timer} 2>/dev/null || true`);
222
+ if (LINUX_UNITS.auth) parts.push(`systemctl --user stop ${LINUX_UNITS.auth} 2>/dev/null || true`);
208
223
  }
209
224
  if (mode === 'npm') {
210
225
  parts.push(`npm install -g mobygate@latest`);
@@ -213,12 +228,15 @@ function buildUpdateCommand({ mode, repoRoot, logPath }) {
213
228
  parts.push(`git pull --ff-only`);
214
229
  parts.push(`npm install`);
215
230
  }
216
- parts.push(`echo "[mobygate-update] starting service on new build"`);
231
+ parts.push(`echo "[mobygate-update] starting services on new build"`);
217
232
  if (IS_MAC) {
218
- const plist = join(process.env.HOME || '~', 'Library', 'LaunchAgents', `${SERVER_LABEL}.plist`);
219
- parts.push(`launchctl load "${plist}"`);
233
+ const serverPlist = join(process.env.HOME || '~', 'Library', 'LaunchAgents', `${SERVER_LABEL}.plist`);
234
+ const authPlist = join(process.env.HOME || '~', 'Library', 'LaunchAgents', `${AUTH_LABEL}.plist`);
235
+ parts.push(`launchctl load "${serverPlist}"`);
236
+ parts.push(`launchctl load "${authPlist}" 2>/dev/null || true`);
220
237
  } else if (IS_LINUX) {
221
- parts.push(`systemctl --user start ${LINUX_SERVER_UNIT}`);
238
+ parts.push(`systemctl --user start ${LINUX_UNITS.server}`);
239
+ if (LINUX_UNITS.timer) parts.push(`systemctl --user start ${LINUX_UNITS.timer} 2>/dev/null || true`);
222
240
  }
223
241
  parts.push(`echo "[mobygate-update] done"`);
224
242
  const script = parts.join('\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mobygate",
3
- "version": "0.7.2",
3
+ "version": "0.8.0",
4
4
  "description": "OpenAI-compatible local proxy for Claude Max. The Möbius-strip gateway: OpenAI shape in, Claude Max out.",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -286,12 +286,20 @@ function messagesToPrompt(messages, { resuming = false } = {}) {
286
286
  }
287
287
  }
288
288
  const toolResultsText = toolMessagesToText(trailingToolMessages);
289
+ if (!userText && !toolResultsText) {
290
+ // Earlier code fell back to extracting whatever was at messages[-1],
291
+ // which on an assistant-terminated history sent the assistant's own
292
+ // previous reply back to the SDK as the new user prompt — and the
293
+ // model would "respond to its own reply." Catch this clearly instead.
294
+ return {
295
+ promptText: '',
296
+ error: 'Resume mode requires the request to end with a user message or tool result. Last message has role "' + (messages[messages.length - 1]?.role || 'unknown') + '".',
297
+ };
298
+ }
289
299
  const parts = [];
290
300
  if (toolResultsText) parts.push(toolResultsText);
291
301
  if (userText) parts.push(userText);
292
- return {
293
- promptText: parts.join('\n\n') || extractContent(messages[messages.length - 1]?.content || ''),
294
- };
302
+ return { promptText: parts.join('\n\n') };
295
303
  }
296
304
 
297
305
  // Fresh request: serialize visible history as XML-wrapped text. No
@@ -396,9 +404,20 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
396
404
  const existing = getSession(sessionKey);
397
405
  const resuming = !!existing?.sdkSessionId;
398
406
  const toolsEnabled = hasTools(body);
399
- const { promptText } = messagesToPrompt(body.messages, { resuming });
407
+ const { promptText, error: promptError } = messagesToPrompt(body.messages, { resuming });
408
+ if (promptError) {
409
+ return res.status(400).json({
410
+ error: { message: promptError, type: 'invalid_request_error', code: 'invalid_resume_messages' },
411
+ });
412
+ }
400
413
  const images = collectImages(body.messages);
401
- const prompt = buildQueryPrompt(promptText, images);
414
+ // NOTE: `prompt` is built inside runQuery (not here) when images are
415
+ // present, because buildQueryPrompt returns a single-use async iterator
416
+ // for multimodal requests. If we built it here and the SDK call hit a
417
+ // 401, runWithAuthRetry would invoke runQuery a second time with the
418
+ // same exhausted iterator → SDK gets an empty user message → silent
419
+ // empty response. Lazy construction inside runQuery rebuilds the
420
+ // iterator per attempt.
402
421
  const model = resolveModel(body.model);
403
422
  // Build the in-process MCP server exposing client tools to the SDK.
404
423
  // null when toolsEnabled is false (or all tools are malformed).
@@ -450,6 +469,9 @@ async function handleStreaming(req, res, body, requestId, sessionKey) {
450
469
  resolvedModel = model;
451
470
  capturedSessionId = existing?.sdkSessionId || null;
452
471
 
472
+ // Build the prompt lazily on each attempt — multimodal returns a
473
+ // single-use async iterator. Keeps 401 auth-retries safe.
474
+ const prompt = buildQueryPrompt(promptText, images);
453
475
  for await (const message of query({
454
476
  prompt,
455
477
  options: {
@@ -623,9 +645,20 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
623
645
  const existing = getSession(sessionKey);
624
646
  const resuming = !!existing?.sdkSessionId;
625
647
  const toolsEnabled = hasTools(body);
626
- const { promptText } = messagesToPrompt(body.messages, { resuming });
648
+ const { promptText, error: promptError } = messagesToPrompt(body.messages, { resuming });
649
+ if (promptError) {
650
+ return res.status(400).json({
651
+ error: { message: promptError, type: 'invalid_request_error', code: 'invalid_resume_messages' },
652
+ });
653
+ }
627
654
  const images = collectImages(body.messages);
628
- const prompt = buildQueryPrompt(promptText, images);
655
+ // NOTE: `prompt` is built inside runQuery (not here) when images are
656
+ // present, because buildQueryPrompt returns a single-use async iterator
657
+ // for multimodal requests. If we built it here and the SDK call hit a
658
+ // 401, runWithAuthRetry would invoke runQuery a second time with the
659
+ // same exhausted iterator → SDK gets an empty user message → silent
660
+ // empty response. Lazy construction inside runQuery rebuilds the
661
+ // iterator per attempt.
629
662
  const model = resolveModel(body.model);
630
663
  const clientToolsServer = toolsEnabled ? buildClientToolsServer(body.tools) : null;
631
664
  const toolsGuidance = clientToolsServer ? buildToolUsageGuidance(body.tools) : null;
@@ -653,6 +686,9 @@ async function handleNonStreaming(res, body, requestId, sessionKey) {
653
686
  outputTokens = 0;
654
687
  capturedSessionId = existing?.sdkSessionId || null;
655
688
 
689
+ // Build the prompt lazily on each attempt — multimodal returns a
690
+ // single-use async iterator. Keeps 401 auth-retries safe.
691
+ const prompt = buildQueryPrompt(promptText, images);
656
692
  for await (const message of query({
657
693
  prompt,
658
694
  options: {
@@ -801,9 +837,17 @@ async function handleAnthropicNonStreaming(res, body, requestId, sessionKey) {
801
837
  const existing = getSession(sessionKey);
802
838
  const resuming = !!existing?.sdkSessionId;
803
839
  const toolsEnabled = hasAnthropicTools(body);
804
- const promptText = anthropicMessagesToPrompt(body, { resuming });
840
+ const { promptText, error: promptError } = anthropicMessagesToPrompt(body, { resuming });
841
+ if (promptError) {
842
+ return res.status(400).json({
843
+ type: 'error',
844
+ error: { type: 'invalid_request_error', message: promptError },
845
+ });
846
+ }
805
847
  const images = collectAnthropicImages(body.messages || []);
806
- const prompt = buildQueryPrompt(promptText, images);
848
+ // See note in handleStreaming — `prompt` is built lazily inside runQuery
849
+ // because the multimodal path returns a single-use async iterator that
850
+ // a 401-retry would exhaust on the first attempt.
807
851
  const model = resolveModel(body.model);
808
852
  // Translate Anthropic tool defs → OpenAI shape that buildClientToolsServer
809
853
  // expects. Both go through the same JSON-Schema → Zod path on the way to
@@ -843,6 +887,9 @@ async function handleAnthropicNonStreaming(res, body, requestId, sessionKey) {
843
887
  capturedSessionId = existing?.sdkSessionId || null;
844
888
  stopReason = 'end_turn';
845
889
 
890
+ // Build the prompt lazily on each attempt — multimodal returns a
891
+ // single-use async iterator. Keeps 401 auth-retries safe.
892
+ const prompt = buildQueryPrompt(promptText, images);
846
893
  for await (const message of query({
847
894
  prompt,
848
895
  options: {
@@ -949,9 +996,17 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
949
996
  const existing = getSession(sessionKey);
950
997
  const resuming = !!existing?.sdkSessionId;
951
998
  const toolsEnabled = hasAnthropicTools(body);
952
- const promptText = anthropicMessagesToPrompt(body, { resuming });
999
+ const { promptText, error: promptError } = anthropicMessagesToPrompt(body, { resuming });
1000
+ if (promptError) {
1001
+ return res.status(400).json({
1002
+ type: 'error',
1003
+ error: { type: 'invalid_request_error', message: promptError },
1004
+ });
1005
+ }
953
1006
  const images = collectAnthropicImages(body.messages || []);
954
- const prompt = buildQueryPrompt(promptText, images);
1007
+ // See note in handleStreaming — `prompt` is built lazily inside runQuery
1008
+ // because the multimodal path returns a single-use async iterator that
1009
+ // a 401-retry would exhaust on the first attempt.
955
1010
  const model = resolveModel(body.model);
956
1011
  const toolsForBridge = toolsEnabled
957
1012
  ? body.tools.map((t) => ({
@@ -1005,6 +1060,9 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
1005
1060
  textEmittedSoFar = '';
1006
1061
  toolUseEmitted = false;
1007
1062
 
1063
+ // Build the prompt lazily on each attempt — multimodal returns a
1064
+ // single-use async iterator. Keeps 401 auth-retries safe.
1065
+ const prompt = buildQueryPrompt(promptText, images);
1008
1066
  for await (const message of query({
1009
1067
  prompt,
1010
1068
  options: {
@@ -1165,6 +1223,62 @@ async function handleAnthropicStreaming(req, res, body, requestId, sessionKey) {
1165
1223
  const app = express();
1166
1224
  app.use(express.json({ limit: '10mb' }));
1167
1225
 
1226
+ // ---------------------------------------------------------------------------
1227
+ // Same-origin gate for control-plane endpoints
1228
+ // ---------------------------------------------------------------------------
1229
+ // The proxy endpoints (/v1/chat/completions, /v1/messages, /v1/models,
1230
+ // /health) are intentionally open: clients from other localhost processes
1231
+ // (Hermes, OpenClaw, etc.) need to hit them. But the *control-plane*
1232
+ // endpoints — anything that triggers privileged actions (npm install,
1233
+ // auth refresh, session deletion) or exposes sensitive data (server log
1234
+ // containing prompt text, live event metadata) — must NOT be reachable
1235
+ // from a browser tab on a malicious site (DNS-rebinding) or a LAN
1236
+ // attacker (when bind: 0.0.0.0).
1237
+ //
1238
+ // Defense:
1239
+ // - Host header must resolve to localhost. DNS rebinding makes the
1240
+ // network connect to 127.0.0.1, but the browser still sends the
1241
+ // attacker's hostname in the Host header — block it.
1242
+ // - If Origin is present (browsers always send it on POST), the
1243
+ // hostname must also be local. Catches cross-origin fetches.
1244
+ // - Non-browser clients (curl, the dashboard's own JS from same
1245
+ // origin, programmatic callers) sail through fine.
1246
+ //
1247
+ // Limitation: this is NOT a substitute for real auth on a LAN-exposed
1248
+ // proxy. With bind: 0.0.0.0, anyone on the LAN can still hit endpoints
1249
+ // directly with a faked Host header. For v0.7.3 we accept that and warn
1250
+ // in the startup banner; a real `MOBYGATE_TOKEN` for LAN use is a
1251
+ // follow-up.
1252
+
1253
+ function isLocalHostname(name) {
1254
+ if (!name) return false;
1255
+ const lower = String(name).toLowerCase();
1256
+ // Strip optional brackets (IPv6) and port suffix.
1257
+ const stripped = lower.replace(/^\[|\]$/g, '').replace(/:[0-9]+$/, '');
1258
+ return stripped === '127.0.0.1' || stripped === 'localhost' || stripped === '::1';
1259
+ }
1260
+
1261
+ function requireLocalOrigin(req, res, next) {
1262
+ if (!isLocalHostname(req.headers.host)) {
1263
+ return res.status(403).json({
1264
+ error: { type: 'forbidden', message: 'Host header is not localhost. Mobygate refuses non-local origins on control-plane endpoints (DNS-rebinding protection).' },
1265
+ });
1266
+ }
1267
+ const origin = req.headers.origin;
1268
+ if (origin) {
1269
+ try {
1270
+ if (!isLocalHostname(new URL(origin).hostname)) {
1271
+ return res.status(403).json({
1272
+ error: { type: 'forbidden', message: 'Origin header is not localhost. Cross-origin fetch refused on control-plane endpoint.' },
1273
+ });
1274
+ }
1275
+ } catch {
1276
+ return res.status(403).json({ error: { type: 'forbidden', message: 'Invalid Origin header.' } });
1277
+ }
1278
+ }
1279
+ next();
1280
+ }
1281
+
1168
1282
  // GET / — serve dashboard. No-cache headers so browsers always re-fetch
1169
1283
  // after a mobygate upgrade; otherwise they keep serving the old index.html
1170
1284
  // from cache and users see a stale dashboard long after the service updated.
@@ -1388,7 +1502,7 @@ app.get('/sessions/:key', (req, res) => {
1388
1502
  });
1389
1503
 
1390
1504
  // DELETE /sessions/:key — clear a session
1391
- app.delete('/sessions/:key', (req, res) => {
1505
+ app.delete('/sessions/:key', requireLocalOrigin, (req, res) => {
1392
1506
  const existed = sessions.delete(req.params.key);
1393
1507
  if (existed) {
1394
1508
  dashboardBus.emitEvent({ type: 'session.expired', key: req.params.key, reason: 'manual' });
@@ -1398,7 +1512,7 @@ app.delete('/sessions/:key', (req, res) => {
1398
1512
  });
1399
1513
 
1400
1514
  // DELETE /sessions — clear all sessions
1401
- app.delete('/sessions', (_req, res) => {
1515
+ app.delete('/sessions', requireLocalOrigin, (_req, res) => {
1402
1516
  const keys = [...sessions.keys()];
1403
1517
  const count = sessions.size;
1404
1518
  sessions.clear();
@@ -1439,7 +1553,7 @@ app.get('/auth/status', async (req, res) => {
1439
1553
 
1440
1554
  // POST /auth/refresh
1441
1555
  // Fires the refresh probe. Intended for use by cron / launchd.
1442
- app.post('/auth/refresh', async (_req, res) => {
1556
+ app.post('/auth/refresh', requireLocalOrigin, async (_req, res) => {
1443
1557
  const probe = await forceRefresh();
1444
1558
  dashboardBus.emitEvent({ type: 'auth.refresh', ok: probe.ok, durationMs: probe.durationMs, error: probe.error });
1445
1559
  res.status(probe.ok ? 200 : 502).json({
@@ -1453,7 +1567,7 @@ app.post('/auth/refresh', async (_req, res) => {
1453
1567
  // ---------------------------------------------------------------------------
1454
1568
 
1455
1569
  // GET /events — SSE stream of dashboard events
1456
- app.get('/events', (req, res) => {
1570
+ app.get('/events', requireLocalOrigin, (req, res) => {
1457
1571
  res.setHeader('Content-Type', 'text/event-stream');
1458
1572
  res.setHeader('Cache-Control', 'no-cache, no-transform');
1459
1573
  res.setHeader('Connection', 'keep-alive');
@@ -1528,7 +1642,7 @@ app.get('/dashboard/sessions', (_req, res) => {
1528
1642
  });
1529
1643
 
1530
1644
  // GET /dashboard/logs — tail the server log file
1531
- app.get('/dashboard/logs', async (req, res) => {
1645
+ app.get('/dashboard/logs', requireLocalOrigin, async (req, res) => {
1532
1646
  const lines = Math.min(2000, parseInt(req.query.lines || '200', 10));
1533
1647
  const logPath = join(LOGS_DIR, 'server.log');
1534
1648
  try {
@@ -1567,7 +1681,7 @@ app.get('/update/check', async (req, res) => {
1567
1681
  // `npm install -g mobygate@latest` (or `git pull && npm install`), then
1568
1682
  // restarts the service — which kills us. The dashboard polls
1569
1683
  // /update/status to show progress and reconnects once the new server is up.
1570
- app.post('/update/apply', (_req, res) => {
1684
+ app.post('/update/apply', requireLocalOrigin, (_req, res) => {
1571
1685
  try {
1572
1686
  const result = applyUpdate({});
1573
1687
  const status = result.started ? 202 : 409;