@apitrail/studio 0.1.0-alpha.0 → 0.1.0-alpha.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/README.md CHANGED
@@ -62,7 +62,15 @@ Output:
62
62
 
63
63
  ## Security
64
64
 
65
- Studio has **no built-in auth**. Default binding is `127.0.0.1` so only your machine can reach it. If you bind to `0.0.0.0` or run it remotely, put it behind a reverse proxy with your own auth — it reads raw request bodies and headers from apitrail, which may include sensitive data even after the masking layer.
65
+ - **Default binding: `127.0.0.1`.** Only processes on your machine reach studio.
66
+ - **Refuses to start on non-loopback hosts without `--auth-basic`.** This is enforced at CLI parse time — you cannot accidentally expose logs.
67
+ - **HTTP Basic Auth** (`--auth-basic user:pass` or `APITRAIL_STUDIO_AUTH=user:pass`) uses constant-time SHA-256 comparison.
68
+ - **Strict security headers** on every response: CSP, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`, `X-Robots-Tag: noindex, nofollow`.
69
+ - **Rate limit** on `/api/*` routes: 300 req / min per client IP.
70
+ - **Parametrized SQL**: every user input hits Postgres as a bound parameter; table names are regex-validated identifiers; trace IDs must match `/^[0-9a-f]{32}$/`.
71
+ - **Sanitized error responses**: 500s return `{"error":"internal error"}` — the actual SQL / stack trace is logged server-side only.
72
+
73
+ For LAN / remote deploys, see the full threat model in [SECURITY.md](../../SECURITY.md) and the [setup walkthrough](../../docs/STUDIO_SETUP.md).
66
74
 
67
75
  ## License
68
76
 
package/dist/cli.js CHANGED
@@ -14,6 +14,40 @@ import { Hono } from "hono";
14
14
  import { cors } from "hono/cors";
15
15
  import pg from "pg";
16
16
 
17
+ // src/server/auth.ts
18
+ import { createHash, timingSafeEqual } from "crypto";
19
+ function basicAuth(expected) {
20
+ const expectedHash = sha256(expected);
21
+ return async (c2, next) => {
22
+ const header = c2.req.header("authorization") ?? "";
23
+ if (!header.toLowerCase().startsWith("basic ")) {
24
+ return unauthorized(c2);
25
+ }
26
+ const encoded = header.slice(6).trim();
27
+ let decoded;
28
+ try {
29
+ decoded = Buffer.from(encoded, "base64").toString("utf8");
30
+ } catch {
31
+ return unauthorized(c2);
32
+ }
33
+ if (!safeEqual(sha256(decoded), expectedHash)) {
34
+ return unauthorized(c2);
35
+ }
36
+ await next();
37
+ };
38
+ }
39
+ function unauthorized(c2) {
40
+ c2.header("WWW-Authenticate", 'Basic realm="apitrail studio", charset="UTF-8"');
41
+ return c2.text("Unauthorized", 401);
42
+ }
43
+ function sha256(s) {
44
+ return createHash("sha256").update(s, "utf8").digest();
45
+ }
46
+ function safeEqual(a, b) {
47
+ if (a.length !== b.length) return false;
48
+ return timingSafeEqual(a, b);
49
+ }
50
+
17
51
  // src/server/queries.ts
18
52
  function qi(name) {
19
53
  if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {
@@ -89,8 +123,62 @@ async function getTrace(pool, table, traceId) {
89
123
  return { root: rootRes.rows[0] ?? null, children: childRes.rows };
90
124
  }
91
125
 
126
+ // src/server/rate-limit.ts
127
+ function createRateLimiter(opts) {
128
+ const buckets = /* @__PURE__ */ new Map();
129
+ const sweep = (now) => {
130
+ for (const [k, b] of buckets) {
131
+ if (b.resetAt < now) buckets.delete(k);
132
+ }
133
+ };
134
+ return (key) => {
135
+ const now = Date.now();
136
+ if (buckets.size > 1024) sweep(now);
137
+ const existing = buckets.get(key);
138
+ if (!existing || existing.resetAt < now) {
139
+ buckets.set(key, { count: 1, resetAt: now + opts.windowMs });
140
+ return true;
141
+ }
142
+ if (existing.count >= opts.max) return false;
143
+ existing.count++;
144
+ return true;
145
+ };
146
+ }
147
+
148
+ // src/server/validate.ts
149
+ var TRACE_ID_RE = /^[0-9a-f]{32}$/i;
150
+ var METHOD_RE = /^[A-Z]{3,10}$/;
151
+ var PATH_LIKE_MAX = 200;
152
+ function parseInt32(raw, { min, max }) {
153
+ if (raw === void 0 || raw === "") return void 0;
154
+ if (!/^-?\d+$/.test(raw)) return void 0;
155
+ const n = Number(raw);
156
+ if (!Number.isInteger(n)) return void 0;
157
+ if (n < min || n > max) return void 0;
158
+ return n;
159
+ }
160
+ function isValidTraceId(id) {
161
+ return TRACE_ID_RE.test(id);
162
+ }
163
+ function parseMethod(raw) {
164
+ if (!raw) return void 0;
165
+ const m = raw.toUpperCase();
166
+ return METHOD_RE.test(m) ? m : void 0;
167
+ }
168
+ function parsePathLike(raw) {
169
+ if (!raw) return void 0;
170
+ if (raw.length > PATH_LIKE_MAX) return void 0;
171
+ if (/[\x00-\x1f\x7f]/.test(raw)) return void 0;
172
+ return raw;
173
+ }
174
+
92
175
  // src/server/index.ts
93
176
  var OVERVIEW_CACHE_MS = 1500;
177
+ function log(level, msg, extra) {
178
+ const prefix = level === "error" ? "[studio][error]" : level === "warn" ? "[studio][warn]" : "[studio]";
179
+ if (extra !== void 0) console[level](prefix, msg, extra);
180
+ else console[level](prefix, msg);
181
+ }
94
182
  async function startServer(opts) {
95
183
  const { Pool } = pg;
96
184
  const pool = new Pool({
@@ -108,7 +196,30 @@ async function startServer(opts) {
108
196
  }
109
197
  const tableName = opts.tableName ?? "apitrail_spans";
110
198
  const app = new Hono();
199
+ app.use("*", async (c2, next) => {
200
+ await next();
201
+ c2.header(
202
+ "Content-Security-Policy",
203
+ "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'; object-src 'none'"
204
+ );
205
+ c2.header("X-Content-Type-Options", "nosniff");
206
+ c2.header("X-Frame-Options", "DENY");
207
+ c2.header("Referrer-Policy", "no-referrer");
208
+ c2.header("Permissions-Policy", "interest-cohort=()");
209
+ c2.header("X-Robots-Tag", "noindex, nofollow");
210
+ });
111
211
  if (opts.dev) app.use("*", cors());
212
+ const allowRequest = createRateLimiter({ max: 300, windowMs: 6e4 });
213
+ app.use("/api/*", async (c2, next) => {
214
+ const ip = c2.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? "local";
215
+ if (!allowRequest(ip)) {
216
+ return c2.json({ error: "rate limit exceeded" }, 429);
217
+ }
218
+ await next();
219
+ });
220
+ if (opts.authBasic) {
221
+ app.use("*", basicAuth(opts.authBasic));
222
+ }
112
223
  app.get("/api/health", (c2) => c2.json({ ok: true }));
113
224
  let overviewCache = null;
114
225
  app.get("/api/overview", async (c2) => {
@@ -129,57 +240,50 @@ async function startServer(opts) {
129
240
  overviewCache = { at: Date.now(), data };
130
241
  return c2.json(data);
131
242
  } catch (err) {
132
- return c2.json({ error: err.message }, 500);
243
+ log("error", "overview failed", err);
244
+ return c2.json({ error: "internal error" }, 500);
133
245
  }
134
246
  });
135
247
  app.get("/api/spans", async (c2) => {
136
248
  try {
137
249
  const q = c2.req.query();
138
250
  const filter = {
139
- limit: q.limit ? Number(q.limit) : void 0,
140
- method: q.method || void 0,
141
- minStatus: q.minStatus ? Number(q.minStatus) : void 0,
142
- maxStatus: q.maxStatus ? Number(q.maxStatus) : void 0,
143
- pathLike: q.pathLike || void 0
251
+ limit: parseInt32(q.limit, { min: 1, max: 500 }) ?? 50,
252
+ method: parseMethod(q.method),
253
+ minStatus: parseInt32(q.minStatus, { min: 100, max: 599 }),
254
+ maxStatus: parseInt32(q.maxStatus, { min: 100, max: 599 }),
255
+ pathLike: parsePathLike(q.pathLike)
144
256
  };
145
257
  const rows = await getSpans(pool, tableName, filter);
146
258
  return c2.json(rows);
147
259
  } catch (err) {
148
- return c2.json({ error: err.message }, 500);
260
+ log("error", "spans failed", err);
261
+ return c2.json({ error: "internal error" }, 500);
149
262
  }
150
263
  });
151
264
  app.get("/api/trace/:id", async (c2) => {
265
+ const id = c2.req.param("id");
266
+ if (!isValidTraceId(id)) {
267
+ return c2.json({ error: "invalid trace id" }, 400);
268
+ }
152
269
  try {
153
- const id = c2.req.param("id");
154
- if (!/^[0-9a-f]{32}$/i.test(id)) {
155
- return c2.json({ error: "invalid trace id" }, 400);
156
- }
157
270
  const data = await getTrace(pool, tableName, id);
158
271
  return c2.json(data);
159
272
  } catch (err) {
160
- return c2.json({ error: err.message }, 500);
273
+ log("error", "trace failed", err);
274
+ return c2.json({ error: "internal error" }, 500);
161
275
  }
162
276
  });
163
277
  const staticRoot = resolveUiDir(opts.uiDir);
164
278
  if (staticRoot && existsSync(staticRoot)) {
165
- app.use(
166
- "/*",
167
- serveStatic({
168
- root: staticRoot,
169
- rewriteRequestPath: (p) => p
170
- })
171
- );
279
+ app.use("/*", serveStatic({ root: staticRoot, rewriteRequestPath: (p) => p }));
172
280
  const indexHtml = join(staticRoot, "index.html");
173
281
  if (existsSync(indexHtml)) {
174
282
  const html = readFileSync(indexHtml, "utf8");
175
283
  app.get("*", (c2) => c2.html(html));
176
284
  }
177
285
  }
178
- const server = serve({
179
- fetch: app.fetch,
180
- port: opts.port,
181
- hostname: opts.host
182
- });
286
+ const server = serve({ fetch: app.fetch, port: opts.port, hostname: opts.host });
183
287
  const url = `http://${opts.host === "0.0.0.0" ? "localhost" : opts.host}:${opts.port}`;
184
288
  return {
185
289
  url,
@@ -193,8 +297,7 @@ async function startServer(opts) {
193
297
  function resolveUiDir(override) {
194
298
  if (override) return resolve(override);
195
299
  const here = dirname(fileURLToPath(import.meta.url));
196
- const candidate = join(here, "ui");
197
- return candidate;
300
+ return join(here, "ui");
198
301
  }
199
302
 
200
303
  // src/cli.ts
@@ -208,21 +311,23 @@ Usage:
208
311
  pnpm dlx @apitrail/studio [options]
209
312
 
210
313
  Options:
211
- --db <url> Postgres connection string (or APITRAIL_DATABASE_URL / DATABASE_URL)
212
- --port <n> HTTP port (default: 4545)
213
- --host <addr> Bind address (default: 127.0.0.1)
214
- --table <name> Table name (default: apitrail_spans)
215
- --no-ssl Disable SSL
216
- --no-open Don't auto-open the browser
217
- --dev Development mode (adds CORS, skips UI serving)
218
- -v, --version Print version
219
- -h, --help Show this help
314
+ --db <url> Postgres connection string (or APITRAIL_DATABASE_URL / DATABASE_URL)
315
+ --port <n> HTTP port (default: 4545)
316
+ --host <addr> Bind address (default: 127.0.0.1). Use 0.0.0.0 for LAN \u2014 REQUIRES --auth-basic.
317
+ --table <name> Table name (default: apitrail_spans)
318
+ --auth-basic <u:p> Enable HTTP Basic Auth with "username:password". REQUIRED if --host is non-loopback.
319
+ --no-ssl Disable SSL on the pg connection
320
+ --no-open Don't auto-open the browser
321
+ --dev Development mode (adds CORS, skips UI serving)
322
+ -v, --version Print version
323
+ -h, --help Show this help
220
324
 
221
325
  Environment:
222
- APITRAIL_DATABASE_URL Preferred
223
- DATABASE_URL Fallback
224
- POSTGRES_URL Fallback
225
- NO_COLOR=1 Disable colors
326
+ APITRAIL_DATABASE_URL Preferred connection string
327
+ DATABASE_URL Fallback
328
+ POSTGRES_URL Fallback
329
+ APITRAIL_STUDIO_AUTH Fallback for --auth-basic (value format: "user:pass")
330
+ NO_COLOR=1 Disable colors
226
331
  `;
227
332
  var c = (open, close) => (s) => {
228
333
  if (process.env.NO_COLOR) return s;
@@ -234,6 +339,10 @@ var dim = c(2, 22);
234
339
  var violet = c(35, 39);
235
340
  var green = c(32, 39);
236
341
  var red = c(31, 39);
342
+ var yellow = c(33, 39);
343
+ function isLoopback(host) {
344
+ return host === "127.0.0.1" || host === "localhost" || host === "::1" || host.startsWith("127.");
345
+ }
237
346
  async function main() {
238
347
  const { values } = parseArgs({
239
348
  args: process.argv.slice(2),
@@ -242,6 +351,7 @@ async function main() {
242
351
  port: { type: "string", default: "4545" },
243
352
  host: { type: "string", default: "127.0.0.1" },
244
353
  table: { type: "string", default: "apitrail_spans" },
354
+ "auth-basic": { type: "string" },
245
355
  ssl: { type: "boolean", default: true },
246
356
  "no-ssl": { type: "boolean", default: false },
247
357
  open: { type: "boolean", default: true },
@@ -267,27 +377,46 @@ async function main() {
267
377
  process.exit(1);
268
378
  }
269
379
  const port = Number(values.port);
270
- if (!Number.isFinite(port) || port < 1 || port > 65535) {
380
+ if (!Number.isInteger(port) || port < 1 || port > 65535) {
271
381
  console.error(red("error:"), `invalid port: ${values.port}`);
272
382
  process.exit(1);
273
383
  }
384
+ const host = values.host ?? "127.0.0.1";
385
+ const authBasic = values["auth-basic"] ?? process.env.APITRAIL_STUDIO_AUTH;
386
+ if (!isLoopback(host) && !authBasic) {
387
+ console.error(red("error:"), `refusing to bind to ${host} without --auth-basic.`);
388
+ console.error(" Binding to a non-loopback address makes your logs reachable over the network.");
389
+ console.error(
390
+ " Pass --auth-basic user:pass (or APITRAIL_STUDIO_AUTH=user:pass), or use --host 127.0.0.1."
391
+ );
392
+ process.exit(1);
393
+ }
394
+ if (authBasic && !/^[^:]+:.+$/.test(authBasic)) {
395
+ console.error(red("error:"), '--auth-basic must be in the form "user:password".');
396
+ process.exit(1);
397
+ }
274
398
  const ssl = !values["no-ssl"];
275
399
  const openBrowser = !values["no-open"];
276
400
  try {
277
401
  const { url, stop } = await startServer({
278
402
  connectionString,
279
403
  port,
280
- host: values.host ?? "127.0.0.1",
404
+ host,
281
405
  tableName: values.table,
282
406
  ssl,
407
+ authBasic,
283
408
  dev: values.dev
284
409
  });
285
410
  console.log();
286
411
  console.log(` ${violet("\u25CF")} ${bold("apitrail studio")} ${dim(`v${pkg.version}`)}`);
287
412
  console.log(` ${green("\u2192")} ${url}`);
413
+ if (authBasic) console.log(` ${dim("auth:")} basic (configured)`);
414
+ if (!isLoopback(host)) {
415
+ console.log(` ${yellow("\u26A0")} bound to ${host} \u2014 ensure you have a reverse proxy with TLS.`);
416
+ }
288
417
  console.log(` ${dim("Press Ctrl+C to stop.")}`);
289
418
  console.log();
290
- if (openBrowser && !values.dev) {
419
+ if (openBrowser && !values.dev && isLoopback(host)) {
291
420
  try {
292
421
  const open = (await import("open")).default;
293
422
  await open(url);
package/dist/cli.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/cli.ts","../src/server/index.ts","../src/server/queries.ts"],"sourcesContent":["import { createRequire } from 'node:module'\nimport { parseArgs } from 'node:util'\nimport { startServer } from './server/index.js'\n\nconst require = createRequire(import.meta.url)\nconst pkg = require('../package.json') as { version: string }\n\nconst HELP = `\napitrail studio v${pkg.version} — standalone dashboard for apitrail\n\nUsage:\n apitrail-studio [options]\n pnpm dlx @apitrail/studio [options]\n\nOptions:\n --db <url> Postgres connection string (or APITRAIL_DATABASE_URL / DATABASE_URL)\n --port <n> HTTP port (default: 4545)\n --host <addr> Bind address (default: 127.0.0.1)\n --table <name> Table name (default: apitrail_spans)\n --no-ssl Disable SSL\n --no-open Don't auto-open the browser\n --dev Development mode (adds CORS, skips UI serving)\n -v, --version Print version\n -h, --help Show this help\n\nEnvironment:\n APITRAIL_DATABASE_URL Preferred\n DATABASE_URL Fallback\n POSTGRES_URL Fallback\n NO_COLOR=1 Disable colors\n`\n\nconst c = (open: number, close: number) => (s: string) => {\n if (process.env.NO_COLOR) return s\n if (!process.stdout.isTTY) return s\n return `\\x1b[${open}m${s}\\x1b[${close}m`\n}\nconst bold = c(1, 22)\nconst dim = c(2, 22)\nconst violet = c(35, 39)\nconst green = c(32, 39)\nconst red = c(31, 39)\n\nasync function main(): Promise<void> {\n const { values } = parseArgs({\n args: process.argv.slice(2),\n options: {\n db: { type: 'string' },\n port: { type: 'string', default: '4545' },\n host: { type: 'string', default: '127.0.0.1' },\n table: { type: 'string', default: 'apitrail_spans' },\n ssl: { type: 'boolean', default: true },\n 'no-ssl': { type: 'boolean', default: false },\n open: { type: 'boolean', default: true },\n 'no-open': { type: 'boolean', default: false },\n dev: { type: 'boolean', default: false },\n version: { type: 'boolean', short: 'v', default: false },\n help: { type: 'boolean', short: 'h', default: false },\n },\n allowPositionals: false,\n })\n\n if (values.help) {\n console.log(HELP)\n return\n }\n if (values.version) {\n console.log(pkg.version)\n return\n }\n\n const connectionString =\n values.db ??\n process.env.APITRAIL_DATABASE_URL ??\n process.env.DATABASE_URL ??\n process.env.POSTGRES_URL\n\n if (!connectionString) {\n console.error(red('error:'), 'no database URL.')\n console.error('Set APITRAIL_DATABASE_URL / DATABASE_URL, or pass --db.')\n process.exit(1)\n }\n\n const port = Number(values.port)\n if (!Number.isFinite(port) || port < 1 || port > 65535) {\n console.error(red('error:'), `invalid port: ${values.port}`)\n process.exit(1)\n }\n\n const ssl = !values['no-ssl']\n const openBrowser = !values['no-open']\n\n try {\n const { url, stop } = await startServer({\n connectionString,\n port,\n host: values.host ?? '127.0.0.1',\n tableName: values.table,\n ssl,\n dev: values.dev,\n })\n\n console.log()\n console.log(` ${violet('●')} ${bold('apitrail studio')} ${dim(`v${pkg.version}`)}`)\n console.log(` ${green('→')} ${url}`)\n console.log(` ${dim('Press Ctrl+C to stop.')}`)\n console.log()\n\n if (openBrowser && !values.dev) {\n try {\n const open = (await import('open')).default\n await open(url)\n } catch {\n // Fails silently in CI/headless — user can still visit the URL.\n }\n }\n\n const shutdown = async (): Promise<void> => {\n console.log(`\\n${dim('shutting down…')}`)\n await stop()\n process.exit(0)\n }\n process.once('SIGINT', () => void shutdown())\n process.once('SIGTERM', () => void shutdown())\n } catch (err) {\n console.error(red('error:'), (err as Error).message)\n if (process.env.APITRAIL_DEBUG) console.error((err as Error).stack)\n process.exit(1)\n }\n}\n\nmain()\n","import { existsSync, readFileSync } from 'node:fs'\nimport { dirname, join, resolve } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { serve } from '@hono/node-server'\nimport { serveStatic } from '@hono/node-server/serve-static'\nimport { Hono } from 'hono'\nimport { cors } from 'hono/cors'\nimport pg from 'pg'\nimport { type SpansFilter, getOverview, getSpans, getTrace } from './queries.js'\n\nexport interface ServerOptions {\n connectionString: string\n port: number\n host: string\n tableName?: string\n ssl?: boolean\n uiDir?: string\n dev?: boolean\n}\n\nconst OVERVIEW_CACHE_MS = 1500 // tiny cache so rapid polling doesn't hammer PG\n\nexport async function startServer(opts: ServerOptions): Promise<{\n url: string\n stop: () => Promise<void>\n}> {\n const { Pool } = pg\n const pool = new Pool({\n connectionString: opts.connectionString,\n ssl: opts.ssl ? { rejectUnauthorized: false } : false,\n max: 5,\n })\n\n // Verify connection upfront — fail fast with a clear error.\n try {\n await pool.query('SELECT 1')\n } catch (err) {\n await pool.end().catch(() => {})\n const e = err as Error\n throw new Error(`Could not connect to Postgres: ${e.message}`)\n }\n\n const tableName = opts.tableName ?? 'apitrail_spans'\n\n const app = new Hono()\n if (opts.dev) app.use('*', cors())\n\n app.get('/api/health', (c) => c.json({ ok: true }))\n\n let overviewCache: { at: number; data: unknown } | null = null\n app.get('/api/overview', async (c) => {\n try {\n if (overviewCache && Date.now() - overviewCache.at < OVERVIEW_CACHE_MS) {\n return c.json(overviewCache.data)\n }\n const raw = await getOverview(pool, tableName)\n const total = Number(raw.total_24h)\n const data = {\n total_24h: total,\n errors_24h: Number(raw.errors_24h),\n slow_24h: Number(raw.slow_24h),\n p50: Number(raw.p50 ?? 0),\n p95: Number(raw.p95 ?? 0),\n rpm: total / (24 * 60),\n }\n overviewCache = { at: Date.now(), data }\n return c.json(data)\n } catch (err) {\n return c.json({ error: (err as Error).message }, 500)\n }\n })\n\n app.get('/api/spans', async (c) => {\n try {\n const q = c.req.query()\n const filter: SpansFilter = {\n limit: q.limit ? Number(q.limit) : undefined,\n method: q.method || undefined,\n minStatus: q.minStatus ? Number(q.minStatus) : undefined,\n maxStatus: q.maxStatus ? Number(q.maxStatus) : undefined,\n pathLike: q.pathLike || undefined,\n }\n const rows = await getSpans(pool, tableName, filter)\n return c.json(rows)\n } catch (err) {\n return c.json({ error: (err as Error).message }, 500)\n }\n })\n\n app.get('/api/trace/:id', async (c) => {\n try {\n const id = c.req.param('id')\n if (!/^[0-9a-f]{32}$/i.test(id)) {\n return c.json({ error: 'invalid trace id' }, 400)\n }\n const data = await getTrace(pool, tableName, id)\n return c.json(data)\n } catch (err) {\n return c.json({ error: (err as Error).message }, 500)\n }\n })\n\n // Serve static UI assets from dist/ui/ — resolve robustly for both dev and\n // published-package layouts.\n const staticRoot = resolveUiDir(opts.uiDir)\n if (staticRoot && existsSync(staticRoot)) {\n app.use(\n '/*',\n serveStatic({\n root: staticRoot,\n rewriteRequestPath: (p) => p,\n }),\n )\n // SPA fallback → serve index.html on unknown routes\n const indexHtml = join(staticRoot, 'index.html')\n if (existsSync(indexHtml)) {\n const html = readFileSync(indexHtml, 'utf8')\n app.get('*', (c) => c.html(html))\n }\n }\n\n const server = serve({\n fetch: app.fetch,\n port: opts.port,\n hostname: opts.host,\n })\n\n const url = `http://${opts.host === '0.0.0.0' ? 'localhost' : opts.host}:${opts.port}`\n\n return {\n url,\n async stop() {\n server.close()\n await pool.end().catch(() => {})\n },\n }\n}\n\nfunction resolveUiDir(override?: string): string | null {\n if (override) return resolve(override)\n // When running from `dist/cli.js` inside the published package:\n // <pkg-root>/dist/cli.js → <pkg-root>/dist/ui/\n const here = dirname(fileURLToPath(import.meta.url))\n const candidate = join(here, 'ui')\n return candidate\n}\n","import type pg from 'pg'\n\nexport interface ServerRow {\n span_id: string\n trace_id: string\n method: string\n path: string\n route: string | null\n status_code: number | null\n duration_ms: string\n start_time: string\n created_at: string\n error_message: string | null\n user_agent: string | null\n client_ip: string | null\n runtime: string\n}\n\nexport interface DetailRow extends ServerRow {\n req_headers: Record<string, string> | null\n req_body: string | null\n res_headers: Record<string, string> | null\n res_body: string | null\n error_stack: string | null\n referer: string | null\n host: string | null\n service_name: string | null\n}\n\nexport interface ChildRow {\n span_id: string\n parent_span_id: string | null\n name: string\n kind: string\n status: string\n start_time: string\n duration_ms: string\n error_message: string | null\n}\n\nexport interface OverviewRow {\n total_24h: string\n errors_24h: string\n slow_24h: string\n p50: number | null\n p95: number | null\n}\n\nexport interface SpansFilter {\n limit?: number\n method?: string\n minStatus?: number\n maxStatus?: number\n pathLike?: string\n}\n\nfunction qi(name: string): string {\n if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {\n throw new Error(`Invalid identifier: ${JSON.stringify(name)}`)\n }\n return `\"${name}\"`\n}\n\nexport async function getOverview(pool: pg.Pool, table: string): Promise<OverviewRow> {\n const t = qi(table)\n const { rows } = await pool.query<OverviewRow>(`\n SELECT\n count(*) FILTER (WHERE kind = 'SERVER')::text AS total_24h,\n count(*) FILTER (WHERE kind = 'SERVER' AND status_code >= 400)::text AS errors_24h,\n count(*) FILTER (WHERE kind = 'SERVER' AND duration_ms > 500)::text AS slow_24h,\n percentile_disc(0.5) WITHIN GROUP (ORDER BY duration_ms) FILTER (WHERE kind = 'SERVER') AS p50,\n percentile_disc(0.95) WITHIN GROUP (ORDER BY duration_ms) FILTER (WHERE kind = 'SERVER') AS p95\n FROM ${t}\n WHERE created_at > now() - interval '24 hours'\n `)\n return rows[0] ?? { total_24h: '0', errors_24h: '0', slow_24h: '0', p50: null, p95: null }\n}\n\nexport async function getSpans(\n pool: pg.Pool,\n table: string,\n filter: SpansFilter,\n): Promise<ServerRow[]> {\n const t = qi(table)\n const conds: string[] = [`kind = 'SERVER'`]\n const params: (string | number)[] = []\n\n if (filter.method) {\n params.push(filter.method.toUpperCase())\n conds.push(`method = $${params.length}`)\n }\n if (filter.minStatus !== undefined) {\n params.push(filter.minStatus)\n conds.push(`status_code >= $${params.length}`)\n }\n if (filter.maxStatus !== undefined) {\n params.push(filter.maxStatus)\n conds.push(`status_code <= $${params.length}`)\n }\n if (filter.pathLike) {\n params.push(`%${filter.pathLike}%`)\n conds.push(`path ILIKE $${params.length}`)\n }\n\n const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500)\n params.push(limit)\n\n const { rows } = await pool.query<ServerRow>(\n `SELECT span_id, trace_id, method, path, route, status_code, duration_ms,\n start_time, created_at, error_message, user_agent, client_ip, runtime\n FROM ${t}\n WHERE ${conds.join(' AND ')}\n ORDER BY created_at DESC\n LIMIT $${params.length}`,\n params,\n )\n return rows\n}\n\nexport async function getTrace(\n pool: pg.Pool,\n table: string,\n traceId: string,\n): Promise<{ root: DetailRow | null; children: ChildRow[] }> {\n const t = qi(table)\n const [rootRes, childRes] = await Promise.all([\n pool.query<DetailRow>(\n `SELECT *\n FROM ${t}\n WHERE trace_id = $1 AND kind = 'SERVER'\n LIMIT 1`,\n [traceId],\n ),\n pool.query<ChildRow>(\n `SELECT span_id, parent_span_id, name, kind, status, start_time, duration_ms, error_message\n FROM ${t}\n WHERE trace_id = $1 AND kind != 'SERVER'\n ORDER BY start_time ASC`,\n [traceId],\n ),\n ])\n return { root: rootRes.rows[0] ?? null, children: childRes.rows }\n}\n"],"mappings":"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","names":["c","require"]}
1
+ {"version":3,"sources":["../src/cli.ts","../src/server/index.ts","../src/server/auth.ts","../src/server/queries.ts","../src/server/rate-limit.ts","../src/server/validate.ts"],"sourcesContent":["import { createRequire } from 'node:module'\nimport { parseArgs } from 'node:util'\nimport { startServer } from './server/index.js'\n\nconst require = createRequire(import.meta.url)\nconst pkg = require('../package.json') as { version: string }\n\nconst HELP = `\napitrail studio v${pkg.version} — standalone dashboard for apitrail\n\nUsage:\n apitrail-studio [options]\n pnpm dlx @apitrail/studio [options]\n\nOptions:\n --db <url> Postgres connection string (or APITRAIL_DATABASE_URL / DATABASE_URL)\n --port <n> HTTP port (default: 4545)\n --host <addr> Bind address (default: 127.0.0.1). Use 0.0.0.0 for LAN — REQUIRES --auth-basic.\n --table <name> Table name (default: apitrail_spans)\n --auth-basic <u:p> Enable HTTP Basic Auth with \"username:password\". REQUIRED if --host is non-loopback.\n --no-ssl Disable SSL on the pg connection\n --no-open Don't auto-open the browser\n --dev Development mode (adds CORS, skips UI serving)\n -v, --version Print version\n -h, --help Show this help\n\nEnvironment:\n APITRAIL_DATABASE_URL Preferred connection string\n DATABASE_URL Fallback\n POSTGRES_URL Fallback\n APITRAIL_STUDIO_AUTH Fallback for --auth-basic (value format: \"user:pass\")\n NO_COLOR=1 Disable colors\n`\n\nconst c = (open: number, close: number) => (s: string) => {\n if (process.env.NO_COLOR) return s\n if (!process.stdout.isTTY) return s\n return `\\x1b[${open}m${s}\\x1b[${close}m`\n}\nconst bold = c(1, 22)\nconst dim = c(2, 22)\nconst violet = c(35, 39)\nconst green = c(32, 39)\nconst red = c(31, 39)\nconst yellow = c(33, 39)\n\nfunction isLoopback(host: string): boolean {\n return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host.startsWith('127.')\n}\n\nasync function main(): Promise<void> {\n const { values } = parseArgs({\n args: process.argv.slice(2),\n options: {\n db: { type: 'string' },\n port: { type: 'string', default: '4545' },\n host: { type: 'string', default: '127.0.0.1' },\n table: { type: 'string', default: 'apitrail_spans' },\n 'auth-basic': { type: 'string' },\n ssl: { type: 'boolean', default: true },\n 'no-ssl': { type: 'boolean', default: false },\n open: { type: 'boolean', default: true },\n 'no-open': { type: 'boolean', default: false },\n dev: { type: 'boolean', default: false },\n version: { type: 'boolean', short: 'v', default: false },\n help: { type: 'boolean', short: 'h', default: false },\n },\n allowPositionals: false,\n })\n\n if (values.help) {\n console.log(HELP)\n return\n }\n if (values.version) {\n console.log(pkg.version)\n return\n }\n\n const connectionString =\n values.db ??\n process.env.APITRAIL_DATABASE_URL ??\n process.env.DATABASE_URL ??\n process.env.POSTGRES_URL\n\n if (!connectionString) {\n console.error(red('error:'), 'no database URL.')\n console.error('Set APITRAIL_DATABASE_URL / DATABASE_URL, or pass --db.')\n process.exit(1)\n }\n\n const port = Number(values.port)\n if (!Number.isInteger(port) || port < 1 || port > 65535) {\n console.error(red('error:'), `invalid port: ${values.port}`)\n process.exit(1)\n }\n\n const host = values.host ?? '127.0.0.1'\n const authBasic = values['auth-basic'] ?? process.env.APITRAIL_STUDIO_AUTH\n\n // ── Security guard: refuse to listen on a non-loopback interface without auth ──\n if (!isLoopback(host) && !authBasic) {\n console.error(red('error:'), `refusing to bind to ${host} without --auth-basic.`)\n console.error(' Binding to a non-loopback address makes your logs reachable over the network.')\n console.error(\n ' Pass --auth-basic user:pass (or APITRAIL_STUDIO_AUTH=user:pass), or use --host 127.0.0.1.',\n )\n process.exit(1)\n }\n\n if (authBasic && !/^[^:]+:.+$/.test(authBasic)) {\n console.error(red('error:'), '--auth-basic must be in the form \"user:password\".')\n process.exit(1)\n }\n\n const ssl = !values['no-ssl']\n const openBrowser = !values['no-open']\n\n try {\n const { url, stop } = await startServer({\n connectionString,\n port,\n host,\n tableName: values.table,\n ssl,\n authBasic,\n dev: values.dev,\n })\n\n console.log()\n console.log(` ${violet('●')} ${bold('apitrail studio')} ${dim(`v${pkg.version}`)}`)\n console.log(` ${green('→')} ${url}`)\n if (authBasic) console.log(` ${dim('auth:')} basic (configured)`)\n if (!isLoopback(host)) {\n console.log(` ${yellow('⚠')} bound to ${host} — ensure you have a reverse proxy with TLS.`)\n }\n console.log(` ${dim('Press Ctrl+C to stop.')}`)\n console.log()\n\n if (openBrowser && !values.dev && isLoopback(host)) {\n try {\n const open = (await import('open')).default\n await open(url)\n } catch {\n // Non-fatal.\n }\n }\n\n const shutdown = async (): Promise<void> => {\n console.log(`\\n${dim('shutting down…')}`)\n await stop()\n process.exit(0)\n }\n process.once('SIGINT', () => void shutdown())\n process.once('SIGTERM', () => void shutdown())\n } catch (err) {\n console.error(red('error:'), (err as Error).message)\n if (process.env.APITRAIL_DEBUG) console.error((err as Error).stack)\n process.exit(1)\n }\n}\n\nmain()\n","import { existsSync, readFileSync } from 'node:fs'\nimport { dirname, join, resolve } from 'node:path'\nimport { fileURLToPath } from 'node:url'\nimport { serve } from '@hono/node-server'\nimport { serveStatic } from '@hono/node-server/serve-static'\nimport { Hono } from 'hono'\nimport { cors } from 'hono/cors'\nimport pg from 'pg'\nimport { basicAuth } from './auth.js'\nimport { type SpansFilter, getOverview, getSpans, getTrace } from './queries.js'\nimport { createRateLimiter } from './rate-limit.js'\nimport { isValidTraceId, parseInt32, parseMethod, parsePathLike } from './validate.js'\n\nexport interface ServerOptions {\n connectionString: string\n port: number\n host: string\n tableName?: string\n ssl?: boolean\n uiDir?: string\n dev?: boolean\n /** `user:pass` for HTTP basic auth. Required if host is non-loopback. */\n authBasic?: string\n}\n\nconst OVERVIEW_CACHE_MS = 1500\n\nfunction log(level: 'info' | 'warn' | 'error', msg: string, extra?: unknown): void {\n const prefix =\n level === 'error' ? '[studio][error]' : level === 'warn' ? '[studio][warn]' : '[studio]'\n if (extra !== undefined) console[level](prefix, msg, extra)\n else console[level](prefix, msg)\n}\n\nexport async function startServer(opts: ServerOptions): Promise<{\n url: string\n stop: () => Promise<void>\n}> {\n const { Pool } = pg\n const pool = new Pool({\n connectionString: opts.connectionString,\n ssl: opts.ssl ? { rejectUnauthorized: false } : false,\n max: 5,\n })\n\n try {\n await pool.query('SELECT 1')\n } catch (err) {\n await pool.end().catch(() => {})\n const e = err as Error\n throw new Error(`Could not connect to Postgres: ${e.message}`)\n }\n\n const tableName = opts.tableName ?? 'apitrail_spans'\n const app = new Hono()\n\n // ── Security headers (defense in depth) ───────────────────────────────\n app.use('*', async (c, next) => {\n await next()\n // Conservative CSP — we load our own bundled JS/CSS from /assets/, an\n // inline favicon, and hit /api on the same origin. No 3rd-party.\n c.header(\n 'Content-Security-Policy',\n \"default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; connect-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'; object-src 'none'\",\n )\n c.header('X-Content-Type-Options', 'nosniff')\n c.header('X-Frame-Options', 'DENY')\n c.header('Referrer-Policy', 'no-referrer')\n c.header('Permissions-Policy', 'interest-cohort=()')\n c.header('X-Robots-Tag', 'noindex, nofollow')\n })\n\n if (opts.dev) app.use('*', cors())\n\n // ── Rate limit the API (not the static UI) ────────────────────────────\n const allowRequest = createRateLimiter({ max: 300, windowMs: 60_000 })\n app.use('/api/*', async (c, next) => {\n const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() ?? 'local'\n if (!allowRequest(ip)) {\n return c.json({ error: 'rate limit exceeded' }, 429)\n }\n await next()\n })\n\n // ── Basic auth (if configured) ────────────────────────────────────────\n if (opts.authBasic) {\n app.use('*', basicAuth(opts.authBasic))\n }\n\n app.get('/api/health', (c) => c.json({ ok: true }))\n\n let overviewCache: { at: number; data: unknown } | null = null\n app.get('/api/overview', async (c) => {\n try {\n if (overviewCache && Date.now() - overviewCache.at < OVERVIEW_CACHE_MS) {\n return c.json(overviewCache.data)\n }\n const raw = await getOverview(pool, tableName)\n const total = Number(raw.total_24h)\n const data = {\n total_24h: total,\n errors_24h: Number(raw.errors_24h),\n slow_24h: Number(raw.slow_24h),\n p50: Number(raw.p50 ?? 0),\n p95: Number(raw.p95 ?? 0),\n rpm: total / (24 * 60),\n }\n overviewCache = { at: Date.now(), data }\n return c.json(data)\n } catch (err) {\n log('error', 'overview failed', err)\n return c.json({ error: 'internal error' }, 500)\n }\n })\n\n app.get('/api/spans', async (c) => {\n try {\n const q = c.req.query()\n const filter: SpansFilter = {\n limit: parseInt32(q.limit, { min: 1, max: 500 }) ?? 50,\n method: parseMethod(q.method),\n minStatus: parseInt32(q.minStatus, { min: 100, max: 599 }),\n maxStatus: parseInt32(q.maxStatus, { min: 100, max: 599 }),\n pathLike: parsePathLike(q.pathLike),\n }\n const rows = await getSpans(pool, tableName, filter)\n return c.json(rows)\n } catch (err) {\n log('error', 'spans failed', err)\n return c.json({ error: 'internal error' }, 500)\n }\n })\n\n app.get('/api/trace/:id', async (c) => {\n const id = c.req.param('id')\n if (!isValidTraceId(id)) {\n return c.json({ error: 'invalid trace id' }, 400)\n }\n try {\n const data = await getTrace(pool, tableName, id)\n return c.json(data)\n } catch (err) {\n log('error', 'trace failed', err)\n return c.json({ error: 'internal error' }, 500)\n }\n })\n\n // ── Serve static UI ───────────────────────────────────────────────────\n const staticRoot = resolveUiDir(opts.uiDir)\n if (staticRoot && existsSync(staticRoot)) {\n app.use('/*', serveStatic({ root: staticRoot, rewriteRequestPath: (p) => p }))\n const indexHtml = join(staticRoot, 'index.html')\n if (existsSync(indexHtml)) {\n const html = readFileSync(indexHtml, 'utf8')\n app.get('*', (c) => c.html(html))\n }\n }\n\n const server = serve({ fetch: app.fetch, port: opts.port, hostname: opts.host })\n const url = `http://${opts.host === '0.0.0.0' ? 'localhost' : opts.host}:${opts.port}`\n\n return {\n url,\n async stop() {\n server.close()\n await pool.end().catch(() => {})\n },\n }\n}\n\nfunction resolveUiDir(override?: string): string | null {\n if (override) return resolve(override)\n const here = dirname(fileURLToPath(import.meta.url))\n return join(here, 'ui')\n}\n","import { createHash, timingSafeEqual } from 'node:crypto'\nimport type { MiddlewareHandler } from 'hono'\n\n/**\n * Very small HTTP Basic Auth middleware. Uses timing-safe equality on SHA-256\n * hashes of the `user:pass` pair so the comparison time doesn't leak the\n * password length or content.\n */\nexport function basicAuth(expected: string): MiddlewareHandler {\n const expectedHash = sha256(expected)\n return async (c, next) => {\n const header = c.req.header('authorization') ?? ''\n if (!header.toLowerCase().startsWith('basic ')) {\n return unauthorized(c)\n }\n const encoded = header.slice(6).trim()\n let decoded: string\n try {\n decoded = Buffer.from(encoded, 'base64').toString('utf8')\n } catch {\n return unauthorized(c)\n }\n if (!safeEqual(sha256(decoded), expectedHash)) {\n return unauthorized(c)\n }\n await next()\n }\n}\n\nfunction unauthorized(c: Parameters<MiddlewareHandler>[0]): Response {\n c.header('WWW-Authenticate', 'Basic realm=\"apitrail studio\", charset=\"UTF-8\"')\n return c.text('Unauthorized', 401)\n}\n\nfunction sha256(s: string): Buffer {\n return createHash('sha256').update(s, 'utf8').digest()\n}\n\nfunction safeEqual(a: Buffer, b: Buffer): boolean {\n if (a.length !== b.length) return false\n return timingSafeEqual(a, b)\n}\n","import type pg from 'pg'\n\nexport interface ServerRow {\n span_id: string\n trace_id: string\n method: string\n path: string\n route: string | null\n status_code: number | null\n duration_ms: string\n start_time: string\n created_at: string\n error_message: string | null\n user_agent: string | null\n client_ip: string | null\n runtime: string\n}\n\nexport interface DetailRow extends ServerRow {\n req_headers: Record<string, string> | null\n req_body: string | null\n res_headers: Record<string, string> | null\n res_body: string | null\n error_stack: string | null\n referer: string | null\n host: string | null\n service_name: string | null\n}\n\nexport interface ChildRow {\n span_id: string\n parent_span_id: string | null\n name: string\n kind: string\n status: string\n start_time: string\n duration_ms: string\n error_message: string | null\n}\n\nexport interface OverviewRow {\n total_24h: string\n errors_24h: string\n slow_24h: string\n p50: number | null\n p95: number | null\n}\n\nexport interface SpansFilter {\n limit?: number\n method?: string\n minStatus?: number\n maxStatus?: number\n pathLike?: string\n}\n\nfunction qi(name: string): string {\n if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) {\n throw new Error(`Invalid identifier: ${JSON.stringify(name)}`)\n }\n return `\"${name}\"`\n}\n\nexport async function getOverview(pool: pg.Pool, table: string): Promise<OverviewRow> {\n const t = qi(table)\n const { rows } = await pool.query<OverviewRow>(`\n SELECT\n count(*) FILTER (WHERE kind = 'SERVER')::text AS total_24h,\n count(*) FILTER (WHERE kind = 'SERVER' AND status_code >= 400)::text AS errors_24h,\n count(*) FILTER (WHERE kind = 'SERVER' AND duration_ms > 500)::text AS slow_24h,\n percentile_disc(0.5) WITHIN GROUP (ORDER BY duration_ms) FILTER (WHERE kind = 'SERVER') AS p50,\n percentile_disc(0.95) WITHIN GROUP (ORDER BY duration_ms) FILTER (WHERE kind = 'SERVER') AS p95\n FROM ${t}\n WHERE created_at > now() - interval '24 hours'\n `)\n return rows[0] ?? { total_24h: '0', errors_24h: '0', slow_24h: '0', p50: null, p95: null }\n}\n\nexport async function getSpans(\n pool: pg.Pool,\n table: string,\n filter: SpansFilter,\n): Promise<ServerRow[]> {\n const t = qi(table)\n const conds: string[] = [`kind = 'SERVER'`]\n const params: (string | number)[] = []\n\n if (filter.method) {\n params.push(filter.method.toUpperCase())\n conds.push(`method = $${params.length}`)\n }\n if (filter.minStatus !== undefined) {\n params.push(filter.minStatus)\n conds.push(`status_code >= $${params.length}`)\n }\n if (filter.maxStatus !== undefined) {\n params.push(filter.maxStatus)\n conds.push(`status_code <= $${params.length}`)\n }\n if (filter.pathLike) {\n params.push(`%${filter.pathLike}%`)\n conds.push(`path ILIKE $${params.length}`)\n }\n\n const limit = Math.min(Math.max(filter.limit ?? 50, 1), 500)\n params.push(limit)\n\n const { rows } = await pool.query<ServerRow>(\n `SELECT span_id, trace_id, method, path, route, status_code, duration_ms,\n start_time, created_at, error_message, user_agent, client_ip, runtime\n FROM ${t}\n WHERE ${conds.join(' AND ')}\n ORDER BY created_at DESC\n LIMIT $${params.length}`,\n params,\n )\n return rows\n}\n\nexport async function getTrace(\n pool: pg.Pool,\n table: string,\n traceId: string,\n): Promise<{ root: DetailRow | null; children: ChildRow[] }> {\n const t = qi(table)\n const [rootRes, childRes] = await Promise.all([\n pool.query<DetailRow>(\n `SELECT *\n FROM ${t}\n WHERE trace_id = $1 AND kind = 'SERVER'\n LIMIT 1`,\n [traceId],\n ),\n pool.query<ChildRow>(\n `SELECT span_id, parent_span_id, name, kind, status, start_time, duration_ms, error_message\n FROM ${t}\n WHERE trace_id = $1 AND kind != 'SERVER'\n ORDER BY start_time ASC`,\n [traceId],\n ),\n ])\n return { root: rootRes.rows[0] ?? null, children: childRes.rows }\n}\n","/**\n * Tiny in-memory rate limiter for local-dev use. Keyed by client address,\n * resets every window. NOT a production rate limiter — if you expose studio\n * on the internet, put a real proxy in front.\n */\nexport function createRateLimiter(opts: { max: number; windowMs: number }): (\n key: string,\n) => boolean {\n const buckets = new Map<string, { count: number; resetAt: number }>()\n\n const sweep = (now: number): void => {\n for (const [k, b] of buckets) {\n if (b.resetAt < now) buckets.delete(k)\n }\n }\n\n return (key: string): boolean => {\n const now = Date.now()\n // Opportunistic cleanup — keeps map from growing unbounded in a long session.\n if (buckets.size > 1024) sweep(now)\n\n const existing = buckets.get(key)\n if (!existing || existing.resetAt < now) {\n buckets.set(key, { count: 1, resetAt: now + opts.windowMs })\n return true\n }\n\n if (existing.count >= opts.max) return false\n existing.count++\n return true\n }\n}\n","const TRACE_ID_RE = /^[0-9a-f]{32}$/i\nconst METHOD_RE = /^[A-Z]{3,10}$/\nconst PATH_LIKE_MAX = 200\n\n/** Strict integer parse. Returns undefined for non-integer / out-of-range input. */\nexport function parseInt32(\n raw: string | undefined,\n { min, max }: { min: number; max: number },\n): number | undefined {\n if (raw === undefined || raw === '') return undefined\n // Reject strings with non-digit characters (handles \"10abc\", \"1e9\", \"0x10\").\n if (!/^-?\\d+$/.test(raw)) return undefined\n const n = Number(raw)\n if (!Number.isInteger(n)) return undefined\n if (n < min || n > max) return undefined\n return n\n}\n\nexport function isValidTraceId(id: string): boolean {\n return TRACE_ID_RE.test(id)\n}\n\nexport function parseMethod(raw: string | undefined): string | undefined {\n if (!raw) return undefined\n const m = raw.toUpperCase()\n return METHOD_RE.test(m) ? m : undefined\n}\n\nexport function parsePathLike(raw: string | undefined): string | undefined {\n if (!raw) return undefined\n if (raw.length > PATH_LIKE_MAX) return undefined\n // Reject NULs and control characters that have no legitimate use in a path.\n // biome-ignore lint/suspicious/noControlCharactersInRegex: filtering out control chars is the point of the rule here\n if (/[\\x00-\\x1f\\x7f]/.test(raw)) return undefined\n return raw\n}\n"],"mappings":"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","names":["c","c","require"]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apitrail/studio",
3
- "version": "0.1.0-alpha.0",
3
+ "version": "0.1.0-alpha.1",
4
4
  "description": "Standalone dev dashboard for apitrail. Run with `pnpm dlx @apitrail/studio`.",
5
5
  "license": "MIT",
6
6
  "author": "apitrail contributors",
@@ -55,7 +55,8 @@
55
55
  "@tailwindcss/vite": "^4.0.0",
56
56
  "tsup": "^8.3.5",
57
57
  "typescript": "^5.7.2",
58
- "vite": "^6.0.7"
58
+ "vite": "^6.0.7",
59
+ "vitest": "^2.1.8"
59
60
  },
60
61
  "scripts": {
61
62
  "build": "pnpm build:ui && pnpm build:server",
@@ -64,6 +65,7 @@
64
65
  "dev": "concurrently -k \"pnpm dev:ui\" \"pnpm dev:server\"",
65
66
  "dev:ui": "vite",
66
67
  "dev:server": "tsup --watch --onSuccess \"node dist/cli.js --db=$APITRAIL_DATABASE_URL --dev\"",
68
+ "test": "vitest run",
67
69
  "typecheck": "tsc --noEmit",
68
70
  "clean": "rm -rf dist .turbo"
69
71
  }