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

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 resolveConfigReadonly, S as resetConfigMeta, T as setConfigValue, _ as initConfig, a as loadCredentials, c as saveAgentConfig, d as DEFAULT_DATA_DIR$1, f as DEFAULT_HOME_DIR$1, h as collectMissingPrompts, l as saveCredentials, m as clientConfigSchema, p as agentConfigSchema, r as ensureFreshAccessToken, s as resolveServerUrl, u as DEFAULT_CONFIG_DIR, v as loadAgents, w as serverConfigSchema, x as resetConfig, y as migrateLegacyHome } from "./bootstrap-B2x4TTyJ.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";
@@ -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",
@@ -2061,7 +2081,6 @@ var ClientConnection = class extends EventEmitter {
2061
2081
  }, WS_CONNECT_TIMEOUT_MS);
2062
2082
  ws.on("open", async () => {
2063
2083
  this.ws = ws;
2064
- this.reconnectAttempt = 0;
2065
2084
  this.wsLogger.debug("socket opened, sending auth");
2066
2085
  try {
2067
2086
  const token = await this.getAccessToken({ minValidityMs: AUTH_REFRESH_LEAD_MS + 5e3 });
@@ -2072,7 +2091,12 @@ var ClientConnection = class extends EventEmitter {
2072
2091
  this.scheduleProactiveAuthRefresh(token);
2073
2092
  } catch (err) {
2074
2093
  this.authLogger.error({ err }, "failed to obtain access token");
2075
- settle(reject, err instanceof Error ? err : new Error(String(err)));
2094
+ const e = err instanceof Error ? err : new Error(String(err));
2095
+ if (e.name === "AuthRefreshFailedError") {
2096
+ this.closing = true;
2097
+ this.emit("auth:fatal", e);
2098
+ }
2099
+ settle(reject, e);
2076
2100
  ws.close();
2077
2101
  }
2078
2102
  });
@@ -2168,6 +2192,7 @@ var ClientConnection = class extends EventEmitter {
2168
2192
  if (type === "client:registered") {
2169
2193
  const isReconnect = this.boundAgents.size > 0 || this.desiredBindings.size > 0;
2170
2194
  this.registered = true;
2195
+ this.reconnectAttempt = 0;
2171
2196
  this.startHeartbeat();
2172
2197
  this.wsLogger.info({ isReconnect }, "registered");
2173
2198
  this.emit("connected");
@@ -2320,6 +2345,7 @@ var ClientConnection = class extends EventEmitter {
2320
2345
  });
2321
2346
  }
2322
2347
  scheduleReconnect() {
2348
+ if (this.closing) return;
2323
2349
  this.reconnectAttempt++;
2324
2350
  this.emit("reconnecting", this.reconnectAttempt);
2325
2351
  const delay = Math.min(RECONNECT_BASE_MS * 2 ** (this.reconnectAttempt - 1), RECONNECT_MAX_MS);
@@ -6267,6 +6293,14 @@ var ClientRuntime = class {
6267
6293
  this.connection.on("auth:expired", () => {
6268
6294
  print.status("⚠️", "access token expired — reconnecting after refresh...");
6269
6295
  });
6296
+ this.connection.on("auth:fatal", (err) => {
6297
+ print.blank();
6298
+ print.status("✗", "auth expired — service is shutting down to break the reconnect loop.");
6299
+ print.status("", err.message);
6300
+ print.status("", "Recovery: get a new connect token from your Hub's Web admin");
6301
+ print.status("", " (Computers → + New Connection), then re-run the command shown.");
6302
+ process.exit(75);
6303
+ });
6270
6304
  this.connection.on("error", (err) => {
6271
6305
  print.status("⚠️", `client connection error: ${err.message}`);
6272
6306
  });
@@ -8064,7 +8098,7 @@ async function onboardCreate(args) {
8064
8098
  }
8065
8099
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
8066
8100
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
8067
- const { bindFeishuBot } = await import("./feishu-viiZmwcn.mjs").then((n) => n.r);
8101
+ const { bindFeishuBot } = await import("./feishu-DQ1l18Ah.mjs").then((n) => n.r);
8068
8102
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
8069
8103
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
8070
8104
  else {
@@ -9044,7 +9078,7 @@ function createFeedbackHandler(config) {
9044
9078
  return { handle };
9045
9079
  }
9046
9080
  //#endregion
9047
- //#region ../server/dist/app-O9kCTpaF.mjs
9081
+ //#region ../server/dist/app-Le92-WQA.mjs
9048
9082
  var __defProp = Object.defineProperty;
9049
9083
  var __exportAll = (all, no_symbols) => {
9050
9084
  let target = {};
@@ -9183,7 +9217,32 @@ function requireMember(request) {
9183
9217
  * - Manageability distinguishes roles: admin can manage all, member only their own.
9184
9218
  * - All conditions include organizationId scoping to prevent cross-org access.
9185
9219
  */
9186
- /** Extract MemberScope from an authenticated request. Single definition, used by all routes. */
9220
+ /**
9221
+ * Extract MemberScope from an authenticated request. Single definition, used by all routes.
9222
+ *
9223
+ * **Org-scoped admin routes must NOT use the result of this directly for
9224
+ * data filtering.** The returned scope is keyed to the JWT default member,
9225
+ * which is whatever org `auth.login` happened to pick at issuance time —
9226
+ * not the org the user has selected in the dropdown (`localStorage.
9227
+ * selectedOrganizationId`). Pair every `memberScope(request)` in
9228
+ * `packages/server/src/api/admin/*.ts` with one of:
9229
+ *
9230
+ * - `resolveAdminScope(db, request, baseScope, request.query.organizationId)`
9231
+ * for routes that accept `?organizationId=` and want a uniformly rotated
9232
+ * scope (every field keyed to the target org).
9233
+ * - `requireMemberInOrg(db, request, orgId)` when only the membership
9234
+ * `(memberId, role, agentId)` is needed for a specific org (e.g. the
9235
+ * creator HUMAN in chat-create).
9236
+ * - `assertCanManage(db, scope, agentUuid)` / `assertAgentVisible(db, scope, agentUuid)`
9237
+ * for routes operating on a single agent — both already rebind authority
9238
+ * to the agent's own org.
9239
+ *
9240
+ * `__tests__/admin-routes-org-scope-invariant.test.ts` pins this rule via a
9241
+ * grep over the admin route directory. If you intentionally land an admin
9242
+ * read/write route that targets only the JWT default org (no
9243
+ * cross-org switch awareness), update the whitelist there with a comment
9244
+ * explaining why.
9245
+ */
9187
9246
  function memberScope(request) {
9188
9247
  const m = requireMember(request);
9189
9248
  return {
@@ -9300,12 +9359,14 @@ async function requireMemberInOrg(db, request, orgId) {
9300
9359
  const m = requireMember(request);
9301
9360
  const [row] = await db.select({
9302
9361
  id: members.id,
9303
- role: members.role
9362
+ role: members.role,
9363
+ agentId: members.agentId
9304
9364
  }).from(members).where(and(eq(members.userId, m.userId), eq(members.organizationId, orgId), eq(members.status, "active"))).limit(1);
9305
9365
  if (!row) throw new ForbiddenError("Not an active member of the target organization");
9306
9366
  return {
9307
9367
  memberId: row.id,
9308
- role: row.role
9368
+ role: row.role,
9369
+ agentId: row.agentId
9309
9370
  };
9310
9371
  }
9311
9372
  /**
@@ -9330,6 +9391,7 @@ async function resolveAdminScope(db, request, scope, requestedOrganizationId) {
9330
9391
  return {
9331
9392
  ...scope,
9332
9393
  memberId: probe.memberId,
9394
+ humanAgentId: probe.agentId,
9333
9395
  organizationId: requestedOrganizationId,
9334
9396
  role: probe.role
9335
9397
  };
@@ -11087,6 +11149,29 @@ async function listClientsForOrgAdmin(db, orgId) {
11087
11149
  metadata: clients.metadata
11088
11150
  }).from(clients).innerJoin(members, eq(members.userId, clients.userId)).where(and(eq(members.organizationId, orgId), eq(members.status, "active"))));
11089
11151
  }
11152
+ /**
11153
+ * Infer whether the client's locally-cached refresh token can plausibly
11154
+ * still mint access tokens. Used by the Web admin dashboard to render an
11155
+ * "AUTH EXPIRED" pill on rows whose offline duration has exceeded the
11156
+ * server's configured refresh-token TTL.
11157
+ *
11158
+ * Uses `lastSeenAt` (not `connectedAt`) because a healthy long-lived
11159
+ * client slides the refresh token continuously, so the absolute connect
11160
+ * time is no proxy for liveness. `lastSeenAt` is updated on register,
11161
+ * heartbeat, and the final disconnect — it lower-bounds the issue time
11162
+ * of the refresh token the client most likely still holds.
11163
+ *
11164
+ * Pure function, no DB access; the column-less design means there's no
11165
+ * server-side revocation path yet — every "expired" decision is purely
11166
+ * time-based. If we ever want admin-driven revocation, add a column
11167
+ * back and OR its value into this function.
11168
+ */
11169
+ function deriveAuthState(row, refreshTokenExpirySeconds) {
11170
+ if (row.status === "disconnected") {
11171
+ if (Date.now() - row.lastSeenAt.getTime() > refreshTokenExpirySeconds * 1e3) return "expired";
11172
+ }
11173
+ return "ok";
11174
+ }
11090
11175
  async function attachAgentCounts(db, rows) {
11091
11176
  const counts = await db.select({
11092
11177
  clientId: agents.clientId,
@@ -12025,13 +12110,23 @@ async function adminAgentRoutes(app) {
12025
12110
  connection
12026
12111
  });
12027
12112
  });
12028
- /** POST /admin/agents/:uuid/chats — create a new workspace chat with the target agent */
12113
+ /** POST /admin/agents/:uuid/chats — create a new workspace chat with the target agent.
12114
+ *
12115
+ * The chat creator is the user's HUMAN agent in the *target agent's* org,
12116
+ * not in the JWT default org. Otherwise a multi-org user creating a chat
12117
+ * with an agent outside their JWT default org gets `createChat`'s
12118
+ * cross-organization guard ("Cross-organization chat not allowed: …"),
12119
+ * even though the human is a member of the target agent's org.
12120
+ * Symptom hit by the inline onboarding flow when the user creates a new
12121
+ * agent in a non-default org and the auto-chat step fires.
12122
+ */
12029
12123
  app.post("/:uuid/chats", async (request, reply) => {
12030
12124
  const { uuid: targetAgentId } = request.params;
12031
12125
  const scope = memberScope(request);
12032
12126
  await assertAgentVisible(app.db, scope, targetAgentId);
12033
- const member = requireMember(request);
12034
- const result = await createChat(app.db, member.agentId, {
12127
+ const targetAgent = await getAgent(app.db, targetAgentId);
12128
+ const probe = await requireMemberInOrg(app.db, request, targetAgent.organizationId);
12129
+ const result = await createChat(app.db, probe.agentId, {
12035
12130
  type: "direct",
12036
12131
  participantIds: [targetAgentId]
12037
12132
  });
@@ -12353,6 +12448,167 @@ async function adminChatRoutes(app) {
12353
12448
  });
12354
12449
  });
12355
12450
  }
12451
+ /** In-memory set of consumed connect token JTIs. Entries auto-expire after 10 minutes. */
12452
+ const consumedConnectJtis = /* @__PURE__ */ new Map();
12453
+ const CONNECT_JTI_TTL_MS = 6e5;
12454
+ async function signToken(secret, payload, expiry) {
12455
+ return new SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setJti(randomUUID()).setExpirationTime(expiry).sign(secret);
12456
+ }
12457
+ /**
12458
+ * Convert an `ms`-style expiry string (e.g. `"30m"`, `"30d"`, `"1w"`) to
12459
+ * seconds. Used to surface the connect token's `expiresIn` in the API
12460
+ * response. Mirrors the subset of `jose.setExpirationTime` we use; falls
12461
+ * through with a clear error on malformed config so a typo in the env var
12462
+ * surfaces at boot, not days later when the first token expires.
12463
+ */
12464
+ function expiryToSeconds(expiry) {
12465
+ const m = /^(\d+)\s*(s|m|h|d|w)$/.exec(expiry.trim());
12466
+ if (!m) throw new Error(`Invalid expiry "${expiry}" — expected forms like "30s", "10m", "2h", "30d", "1w".`);
12467
+ return Number(m[1]) * {
12468
+ s: 1,
12469
+ m: 60,
12470
+ h: 3600,
12471
+ d: 86400,
12472
+ w: 604800
12473
+ }[m[2]];
12474
+ }
12475
+ /**
12476
+ * Sign an `(access, refresh)` pair for the given member. Used by both the
12477
+ * legacy username/password login path and the SaaS GitHub OAuth callback,
12478
+ * so the issuance shape stays in one place.
12479
+ */
12480
+ async function signTokensForMember(jwtSecretKey, member, expiries) {
12481
+ const secret = new TextEncoder().encode(jwtSecretKey);
12482
+ const tokenBase = {
12483
+ sub: member.userId,
12484
+ memberId: member.memberId,
12485
+ organizationId: member.organizationId,
12486
+ role: member.role
12487
+ };
12488
+ return {
12489
+ accessToken: await signToken(secret, {
12490
+ ...tokenBase,
12491
+ type: "access"
12492
+ }, expiries.accessTokenExpiry),
12493
+ refreshToken: await signToken(secret, {
12494
+ ...tokenBase,
12495
+ type: "refresh"
12496
+ }, expiries.refreshTokenExpiry)
12497
+ };
12498
+ }
12499
+ async function login(db, username, password, jwtSecretKey, expiries) {
12500
+ const [user] = await db.select().from(users).where(eq(users.username, username)).limit(1);
12501
+ if (!user || user.status !== "active") throw new UnauthorizedError("Invalid username or password");
12502
+ if (!await bcrypt.compare(password, user.passwordHash)) throw new UnauthorizedError("Invalid username or password");
12503
+ 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);
12504
+ if (!member) throw new UnauthorizedError("No organization membership found");
12505
+ const tokens = await signTokensForMember(jwtSecretKey, {
12506
+ userId: user.id,
12507
+ memberId: member.id,
12508
+ organizationId: member.organizationId,
12509
+ role: member.role
12510
+ }, expiries);
12511
+ await db.update(users).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(users.id, user.id));
12512
+ return tokens;
12513
+ }
12514
+ /**
12515
+ * Refresh an access token. Sliding-window: the response also carries a
12516
+ * fresh refresh token whose lifetime restarts from now, so an actively-used
12517
+ * client never hits the absolute `refreshTokenExpiry`. The previously
12518
+ * issued refresh token remains valid until its own `exp` — we deliberately
12519
+ * do **not** maintain a server-side jti revocation set. Same-process
12520
+ * concurrent refreshes therefore both succeed; the surviving refresh token
12521
+ * is whichever one the client persists last. The alternative (server-side
12522
+ * invalidation) is more defensive against token theft but introduces races
12523
+ * across systemd-supervised restarts and reconnect storms — a tradeoff
12524
+ * we're not paying for here.
12525
+ */
12526
+ async function refreshAccessToken(db, refreshToken, jwtSecretKey, expiries) {
12527
+ const secret = new TextEncoder().encode(jwtSecretKey);
12528
+ let payload;
12529
+ try {
12530
+ const { payload: p } = await jwtVerify(refreshToken, secret);
12531
+ payload = p;
12532
+ } catch {
12533
+ throw new UnauthorizedError("Invalid or expired refresh token");
12534
+ }
12535
+ if (payload.type !== "refresh" || !payload.sub) throw new UnauthorizedError("Invalid token type");
12536
+ const [user] = await db.select({
12537
+ id: users.id,
12538
+ status: users.status
12539
+ }).from(users).where(eq(users.id, payload.sub)).limit(1);
12540
+ if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
12541
+ const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
12542
+ if (!member) throw new UnauthorizedError("Membership not found");
12543
+ return signTokensForMember(jwtSecretKey, {
12544
+ userId: user.id,
12545
+ memberId: member.id,
12546
+ organizationId: member.organizationId,
12547
+ role: member.role
12548
+ }, expiries);
12549
+ }
12550
+ /**
12551
+ * Generate a short-lived connect token for CLI authentication.
12552
+ * The connect token carries the member's identity and can be exchanged
12553
+ * for full access+refresh tokens via exchangeConnectToken().
12554
+ *
12555
+ * `iss` (when supplied) is stamped into the JWT so the CLI can derive
12556
+ * the hub URL with no additional argument. Production servers must
12557
+ * always pass it; dev callers may omit and the CLI will require an
12558
+ * explicit `--server-url` (legacy form).
12559
+ */
12560
+ async function generateConnectToken(member, jwtSecretKey, expiries, iss) {
12561
+ const secret = new TextEncoder().encode(jwtSecretKey);
12562
+ const jti = randomUUID();
12563
+ const builder = new SignJWT({
12564
+ sub: member.userId,
12565
+ memberId: member.memberId,
12566
+ organizationId: member.organizationId,
12567
+ role: member.role,
12568
+ type: "connect"
12569
+ }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setJti(jti).setExpirationTime(expiries.connectTokenExpiry);
12570
+ if (iss) builder.setIssuer(iss);
12571
+ return {
12572
+ token: await builder.sign(secret),
12573
+ expiresIn: expiryToSeconds(expiries.connectTokenExpiry)
12574
+ };
12575
+ }
12576
+ /**
12577
+ * Exchange a connect token for full access+refresh tokens.
12578
+ * Validates the connect token, verifies the user is still active,
12579
+ * and issues a fresh token pair.
12580
+ */
12581
+ async function exchangeConnectToken(db, connectToken, jwtSecretKey, expiries) {
12582
+ const secret = new TextEncoder().encode(jwtSecretKey);
12583
+ let payload;
12584
+ try {
12585
+ const { payload: p } = await jwtVerify(connectToken, secret);
12586
+ payload = p;
12587
+ } catch {
12588
+ throw new UnauthorizedError("Invalid or expired connect token");
12589
+ }
12590
+ if (payload.type !== "connect" || !payload.sub || !payload.memberId) throw new UnauthorizedError("Invalid token type — expected connect token");
12591
+ const jti = payload.jti;
12592
+ if (jti) {
12593
+ if (consumedConnectJtis.has(jti)) throw new UnauthorizedError("Connect token has already been used");
12594
+ consumedConnectJtis.set(jti, Date.now());
12595
+ const cutoff = Date.now() - CONNECT_JTI_TTL_MS;
12596
+ for (const [k, ts] of consumedConnectJtis) if (ts < cutoff) consumedConnectJtis.delete(k);
12597
+ }
12598
+ const [user] = await db.select({
12599
+ id: users.id,
12600
+ status: users.status
12601
+ }).from(users).where(eq(users.id, payload.sub)).limit(1);
12602
+ if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
12603
+ const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
12604
+ if (!member) throw new UnauthorizedError("Membership not found");
12605
+ return signTokensForMember(jwtSecretKey, {
12606
+ userId: user.id,
12607
+ memberId: member.id,
12608
+ organizationId: member.organizationId,
12609
+ role: member.role
12610
+ }, expiries);
12611
+ }
12356
12612
  /** Serialize a Date to ISO string, or null. */
12357
12613
  function serializeDate(d) {
12358
12614
  return d ? d.toISOString() : null;
@@ -12362,13 +12618,16 @@ async function adminClientRoutes(app) {
12362
12618
  app.get("/", async (request) => {
12363
12619
  const scope = memberScope(request);
12364
12620
  const { organizationId } = listClientsQuerySchema.parse(request.query);
12365
- return (organizationId ? await (async () => {
12621
+ const clients = organizationId ? await (async () => {
12366
12622
  if ((await requireMemberInOrg(app.db, request, organizationId)).role !== "admin") throw new ForbiddenError("Admin role required");
12367
12623
  return listClientsForOrgAdmin(app.db, organizationId);
12368
- })() : await listClients(app.db, { userId: scope.userId })).map((c) => ({
12624
+ })() : await listClients(app.db, { userId: scope.userId });
12625
+ const refreshExpirySeconds = expiryToSeconds(app.config.auth.refreshTokenExpiry);
12626
+ return clients.map((c) => ({
12369
12627
  id: c.id,
12370
12628
  userId: c.userId,
12371
12629
  status: c.status,
12630
+ authState: deriveAuthState(c, refreshExpirySeconds),
12372
12631
  sdkVersion: c.sdkVersion,
12373
12632
  hostname: c.hostname,
12374
12633
  os: c.os,
@@ -12395,10 +12654,12 @@ async function adminClientRoutes(app) {
12395
12654
  if (!client) throw new Error("unreachable: client missing after owner check");
12396
12655
  const metadata = client.metadata ?? {};
12397
12656
  const capabilities = metadata.capabilities && typeof metadata.capabilities === "object" ? metadata.capabilities : {};
12657
+ const refreshExpirySeconds = expiryToSeconds(app.config.auth.refreshTokenExpiry);
12398
12658
  return {
12399
12659
  id: client.id,
12400
12660
  userId: client.userId,
12401
12661
  status: client.status,
12662
+ authState: deriveAuthState(client, refreshExpirySeconds),
12402
12663
  sdkVersion: client.sdkVersion,
12403
12664
  hostname: client.hostname,
12404
12665
  os: client.os,
@@ -12428,9 +12689,12 @@ async function adminClientRoutes(app) {
12428
12689
  return reply.status(204).send();
12429
12690
  });
12430
12691
  }
12692
+ const activityQuerySchema = z.object({ organizationId: z.string().min(1).optional() });
12431
12693
  async function adminActivityRoutes(app) {
12432
12694
  app.get("/", async (request) => {
12433
- const scope = memberScope(request);
12695
+ const baseScope = memberScope(request);
12696
+ const { organizationId } = activityQuerySchema.parse(request.query);
12697
+ const scope = await resolveAdminScope(app.db, request, baseScope, organizationId);
12434
12698
  const overview = await getActivityOverview(app.db);
12435
12699
  const runningAgents = await listAgentsWithRuntime(app.db, scope);
12436
12700
  return {
@@ -15326,141 +15590,6 @@ function clientWsRoutes(notifier, instanceId) {
15326
15590
  });
15327
15591
  };
15328
15592
  }
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
15593
  /**
15465
15594
  * Third-party / local auth identities for a user. Models "how does this user
15466
15595
  * prove they are who they say they are". A single user MAY have multiple
@@ -16045,7 +16174,7 @@ async function completeOauthFlow(app, request, reply, profile, next) {
16045
16174
  memberId: memberInfo.memberId,
16046
16175
  organizationId: memberInfo.organizationId,
16047
16176
  role: memberInfo.role
16048
- });
16177
+ }, app.config.auth);
16049
16178
  const fragment = new URLSearchParams({
16050
16179
  access: tokens.accessToken,
16051
16180
  refresh: tokens.refreshToken,
@@ -16061,7 +16190,7 @@ async function authRoutes(app) {
16061
16190
  timeWindow: "1 minute"
16062
16191
  } } }, async (request, reply) => {
16063
16192
  const body = loginSchema.parse(request.body);
16064
- const result = await login(app.db, body.username, body.password, app.config.secrets.jwtSecret);
16193
+ const result = await login(app.db, body.username, body.password, app.config.secrets.jwtSecret, app.config.auth);
16065
16194
  return reply.send(result);
16066
16195
  });
16067
16196
  app.post("/refresh", { config: { rateLimit: {
@@ -16069,7 +16198,7 @@ async function authRoutes(app) {
16069
16198
  timeWindow: "1 minute"
16070
16199
  } } }, async (request, reply) => {
16071
16200
  const body = refreshTokenSchema.parse(request.body);
16072
- const result = await refreshAccessToken(app.db, body.refreshToken, app.config.secrets.jwtSecret);
16201
+ const result = await refreshAccessToken(app.db, body.refreshToken, app.config.secrets.jwtSecret, app.config.auth);
16073
16202
  return reply.send(result);
16074
16203
  });
16075
16204
  app.post("/connect-token", { config: { rateLimit: {
@@ -16077,7 +16206,7 @@ async function authRoutes(app) {
16077
16206
  timeWindow: "1 minute"
16078
16207
  } } }, async (request, reply) => {
16079
16208
  const body = connectTokenExchangeSchema.parse(request.body);
16080
- const result = await exchangeConnectToken(app.db, body.token, app.config.secrets.jwtSecret);
16209
+ const result = await exchangeConnectToken(app.db, body.token, app.config.secrets.jwtSecret, app.config.auth);
16081
16210
  return reply.send(result);
16082
16211
  });
16083
16212
  }
@@ -16288,7 +16417,7 @@ async function meRoutes(app) {
16288
16417
  memberId: m.memberId,
16289
16418
  organizationId: m.organizationId,
16290
16419
  role: m.role
16291
- }, app.config.secrets.jwtSecret, issuer);
16420
+ }, app.config.secrets.jwtSecret, app.config.auth, issuer);
16292
16421
  return {
16293
16422
  token,
16294
16423
  expiresIn,
@@ -16338,7 +16467,7 @@ async function meRoutes(app) {
16338
16467
  memberId: created.memberId,
16339
16468
  organizationId: created.organizationId,
16340
16469
  role: "admin"
16341
- });
16470
+ }, app.config.auth);
16342
16471
  return reply.status(201).send({
16343
16472
  organization: {
16344
16473
  id: created.organizationId,
@@ -16380,7 +16509,7 @@ async function meRoutes(app) {
16380
16509
  memberId: member.id,
16381
16510
  organizationId: member.organizationId,
16382
16511
  role: member.role
16383
- });
16512
+ }, app.config.auth);
16384
16513
  return reply.status(200).send({
16385
16514
  organizationId: member.organizationId,
16386
16515
  memberId: member.id,
@@ -16426,7 +16555,7 @@ async function inferWizardStep(app, m) {
16426
16555
  * landing page.
16427
16556
  */
16428
16557
  async function publicInvitePreviewRoute(app) {
16429
- const { previewInvitation } = await import("./invitation-CBnQyB7o-DLQyW5ek.mjs");
16558
+ const { previewInvitation } = await import("./invitation-CBnQyB7o-Bulf3Sl7.mjs");
16430
16559
  app.get("/:token/preview", async (request, reply) => {
16431
16560
  if (!request.params.token) throw new UnauthorizedError("Token required");
16432
16561
  const preview = await previewInvitation(app.db, request.params.token);
@@ -16456,7 +16585,7 @@ async function adminInvitationRoutes(app) {
16456
16585
  const m = requireMember(request);
16457
16586
  if (m.role !== "admin") throw new ForbiddenError("Admin role required");
16458
16587
  if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
16459
- const { rotateInvitation } = await import("./invitation-CBnQyB7o-DLQyW5ek.mjs");
16588
+ const { rotateInvitation } = await import("./invitation-CBnQyB7o-Bulf3Sl7.mjs");
16460
16589
  const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
16461
16590
  return {
16462
16591
  id: inv.id,
@@ -18331,6 +18460,14 @@ function resolveCommandVersion$1(injected) {
18331
18460
  return "0.0.0";
18332
18461
  }
18333
18462
  async function buildApp(config) {
18463
+ try {
18464
+ expiryToSeconds(config.auth.accessTokenExpiry);
18465
+ expiryToSeconds(config.auth.refreshTokenExpiry);
18466
+ expiryToSeconds(config.auth.connectTokenExpiry);
18467
+ } catch (err) {
18468
+ const msg = err instanceof Error ? err.message : String(err);
18469
+ 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}).`);
18470
+ }
18334
18471
  applyLoggerConfig({
18335
18472
  level: config.observability.logging.level,
18336
18473
  format: config.observability.logging.format,