@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 +9 -1
- package/dist/cli.js +171 -42
- package/dist/cli.js.map +1 -1
- package/package.json +4 -2
package/README.md
CHANGED
|
@@ -62,7 +62,15 @@ Output:
|
|
|
62
62
|
|
|
63
63
|
## Security
|
|
64
64
|
|
|
65
|
-
|
|
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
|
-
|
|
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
|
|
140
|
-
method: q.method
|
|
141
|
-
minStatus: q.minStatus
|
|
142
|
-
maxStatus: q.maxStatus
|
|
143
|
-
pathLike: q.pathLike
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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>
|
|
212
|
-
--port <n>
|
|
213
|
-
--host <addr>
|
|
214
|
-
--table <name>
|
|
215
|
-
--
|
|
216
|
-
--no-
|
|
217
|
-
--
|
|
218
|
-
|
|
219
|
-
-
|
|
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
|
|
223
|
-
DATABASE_URL
|
|
224
|
-
POSTGRES_URL
|
|
225
|
-
|
|
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.
|
|
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
|
|
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.
|
|
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
|
}
|