@oh-my-pi/omp-stats 17.2.8 → 17.2.9

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/CHANGELOG.md CHANGED
@@ -2,6 +2,12 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [17.2.9] - 2026-08-05
6
+
7
+ ### Fixed
8
+
9
+ - Restricted the stats dashboard to IPv4 loopback and removed wildcard CORS access to its API ([#7633](https://github.com/can1357/oh-my-pi/issues/7633)).
10
+
5
11
  ## [17.2.4] - 2026-08-01
6
12
 
7
13
  ### Fixed
@@ -1,4 +1,14 @@
1
1
  /** Header stamped on every dashboard response so reuse probes can identify us. */
2
2
  export declare const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard";
3
- /** Reuse a live stats dashboard or reclaim the port from a stale omp runtime. */
3
+ /** Identity-header value for dashboards enforcing loopback-only, same-origin access. */
4
+ export declare const STATS_DASHBOARD_SECURITY_VERSION = "2";
5
+ /** IPv4 loopback address shared by the dashboard server and reuse probe. */
6
+ export declare const STATS_DASHBOARD_HOSTNAME = "127.0.0.1";
7
+ /**
8
+ * Reuse a secure dashboard or reclaim an insecure HTTP dashboard before binding.
9
+ * The preflight is needed on platforms that permit wildcard and loopback-specific
10
+ * listeners to coexist on one port.
11
+ */
12
+ export declare function prepareStatsPort(port: number): Promise<"retry" | "reuse">;
13
+ /** Reuse or reclaim a listener found after the server bind reports EADDRINUSE. */
4
14
  export declare function recoverStatsPort(port: number): Promise<"retry" | "reuse">;
@@ -6,6 +6,7 @@ export declare function handleApi(req: Request): Promise<Response>;
6
6
  * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
7
7
  */
8
8
  export declare function startServer(port?: number): Promise<{
9
+ hostname: string;
9
10
  port: number;
10
11
  stop: () => void;
11
12
  }>;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "17.2.8",
4
+ "version": "17.2.9",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -39,9 +39,9 @@
39
39
  "fmt": "biome format --write ."
40
40
  },
41
41
  "dependencies": {
42
- "@oh-my-pi/pi-ai": "17.2.8",
43
- "@oh-my-pi/pi-catalog": "17.2.8",
44
- "@oh-my-pi/pi-utils": "17.2.8",
42
+ "@oh-my-pi/pi-ai": "17.2.9",
43
+ "@oh-my-pi/pi-catalog": "17.2.9",
44
+ "@oh-my-pi/pi-utils": "17.2.9",
45
45
  "@tailwindcss/node": "^4.3.2",
46
46
  "chart.js": "^4.5.1",
47
47
  "date-fns": "^4.4.0",
package/src/index.ts CHANGED
@@ -171,8 +171,8 @@ Examples:
171
171
 
172
172
  // Start server
173
173
  const port = parseInt(values.port || "3847", 10);
174
- const { port: actualPort } = await startServer(port);
175
- console.log(`Dashboard available at: http://localhost:${actualPort}`);
174
+ const { hostname, port: actualPort } = await startServer(port);
175
+ console.log(`Dashboard available at: http://${hostname}:${actualPort}`);
176
176
  console.log("Press Ctrl+C to stop\n");
177
177
 
178
178
  // Keep process running
@@ -18,30 +18,27 @@ interface PortHolder {
18
18
  /** Header stamped on every dashboard response so reuse probes can identify us. */
19
19
  export const STATS_DASHBOARD_HEADER = "x-omp-stats-dashboard";
20
20
 
21
- async function probeStatsDashboard(port: number): Promise<boolean> {
21
+ /** Identity-header value for dashboards enforcing loopback-only, same-origin access. */
22
+ export const STATS_DASHBOARD_SECURITY_VERSION = "2";
23
+
24
+ /** IPv4 loopback address shared by the dashboard server and reuse probe. */
25
+ export const STATS_DASHBOARD_HOSTNAME = "127.0.0.1";
26
+
27
+ type StatsDashboardProbe = "reusable" | "occupied" | "unreachable";
28
+
29
+ async function probeStatsDashboard(port: number): Promise<StatsDashboardProbe> {
22
30
  try {
23
- const response = await fetch(`http://localhost:${port}/api/stats/models`, {
31
+ const response = await fetch(`http://${STATS_DASHBOARD_HOSTNAME}:${port}/api/stats/models`, {
24
32
  signal: AbortSignal.timeout(STATS_PROBE_TIMEOUT_MS),
25
33
  });
26
- if (response.status !== 200) {
27
- await response.body?.cancel();
28
- return false;
29
- }
30
- // A live omp-stats dashboard stamps this header on every response.
31
- if (response.headers.get(STATS_DASHBOARD_HEADER)) {
32
- await response.body?.cancel();
33
- return true;
34
- }
35
- // Older dashboards predate the header; fall back to the response shape
36
- // (`/api/stats/models` returns a JSON array) so we never reuse — or later
37
- // kill — a foreign 200 responder such as an SPA dev server catch-all.
38
- if (!(response.headers.get("content-type") ?? "").includes("application/json")) {
39
- await response.body?.cancel();
40
- return false;
41
- }
42
- return Array.isArray(await response.json());
34
+ const reusable =
35
+ response.status === 200 &&
36
+ response.headers.get(STATS_DASHBOARD_HEADER) === STATS_DASHBOARD_SECURITY_VERSION &&
37
+ !response.headers.has("Access-Control-Allow-Origin");
38
+ await response.body?.cancel();
39
+ return reusable ? "reusable" : "occupied";
43
40
  } catch {
44
- return false;
41
+ return "unreachable";
45
42
  }
46
43
  }
47
44
 
@@ -216,10 +213,7 @@ async function terminatePortHolder(holder: PortHolder): Promise<void> {
216
213
  await Bun.sleep(PROCESS_EXIT_POLL_MS);
217
214
  }
218
215
 
219
- /** Reuse a live stats dashboard or reclaim the port from a stale omp runtime. */
220
- export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> {
221
- if (await probeStatsDashboard(port)) return "reuse";
222
-
216
+ async function reclaimStatsPort(port: number): Promise<"retry"> {
223
217
  const holder = await findPortHolder(port);
224
218
  if (!holder) {
225
219
  throw new Error(`Port ${port} is in use, but the listening process could not be identified.`);
@@ -248,3 +242,22 @@ export async function recoverStatsPort(port: number): Promise<"retry" | "reuse">
248
242
  await terminatePortHolder(holder);
249
243
  return "retry";
250
244
  }
245
+
246
+ /**
247
+ * Reuse a secure dashboard or reclaim an insecure HTTP dashboard before binding.
248
+ * The preflight is needed on platforms that permit wildcard and loopback-specific
249
+ * listeners to coexist on one port.
250
+ */
251
+ export async function prepareStatsPort(port: number): Promise<"retry" | "reuse"> {
252
+ if (port === 0) return "retry";
253
+ const probe = await probeStatsDashboard(port);
254
+ if (probe === "reusable") return "reuse";
255
+ if (probe === "occupied") return reclaimStatsPort(port);
256
+ return "retry";
257
+ }
258
+
259
+ /** Reuse or reclaim a listener found after the server bind reports EADDRINUSE. */
260
+ export async function recoverStatsPort(port: number): Promise<"retry" | "reuse"> {
261
+ if ((await probeStatsDashboard(port)) === "reusable") return "reuse";
262
+ return reclaimStatsPort(port);
263
+ }
package/src/server.ts CHANGED
@@ -21,7 +21,13 @@ import {
21
21
  import { decodeEmbeddedClientArchive } from "./embedded-client";
22
22
  import embeddedClientArchiveTxt from "./embedded-client.generated.txt";
23
23
  import { getGainDashboardStats } from "./gain-aggregator";
24
- import { recoverStatsPort, STATS_DASHBOARD_HEADER } from "./port-conflict";
24
+ import {
25
+ prepareStatsPort,
26
+ recoverStatsPort,
27
+ STATS_DASHBOARD_HEADER,
28
+ STATS_DASHBOARD_HOSTNAME,
29
+ STATS_DASHBOARD_SECURITY_VERSION,
30
+ } from "./port-conflict";
25
31
 
26
32
  const EMBEDDED_CLIENT_ARCHIVE = decodeEmbeddedClientArchive(embeddedClientArchiveTxt);
27
33
 
@@ -303,21 +309,19 @@ async function handleStatic(requestPath: string): Promise<Response> {
303
309
  function createDashboardServer(port: number) {
304
310
  const server = Bun.serve({
305
311
  port,
312
+ hostname: STATS_DASHBOARD_HOSTNAME,
306
313
  async fetch(req) {
307
314
  const url = new URL(req.url);
308
315
  const path = url.pathname;
309
316
 
310
- // CORS headers for local development; the identity header lets another
311
- // omp session's reuse probe positively recognize this dashboard.
312
- const corsHeaders: Record<string, string> = {
313
- "Access-Control-Allow-Origin": "*",
314
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
315
- "Access-Control-Allow-Headers": "Content-Type",
316
- [STATS_DASHBOARD_HEADER]: "1",
317
+ // The identity header lets another omp session's reuse probe positively
318
+ // recognize this dashboard without allowing cross-origin API reads.
319
+ const dashboardHeaders: Record<string, string> = {
320
+ [STATS_DASHBOARD_HEADER]: STATS_DASHBOARD_SECURITY_VERSION,
317
321
  };
318
322
 
319
323
  if (req.method === "OPTIONS") {
320
- return new Response(null, { headers: corsHeaders });
324
+ return new Response(null, { headers: dashboardHeaders });
321
325
  }
322
326
 
323
327
  try {
@@ -329,10 +333,10 @@ function createDashboardServer(port: number) {
329
333
  response = await handleStatic(path);
330
334
  }
331
335
 
332
- // Add CORS headers to all responses
336
+ // Add the dashboard identity header to all responses.
333
337
  const headers = new Headers(response.headers);
334
- for (const key in corsHeaders) {
335
- headers.set(key, corsHeaders[key]);
338
+ for (const key in dashboardHeaders) {
339
+ headers.set(key, dashboardHeaders[key]);
336
340
  }
337
341
 
338
342
  return new Response(response.body, {
@@ -343,7 +347,7 @@ function createDashboardServer(port: number) {
343
347
  console.error("Server error:", error);
344
348
  return Response.json(
345
349
  { error: error instanceof Error ? error.message : "Unknown error" },
346
- { status: 500, headers: corsHeaders },
350
+ { status: 500, headers: dashboardHeaders },
347
351
  );
348
352
  }
349
353
  },
@@ -354,12 +358,17 @@ function createDashboardServer(port: number) {
354
358
  /**
355
359
  * Start the HTTP server, reusing a live dashboard or reclaiming a stale omp listener.
356
360
  */
357
- export async function startServer(port = 3847): Promise<{ port: number; stop: () => void }> {
361
+ export async function startServer(port = 3847): Promise<{ hostname: string; port: number; stop: () => void }> {
358
362
  await ensureClientBuild();
363
+ const preparation = await prepareStatsPort(port);
364
+ if (preparation === "reuse") {
365
+ return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} };
366
+ }
359
367
 
360
368
  try {
361
369
  const server = createDashboardServer(port);
362
370
  return {
371
+ hostname: STATS_DASHBOARD_HOSTNAME,
363
372
  port: server.port ?? port,
364
373
  stop: () => server.stop(),
365
374
  };
@@ -368,12 +377,13 @@ export async function startServer(port = 3847): Promise<{ port: number; stop: ()
368
377
 
369
378
  const recovery = await recoverStatsPort(port);
370
379
  if (recovery === "reuse") {
371
- return { port, stop: () => {} };
380
+ return { hostname: STATS_DASHBOARD_HOSTNAME, port, stop: () => {} };
372
381
  }
373
382
 
374
383
  try {
375
384
  const server = createDashboardServer(port);
376
385
  return {
386
+ hostname: STATS_DASHBOARD_HOSTNAME,
377
387
  port: server.port ?? port,
378
388
  stop: () => server.stop(),
379
389
  };