@threadbase-sh/streamer 1.20.0 → 1.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -406,6 +406,31 @@ function validatePublicUrl(raw) {
406
406
  function stripTrailingSlash(url) {
407
407
  return url.endsWith("/") ? url.slice(0, -1) : url;
408
408
  }
409
+ function setApiKey(key) {
410
+ (0, import_fs.mkdirSync)(CONFIG_DIR, { recursive: true });
411
+ let content = "";
412
+ try {
413
+ content = (0, import_fs.readFileSync)(CONFIG_FILE, "utf-8");
414
+ } catch (err) {
415
+ if (err.code !== "ENOENT") throw err;
416
+ }
417
+ const apiKeyLine = `api_key: ${key}`;
418
+ let updated;
419
+ if (/^api_key:\s*.+$/m.test(content)) {
420
+ updated = content.replace(/^api_key:\s*.+$/m, apiKeyLine);
421
+ } else if (content.length === 0 || content.endsWith("\n")) {
422
+ updated = `${content}${apiKeyLine}
423
+ `;
424
+ } else {
425
+ updated = `${content}
426
+ ${apiKeyLine}
427
+ `;
428
+ }
429
+ const tmpFile = `${CONFIG_FILE}.tmp`;
430
+ (0, import_fs.writeFileSync)(tmpFile, updated, { encoding: "utf-8", mode: 384 });
431
+ (0, import_fs.chmodSync)(tmpFile, 384);
432
+ (0, import_fs.renameSync)(tmpFile, CONFIG_FILE);
433
+ }
409
434
 
410
435
  // src/db/config.ts
411
436
  function isDbEnabled() {
@@ -1799,13 +1824,23 @@ var authMiddleware = (deps) => async (c, next) => {
1799
1824
  };
1800
1825
 
1801
1826
  // src/api/middleware/cors.middleware.ts
1827
+ var ALLOWED_ORIGINS = /* @__PURE__ */ new Set([
1828
+ "http://localhost:8081",
1829
+ "http://localhost:19006",
1830
+ "http://localhost:3000"
1831
+ ]);
1802
1832
  var corsMiddleware = () => async (c, next) => {
1803
- c.res.headers.set("Access-Control-Allow-Origin", "*");
1804
- c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
1805
- c.res.headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
1806
- c.res.headers.set("Access-Control-Expose-Headers", "ETag");
1833
+ const origin = c.req.header("origin");
1834
+ const allowedOrigin = origin && ALLOWED_ORIGINS.has(origin) ? origin : null;
1835
+ if (allowedOrigin) {
1836
+ c.res.headers.set("Access-Control-Allow-Origin", allowedOrigin);
1837
+ c.res.headers.set("Vary", "Origin");
1838
+ c.res.headers.set("Access-Control-Allow-Methods", "GET, POST, PATCH, OPTIONS");
1839
+ c.res.headers.set("Access-Control-Allow-Headers", "Authorization, Content-Type, If-None-Match");
1840
+ c.res.headers.set("Access-Control-Expose-Headers", "ETag");
1841
+ }
1807
1842
  if (c.req.method === "OPTIONS") {
1808
- return c.newResponse(null, 204);
1843
+ return c.newResponse(null, allowedOrigin ? 204 : 403);
1809
1844
  }
1810
1845
  await next();
1811
1846
  };
@@ -1992,6 +2027,19 @@ var createMiscRoutes = (deps) => {
1992
2027
  });
1993
2028
  });
1994
2029
  app.get("/api/profiles", (c) => c.json([]));
2030
+ app.post("/api/auth/rotate", (c) => {
2031
+ if (deps.localNoAuth) {
2032
+ return c.json({ error: "key rotation is disabled while localNoAuth is active" }, 403);
2033
+ }
2034
+ const { newKey, persisted } = deps.rotateApiKey();
2035
+ return c.json({
2036
+ apiKey: newKey,
2037
+ persisted,
2038
+ ...persisted ? {} : {
2039
+ warning: "Key rotated in memory only. The server was started with --api-key, so the old key will be restored on restart. Remove --api-key and let the server manage the key via ~/.threadbase/server.yaml for rotation to survive restarts."
2040
+ }
2041
+ });
2042
+ });
1995
2043
  app.post("/api/push/register", (c) => c.json({ ok: true }));
1996
2044
  app.post("/api/__update", async (c) => {
1997
2045
  const cfg = loadUpdateConfig();
@@ -2065,6 +2113,11 @@ var ALREADY_HANDLED4 = 597;
2065
2113
  var alreadyHandled4 = () => new Response(null, { status: ALREADY_HANDLED4 });
2066
2114
  var createProjectRoutes = (deps) => {
2067
2115
  const app = new import_hono7.Hono();
2116
+ app.get("/", (c) => {
2117
+ const url = new URL(c.req.url);
2118
+ deps.handleListProjects(url, c.env.outgoing);
2119
+ return alreadyHandled4();
2120
+ });
2068
2121
  app.get("/popular", (c) => {
2069
2122
  const url = new URL(c.req.url);
2070
2123
  deps.handleGetPopularProjects(url, c.env.outgoing);
@@ -2084,11 +2137,6 @@ var createScannerRoutes = (deps) => {
2084
2137
  await deps.handleSearch(url, c.env.outgoing);
2085
2138
  return alreadyHandled5();
2086
2139
  });
2087
- app.get("/project-chats", async (c) => {
2088
- const url = new URL(c.req.url);
2089
- await deps.handleListProjectChats(url, c.env.outgoing);
2090
- return alreadyHandled5();
2091
- });
2092
2140
  return app;
2093
2141
  };
2094
2142
 
@@ -3261,297 +3309,39 @@ async function recordUpload(pool2, instanceId, row) {
3261
3309
  );
3262
3310
  }
3263
3311
 
3264
- // src/schemas/queryParams.schema.ts
3265
- var import_zod2 = require("zod");
3266
- var ListProjectChatsQuerySchema = import_zod2.z.object({
3267
- refresh: import_zod2.z.enum(["1"]).optional(),
3268
- refreshConversations: import_zod2.z.enum(["1"]).optional()
3269
- });
3270
-
3271
- // src/services/cache/cacheMetadata.ts
3272
- function getCacheMetadata(repo, key) {
3273
- return repo.getCacheMetadata(key);
3274
- }
3275
- function setCacheMetadata(repo, key, value) {
3276
- repo.setCacheMetadata(key, value);
3277
- }
3278
-
3279
- // src/utils/dates.ts
3280
- var import_date_fns = require("date-fns");
3281
- function parseIsoDateOrNull(value) {
3282
- if (!value) return null;
3283
- const parsed = (0, import_date_fns.parseISO)(value);
3284
- return (0, import_date_fns.isValid)(parsed) ? parsed : null;
3285
- }
3286
- function compareIsoDesc(a, b) {
3287
- const dateA = parseIsoDateOrNull(a);
3288
- const dateB = parseIsoDateOrNull(b);
3289
- if (!dateA && !dateB) return 0;
3290
- if (!dateA) return 1;
3291
- if (!dateB) return -1;
3292
- return (0, import_date_fns.compareDesc)(dateA, dateB);
3293
- }
3294
-
3295
- // src/services/projects/ensureProjectsForConversations.ts
3296
- function ensureProjectsForConversations(repo, conversations) {
3297
- const conversationsByPath = /* @__PURE__ */ new Map();
3298
- for (const conversation of conversations) {
3299
- if (!conversation.projectPath) continue;
3300
- const canonical = canonicalizeProjectPath(conversation.projectPath);
3301
- if (!canonical) continue;
3302
- const existing = conversationsByPath.get(canonical) ?? [];
3303
- existing.push(conversation);
3304
- conversationsByPath.set(canonical, existing);
3305
- }
3306
- const pathToProjectId = /* @__PURE__ */ new Map();
3307
- for (const [path, projectConversations] of conversationsByPath) {
3308
- const latest = pickLatestConversation(projectConversations);
3309
- const project = repo.upsertProjectByPath(path, {
3310
- lastConversationId: latest?.id ?? null,
3311
- lastConversationCreatedAt: latest?.createdAt ?? null,
3312
- latestMessageAt: latest?.latestMessageAt ?? null
3313
- });
3314
- pathToProjectId.set(path, project.id);
3315
- }
3316
- return pathToProjectId;
3317
- }
3318
- function pickLatestConversation(conversations) {
3319
- if (conversations.length === 0) return void 0;
3320
- return [...conversations].sort((a, b) => {
3321
- const cmp = compareIsoDesc(a.latestMessageAt ?? null, b.latestMessageAt ?? null);
3322
- if (cmp !== 0) return cmp;
3323
- return compareIsoDesc(a.createdAt ?? null, b.createdAt ?? null);
3324
- })[0];
3325
- }
3326
-
3327
- // src/services/conversations/refreshConversationCache.ts
3328
- function refreshConversationCache(deps) {
3329
- const { projectsRepo, conversationsRepo, cacheMetadataRepo } = deps;
3330
- const conversations = conversationsRepo.listConversationsForProjectBackfill();
3331
- const pathToProjectId = ensureProjectsForConversations(
3332
- projectsRepo,
3333
- conversations.map((c) => ({
3334
- id: c.id,
3335
- projectPath: c.projectPath,
3336
- latestMessageAt: c.lastActivity ?? null,
3337
- createdAt: c.lastActivity ?? null
3338
- }))
3339
- );
3340
- let conversationsBackfilled = 0;
3341
- for (const conversation of conversations) {
3342
- if (!conversation.projectPath) continue;
3343
- if (conversation.projectId) continue;
3344
- const projectId = pathToProjectId.get(canonicalizeProjectPath(conversation.projectPath));
3345
- if (!projectId) continue;
3346
- conversationsRepo.updateConversationProjectId({
3347
- conversationId: conversation.id,
3348
- projectId
3349
- });
3350
- conversationsBackfilled += 1;
3351
- }
3352
- const latest = conversationsRepo.getLatestConversation();
3353
- if (latest) {
3354
- setCacheMetadata(cacheMetadataRepo, "last_conversation_id", latest.id);
3355
- if (latest.lastActivity) {
3356
- setCacheMetadata(cacheMetadataRepo, "last_conversation_created_at", latest.lastActivity);
3357
- }
3358
- }
3359
- setCacheMetadata(cacheMetadataRepo, "conversations_last_indexed_at", (/* @__PURE__ */ new Date()).toISOString());
3360
- return {
3361
- projectsTouched: pathToProjectId.size,
3362
- conversationsBackfilled,
3363
- latestConversationId: latest?.id ?? null
3364
- };
3365
- }
3366
-
3367
- // src/services/conversations/shouldRefreshProjectsFromHdd.ts
3312
+ // src/handlers/handleListProjects.ts
3368
3313
  var import_fs8 = require("fs");
3369
3314
  var import_os5 = require("os");
3370
3315
  var import_path9 = require("path");
3371
- var DEFAULT_PROJECTS_DIR = (0, import_path9.join)((0, import_os5.homedir)(), ".claude", "projects");
3372
- function shouldRefreshProjectsFromHdd(conversationsRepo, cacheMetadataRepo, opts = {}) {
3373
- if (conversationsRepo.hasOrphanRows()) return true;
3374
- const projectsDir = opts.projectsDir ?? DEFAULT_PROJECTS_DIR;
3375
- let dirMtimeMs;
3316
+ function decodeProjectPath(dirName) {
3317
+ return dirName.replace(/-/g, "/");
3318
+ }
3319
+ function handleListProjects(url, res) {
3320
+ const limit = Math.max(1, parseInt(url.searchParams.get("limit") ?? "50", 10) || 50);
3321
+ const offset = Math.max(0, parseInt(url.searchParams.get("offset") ?? "0", 10) || 0);
3322
+ const projectsDir = (0, import_path9.join)((0, import_os5.homedir)(), ".claude", "projects");
3323
+ let entries;
3376
3324
  try {
3377
- dirMtimeMs = (0, import_fs8.statSync)(projectsDir).mtimeMs;
3325
+ entries = (0, import_fs8.readdirSync)(projectsDir).map((dirName) => {
3326
+ const fullPath = (0, import_path9.join)(projectsDir, dirName);
3327
+ let mtime = 0;
3328
+ try {
3329
+ mtime = (0, import_fs8.statSync)(fullPath).mtimeMs;
3330
+ } catch {
3331
+ }
3332
+ const path = decodeProjectPath(dirName);
3333
+ const name = path.split("/").filter(Boolean).pop() ?? dirName;
3334
+ return { name, path, dirName, mtime };
3335
+ }).sort((a, b) => b.mtime - a.mtime);
3378
3336
  } catch {
3379
- return false;
3380
- }
3381
- const lastIndexedIso = getCacheMetadata(cacheMetadataRepo, "conversations_last_indexed_at");
3382
- if (!lastIndexedIso) return true;
3383
- const lastIndexedMs = Date.parse(lastIndexedIso);
3384
- if (Number.isNaN(lastIndexedMs)) return true;
3385
- return dirMtimeMs > lastIndexedMs;
3386
- }
3387
-
3388
- // src/services/sessions/ensureSessionProjectIdsFromExistingProjects.ts
3389
- function ensureSessionProjectIdsFromExistingProjects(projectsRepo, sessionsRepo) {
3390
- let linked = 0;
3391
- let missing = 0;
3392
- for (const session of sessionsRepo.listManagedSessions()) {
3393
- if (session.projectId) continue;
3394
- const canonical = canonicalizeProjectPath(session.projectPath);
3395
- if (!canonical) continue;
3396
- const project = projectsRepo.getProjectByPath(canonical);
3397
- if (!project) {
3398
- missing += 1;
3399
- continue;
3400
- }
3401
- sessionsRepo.updateSessionProjectId({ sessionId: session.id, projectId: project.id });
3402
- linked += 1;
3403
- }
3404
- return { linked, missing };
3405
- }
3406
-
3407
- // src/services/projectChats/sortProjectChats.ts
3408
- function sortProjectChats(a, b) {
3409
- const byLatest = compareIsoDesc(a.latestMessageAt, b.latestMessageAt);
3410
- if (byLatest !== 0) return byLatest;
3411
- const byUpdated = compareIsoDesc(a.updatedAt ?? null, b.updatedAt ?? null);
3412
- if (byUpdated !== 0) return byUpdated;
3413
- const byCreated = compareIsoDesc(a.createdAt ?? null, b.createdAt ?? null);
3414
- if (byCreated !== 0) return byCreated;
3415
- return a.title.localeCompare(b.title);
3416
- }
3417
-
3418
- // src/services/projectChats/mergeProjectChats.ts
3419
- function mergeProjectChats(args) {
3420
- const { sessions, conversations } = args;
3421
- const resumedConversationIds = new Set(
3422
- sessions.filter((c) => c.type === "session").map((c) => c.resumedFromConversationId).filter((id) => Boolean(id))
3423
- );
3424
- const visibleConversations = conversations.filter((c) => {
3425
- if (c.type !== "conversation") return true;
3426
- return !resumedConversationIds.has(c.id);
3427
- });
3428
- return [...sessions, ...visibleConversations].sort(sortProjectChats);
3429
- }
3430
-
3431
- // src/services/projectChats/deriveProjectChatTitle.ts
3432
- function deriveProjectChatTitle(input) {
3433
- const trimmed = input.title?.trim();
3434
- if (trimmed) return trimmed;
3435
- const name = input.projectName?.trim();
3436
- if (name) return name;
3437
- const pathSuffix = input.projectPath ? input.projectPath.split(/[/\\]/).filter(Boolean).slice(-2).join("/") : "";
3438
- if (pathSuffix) return pathSuffix;
3439
- return `Untitled \xB7 ${input.id.slice(0, 8)}`;
3440
- }
3441
-
3442
- // src/services/projectChats/normalizeConversationToProjectChat.ts
3443
- function normalizeConversationToProjectChat(conversation) {
3444
- if (!conversation.projectId) {
3445
- throw new Error(
3446
- `normalizeConversationToProjectChat: conversation ${conversation.id} has no projectId \u2014 resolve it before normalizing`
3447
- );
3448
- }
3449
- return {
3450
- type: "conversation",
3451
- id: conversation.id,
3452
- projectId: conversation.projectId,
3453
- projectPath: conversation.projectPath ?? null,
3454
- title: deriveProjectChatTitle({
3455
- title: conversation.title,
3456
- projectName: conversation.projectName,
3457
- projectPath: conversation.projectPath,
3458
- id: conversation.id
3459
- }),
3460
- latestMessageAt: conversation.lastActivity ?? null,
3461
- updatedAt: conversation.lastActivity ?? null,
3462
- createdAt: null,
3463
- status: "resumable",
3464
- source: "hdd-cache",
3465
- provider: conversation.provider ?? CLAUDE_CODE_PROVIDER,
3466
- indexedAt: null,
3467
- fileMtime: null,
3468
- filePath: conversation.filePath ?? null,
3469
- sourceHash: null
3470
- };
3471
- }
3472
-
3473
- // src/services/projectChats/normalizeSessionToProjectChat.ts
3474
- function normalizeSessionToProjectChat(session) {
3475
- if (!session.projectId) {
3476
- throw new Error(
3477
- `normalizeSessionToProjectChat: session ${session.id} has no projectId \u2014 resolve it before normalizing`
3478
- );
3479
- }
3480
- return {
3481
- type: "session",
3482
- id: session.id,
3483
- projectId: session.projectId,
3484
- projectPath: session.projectPath ?? null,
3485
- title: deriveProjectChatTitle({
3486
- title: session.sessionName,
3487
- projectName: session.projectName,
3488
- projectPath: session.projectPath,
3489
- id: session.id
3490
- }),
3491
- latestMessageAt: session.lastMessageAt ?? session.lastActivityAt ?? null,
3492
- updatedAt: session.lastActivityAt ?? null,
3493
- createdAt: session.startedAt ?? null,
3494
- status: "active",
3495
- source: "session-store",
3496
- resumedFromConversationId: session.resumedFromConversationId ?? null
3497
- };
3498
- }
3499
-
3500
- // src/services/projectChats/listProjectChats.ts
3501
- async function listProjectChats(deps, args) {
3502
- const {
3503
- cache,
3504
- projectsRepo,
3505
- conversationsRepo,
3506
- sessionsRepo,
3507
- cacheMetadataRepo,
3508
- getSessionResponses,
3509
- getFreshScanner,
3510
- projectsDir
3511
- } = deps;
3512
- const needsRefresh = args.refreshConversations || shouldRefreshProjectsFromHdd(conversationsRepo, cacheMetadataRepo, { projectsDir });
3513
- if (needsRefresh) {
3514
- const scanner = await getFreshScanner();
3515
- const metas = [...scanner.getMetadataCache().values()];
3516
- if (metas.length > 0) {
3517
- cache.upsertFromScannerMeta(metas);
3518
- }
3519
- refreshConversationCache({ cache, projectsRepo, conversationsRepo, cacheMetadataRepo });
3520
- }
3521
- ensureSessionProjectIdsFromExistingProjects(projectsRepo, sessionsRepo);
3522
- const sessionResponses = getSessionResponses();
3523
- const sessionChats = [];
3524
- for (const s of sessionResponses) {
3525
- if (!s.projectId) continue;
3526
- sessionChats.push(normalizeSessionToProjectChat(s));
3527
- }
3528
- const conversationChats = [];
3529
- const { conversations } = cache.listConversations({ limit: 1e3, offset: 0 });
3530
- for (const c of conversations) {
3531
- if (!c.projectId) continue;
3532
- conversationChats.push(normalizeConversationToProjectChat(c));
3533
- }
3534
- return mergeProjectChats({ sessions: sessionChats, conversations: conversationChats });
3535
- }
3536
-
3537
- // src/handlers/handleListProjectChats.ts
3538
- async function handleListProjectChats(url, res, deps) {
3539
- const queryObj = Object.fromEntries(url.searchParams.entries());
3540
- const parsed = ListProjectChatsQuerySchema.safeParse(queryObj);
3541
- if (!parsed.success) {
3542
- res.writeHead(400, { "Content-Type": "application/json" });
3543
- res.end(
3544
- JSON.stringify({
3545
- error: "Invalid query parameters",
3546
- details: parsed.error.flatten()
3547
- })
3548
- );
3337
+ res.writeHead(200, { "Content-Type": "application/json" });
3338
+ res.end(JSON.stringify({ projects: [], total: 0 }));
3549
3339
  return;
3550
3340
  }
3551
- const refreshConversations = parsed.data.refreshConversations === "1" || parsed.data.refresh === "1";
3552
- const chats = await listProjectChats(deps, { refreshConversations });
3341
+ const total = entries.length;
3342
+ const page = entries.slice(offset, offset + limit).map(({ name, path, dirName }) => ({ name, path, dirName }));
3553
3343
  res.writeHead(200, { "Content-Type": "application/json" });
3554
- res.end(JSON.stringify({ projectChats: chats }));
3344
+ res.end(JSON.stringify({ projects: page, total }));
3555
3345
  }
3556
3346
 
3557
3347
  // src/pair-store.ts
@@ -3780,6 +3570,17 @@ function pruneAgentConversations(cache) {
3780
3570
  return { scanned: rows.length, pruned, missing };
3781
3571
  }
3782
3572
 
3573
+ // src/services/projectChats/deriveProjectChatTitle.ts
3574
+ function deriveProjectChatTitle(input) {
3575
+ const trimmed = input.title?.trim();
3576
+ if (trimmed) return trimmed;
3577
+ const name = input.projectName?.trim();
3578
+ if (name) return name;
3579
+ const pathSuffix = input.projectPath ? input.projectPath.split(/[/\\]/).filter(Boolean).slice(-2).join("/") : "";
3580
+ if (pathSuffix) return pathSuffix;
3581
+ return `Untitled \xB7 ${input.id.slice(0, 8)}`;
3582
+ }
3583
+
3783
3584
  // src/services/questions/detectAskUserQuestion.ts
3784
3585
  function normalizeContent2(raw) {
3785
3586
  if (Array.isArray(raw)) return raw;
@@ -4183,6 +3984,14 @@ function debounce(fn, waitMs) {
4183
3984
  return debounced;
4184
3985
  }
4185
3986
 
3987
+ // src/utils/dates.ts
3988
+ var import_date_fns = require("date-fns");
3989
+ function parseIsoDateOrNull(value) {
3990
+ if (!value) return null;
3991
+ const parsed = (0, import_date_fns.parseISO)(value);
3992
+ return (0, import_date_fns.isValid)(parsed) ? parsed : null;
3993
+ }
3994
+
4186
3995
  // src/utils/isScannedSnapshotStale.ts
4187
3996
  var STALENESS_TOLERANCE_MS = 1e3;
4188
3997
  function isScannedSnapshotStale(snapshotTimestamp, fileMtimeMs) {
@@ -4358,6 +4167,7 @@ var StreamerServer = class {
4358
4167
  binding = false;
4359
4168
  cacheReady = false;
4360
4169
  apiKey;
4170
+ apiKeySource;
4361
4171
  localNoAuth;
4362
4172
  logMenubarRequests;
4363
4173
  verbose;
@@ -4369,6 +4179,8 @@ var StreamerServer = class {
4369
4179
  publicUrl = null;
4370
4180
  pairTokens = new PairTokenStore();
4371
4181
  exchangeAttempts = /* @__PURE__ */ new Map();
4182
+ sessionStartAttempts = /* @__PURE__ */ new Map();
4183
+ sessionInputAttempts = /* @__PURE__ */ new Map();
4372
4184
  ptyGracePeriodMs;
4373
4185
  defaultSystemPrompt;
4374
4186
  // Map of sessionId → grace timer; fires to kill PTY after WS disconnect
@@ -4404,8 +4216,14 @@ var StreamerServer = class {
4404
4216
  constructor(config) {
4405
4217
  this.sessionStatusBus.setMaxListeners(0);
4406
4218
  this.apiKey = config.apiKey;
4219
+ this.apiKeySource = config.apiKeySource ?? "config";
4407
4220
  this.localNoAuth = config.localNoAuth ?? false;
4408
4221
  this.logMenubarRequests = config.logMenubarRequests ?? false;
4222
+ if (this.localNoAuth) {
4223
+ console.warn(
4224
+ "[WARN] localNoAuth is ENABLED \u2014 all requests from localhost bypass authentication. Do not run with --local-no-auth in shared or production environments."
4225
+ );
4226
+ }
4409
4227
  this.verbose = config.verbose ?? false;
4410
4228
  this.disableDb = config.disableDb ?? false;
4411
4229
  this.scanProfiles = config.scanProfiles;
@@ -4583,10 +4401,15 @@ var StreamerServer = class {
4583
4401
  });
4584
4402
  }
4585
4403
  const agentClient = this.agentClient;
4404
+ const self = this;
4586
4405
  const apiDeps = {
4587
- apiKey: this.apiKey,
4406
+ // ponytail: getter so rotateApiKey() takes effect without restarting the server
4407
+ get apiKey() {
4408
+ return self.apiKey;
4409
+ },
4588
4410
  localNoAuth: this.localNoAuth,
4589
4411
  logMenubarRequests: this.logMenubarRequests,
4412
+ rotateApiKey: () => this.rotateApiKey(),
4590
4413
  publicUrl: this.publicUrl,
4591
4414
  browseRoot: this.browseRoot,
4592
4415
  ptyManager: this.ptyManager,
@@ -4617,8 +4440,8 @@ var StreamerServer = class {
4617
4440
  handleConversationsCount: (url, res) => this.handleConversationsCount(url, res),
4618
4441
  handleGetConversation: (id, url, res, ifNoneMatch) => this.handleGetConversation(id, url, res, ifNoneMatch),
4619
4442
  handleSearch: (url, res) => this.handleSearch(url, res),
4443
+ handleListProjects: (url, res) => handleListProjects(url, res),
4620
4444
  handleGetPopularProjects: (url, res) => this.handleGetPopularProjects(url, res),
4621
- handleListProjectChats: (url, res) => this.handleListProjectChats(url, res),
4622
4445
  handlePairStart: (res) => this.handlePairStart(res),
4623
4446
  handlePairExchange: (req, res) => this.handlePairExchange(req, res),
4624
4447
  handleBrowse: (url, res) => this.handleBrowse(url, res),
@@ -5053,19 +4876,45 @@ var StreamerServer = class {
5053
4876
  machineName: hostname2()
5054
4877
  });
5055
4878
  }
5056
- checkExchangeRateLimit(ip) {
4879
+ rotateApiKey() {
4880
+ const oldKey = this.apiKey;
4881
+ const newKey = generateApiKey();
4882
+ const persisted = this.apiKeySource === "config";
4883
+ if (persisted) setApiKey(newKey);
4884
+ this.apiKey = newKey;
4885
+ this.log.info("API key rotated", {
4886
+ event: "auth.api_key_rotated",
4887
+ oldKeyMasked: `${oldKey.slice(0, 6)}\u2026`,
4888
+ newKeyMasked: `${newKey.slice(0, 6)}\u2026`,
4889
+ persisted
4890
+ });
4891
+ return { newKey, persisted };
4892
+ }
4893
+ checkRateLimit(map, key, limit, windowMs) {
5057
4894
  const now = Date.now();
5058
- const windowMs = 6e4;
5059
- const limit = 5;
5060
- const arr = (this.exchangeAttempts.get(ip) ?? []).filter((t) => now - t < windowMs);
4895
+ const arr = (map.get(key) ?? []).filter((t) => now - t < windowMs);
5061
4896
  if (arr.length >= limit) {
5062
- this.exchangeAttempts.set(ip, arr);
4897
+ map.set(key, arr);
5063
4898
  return false;
5064
4899
  }
5065
4900
  arr.push(now);
5066
- this.exchangeAttempts.set(ip, arr);
4901
+ map.set(key, arr);
4902
+ setTimeout(() => {
4903
+ const remaining = (map.get(key) ?? []).filter((t) => Date.now() - t < windowMs);
4904
+ if (remaining.length === 0) map.delete(key);
4905
+ else map.set(key, remaining);
4906
+ }, windowMs);
5067
4907
  return true;
5068
4908
  }
4909
+ checkExchangeRateLimit(ip) {
4910
+ return this.checkRateLimit(this.exchangeAttempts, ip, 5, 6e4);
4911
+ }
4912
+ checkSessionStartRateLimit(ip) {
4913
+ return this.checkRateLimit(this.sessionStartAttempts, ip, 10, 6e4);
4914
+ }
4915
+ checkSessionInputRateLimit(sessionId) {
4916
+ return this.checkRateLimit(this.sessionInputAttempts, sessionId, 500, 6e4);
4917
+ }
5069
4918
  async handleListConversations(url, res) {
5070
4919
  const limit = intParam(url, "limit", 50);
5071
4920
  const offset = intParam(url, "offset", 0);
@@ -5227,21 +5076,6 @@ var StreamerServer = class {
5227
5076
  const projects = this.cache.getPopularProjects(limit);
5228
5077
  json(res, 200, { projects, total: projects.length });
5229
5078
  }
5230
- async handleListProjectChats(url, res) {
5231
- if (!this.cache || !this.projectsRepo || !this.conversationsRepo || !this.sessionsRepo || !this.cacheMetadataRepo) {
5232
- json(res, 503, { error: "Cache not available" });
5233
- return;
5234
- }
5235
- await handleListProjectChats(url, res, {
5236
- cache: this.cache,
5237
- projectsRepo: this.projectsRepo,
5238
- conversationsRepo: this.conversationsRepo,
5239
- sessionsRepo: this.sessionsRepo,
5240
- cacheMetadataRepo: this.cacheMetadataRepo,
5241
- getSessionResponses: () => this.sessionStore.list(this.ptyAttachedIds()),
5242
- getFreshScanner: () => this.getFreshScanner()
5243
- });
5244
- }
5245
5079
  buildStatCache(previousScanner) {
5246
5080
  if (!this.cache) return void 0;
5247
5081
  const dbStats = this.cache.getFileStats();
@@ -5718,6 +5552,10 @@ var StreamerServer = class {
5718
5552
  }
5719
5553
  }
5720
5554
  async handleSendInput(sessionId, req, res) {
5555
+ if (!this.checkSessionInputRateLimit(sessionId)) {
5556
+ json(res, 429, { error: "Too many input requests for this session. Please slow down." });
5557
+ return;
5558
+ }
5721
5559
  if (this.agentConfig.enabled) {
5722
5560
  const body2 = await readBody(req);
5723
5561
  const cache = this.cache;
@@ -5999,6 +5837,13 @@ var StreamerServer = class {
5999
5837
  json(res, 201, { sessionId: session.id });
6000
5838
  }
6001
5839
  async handleStartSession(req, res) {
5840
+ const ip = req.socket?.remoteAddress ?? "unknown";
5841
+ if (!this.checkSessionStartRateLimit(ip)) {
5842
+ json(res, 429, {
5843
+ error: "Too many session start requests. Please wait before trying again."
5844
+ });
5845
+ return;
5846
+ }
6002
5847
  if (this.agentConfig.enabled) {
6003
5848
  const body2 = await readBody(req);
6004
5849
  const result = await handleStartAgentSession(body2, {