@agent-team-foundation/first-tree-hub 0.10.13 → 0.10.15

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.
@@ -1,7 +1,7 @@
1
1
  import { m as __toESM } from "./esm-CYu4tXXn.mjs";
2
2
  import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DPyf745N-BSc8QNcR.mjs";
3
- import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, b as resetConfig, c as saveCredentials, d as DEFAULT_HOME_DIR$1, f as agentConfigSchema, g as initConfig, i as loadCredentials, l as DEFAULT_CONFIG_DIR, m as collectMissingPrompts, n as ensureFreshAccessToken, o as resolveServerUrl, p as clientConfigSchema, s as saveAgentConfig, u as DEFAULT_DATA_DIR$1, v as migrateLegacyHome, w as setConfigValue, x as resetConfigMeta } from "./bootstrap-CDeXqhkQ.mjs";
4
- import { $ as refreshTokenSchema, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as isReservedAgentName$1, H as inboxDeliverFrameSchema$1, I as extractMentions, J as loginSchema, K as joinByInvitationSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as rebindAgentSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, St as wsAuthFrameSchema, T as createAdapterConfigSchema, U as inboxPollQuerySchema, V as inboxAckFrameSchema, W as isRedactedEnvValue, X as notificationQuerySchema, Y as messageSourceSchema$1, Z as paginationQuerySchema, _ as adminUpdateTaskSchema, _t as updateClientCapabilitiesSchema, a as AGENT_STATUSES, at as sendToAgentSchema, b as agentRuntimeConfigPayloadSchema$1, bt as updateSystemConfigSchema, ct as sessionEventSchema$1, d as TASK_HEALTH_SIGNALS, dt as switchOrgSchema, et as runtimeStateMessageSchema, f as TASK_STATUSES, ft as taskListQuerySchema, g as adminCreateTaskSchema, gt as updateChatSchema, h as addParticipantSchema, ht as updateAgentSchema, i as AGENT_SOURCES, it as sendMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as sessionReconcileRequestSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateAgentRuntimeConfigSchema, n as AGENT_NAME_REGEX$1, nt as scanMentionTokens, o as AGENT_TYPES, ot as sessionCompletionMessageSchema, p as TASK_TERMINAL_STATUSES, pt as updateAdapterConfigSchema, q as linkTaskChatSchema, r as AGENT_SELECTOR_HEADER$1, rt as selfServiceFeishuBotSchema, s as AGENT_VISIBILITY, st as sessionEventMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as safeRedirectPath, u as TASK_CREATOR_TYPES, ut as sessionStateMessageSchema, v as agentBindRequestSchema, vt as updateMemberSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, xt as updateTaskStatusSchema, y as agentPinnedMessageSchema$1, yt as updateOrganizationSchema, z as githubStartQuerySchema } from "./dist-DwbhZyGi.mjs";
3
+ import { C as resetConfigMeta, E as setConfigValue, S as resetConfig, T as serverConfigSchema, b as migrateLegacyHome, c as resolveServerUrl, d as DEFAULT_CONFIG_DIR, f as DEFAULT_DATA_DIR$1, g as collectMissingPrompts, h as clientConfigSchema, i as ensureFreshAccessToken, l as saveAgentConfig, m as agentConfigSchema, o as loadCredentials, p as DEFAULT_HOME_DIR$1, u as saveCredentials, v as initConfig, w as resolveConfigReadonly, y as loadAgents } from "./bootstrap-DUeYbwm-.mjs";
4
+ import { $ as refreshTokenSchema, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as isReservedAgentName$1, H as inboxDeliverFrameSchema$1, I as extractMentions, J as loginSchema, K as joinByInvitationSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as rebindAgentSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, St as wsAuthFrameSchema, T as createAdapterConfigSchema, U as inboxPollQuerySchema, V as inboxAckFrameSchema, W as isRedactedEnvValue, X as notificationQuerySchema, Y as messageSourceSchema$1, Z as paginationQuerySchema, _ as adminUpdateTaskSchema, _t as updateClientCapabilitiesSchema, a as AGENT_STATUSES, at as sendToAgentSchema, b as agentRuntimeConfigPayloadSchema$1, bt as updateSystemConfigSchema, ct as sessionEventSchema$1, d as TASK_HEALTH_SIGNALS, dt as switchOrgSchema, et as runtimeStateMessageSchema, f as TASK_STATUSES, ft as taskListQuerySchema, g as adminCreateTaskSchema, gt as updateChatSchema, h as addParticipantSchema, ht as updateAgentSchema, i as AGENT_SOURCES, it as sendMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as sessionReconcileRequestSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateAgentRuntimeConfigSchema, n as AGENT_NAME_REGEX$1, nt as scanMentionTokens, o as AGENT_TYPES, ot as sessionCompletionMessageSchema, p as TASK_TERMINAL_STATUSES, pt as updateAdapterConfigSchema, q as linkTaskChatSchema, r as AGENT_SELECTOR_HEADER$1, rt as selfServiceFeishuBotSchema, s as AGENT_VISIBILITY, st as sessionEventMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as safeRedirectPath, u as TASK_CREATOR_TYPES, ut as sessionStateMessageSchema, v as agentBindRequestSchema, vt as updateMemberSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, xt as updateTaskStatusSchema, y as agentPinnedMessageSchema$1, yt as updateOrganizationSchema, z as githubStartQuerySchema } from "./dist-D6AOiyNg.mjs";
5
5
  import { _ as recordRedemption, a as ConflictError, b as uuidv7, c as UnauthorizedError, d as findActiveByToken, f as getActiveInvitation, h as organizations, i as ClientUserMismatchError$1, l as buildInviteUrl, m as invitations, n as BadRequestError, o as ForbiddenError, p as invitationRedemptions, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as ensureActiveInvitation, y as users } from "./invitation-B1pjAyOz-BaCA9PII.mjs";
6
6
  import { createRequire } from "node:module";
7
7
  import { ZodError, z } from "zod";
@@ -21,7 +21,7 @@ import { fileURLToPath } from "node:url";
21
21
  import * as semver from "semver";
22
22
  import { confirm, input, password, select } from "@inquirer/prompts";
23
23
  import bcrypt from "bcrypt";
24
- import { and, asc, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
24
+ import { and, asc, count, desc, eq, gt, gte, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
25
25
  import { drizzle } from "drizzle-orm/postgres-js";
26
26
  import postgres from "postgres";
27
27
  import { migrate } from "drizzle-orm/postgres-js/migrator";
@@ -742,10 +742,25 @@ z.object({
742
742
  });
743
743
  z.object({ agentId: z.string().min(1) });
744
744
  const clientStatusSchema = z.enum(["connected", "disconnected"]);
745
+ /**
746
+ * Auth health channel surfaced to the Web admin dashboard. Computed
747
+ * server-side per request from the row's offline duration vs the
748
+ * configured refresh-token TTL — there is no DB column. See
749
+ * `deriveAuthState` server-side.
750
+ *
751
+ * - `ok` — online, or recently offline (cached refresh token can
752
+ * plausibly still mint access tokens).
753
+ * - `expired` — offline longer than the refresh-token TTL; the client
754
+ * cannot recover on its own. The operator mints a fresh
755
+ * connect token via the Web "+ New Connection" button
756
+ * (or the inline Reconnect button on the row).
757
+ */
758
+ const clientAuthStateSchema = z.enum(["ok", "expired"]);
745
759
  z.object({
746
760
  id: z.string(),
747
761
  userId: z.string().nullable(),
748
762
  status: clientStatusSchema,
763
+ authState: clientAuthStateSchema,
749
764
  sdkVersion: z.string().max(50).nullable(),
750
765
  hostname: z.string().max(100).nullable(),
751
766
  os: z.string().max(50).nullable(),
@@ -1517,6 +1532,11 @@ defineConfig({
1517
1532
  secret: true
1518
1533
  })
1519
1534
  },
1535
+ auth: {
1536
+ accessTokenExpiry: field(z.string().default("30m"), { env: "FIRST_TREE_HUB_AUTH_ACCESS_TOKEN_EXPIRY" }),
1537
+ refreshTokenExpiry: field(z.string().default("30d"), { env: "FIRST_TREE_HUB_AUTH_REFRESH_TOKEN_EXPIRY" }),
1538
+ connectTokenExpiry: field(z.string().default("10m"), { env: "FIRST_TREE_HUB_AUTH_CONNECT_TOKEN_EXPIRY" })
1539
+ },
1520
1540
  contextTree: optional({
1521
1541
  repo: field(z.string(), {
1522
1542
  env: "FIRST_TREE_HUB_CONTEXT_TREE_REPO",
@@ -1863,6 +1883,13 @@ var ClientConnection = class extends EventEmitter {
1863
1883
  /** Fires ~60s before JWT exp so we reconnect with a fresh token first. */
1864
1884
  authRefreshTimer = null;
1865
1885
  reconnectAttempt = 0;
1886
+ /**
1887
+ * If the most recent refresh attempt was rate-limited (HTTP 429), the
1888
+ * server-suggested wait in ms — consumed by the next `scheduleReconnect`
1889
+ * to floor its delay so we don't keep retrying inside the same 60s
1890
+ * limiter window. Cleared after one use.
1891
+ */
1892
+ nextReconnectMinDelayMs = 0;
1866
1893
  closing = false;
1867
1894
  registered = false;
1868
1895
  /** Count of `server:welcome` frames received; drives `isReconnect` flag. */
@@ -2061,7 +2088,6 @@ var ClientConnection = class extends EventEmitter {
2061
2088
  }, WS_CONNECT_TIMEOUT_MS);
2062
2089
  ws.on("open", async () => {
2063
2090
  this.ws = ws;
2064
- this.reconnectAttempt = 0;
2065
2091
  this.wsLogger.debug("socket opened, sending auth");
2066
2092
  try {
2067
2093
  const token = await this.getAccessToken({ minValidityMs: AUTH_REFRESH_LEAD_MS + 5e3 });
@@ -2072,7 +2098,16 @@ var ClientConnection = class extends EventEmitter {
2072
2098
  this.scheduleProactiveAuthRefresh(token);
2073
2099
  } catch (err) {
2074
2100
  this.authLogger.error({ err }, "failed to obtain access token");
2075
- settle(reject, err instanceof Error ? err : new Error(String(err)));
2101
+ const e = err instanceof Error ? err : new Error(String(err));
2102
+ if (e.name === "AuthRefreshFailedError") {
2103
+ this.closing = true;
2104
+ this.emit("auth:fatal", e);
2105
+ } else if (e.name === "AuthRefreshRateLimitedError") {
2106
+ const retryAfterMs = e.retryAfterMs ?? 3e4;
2107
+ this.nextReconnectMinDelayMs = Math.max(this.nextReconnectMinDelayMs, retryAfterMs);
2108
+ this.authLogger.warn({ retryAfterMs }, "refresh rate-limited; deferring reconnect");
2109
+ }
2110
+ settle(reject, e);
2076
2111
  ws.close();
2077
2112
  }
2078
2113
  });
@@ -2168,6 +2203,7 @@ var ClientConnection = class extends EventEmitter {
2168
2203
  if (type === "client:registered") {
2169
2204
  const isReconnect = this.boundAgents.size > 0 || this.desiredBindings.size > 0;
2170
2205
  this.registered = true;
2206
+ this.reconnectAttempt = 0;
2171
2207
  this.startHeartbeat();
2172
2208
  this.wsLogger.info({ isReconnect }, "registered");
2173
2209
  this.emit("connected");
@@ -2320,12 +2356,21 @@ var ClientConnection = class extends EventEmitter {
2320
2356
  });
2321
2357
  }
2322
2358
  scheduleReconnect() {
2359
+ if (this.closing) return;
2323
2360
  this.reconnectAttempt++;
2324
2361
  this.emit("reconnecting", this.reconnectAttempt);
2325
- const delay = Math.min(RECONNECT_BASE_MS * 2 ** (this.reconnectAttempt - 1), RECONNECT_MAX_MS);
2362
+ const exponential = Math.min(RECONNECT_BASE_MS * 2 ** (this.reconnectAttempt - 1), RECONNECT_MAX_MS);
2363
+ const floor = this.nextReconnectMinDelayMs;
2364
+ this.nextReconnectMinDelayMs = 0;
2365
+ let delay = Math.max(exponential, floor);
2366
+ if (floor > 0) {
2367
+ const jitter = delay * .2 * (Math.random() * 2 - 1);
2368
+ delay = Math.max(0, Math.round(delay + jitter));
2369
+ }
2326
2370
  this.wsLogger.debug({
2327
2371
  attempt: this.reconnectAttempt,
2328
- delayMs: delay
2372
+ delayMs: delay,
2373
+ floorMs: floor || void 0
2329
2374
  }, "scheduling reconnect");
2330
2375
  this.reconnectTimer = setTimeout(() => {
2331
2376
  this.reconnectTimer = null;
@@ -2375,6 +2420,18 @@ var ClientConnection = class extends EventEmitter {
2375
2420
  * before the server's scheduleAuthExpiry timer fires. Short-lived tokens
2376
2421
  * (exp <= lead window) skip the proactive reconnect entirely — we let the
2377
2422
  * server push `auth:expired` and handle that path.
2423
+ *
2424
+ * Order is "refresh-then-close", not "close-then-let-reconnect-refresh".
2425
+ * The earlier shape relied on the new connection's open handler to do the
2426
+ * `/auth/refresh` HTTP, which forced ≥1s of WS downtime per cycle even on
2427
+ * the happy path (one base reconnect delay + the refresh round-trip) and
2428
+ * compounded badly under 429: every retry attempt also closed/reopened the
2429
+ * WS, holding the agent offline for 15-20s while the limiter cooled down.
2430
+ * Refreshing first lets us swap the new token onto a still-open WS with no
2431
+ * observable disconnect when the refresh succeeds; the original close-and-
2432
+ * reconnect flow only runs on failure as a last-ditch fallback (it'll hit
2433
+ * the same 429 on its next retry, but at least the Retry-After floor is
2434
+ * now wired up so we don't pile attempts inside the same window).
2378
2435
  */
2379
2436
  scheduleProactiveAuthRefresh(token) {
2380
2437
  this.clearAuthRefreshTimer();
@@ -2384,12 +2441,29 @@ var ClientConnection = class extends EventEmitter {
2384
2441
  if (delay <= 0) return;
2385
2442
  this.authLogger.debug({ delayMs: delay }, "scheduled proactive auth refresh");
2386
2443
  this.authRefreshTimer = setTimeout(() => {
2387
- this.authRefreshTimer = null;
2388
- if (this.closing) return;
2389
- this.authLogger.info("triggering proactive auth refresh");
2390
- this.ws?.close(1e3, "proactive auth refresh");
2444
+ this.runProactiveAuthRefresh();
2391
2445
  }, delay);
2392
2446
  }
2447
+ async runProactiveAuthRefresh() {
2448
+ this.authRefreshTimer = null;
2449
+ if (this.closing) return;
2450
+ this.authLogger.info("triggering proactive auth refresh");
2451
+ try {
2452
+ await this.getAccessToken({ minValidityMs: AUTH_REFRESH_LEAD_MS + 5e3 });
2453
+ } catch (err) {
2454
+ const e = err instanceof Error ? err : new Error(String(err));
2455
+ if (e.name === "AuthRefreshRateLimitedError") {
2456
+ const retryAfterMs = e.retryAfterMs ?? 3e4;
2457
+ this.nextReconnectMinDelayMs = Math.max(this.nextReconnectMinDelayMs, retryAfterMs);
2458
+ this.authLogger.warn({ retryAfterMs }, "proactive refresh rate-limited; deferring reconnect");
2459
+ } else if (e.name === "AuthRefreshFailedError") {
2460
+ this.closing = true;
2461
+ this.emit("auth:fatal", e);
2462
+ return;
2463
+ } else this.authLogger.warn({ err: e }, "proactive refresh failed; falling back to reconnect path");
2464
+ }
2465
+ this.ws?.close(1e3, "proactive auth refresh");
2466
+ }
2393
2467
  };
2394
2468
  /** Built-in handler registry. Populated by handler modules. */
2395
2469
  const HANDLER_REGISTRY = /* @__PURE__ */ new Map();
@@ -6267,6 +6341,14 @@ var ClientRuntime = class {
6267
6341
  this.connection.on("auth:expired", () => {
6268
6342
  print.status("⚠️", "access token expired — reconnecting after refresh...");
6269
6343
  });
6344
+ this.connection.on("auth:fatal", (err) => {
6345
+ print.blank();
6346
+ print.status("✗", "auth expired — service is shutting down to break the reconnect loop.");
6347
+ print.status("", err.message);
6348
+ print.status("", "Recovery: get a new connect token from your Hub's Web admin");
6349
+ print.status("", " (Computers → + New Connection), then re-run the command shown.");
6350
+ process.exit(75);
6351
+ });
6270
6352
  this.connection.on("error", (err) => {
6271
6353
  print.status("⚠️", `client connection error: ${err.message}`);
6272
6354
  });
@@ -8064,7 +8146,7 @@ async function onboardCreate(args) {
8064
8146
  }
8065
8147
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
8066
8148
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
8067
- const { bindFeishuBot } = await import("./feishu-viiZmwcn.mjs").then((n) => n.r);
8149
+ const { bindFeishuBot } = await import("./feishu-DQ1l18Ah.mjs").then((n) => n.r);
8068
8150
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
8069
8151
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
8070
8152
  else {
@@ -9044,7 +9126,7 @@ function createFeedbackHandler(config) {
9044
9126
  return { handle };
9045
9127
  }
9046
9128
  //#endregion
9047
- //#region ../server/dist/app-O9kCTpaF.mjs
9129
+ //#region ../server/dist/app-DFDhctwC.mjs
9048
9130
  var __defProp = Object.defineProperty;
9049
9131
  var __exportAll = (all, no_symbols) => {
9050
9132
  let target = {};
@@ -9183,7 +9265,32 @@ function requireMember(request) {
9183
9265
  * - Manageability distinguishes roles: admin can manage all, member only their own.
9184
9266
  * - All conditions include organizationId scoping to prevent cross-org access.
9185
9267
  */
9186
- /** Extract MemberScope from an authenticated request. Single definition, used by all routes. */
9268
+ /**
9269
+ * Extract MemberScope from an authenticated request. Single definition, used by all routes.
9270
+ *
9271
+ * **Org-scoped admin routes must NOT use the result of this directly for
9272
+ * data filtering.** The returned scope is keyed to the JWT default member,
9273
+ * which is whatever org `auth.login` happened to pick at issuance time —
9274
+ * not the org the user has selected in the dropdown (`localStorage.
9275
+ * selectedOrganizationId`). Pair every `memberScope(request)` in
9276
+ * `packages/server/src/api/admin/*.ts` with one of:
9277
+ *
9278
+ * - `resolveAdminScope(db, request, baseScope, request.query.organizationId)`
9279
+ * for routes that accept `?organizationId=` and want a uniformly rotated
9280
+ * scope (every field keyed to the target org).
9281
+ * - `requireMemberInOrg(db, request, orgId)` when only the membership
9282
+ * `(memberId, role, agentId)` is needed for a specific org (e.g. the
9283
+ * creator HUMAN in chat-create).
9284
+ * - `assertCanManage(db, scope, agentUuid)` / `assertAgentVisible(db, scope, agentUuid)`
9285
+ * for routes operating on a single agent — both already rebind authority
9286
+ * to the agent's own org.
9287
+ *
9288
+ * `__tests__/admin-routes-org-scope-invariant.test.ts` pins this rule via a
9289
+ * grep over the admin route directory. If you intentionally land an admin
9290
+ * read/write route that targets only the JWT default org (no
9291
+ * cross-org switch awareness), update the whitelist there with a comment
9292
+ * explaining why.
9293
+ */
9187
9294
  function memberScope(request) {
9188
9295
  const m = requireMember(request);
9189
9296
  return {
@@ -9300,12 +9407,14 @@ async function requireMemberInOrg(db, request, orgId) {
9300
9407
  const m = requireMember(request);
9301
9408
  const [row] = await db.select({
9302
9409
  id: members.id,
9303
- role: members.role
9410
+ role: members.role,
9411
+ agentId: members.agentId
9304
9412
  }).from(members).where(and(eq(members.userId, m.userId), eq(members.organizationId, orgId), eq(members.status, "active"))).limit(1);
9305
9413
  if (!row) throw new ForbiddenError("Not an active member of the target organization");
9306
9414
  return {
9307
9415
  memberId: row.id,
9308
- role: row.role
9416
+ role: row.role,
9417
+ agentId: row.agentId
9309
9418
  };
9310
9419
  }
9311
9420
  /**
@@ -9330,6 +9439,7 @@ async function resolveAdminScope(db, request, scope, requestedOrganizationId) {
9330
9439
  return {
9331
9440
  ...scope,
9332
9441
  memberId: probe.memberId,
9442
+ humanAgentId: probe.agentId,
9333
9443
  organizationId: requestedOrganizationId,
9334
9444
  role: probe.role
9335
9445
  };
@@ -11087,6 +11197,29 @@ async function listClientsForOrgAdmin(db, orgId) {
11087
11197
  metadata: clients.metadata
11088
11198
  }).from(clients).innerJoin(members, eq(members.userId, clients.userId)).where(and(eq(members.organizationId, orgId), eq(members.status, "active"))));
11089
11199
  }
11200
+ /**
11201
+ * Infer whether the client's locally-cached refresh token can plausibly
11202
+ * still mint access tokens. Used by the Web admin dashboard to render an
11203
+ * "AUTH EXPIRED" pill on rows whose offline duration has exceeded the
11204
+ * server's configured refresh-token TTL.
11205
+ *
11206
+ * Uses `lastSeenAt` (not `connectedAt`) because a healthy long-lived
11207
+ * client slides the refresh token continuously, so the absolute connect
11208
+ * time is no proxy for liveness. `lastSeenAt` is updated on register,
11209
+ * heartbeat, and the final disconnect — it lower-bounds the issue time
11210
+ * of the refresh token the client most likely still holds.
11211
+ *
11212
+ * Pure function, no DB access; the column-less design means there's no
11213
+ * server-side revocation path yet — every "expired" decision is purely
11214
+ * time-based. If we ever want admin-driven revocation, add a column
11215
+ * back and OR its value into this function.
11216
+ */
11217
+ function deriveAuthState(row, refreshTokenExpirySeconds) {
11218
+ if (row.status === "disconnected") {
11219
+ if (Date.now() - row.lastSeenAt.getTime() > refreshTokenExpirySeconds * 1e3) return "expired";
11220
+ }
11221
+ return "ok";
11222
+ }
11090
11223
  async function attachAgentCounts(db, rows) {
11091
11224
  const counts = await db.select({
11092
11225
  clientId: agents.clientId,
@@ -11231,6 +11364,22 @@ function removeClientConnection(clientId, ws) {
11231
11364
  clientConnections.delete(clientId);
11232
11365
  return agentIds;
11233
11366
  }
11367
+ /**
11368
+ * Was `ws` the socket currently registered as `clientId`'s active connection
11369
+ * at the time of the call? Used by ws-client.ts's `socket.on("close")` to
11370
+ * decide whether to write `clients.status='disconnected'` to the DB — when a
11371
+ * fast reconnect happens, the new socket has already swapped itself in via
11372
+ * `setClientConnection`, so the old socket's late-arriving onClose must NOT
11373
+ * stamp the row back to disconnected.
11374
+ *
11375
+ * The check is "this socket equals the registered ws", not "this socket is
11376
+ * still OPEN" — the close handler runs precisely when the socket is no
11377
+ * longer OPEN, but the in-memory entry might still legitimately point at
11378
+ * us if no new connection has taken over yet.
11379
+ */
11380
+ function isActiveClientConnection(clientId, ws) {
11381
+ return clientConnections.get(clientId)?.ws === ws;
11382
+ }
11234
11383
  /** Send a message to a client's WebSocket. Returns true if delivered. */
11235
11384
  function sendToClient(clientId, message) {
11236
11385
  const entry = clientConnections.get(clientId);
@@ -12025,13 +12174,23 @@ async function adminAgentRoutes(app) {
12025
12174
  connection
12026
12175
  });
12027
12176
  });
12028
- /** POST /admin/agents/:uuid/chats — create a new workspace chat with the target agent */
12177
+ /** POST /admin/agents/:uuid/chats — create a new workspace chat with the target agent.
12178
+ *
12179
+ * The chat creator is the user's HUMAN agent in the *target agent's* org,
12180
+ * not in the JWT default org. Otherwise a multi-org user creating a chat
12181
+ * with an agent outside their JWT default org gets `createChat`'s
12182
+ * cross-organization guard ("Cross-organization chat not allowed: …"),
12183
+ * even though the human is a member of the target agent's org.
12184
+ * Symptom hit by the inline onboarding flow when the user creates a new
12185
+ * agent in a non-default org and the auto-chat step fires.
12186
+ */
12029
12187
  app.post("/:uuid/chats", async (request, reply) => {
12030
12188
  const { uuid: targetAgentId } = request.params;
12031
12189
  const scope = memberScope(request);
12032
12190
  await assertAgentVisible(app.db, scope, targetAgentId);
12033
- const member = requireMember(request);
12034
- const result = await createChat(app.db, member.agentId, {
12191
+ const targetAgent = await getAgent(app.db, targetAgentId);
12192
+ const probe = await requireMemberInOrg(app.db, request, targetAgent.organizationId);
12193
+ const result = await createChat(app.db, probe.agentId, {
12035
12194
  type: "direct",
12036
12195
  participantIds: [targetAgentId]
12037
12196
  });
@@ -12353,6 +12512,167 @@ async function adminChatRoutes(app) {
12353
12512
  });
12354
12513
  });
12355
12514
  }
12515
+ /** In-memory set of consumed connect token JTIs. Entries auto-expire after 10 minutes. */
12516
+ const consumedConnectJtis = /* @__PURE__ */ new Map();
12517
+ const CONNECT_JTI_TTL_MS = 6e5;
12518
+ async function signToken(secret, payload, expiry) {
12519
+ return new SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setJti(randomUUID()).setExpirationTime(expiry).sign(secret);
12520
+ }
12521
+ /**
12522
+ * Convert an `ms`-style expiry string (e.g. `"30m"`, `"30d"`, `"1w"`) to
12523
+ * seconds. Used to surface the connect token's `expiresIn` in the API
12524
+ * response. Mirrors the subset of `jose.setExpirationTime` we use; falls
12525
+ * through with a clear error on malformed config so a typo in the env var
12526
+ * surfaces at boot, not days later when the first token expires.
12527
+ */
12528
+ function expiryToSeconds(expiry) {
12529
+ const m = /^(\d+)\s*(s|m|h|d|w)$/.exec(expiry.trim());
12530
+ if (!m) throw new Error(`Invalid expiry "${expiry}" — expected forms like "30s", "10m", "2h", "30d", "1w".`);
12531
+ return Number(m[1]) * {
12532
+ s: 1,
12533
+ m: 60,
12534
+ h: 3600,
12535
+ d: 86400,
12536
+ w: 604800
12537
+ }[m[2]];
12538
+ }
12539
+ /**
12540
+ * Sign an `(access, refresh)` pair for the given member. Used by both the
12541
+ * legacy username/password login path and the SaaS GitHub OAuth callback,
12542
+ * so the issuance shape stays in one place.
12543
+ */
12544
+ async function signTokensForMember(jwtSecretKey, member, expiries) {
12545
+ const secret = new TextEncoder().encode(jwtSecretKey);
12546
+ const tokenBase = {
12547
+ sub: member.userId,
12548
+ memberId: member.memberId,
12549
+ organizationId: member.organizationId,
12550
+ role: member.role
12551
+ };
12552
+ return {
12553
+ accessToken: await signToken(secret, {
12554
+ ...tokenBase,
12555
+ type: "access"
12556
+ }, expiries.accessTokenExpiry),
12557
+ refreshToken: await signToken(secret, {
12558
+ ...tokenBase,
12559
+ type: "refresh"
12560
+ }, expiries.refreshTokenExpiry)
12561
+ };
12562
+ }
12563
+ async function login(db, username, password, jwtSecretKey, expiries) {
12564
+ const [user] = await db.select().from(users).where(eq(users.username, username)).limit(1);
12565
+ if (!user || user.status !== "active") throw new UnauthorizedError("Invalid username or password");
12566
+ if (!await bcrypt.compare(password, user.passwordHash)) throw new UnauthorizedError("Invalid username or password");
12567
+ const [member] = await db.select().from(members).where(and(eq(members.userId, user.id), eq(members.status, "active"))).orderBy(desc(members.createdAt), desc(members.id)).limit(1);
12568
+ if (!member) throw new UnauthorizedError("No organization membership found");
12569
+ const tokens = await signTokensForMember(jwtSecretKey, {
12570
+ userId: user.id,
12571
+ memberId: member.id,
12572
+ organizationId: member.organizationId,
12573
+ role: member.role
12574
+ }, expiries);
12575
+ await db.update(users).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(users.id, user.id));
12576
+ return tokens;
12577
+ }
12578
+ /**
12579
+ * Refresh an access token. Sliding-window: the response also carries a
12580
+ * fresh refresh token whose lifetime restarts from now, so an actively-used
12581
+ * client never hits the absolute `refreshTokenExpiry`. The previously
12582
+ * issued refresh token remains valid until its own `exp` — we deliberately
12583
+ * do **not** maintain a server-side jti revocation set. Same-process
12584
+ * concurrent refreshes therefore both succeed; the surviving refresh token
12585
+ * is whichever one the client persists last. The alternative (server-side
12586
+ * invalidation) is more defensive against token theft but introduces races
12587
+ * across systemd-supervised restarts and reconnect storms — a tradeoff
12588
+ * we're not paying for here.
12589
+ */
12590
+ async function refreshAccessToken(db, refreshToken, jwtSecretKey, expiries) {
12591
+ const secret = new TextEncoder().encode(jwtSecretKey);
12592
+ let payload;
12593
+ try {
12594
+ const { payload: p } = await jwtVerify(refreshToken, secret);
12595
+ payload = p;
12596
+ } catch {
12597
+ throw new UnauthorizedError("Invalid or expired refresh token");
12598
+ }
12599
+ if (payload.type !== "refresh" || !payload.sub) throw new UnauthorizedError("Invalid token type");
12600
+ const [user] = await db.select({
12601
+ id: users.id,
12602
+ status: users.status
12603
+ }).from(users).where(eq(users.id, payload.sub)).limit(1);
12604
+ if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
12605
+ const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
12606
+ if (!member) throw new UnauthorizedError("Membership not found");
12607
+ return signTokensForMember(jwtSecretKey, {
12608
+ userId: user.id,
12609
+ memberId: member.id,
12610
+ organizationId: member.organizationId,
12611
+ role: member.role
12612
+ }, expiries);
12613
+ }
12614
+ /**
12615
+ * Generate a short-lived connect token for CLI authentication.
12616
+ * The connect token carries the member's identity and can be exchanged
12617
+ * for full access+refresh tokens via exchangeConnectToken().
12618
+ *
12619
+ * `iss` (when supplied) is stamped into the JWT so the CLI can derive
12620
+ * the hub URL with no additional argument. Production servers must
12621
+ * always pass it; dev callers may omit and the CLI will require an
12622
+ * explicit `--server-url` (legacy form).
12623
+ */
12624
+ async function generateConnectToken(member, jwtSecretKey, expiries, iss) {
12625
+ const secret = new TextEncoder().encode(jwtSecretKey);
12626
+ const jti = randomUUID();
12627
+ const builder = new SignJWT({
12628
+ sub: member.userId,
12629
+ memberId: member.memberId,
12630
+ organizationId: member.organizationId,
12631
+ role: member.role,
12632
+ type: "connect"
12633
+ }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setJti(jti).setExpirationTime(expiries.connectTokenExpiry);
12634
+ if (iss) builder.setIssuer(iss);
12635
+ return {
12636
+ token: await builder.sign(secret),
12637
+ expiresIn: expiryToSeconds(expiries.connectTokenExpiry)
12638
+ };
12639
+ }
12640
+ /**
12641
+ * Exchange a connect token for full access+refresh tokens.
12642
+ * Validates the connect token, verifies the user is still active,
12643
+ * and issues a fresh token pair.
12644
+ */
12645
+ async function exchangeConnectToken(db, connectToken, jwtSecretKey, expiries) {
12646
+ const secret = new TextEncoder().encode(jwtSecretKey);
12647
+ let payload;
12648
+ try {
12649
+ const { payload: p } = await jwtVerify(connectToken, secret);
12650
+ payload = p;
12651
+ } catch {
12652
+ throw new UnauthorizedError("Invalid or expired connect token");
12653
+ }
12654
+ if (payload.type !== "connect" || !payload.sub || !payload.memberId) throw new UnauthorizedError("Invalid token type — expected connect token");
12655
+ const jti = payload.jti;
12656
+ if (jti) {
12657
+ if (consumedConnectJtis.has(jti)) throw new UnauthorizedError("Connect token has already been used");
12658
+ consumedConnectJtis.set(jti, Date.now());
12659
+ const cutoff = Date.now() - CONNECT_JTI_TTL_MS;
12660
+ for (const [k, ts] of consumedConnectJtis) if (ts < cutoff) consumedConnectJtis.delete(k);
12661
+ }
12662
+ const [user] = await db.select({
12663
+ id: users.id,
12664
+ status: users.status
12665
+ }).from(users).where(eq(users.id, payload.sub)).limit(1);
12666
+ if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
12667
+ const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
12668
+ if (!member) throw new UnauthorizedError("Membership not found");
12669
+ return signTokensForMember(jwtSecretKey, {
12670
+ userId: user.id,
12671
+ memberId: member.id,
12672
+ organizationId: member.organizationId,
12673
+ role: member.role
12674
+ }, expiries);
12675
+ }
12356
12676
  /** Serialize a Date to ISO string, or null. */
12357
12677
  function serializeDate(d) {
12358
12678
  return d ? d.toISOString() : null;
@@ -12362,13 +12682,16 @@ async function adminClientRoutes(app) {
12362
12682
  app.get("/", async (request) => {
12363
12683
  const scope = memberScope(request);
12364
12684
  const { organizationId } = listClientsQuerySchema.parse(request.query);
12365
- return (organizationId ? await (async () => {
12685
+ const clients = organizationId ? await (async () => {
12366
12686
  if ((await requireMemberInOrg(app.db, request, organizationId)).role !== "admin") throw new ForbiddenError("Admin role required");
12367
12687
  return listClientsForOrgAdmin(app.db, organizationId);
12368
- })() : await listClients(app.db, { userId: scope.userId })).map((c) => ({
12688
+ })() : await listClients(app.db, { userId: scope.userId });
12689
+ const refreshExpirySeconds = expiryToSeconds(app.config.auth.refreshTokenExpiry);
12690
+ return clients.map((c) => ({
12369
12691
  id: c.id,
12370
12692
  userId: c.userId,
12371
12693
  status: c.status,
12694
+ authState: deriveAuthState(c, refreshExpirySeconds),
12372
12695
  sdkVersion: c.sdkVersion,
12373
12696
  hostname: c.hostname,
12374
12697
  os: c.os,
@@ -12395,10 +12718,12 @@ async function adminClientRoutes(app) {
12395
12718
  if (!client) throw new Error("unreachable: client missing after owner check");
12396
12719
  const metadata = client.metadata ?? {};
12397
12720
  const capabilities = metadata.capabilities && typeof metadata.capabilities === "object" ? metadata.capabilities : {};
12721
+ const refreshExpirySeconds = expiryToSeconds(app.config.auth.refreshTokenExpiry);
12398
12722
  return {
12399
12723
  id: client.id,
12400
12724
  userId: client.userId,
12401
12725
  status: client.status,
12726
+ authState: deriveAuthState(client, refreshExpirySeconds),
12402
12727
  sdkVersion: client.sdkVersion,
12403
12728
  hostname: client.hostname,
12404
12729
  os: client.os,
@@ -12428,9 +12753,12 @@ async function adminClientRoutes(app) {
12428
12753
  return reply.status(204).send();
12429
12754
  });
12430
12755
  }
12756
+ const activityQuerySchema = z.object({ organizationId: z.string().min(1).optional() });
12431
12757
  async function adminActivityRoutes(app) {
12432
12758
  app.get("/", async (request) => {
12433
- const scope = memberScope(request);
12759
+ const baseScope = memberScope(request);
12760
+ const { organizationId } = activityQuerySchema.parse(request.query);
12761
+ const scope = await resolveAdminScope(app.db, request, baseScope, organizationId);
12434
12762
  const overview = await getActivityOverview(app.db);
12435
12763
  const runningAgents = await listAgentsWithRuntime(app.db, scope);
12436
12764
  return {
@@ -14142,18 +14470,11 @@ async function pollInbox(db, inboxId, limit) {
14142
14470
  }
14143
14471
  async function pollInboxInner(db, inboxId, limit) {
14144
14472
  return db.transaction(async (tx) => {
14145
- return bundleDeliveryWithSilentContext(tx, inboxId, await tx.execute(sql`
14146
- UPDATE inbox_entries
14147
- SET status = 'delivered', delivered_at = NOW()
14148
- WHERE id IN (
14149
- SELECT id FROM inbox_entries
14150
- WHERE inbox_id = ${inboxId} AND status = 'pending' AND notify = true
14151
- ORDER BY created_at
14152
- LIMIT ${limit}
14153
- FOR UPDATE SKIP LOCKED
14154
- )
14155
- RETURNING *
14156
- `));
14473
+ const targetIds = tx.select({ id: inboxEntries.id }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, true))).orderBy(asc(inboxEntries.createdAt)).limit(limit).for("update", { skipLocked: true });
14474
+ return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
14475
+ status: "delivered",
14476
+ deliveredAt: /* @__PURE__ */ new Date()
14477
+ }).where(inArray(inboxEntries.id, targetIds)).returning());
14157
14478
  });
14158
14479
  }
14159
14480
  /**
@@ -14166,7 +14487,7 @@ async function pollInboxInner(db, inboxId, limit) {
14166
14487
  * hub-inbox-ws-data-plane §3.2 risk #1).
14167
14488
  *
14168
14489
  * Steps:
14169
- * 1. Sort by `created_at` ASC (PG `RETURNING` does not guarantee order).
14490
+ * 1. Sort by `createdAt` ASC (PG `RETURNING` does not guarantee order).
14170
14491
  * 2. For each trigger, collect silent context & bulk-ack stale silent rows.
14171
14492
  * 3. Fetch the trigger messages.
14172
14493
  * 4. Build wire payloads via the single dispatcher.
@@ -14175,16 +14496,16 @@ async function pollInboxInner(db, inboxId, limit) {
14175
14496
  */
14176
14497
  async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
14177
14498
  if (claimed.length === 0) return [];
14178
- claimed.sort((a, b) => a.created_at.localeCompare(b.created_at));
14499
+ claimed.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
14179
14500
  const precedingByEntryId = await collectPrecedingContext(tx, inboxId, claimed);
14180
- const messageIds = claimed.map((e) => e.message_id);
14501
+ const messageIds = claimed.map((e) => e.messageId);
14181
14502
  const msgs = await tx.select().from(messages).where(inArray(messages.id, messageIds));
14182
14503
  const msgMap = new Map(msgs.map((m) => [m.id, m]));
14183
14504
  const payloads = await buildClientMessagePayloadsForInbox(tx, inboxId, claimed.map((entry) => {
14184
- const msg = msgMap.get(entry.message_id);
14185
- if (!msg) throw new Error(`Unexpected: message ${entry.message_id} not found`);
14505
+ const msg = msgMap.get(entry.messageId);
14506
+ if (!msg) throw new Error(`Unexpected: message ${entry.messageId} not found`);
14186
14507
  return {
14187
- entryChatId: entry.chat_id,
14508
+ entryChatId: entry.chatId,
14188
14509
  precedingMessages: precedingByEntryId.get(entry.id) ?? [],
14189
14510
  message: {
14190
14511
  id: msg.id,
@@ -14205,15 +14526,15 @@ async function bundleDeliveryWithSilentContext(tx, inboxId, claimed) {
14205
14526
  const payload = payloads[idx];
14206
14527
  if (!payload) throw new Error(`Unexpected: payload for entry ${entry.id} not built`);
14207
14528
  return {
14208
- id: Number(entry.id),
14209
- inboxId: entry.inbox_id,
14210
- messageId: entry.message_id,
14211
- chatId: entry.chat_id,
14529
+ id: entry.id,
14530
+ inboxId: entry.inboxId,
14531
+ messageId: entry.messageId,
14532
+ chatId: entry.chatId,
14212
14533
  status: entry.status,
14213
- retryCount: entry.retry_count,
14214
- createdAt: entry.created_at,
14215
- deliveredAt: entry.delivered_at ?? null,
14216
- ackedAt: entry.acked_at ?? null,
14534
+ retryCount: entry.retryCount,
14535
+ createdAt: entry.createdAt.toISOString(),
14536
+ deliveredAt: entry.deliveredAt?.toISOString() ?? null,
14537
+ ackedAt: entry.ackedAt?.toISOString() ?? null,
14217
14538
  message: payload
14218
14539
  };
14219
14540
  });
@@ -14248,21 +14569,11 @@ async function claimAndBuildForPush(db, inboxId, messageId) {
14248
14569
  "inbox.id": inboxId,
14249
14570
  "message.id": messageId
14250
14571
  }, () => db.transaction(async (tx) => {
14251
- return bundleDeliveryWithSilentContext(tx, inboxId, await tx.execute(sql`
14252
- UPDATE inbox_entries
14253
- SET status = 'delivered', delivered_at = NOW()
14254
- WHERE id IN (
14255
- SELECT id FROM inbox_entries
14256
- WHERE inbox_id = ${inboxId}
14257
- AND message_id = ${messageId}
14258
- AND status = 'pending'
14259
- AND notify = true
14260
- ORDER BY created_at
14261
- LIMIT ${PUSH_CLAIM_BATCH_LIMIT}
14262
- FOR UPDATE SKIP LOCKED
14263
- )
14264
- RETURNING *
14265
- `));
14572
+ const targetIds = tx.select({ id: inboxEntries.id }).from(inboxEntries).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.messageId, messageId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, true))).orderBy(asc(inboxEntries.createdAt)).limit(PUSH_CLAIM_BATCH_LIMIT).for("update", { skipLocked: true });
14573
+ return bundleDeliveryWithSilentContext(tx, inboxId, await tx.update(inboxEntries).set({
14574
+ status: "delivered",
14575
+ deliveredAt: /* @__PURE__ */ new Date()
14576
+ }).where(inArray(inboxEntries.id, targetIds)).returning());
14266
14577
  }));
14267
14578
  }
14268
14579
  /**
@@ -14285,7 +14596,7 @@ async function claimBacklogForPush(db, inboxId, limit) {
14285
14596
  * `PRECEDING_CONTEXT_WINDOW_SECONDS`. Returned messages are oldest-first.
14286
14597
  *
14287
14598
  * Side effect: bulk-ack ALL silent pending rows in each chat with
14288
- * created_at < latest_trigger.created_at — including ones that fell outside
14599
+ * createdAt < latest_trigger.createdAt — including ones that fell outside
14289
14600
  * the window/cap. Otherwise stale silent rows would accumulate and re-load
14290
14601
  * on every poll.
14291
14602
  */
@@ -14293,51 +14604,41 @@ async function collectPrecedingContext(tx, inboxId, triggers) {
14293
14604
  const result = /* @__PURE__ */ new Map();
14294
14605
  const byChat = /* @__PURE__ */ new Map();
14295
14606
  for (const t of triggers) {
14296
- if (t.chat_id === null) continue;
14297
- const list = byChat.get(t.chat_id) ?? [];
14607
+ if (t.chatId === null) continue;
14608
+ const list = byChat.get(t.chatId) ?? [];
14298
14609
  list.push(t);
14299
- byChat.set(t.chat_id, list);
14610
+ byChat.set(t.chatId, list);
14300
14611
  }
14301
14612
  for (const [chatId, chatTriggers] of byChat) {
14302
- chatTriggers.sort((a, b) => a.created_at.localeCompare(b.created_at));
14613
+ chatTriggers.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime());
14303
14614
  let prevCreatedAt = null;
14304
14615
  for (const trigger of chatTriggers) {
14305
- const preceding = (await tx.execute(sql`
14306
- SELECT ie.id, m.id AS message_id, m.sender_id, m.format, m.content, m.metadata,
14307
- m.created_at
14308
- FROM inbox_entries ie
14309
- JOIN messages m ON m.id = ie.message_id
14310
- WHERE ie.inbox_id = ${inboxId}
14311
- AND ie.chat_id = ${chatId}
14312
- AND ie.status = 'pending'
14313
- AND ie.notify = false
14314
- AND ie.created_at < ${trigger.created_at}
14315
- ${prevCreatedAt === null ? sql`` : sql`AND ie.created_at > ${prevCreatedAt}`}
14316
- AND ie.created_at > NOW() - make_interval(secs => ${PRECEDING_CONTEXT_WINDOW_SECONDS})
14317
- ORDER BY ie.created_at DESC
14318
- LIMIT ${50}
14319
- FOR UPDATE OF ie SKIP LOCKED
14320
- `)).map((r) => ({
14321
- id: r.message_id,
14322
- senderId: r.sender_id,
14616
+ const preceding = (await tx.select({
14617
+ messageId: messages.id,
14618
+ senderId: messages.senderId,
14619
+ format: messages.format,
14620
+ content: messages.content,
14621
+ metadata: messages.metadata,
14622
+ createdAt: messages.createdAt
14623
+ }).from(inboxEntries).innerJoin(messages, eq(messages.id, inboxEntries.messageId)).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, trigger.createdAt), prevCreatedAt === null ? void 0 : gt(inboxEntries.createdAt, prevCreatedAt), sql`${inboxEntries.createdAt} > NOW() - make_interval(secs => ${PRECEDING_CONTEXT_WINDOW_SECONDS})`)).orderBy(desc(inboxEntries.createdAt)).limit(50).for("update", {
14624
+ of: inboxEntries,
14625
+ skipLocked: true
14626
+ })).map((r) => ({
14627
+ id: r.messageId,
14628
+ senderId: r.senderId,
14323
14629
  format: r.format,
14324
14630
  content: r.content,
14325
14631
  metadata: r.metadata ?? {},
14326
- createdAt: r.created_at
14632
+ createdAt: r.createdAt.toISOString()
14327
14633
  })).reverse();
14328
14634
  result.set(trigger.id, preceding);
14329
- prevCreatedAt = trigger.created_at;
14635
+ prevCreatedAt = trigger.createdAt;
14330
14636
  }
14331
14637
  const latestTrigger = chatTriggers[chatTriggers.length - 1];
14332
- if (latestTrigger) await tx.execute(sql`
14333
- UPDATE inbox_entries
14334
- SET status = 'acked', acked_at = NOW()
14335
- WHERE inbox_id = ${inboxId}
14336
- AND chat_id = ${chatId}
14337
- AND status = 'pending'
14338
- AND notify = false
14339
- AND created_at < ${latestTrigger.created_at}
14340
- `);
14638
+ if (latestTrigger) await tx.update(inboxEntries).set({
14639
+ status: "acked",
14640
+ ackedAt: /* @__PURE__ */ new Date()
14641
+ }).where(and(eq(inboxEntries.inboxId, inboxId), eq(inboxEntries.chatId, chatId), eq(inboxEntries.status, "pending"), eq(inboxEntries.notify, false), lt(inboxEntries.createdAt, latestTrigger.createdAt)));
14341
14642
  }
14342
14643
  return result;
14343
14644
  }
@@ -14380,23 +14681,14 @@ async function renewEntry(db, entryId, inboxId) {
14380
14681
  return entry;
14381
14682
  }
14382
14683
  async function resetTimedOutEntries(db, timeoutSeconds = DEFAULT_INBOX_TIMEOUT_SECONDS, maxRetries = DEFAULT_MAX_RETRY_COUNT) {
14383
- const resetResult = await db.execute(sql`
14384
- UPDATE inbox_entries SET status = 'pending', retry_count = retry_count + 1
14385
- WHERE status = 'delivered'
14386
- AND delivered_at < NOW() - make_interval(secs => ${timeoutSeconds})
14387
- AND retry_count < ${maxRetries}
14388
- RETURNING id
14389
- `);
14390
- const failedResult = await db.execute(sql`
14391
- UPDATE inbox_entries SET status = 'failed'
14392
- WHERE status = 'delivered'
14393
- AND delivered_at < NOW() - make_interval(secs => ${timeoutSeconds})
14394
- AND retry_count >= ${maxRetries}
14395
- RETURNING id
14396
- `);
14684
+ const reset = await db.update(inboxEntries).set({
14685
+ status: "pending",
14686
+ retryCount: sql`${inboxEntries.retryCount} + 1`
14687
+ }).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, lt(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
14688
+ const failed = await db.update(inboxEntries).set({ status: "failed" }).where(and(eq(inboxEntries.status, "delivered"), sql`${inboxEntries.deliveredAt} < NOW() - make_interval(secs => ${timeoutSeconds})`, gte(inboxEntries.retryCount, maxRetries))).returning({ id: inboxEntries.id });
14397
14689
  return {
14398
- reset: resetResult.length,
14399
- failed: failedResult.length
14690
+ reset: reset.length,
14691
+ failed: failed.length
14400
14692
  };
14401
14693
  }
14402
14694
  /** Default age (30 days) past which silent rows that no notify-true delivery
@@ -14415,7 +14707,7 @@ const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
14415
14707
  * `(inbox_id, message_id, chat_id)` means leaving them around blocks
14416
14708
  * legitimate retries with the same key.
14417
14709
  *
14418
- * 2. `notify=false AND status='pending' AND created_at < NOW() - maxAge` —
14710
+ * 2. `notify=false AND status='pending' AND createdAt < NOW() - maxAge` —
14419
14711
  * stale silent rows that no trigger ever caught up with. After 30
14420
14712
  * days they're useless as preceding context (the @mention almost
14421
14713
  * certainly already happened or the chat went dormant).
@@ -14424,22 +14716,11 @@ const SILENT_ROW_GC_MAX_AGE_SECONDS = 720 * 60 * 60;
14424
14716
  * can log meaningful counts.
14425
14717
  */
14426
14718
  async function pruneStaleSilentEntries(db, maxAgeSeconds = SILENT_ROW_GC_MAX_AGE_SECONDS) {
14427
- const ackedResult = await db.execute(sql`
14428
- DELETE FROM inbox_entries
14429
- WHERE notify = false
14430
- AND status = 'acked'
14431
- RETURNING id
14432
- `);
14433
- const staleResult = await db.execute(sql`
14434
- DELETE FROM inbox_entries
14435
- WHERE notify = false
14436
- AND status = 'pending'
14437
- AND created_at < NOW() - make_interval(secs => ${maxAgeSeconds})
14438
- RETURNING id
14439
- `);
14719
+ const ackedDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "acked"))).returning({ id: inboxEntries.id });
14720
+ const stalePendingDeleted = await db.delete(inboxEntries).where(and(eq(inboxEntries.notify, false), eq(inboxEntries.status, "pending"), sql`${inboxEntries.createdAt} < NOW() - make_interval(secs => ${maxAgeSeconds})`)).returning({ id: inboxEntries.id });
14440
14721
  return {
14441
- ackedDeleted: ackedResult.length,
14442
- stalePendingDeleted: staleResult.length
14722
+ ackedDeleted: ackedDeleted.length,
14723
+ stalePendingDeleted: stalePendingDeleted.length
14443
14724
  };
14444
14725
  }
14445
14726
  async function agentInboxRoutes(app) {
@@ -15317,8 +15598,9 @@ function clientWsRoutes(notifier, instanceId) {
15317
15598
  }
15318
15599
  boundAgents.clear();
15319
15600
  if (clientId) {
15601
+ const stillActive = isActiveClientConnection(clientId, socket);
15320
15602
  removeClientConnection(clientId, socket);
15321
- try {
15603
+ if (stillActive) try {
15322
15604
  await disconnectClient(app.db, clientId);
15323
15605
  } catch {}
15324
15606
  }
@@ -15326,141 +15608,6 @@ function clientWsRoutes(notifier, instanceId) {
15326
15608
  });
15327
15609
  };
15328
15610
  }
15329
- const ACCESS_TOKEN_EXPIRY = "30m";
15330
- const REFRESH_TOKEN_EXPIRY = "7d";
15331
- const CONNECT_TOKEN_EXPIRY = "10m";
15332
- /** In-memory set of consumed connect token JTIs. Entries auto-expire after 10 minutes. */
15333
- const consumedConnectJtis = /* @__PURE__ */ new Map();
15334
- const CONNECT_JTI_TTL_MS = 6e5;
15335
- async function signToken(secret, payload, expiry) {
15336
- return new SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiry).sign(secret);
15337
- }
15338
- /**
15339
- * Sign an `(access, refresh)` pair for the given member. Used by both the
15340
- * legacy username/password login path and the SaaS GitHub OAuth callback,
15341
- * so the issuance shape stays in one place.
15342
- */
15343
- async function signTokensForMember(jwtSecretKey, member) {
15344
- const secret = new TextEncoder().encode(jwtSecretKey);
15345
- const tokenBase = {
15346
- sub: member.userId,
15347
- memberId: member.memberId,
15348
- organizationId: member.organizationId,
15349
- role: member.role
15350
- };
15351
- return {
15352
- accessToken: await signToken(secret, {
15353
- ...tokenBase,
15354
- type: "access"
15355
- }, ACCESS_TOKEN_EXPIRY),
15356
- refreshToken: await signToken(secret, {
15357
- ...tokenBase,
15358
- type: "refresh"
15359
- }, REFRESH_TOKEN_EXPIRY)
15360
- };
15361
- }
15362
- async function login(db, username, password, jwtSecretKey) {
15363
- const [user] = await db.select().from(users).where(eq(users.username, username)).limit(1);
15364
- if (!user || user.status !== "active") throw new UnauthorizedError("Invalid username or password");
15365
- if (!await bcrypt.compare(password, user.passwordHash)) throw new UnauthorizedError("Invalid username or password");
15366
- const [member] = await db.select().from(members).where(and(eq(members.userId, user.id), eq(members.status, "active"))).orderBy(desc(members.createdAt), desc(members.id)).limit(1);
15367
- if (!member) throw new UnauthorizedError("No organization membership found");
15368
- const tokens = await signTokensForMember(jwtSecretKey, {
15369
- userId: user.id,
15370
- memberId: member.id,
15371
- organizationId: member.organizationId,
15372
- role: member.role
15373
- });
15374
- await db.update(users).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(users.id, user.id));
15375
- return tokens;
15376
- }
15377
- async function refreshAccessToken(db, refreshToken, jwtSecretKey) {
15378
- const secret = new TextEncoder().encode(jwtSecretKey);
15379
- let payload;
15380
- try {
15381
- const { payload: p } = await jwtVerify(refreshToken, secret);
15382
- payload = p;
15383
- } catch {
15384
- throw new UnauthorizedError("Invalid or expired refresh token");
15385
- }
15386
- if (payload.type !== "refresh" || !payload.sub) throw new UnauthorizedError("Invalid token type");
15387
- const [user] = await db.select({
15388
- id: users.id,
15389
- status: users.status
15390
- }).from(users).where(eq(users.id, payload.sub)).limit(1);
15391
- if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
15392
- const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
15393
- if (!member) throw new UnauthorizedError("Membership not found");
15394
- return { accessToken: await signToken(secret, {
15395
- sub: user.id,
15396
- memberId: member.id,
15397
- organizationId: member.organizationId,
15398
- role: member.role,
15399
- type: "access"
15400
- }, ACCESS_TOKEN_EXPIRY) };
15401
- }
15402
- /**
15403
- * Generate a short-lived connect token for CLI authentication.
15404
- * The connect token carries the member's identity and can be exchanged
15405
- * for full access+refresh tokens via exchangeConnectToken().
15406
- *
15407
- * `iss` (when supplied) is stamped into the JWT so the CLI can derive
15408
- * the hub URL with no additional argument. Production servers must
15409
- * always pass it; dev callers may omit and the CLI will require an
15410
- * explicit `--server-url` (legacy form).
15411
- */
15412
- async function generateConnectToken(member, jwtSecretKey, iss) {
15413
- const secret = new TextEncoder().encode(jwtSecretKey);
15414
- const jti = randomUUID();
15415
- const builder = new SignJWT({
15416
- sub: member.userId,
15417
- memberId: member.memberId,
15418
- organizationId: member.organizationId,
15419
- role: member.role,
15420
- type: "connect"
15421
- }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setJti(jti).setExpirationTime(CONNECT_TOKEN_EXPIRY);
15422
- if (iss) builder.setIssuer(iss);
15423
- return {
15424
- token: await builder.sign(secret),
15425
- expiresIn: 600
15426
- };
15427
- }
15428
- /**
15429
- * Exchange a connect token for full access+refresh tokens.
15430
- * Validates the connect token, verifies the user is still active,
15431
- * and issues a fresh token pair.
15432
- */
15433
- async function exchangeConnectToken(db, connectToken, jwtSecretKey) {
15434
- const secret = new TextEncoder().encode(jwtSecretKey);
15435
- let payload;
15436
- try {
15437
- const { payload: p } = await jwtVerify(connectToken, secret);
15438
- payload = p;
15439
- } catch {
15440
- throw new UnauthorizedError("Invalid or expired connect token");
15441
- }
15442
- if (payload.type !== "connect" || !payload.sub || !payload.memberId) throw new UnauthorizedError("Invalid token type — expected connect token");
15443
- const jti = payload.jti;
15444
- if (jti) {
15445
- if (consumedConnectJtis.has(jti)) throw new UnauthorizedError("Connect token has already been used");
15446
- consumedConnectJtis.set(jti, Date.now());
15447
- const cutoff = Date.now() - CONNECT_JTI_TTL_MS;
15448
- for (const [k, ts] of consumedConnectJtis) if (ts < cutoff) consumedConnectJtis.delete(k);
15449
- }
15450
- const [user] = await db.select({
15451
- id: users.id,
15452
- status: users.status
15453
- }).from(users).where(eq(users.id, payload.sub)).limit(1);
15454
- if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
15455
- const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
15456
- if (!member) throw new UnauthorizedError("Membership not found");
15457
- return signTokensForMember(jwtSecretKey, {
15458
- userId: user.id,
15459
- memberId: member.id,
15460
- organizationId: member.organizationId,
15461
- role: member.role
15462
- });
15463
- }
15464
15611
  /**
15465
15612
  * Third-party / local auth identities for a user. Models "how does this user
15466
15613
  * prove they are who they say they are". A single user MAY have multiple
@@ -16045,7 +16192,7 @@ async function completeOauthFlow(app, request, reply, profile, next) {
16045
16192
  memberId: memberInfo.memberId,
16046
16193
  organizationId: memberInfo.organizationId,
16047
16194
  role: memberInfo.role
16048
- });
16195
+ }, app.config.auth);
16049
16196
  const fragment = new URLSearchParams({
16050
16197
  access: tokens.accessToken,
16051
16198
  refresh: tokens.refreshToken,
@@ -16061,7 +16208,7 @@ async function authRoutes(app) {
16061
16208
  timeWindow: "1 minute"
16062
16209
  } } }, async (request, reply) => {
16063
16210
  const body = loginSchema.parse(request.body);
16064
- const result = await login(app.db, body.username, body.password, app.config.secrets.jwtSecret);
16211
+ const result = await login(app.db, body.username, body.password, app.config.secrets.jwtSecret, app.config.auth);
16065
16212
  return reply.send(result);
16066
16213
  });
16067
16214
  app.post("/refresh", { config: { rateLimit: {
@@ -16069,7 +16216,7 @@ async function authRoutes(app) {
16069
16216
  timeWindow: "1 minute"
16070
16217
  } } }, async (request, reply) => {
16071
16218
  const body = refreshTokenSchema.parse(request.body);
16072
- const result = await refreshAccessToken(app.db, body.refreshToken, app.config.secrets.jwtSecret);
16219
+ const result = await refreshAccessToken(app.db, body.refreshToken, app.config.secrets.jwtSecret, app.config.auth);
16073
16220
  return reply.send(result);
16074
16221
  });
16075
16222
  app.post("/connect-token", { config: { rateLimit: {
@@ -16077,7 +16224,7 @@ async function authRoutes(app) {
16077
16224
  timeWindow: "1 minute"
16078
16225
  } } }, async (request, reply) => {
16079
16226
  const body = connectTokenExchangeSchema.parse(request.body);
16080
- const result = await exchangeConnectToken(app.db, body.token, app.config.secrets.jwtSecret);
16227
+ const result = await exchangeConnectToken(app.db, body.token, app.config.secrets.jwtSecret, app.config.auth);
16081
16228
  return reply.send(result);
16082
16229
  });
16083
16230
  }
@@ -16288,7 +16435,7 @@ async function meRoutes(app) {
16288
16435
  memberId: m.memberId,
16289
16436
  organizationId: m.organizationId,
16290
16437
  role: m.role
16291
- }, app.config.secrets.jwtSecret, issuer);
16438
+ }, app.config.secrets.jwtSecret, app.config.auth, issuer);
16292
16439
  return {
16293
16440
  token,
16294
16441
  expiresIn,
@@ -16338,7 +16485,7 @@ async function meRoutes(app) {
16338
16485
  memberId: created.memberId,
16339
16486
  organizationId: created.organizationId,
16340
16487
  role: "admin"
16341
- });
16488
+ }, app.config.auth);
16342
16489
  return reply.status(201).send({
16343
16490
  organization: {
16344
16491
  id: created.organizationId,
@@ -16380,7 +16527,7 @@ async function meRoutes(app) {
16380
16527
  memberId: member.id,
16381
16528
  organizationId: member.organizationId,
16382
16529
  role: member.role
16383
- });
16530
+ }, app.config.auth);
16384
16531
  return reply.status(200).send({
16385
16532
  organizationId: member.organizationId,
16386
16533
  memberId: member.id,
@@ -16426,7 +16573,7 @@ async function inferWizardStep(app, m) {
16426
16573
  * landing page.
16427
16574
  */
16428
16575
  async function publicInvitePreviewRoute(app) {
16429
- const { previewInvitation } = await import("./invitation-CBnQyB7o-DLQyW5ek.mjs");
16576
+ const { previewInvitation } = await import("./invitation-CBnQyB7o-Bulf3Sl7.mjs");
16430
16577
  app.get("/:token/preview", async (request, reply) => {
16431
16578
  if (!request.params.token) throw new UnauthorizedError("Token required");
16432
16579
  const preview = await previewInvitation(app.db, request.params.token);
@@ -16456,7 +16603,7 @@ async function adminInvitationRoutes(app) {
16456
16603
  const m = requireMember(request);
16457
16604
  if (m.role !== "admin") throw new ForbiddenError("Admin role required");
16458
16605
  if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
16459
- const { rotateInvitation } = await import("./invitation-CBnQyB7o-DLQyW5ek.mjs");
16606
+ const { rotateInvitation } = await import("./invitation-CBnQyB7o-Bulf3Sl7.mjs");
16460
16607
  const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
16461
16608
  return {
16462
16609
  id: inv.id,
@@ -18331,6 +18478,14 @@ function resolveCommandVersion$1(injected) {
18331
18478
  return "0.0.0";
18332
18479
  }
18333
18480
  async function buildApp(config) {
18481
+ try {
18482
+ expiryToSeconds(config.auth.accessTokenExpiry);
18483
+ expiryToSeconds(config.auth.refreshTokenExpiry);
18484
+ expiryToSeconds(config.auth.connectTokenExpiry);
18485
+ } catch (err) {
18486
+ const msg = err instanceof Error ? err.message : String(err);
18487
+ throw new Error(`${msg} — check FIRST_TREE_HUB_AUTH_*_EXPIRY env vars (got access=${config.auth.accessTokenExpiry}, refresh=${config.auth.refreshTokenExpiry}, connect=${config.auth.connectTokenExpiry}).`);
18488
+ }
18334
18489
  applyLoggerConfig({
18335
18490
  level: config.observability.logging.level,
18336
18491
  format: config.observability.logging.format,