@agent-team-foundation/first-tree-hub 0.10.1 → 0.10.2

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.
Files changed (24) hide show
  1. package/dist/{bootstrap-CtVqQA8a.mjs → bootstrap-Ca5Fiqz6.mjs} +10 -1
  2. package/dist/cli/index.mjs +9 -19
  3. package/dist/{feishu-DEmwoNn_.mjs → dist-CLiN7cVS.mjs} +88 -51
  4. package/dist/drizzle/0026_saas_onboarding.sql +153 -0
  5. package/dist/drizzle/meta/_journal.json +7 -0
  6. package/dist/feishu-FTWnoOsc.mjs +52 -0
  7. package/dist/{getMachineId-bsd-BB-fnFLA.mjs → getMachineId-bsd-D0w3uAZa.mjs} +1 -1
  8. package/dist/{getMachineId-darwin-DAYWNsYK.mjs → getMachineId-darwin-DOoYFb2_.mjs} +1 -1
  9. package/dist/{getMachineId-win-H5RT49ov.mjs → getMachineId-win-B6hY8edq.mjs} +1 -1
  10. package/dist/index.mjs +7 -5
  11. package/dist/invitation-BTlGMy0o-dIoR8JRj.mjs +3 -0
  12. package/dist/invitation-C_zAhB8x-8Khychlu.mjs +258 -0
  13. package/dist/{observability-DDkJwSKv.mjs → observability-C08jUFsJ.mjs} +1 -1
  14. package/dist/{observability-DV_fQKqV-oxfXX6Z2.mjs → observability-DPyf745N-BSc8QNcR.mjs} +6 -6
  15. package/dist/{core-BgiFGT7Y.mjs → saas-connect-idjpoPTk.mjs} +1111 -153
  16. package/dist/web/assets/index-CEAPwdg7.js +377 -0
  17. package/dist/web/assets/index-CzWeWItA.css +1 -0
  18. package/dist/web/index.html +2 -2
  19. package/package.json +1 -1
  20. package/dist/web/assets/index-Cd290Lq6.css +0 -1
  21. package/dist/web/assets/index-xi7JmCtW.js +0 -361
  22. /package/dist/{execAsync-CP8iWV5b.mjs → execAsync-XMc-nFn-.mjs} +0 -0
  23. /package/dist/{getMachineId-linux-BU7Fi6S0.mjs → getMachineId-linux-MlY63Zsw.mjs} +0 -0
  24. /package/dist/{getMachineId-unsupported-BhWCxKBo.mjs → getMachineId-unsupported-BS652RIy.mjs} +0 -0
@@ -1,7 +1,8 @@
1
1
  import { m as __toESM } from "./esm-CYu4tXXn.mjs";
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-DV_fQKqV-oxfXX6Z2.mjs";
3
- import { C as serverConfigSchema, S as resolveConfigReadonly, _ as loadAgents, 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 } from "./bootstrap-CtVqQA8a.mjs";
4
- import { $ as sessionEventMessageSchema, A as createChatSchema, B as isReservedAgentName$1, C as agentRuntimeConfigPayloadSchema$1, D as createAdapterConfigSchema, E as connectTokenExchangeSchema, F as dryRunAgentRuntimeConfigSchema, G as paginationQuerySchema, H as loginSchema, I as extractMentions, J as scanMentionTokens, K as refreshTokenSchema, L as imageInlineContentSchema, M as createOrganizationSchema, N as createTaskSchema, O as createAdapterMappingSchema, P as delegateFeishuUserSchema, Q as sessionCompletionMessageSchema, R as inboxPollQuerySchema, S as agentPinnedMessageSchema$1, T as clientRegisterSchema, U as messageSourceSchema$1, V as linkTaskChatSchema, W as notificationQuerySchema, X as sendMessageSchema, Y as selfServiceFeishuBotSchema, Z as sendToAgentSchema, _ as WS_AUTH_FRAME_TIMEOUT_MS, a as AGENT_NAME_REGEX$1, at as updateAgentRuntimeConfigSchema, b as adminUpdateTaskSchema, c as AGENT_STATUSES, ct as updateMemberSchema, d as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, dt as updateTaskStatusSchema, et as sessionEventSchema$1, f as SYSTEM_CONFIG_DEFAULTS, ft as wsAuthFrameSchema, g as TASK_TERMINAL_STATUSES, h as TASK_STATUSES, i as AGENT_BIND_REJECT_REASONS, it as updateAdapterConfigSchema, j as createMemberSchema, k as createAgentSchema, l as AGENT_TYPES, lt as updateOrganizationSchema, m as TASK_HEALTH_SIGNALS, nt as sessionStateMessageSchema, o as AGENT_SELECTOR_HEADER$1, ot as updateAgentSchema, p as TASK_CREATOR_TYPES, q as runtimeStateMessageSchema, rt as taskListQuerySchema, s as AGENT_SOURCES, st as updateChatSchema, tt as sessionReconcileRequestSchema, u as AGENT_VISIBILITY, ut as updateSystemConfigSchema, v as addParticipantSchema, w as agentTypeSchema$1, x as agentBindRequestSchema, y as adminCreateTaskSchema, z as isRedactedEnvValue } from "./feishu-DEmwoNn_.mjs";
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-Ca5Fiqz6.mjs";
4
+ import { $ as sendMessageSchema, A as createOrganizationSchema, B as isRedactedEnvValue, C as connectTokenExchangeSchema, D as createChatSchema, E as createAgentSchema, F as githubCallbackQuerySchema, G as messageSourceSchema$1, H as joinByInvitationSchema, I as githubDevCallbackQuerySchema, J as refreshTokenSchema, K as notificationQuerySchema, L as githubStartQuerySchema, M as delegateFeishuUserSchema, N as dryRunAgentRuntimeConfigSchema, O as createMemberSchema, P as extractMentions, Q as selfServiceFeishuBotSchema, R as imageInlineContentSchema, S as clientRegisterSchema, T as createAdapterMappingSchema, U as linkTaskChatSchema, V as isReservedAgentName$1, W as loginSchema, X as safeRedirectPath, Y as runtimeStateMessageSchema, Z as scanMentionTokens, _ as adminUpdateTaskSchema, a as AGENT_STATUSES, at as sessionStateMessageSchema, b as agentRuntimeConfigPayloadSchema$1, c as DEFAULT_AGENT_RUNTIME_CONFIG_PAYLOAD, ct as updateAdapterConfigSchema, d as TASK_HEALTH_SIGNALS, dt as updateChatSchema, et as sendToAgentSchema, f as TASK_STATUSES, ft as updateMemberSchema, g as adminCreateTaskSchema, gt as wsAuthFrameSchema, h as addParticipantSchema, ht as updateTaskStatusSchema, i as AGENT_SOURCES, it as sessionReconcileRequestSchema, j as createTaskSchema, k as createOrgFromMeSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as updateAgentRuntimeConfigSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateSystemConfigSchema, n as AGENT_NAME_REGEX$1, nt as sessionEventMessageSchema, o as AGENT_TYPES, ot as switchOrgSchema, p as TASK_TERMINAL_STATUSES, pt as updateOrganizationSchema, q as paginationQuerySchema, r as AGENT_SELECTOR_HEADER$1, rt as sessionEventSchema$1, s as AGENT_VISIBILITY, st as taskListQuerySchema, t as AGENT_BIND_REJECT_REASONS, tt as sessionCompletionMessageSchema, u as TASK_CREATOR_TYPES, ut as updateAgentSchema, v as agentBindRequestSchema, w as createAdapterConfigSchema, x as agentTypeSchema$1, y as agentPinnedMessageSchema$1, z as inboxPollQuerySchema } from "./dist-CLiN7cVS.mjs";
5
+ import { a as ForbiddenError, c as buildInviteUrl, d as getActiveInvitation, f as invitationRedemptions, g as recordRedemption, i as ConflictError, l as ensureActiveInvitation, m as organizations, n as BadRequestError, o as NotFoundError, p as invitations, r as ClientOrgMismatchError$1, s as UnauthorizedError, t as AppError, u as findActiveByToken, v as users, y as uuidv7 } from "./invitation-C_zAhB8x-8Khychlu.mjs";
5
6
  import { createRequire } from "node:module";
6
7
  import { ZodError, z } from "zod";
7
8
  import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
@@ -23,12 +24,12 @@ import postgres from "postgres";
23
24
  import { confirm, input, password, select } from "@inquirer/prompts";
24
25
  import { fileURLToPath } from "node:url";
25
26
  import { migrate } from "drizzle-orm/postgres-js/migrator";
27
+ import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
26
28
  import cors from "@fastify/cors";
27
29
  import rateLimit from "@fastify/rate-limit";
28
30
  import fastifyStatic from "@fastify/static";
29
31
  import websocket from "@fastify/websocket";
30
32
  import Fastify from "fastify";
31
- import { bigserial, boolean, index, integer, jsonb, pgTable, primaryKey, serial, text, timestamp, unique, uniqueIndex } from "drizzle-orm/pg-core";
32
33
  import { SignJWT, jwtVerify } from "jose";
33
34
  import { Client, EventDispatcher, LoggerLevel, WSClient } from "@larksuiteoapi/node-sdk";
34
35
  //#region ../client/dist/observability-B4kO005X.mjs
@@ -897,6 +898,39 @@ z.object({
897
898
  ackedAt: z.string().nullable()
898
899
  }).extend({ message: clientMessageSchema });
899
900
  z.object({ limit: z.coerce.number().int().min(1).max(50).default(10) });
901
+ z.object({
902
+ organizationId: z.string(),
903
+ organizationName: z.string(),
904
+ organizationDisplayName: z.string(),
905
+ role: z.string()
906
+ });
907
+ z.object({
908
+ id: z.string(),
909
+ organizationId: z.string(),
910
+ token: z.string(),
911
+ inviteUrl: z.string(),
912
+ role: z.string(),
913
+ createdAt: z.string(),
914
+ expiresAt: z.string().nullable()
915
+ });
916
+ z.object({ token: z.string().min(1) });
917
+ z.object({}).optional();
918
+ z.enum([
919
+ "connect",
920
+ "create_agent",
921
+ "completed"
922
+ ]);
923
+ z.object({
924
+ id: z.string(),
925
+ name: z.string(),
926
+ displayName: z.string(),
927
+ role: z.enum(["admin", "member"])
928
+ });
929
+ z.object({
930
+ name: z.string().min(2).max(50).regex(/^[a-z0-9][a-z0-9-]*$/),
931
+ displayName: z.string().min(1).max(200)
932
+ });
933
+ z.object({ organizationId: z.string().min(1) });
900
934
  const memberRoleSchema = z.enum(["admin", "member"]);
901
935
  const memberSchema = z.object({
902
936
  id: z.string(),
@@ -953,6 +987,18 @@ z.object({
953
987
  read: z.enum(["true", "false"]).transform((v) => v === "true").optional(),
954
988
  agentId: z.string().optional()
955
989
  });
990
+ z.object({ next: z.string().max(256).optional() });
991
+ z.object({
992
+ code: z.string().min(1),
993
+ state: z.string().min(1)
994
+ });
995
+ z.object({
996
+ githubId: z.string().min(1),
997
+ login: z.string().min(1),
998
+ email: z.string().email().optional(),
999
+ displayName: z.string().optional(),
1000
+ next: z.string().max(256).optional()
1001
+ });
956
1002
  const UUID_PATTERN$1 = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
957
1003
  z.object({
958
1004
  name: z.string().min(2).max(50).regex(/^[a-z0-9][a-z0-9-]*$/, "Must start with a letter or digit and contain only lowercase alphanumeric and hyphens").refine((v) => !UUID_PATTERN$1.test(v), "Name must not be a UUID format"),
@@ -1330,7 +1376,8 @@ defineConfig({
1330
1376
  },
1331
1377
  server: {
1332
1378
  port: field(z.number().default(8e3), { env: "FIRST_TREE_HUB_PORT" }),
1333
- host: field(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" })
1379
+ host: field(z.string().default("127.0.0.1"), { env: "FIRST_TREE_HUB_HOST" }),
1380
+ publicUrl: field(z.string().optional(), { env: "FIRST_TREE_HUB_PUBLIC_URL" })
1334
1381
  },
1335
1382
  secrets: {
1336
1383
  jwtSecret: field(z.string(), {
@@ -1358,6 +1405,14 @@ defineConfig({
1358
1405
  }),
1359
1406
  allowedOrg: field(z.string().optional(), { env: "FIRST_TREE_HUB_GITHUB_ALLOWED_ORG" })
1360
1407
  },
1408
+ oauth: optional({ github: optional({
1409
+ clientId: field(z.string(), { env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_ID" }),
1410
+ clientSecret: field(z.string(), {
1411
+ env: "FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_SECRET",
1412
+ secret: true
1413
+ }),
1414
+ devCallbackEnabled: field(z.boolean().default(false), { env: "FIRST_TREE_HUB_GITHUB_OAUTH_DEV_CALLBACK" })
1415
+ }) }),
1361
1416
  cors: optional({ origin: field(z.string(), { env: "FIRST_TREE_HUB_CORS_ORIGIN" }) }),
1362
1417
  rateLimit: optional({
1363
1418
  max: field(z.number().default(100), { env: "FIRST_TREE_HUB_RATE_LIMIT_MAX" }),
@@ -1576,7 +1631,7 @@ var SdkError = class extends Error {
1576
1631
  * different organization. The CLI layer detects this via `instanceof` and
1577
1632
  * prompts the user before rotating the local clientId.
1578
1633
  */
1579
- var ClientOrgMismatchError$1 = class extends Error {
1634
+ var ClientOrgMismatchError = class extends Error {
1580
1635
  code = "CLIENT_ORG_MISMATCH";
1581
1636
  constructor(message = "Client belongs to a different organization") {
1582
1637
  super(message);
@@ -1886,7 +1941,7 @@ var ClientConnection = class extends EventEmitter {
1886
1941
  const code = typeof msg.code === "string" ? msg.code : void 0;
1887
1942
  const message = typeof msg.message === "string" ? msg.message : "unknown";
1888
1943
  this.closing = true;
1889
- const err = code === "CLIENT_ORG_MISMATCH" ? new ClientOrgMismatchError$1(message) : /* @__PURE__ */ new Error(`client:register rejected: ${message}`);
1944
+ const err = code === "CLIENT_ORG_MISMATCH" ? new ClientOrgMismatchError(message) : /* @__PURE__ */ new Error(`client:register rejected: ${message}`);
1890
1945
  this.lastHandshakeError = err;
1891
1946
  this.wsLogger.error({
1892
1947
  code,
@@ -4802,7 +4857,7 @@ function result(data) {
4802
4857
  data
4803
4858
  })}\n`);
4804
4859
  }
4805
- function fail(code, message, exitCode = 1) {
4860
+ function fail$1(code, message, exitCode = 1) {
4806
4861
  process.stderr.write(`${JSON.stringify({
4807
4862
  ok: false,
4808
4863
  error: {
@@ -4837,13 +4892,26 @@ function line(text) {
4837
4892
  }
4838
4893
  const print = {
4839
4894
  result,
4840
- fail,
4895
+ fail: fail$1,
4841
4896
  status,
4842
4897
  check,
4843
4898
  blank,
4844
4899
  line
4845
4900
  };
4846
4901
  //#endregion
4902
+ //#region src/cli/output.ts
4903
+ /**
4904
+ * CLI output re-exports. The underlying implementation lives in
4905
+ * `core/output.ts` (the Print layer). Keep these thin wrappers so callers that
4906
+ * only depend on `cli/output.ts` keep working during the migration.
4907
+ */
4908
+ function success(data) {
4909
+ print.result(data);
4910
+ }
4911
+ function fail(code, message, exitCode = 1) {
4912
+ return print.fail(code, message, exitCode);
4913
+ }
4914
+ //#endregion
4847
4915
  //#region src/core/agent-messaging.ts
4848
4916
  /**
4849
4917
  * Resolve `replyTo` envelope fields for `agent send`. When the CLI is invoked
@@ -6530,7 +6598,7 @@ async function onboardCreate(args) {
6530
6598
  }
6531
6599
  const runtimeAgent = args.type === "human" ? args.assistant : args.id;
6532
6600
  if (args.feishuBotAppId && args.feishuBotAppSecret) {
6533
- const { bindFeishuBot } = await import("./feishu-DEmwoNn_.mjs").then((n) => n.r);
6601
+ const { bindFeishuBot } = await import("./feishu-FTWnoOsc.mjs").then((n) => n.r);
6534
6602
  const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
6535
6603
  if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
6536
6604
  else {
@@ -7450,7 +7518,7 @@ function createFeedbackHandler(config) {
7450
7518
  return { handle };
7451
7519
  }
7452
7520
  //#endregion
7453
- //#region ../server/dist/app-DugUZNsw.mjs
7521
+ //#region ../server/dist/app-BvSSa9Ak.mjs
7454
7522
  var __defProp = Object.defineProperty;
7455
7523
  var __exportAll = (all, no_symbols) => {
7456
7524
  let target = {};
@@ -7461,28 +7529,6 @@ var __exportAll = (all, no_symbols) => {
7461
7529
  if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
7462
7530
  return target;
7463
7531
  };
7464
- /** Organization entity. Agents and chats belong to exactly one organization. */
7465
- const organizations = pgTable("organizations", {
7466
- id: text("id").primaryKey(),
7467
- name: text("name").unique().notNull(),
7468
- displayName: text("display_name").notNull(),
7469
- maxAgents: integer("max_agents").notNull().default(0),
7470
- maxMessagesPerMinute: integer("max_messages_per_minute").notNull().default(0),
7471
- features: jsonb("features").$type().notNull().default({}),
7472
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
7473
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
7474
- });
7475
- /** User accounts. Passwords are stored as bcrypt hashes. */
7476
- const users = pgTable("users", {
7477
- id: text("id").primaryKey(),
7478
- username: text("username").unique().notNull(),
7479
- passwordHash: text("password_hash").notNull(),
7480
- displayName: text("display_name").notNull(),
7481
- avatarUrl: text("avatar_url"),
7482
- status: text("status").notNull().default("active"),
7483
- createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
7484
- updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
7485
- });
7486
7532
  /**
7487
7533
  * Client connections. A client is a single SDK process (AgentRuntime) that may
7488
7534
  * host multiple agents. From the unified-user-token milestone on, a client is
@@ -7546,57 +7592,6 @@ const adapterAgentMappings = pgTable("adapter_agent_mappings", {
7546
7592
  metadata: jsonb("metadata").$type().notNull().default({}),
7547
7593
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
7548
7594
  }, (table) => [unique("uq_adapter_agent_mapping").on(table.platform, table.externalUserId)]);
7549
- var AppError = class extends Error {
7550
- constructor(statusCode, message) {
7551
- super(message);
7552
- this.statusCode = statusCode;
7553
- this.name = "AppError";
7554
- }
7555
- };
7556
- var NotFoundError = class extends AppError {
7557
- constructor(message = "Not found") {
7558
- super(404, message);
7559
- this.name = "NotFoundError";
7560
- }
7561
- };
7562
- var UnauthorizedError = class extends AppError {
7563
- constructor(message = "Unauthorized") {
7564
- super(401, message);
7565
- this.name = "UnauthorizedError";
7566
- }
7567
- };
7568
- var ForbiddenError = class extends AppError {
7569
- constructor(message = "Forbidden") {
7570
- super(403, message);
7571
- this.name = "ForbiddenError";
7572
- }
7573
- };
7574
- var ConflictError = class extends AppError {
7575
- constructor(message = "Conflict") {
7576
- super(409, message);
7577
- this.name = "ConflictError";
7578
- }
7579
- };
7580
- var BadRequestError = class extends AppError {
7581
- constructor(message = "Bad request") {
7582
- super(400, message);
7583
- this.name = "BadRequestError";
7584
- }
7585
- };
7586
- /**
7587
- * Thrown when an operation targets a client whose organization does not match
7588
- * the caller's authenticated organization. A client is bound to exactly one
7589
- * org for its lifetime; re-registering or operating under a different org's
7590
- * credentials is refused. CLI consumers recognize the `code` field and
7591
- * respond by abandoning the local clientId to register a fresh one.
7592
- */
7593
- var ClientOrgMismatchError = class extends AppError {
7594
- code = "CLIENT_ORG_MISMATCH";
7595
- constructor(message = "Client belongs to a different organization") {
7596
- super(403, message);
7597
- this.name = "ClientOrgMismatchError";
7598
- }
7599
- };
7600
7595
  /** Communication container. All messages between agents flow within a Chat. */
7601
7596
  const chats = pgTable("chats", {
7602
7597
  id: text("id").primaryKey(),
@@ -7747,26 +7742,6 @@ const adapterMessageReferences = pgTable("adapter_message_references", {
7747
7742
  externalChannelId: text("external_channel_id").notNull(),
7748
7743
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
7749
7744
  }, (table) => [unique("uq_adapter_message_ref").on(table.messageId, table.platform)]);
7750
- /** Generate a UUID v7 (time-ordered). No external dependency. */
7751
- function uuidv7() {
7752
- const now = BigInt(Date.now());
7753
- const bytes = new Uint8Array(16);
7754
- bytes[0] = Number(now >> 40n & 255n);
7755
- bytes[1] = Number(now >> 32n & 255n);
7756
- bytes[2] = Number(now >> 24n & 255n);
7757
- bytes[3] = Number(now >> 16n & 255n);
7758
- bytes[4] = Number(now >> 8n & 255n);
7759
- bytes[5] = Number(now & 255n);
7760
- const rand = randomBytes(10);
7761
- for (let i = 0; i < 10; i++) {
7762
- const b = rand[i];
7763
- if (b !== void 0) bytes[6 + i] = b;
7764
- }
7765
- bytes[6] = (bytes[6] ?? 0) & 15 | 112;
7766
- bytes[8] = (bytes[8] ?? 0) & 63 | 128;
7767
- const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
7768
- return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
7769
- }
7770
7745
  /** UUID v7 regex pattern for distinguishing UUIDs from name slugs. */
7771
7746
  const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
7772
7747
  /**
@@ -8411,6 +8386,7 @@ const members = pgTable("members", {
8411
8386
  organizationId: text("organization_id").notNull().references(() => organizations.id),
8412
8387
  agentId: text("agent_id").unique().notNull().references(() => agents.uuid),
8413
8388
  role: text("role").notNull(),
8389
+ status: text("status").notNull().default("active"),
8414
8390
  createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
8415
8391
  }, (table) => [
8416
8392
  unique("uq_members_user_org").on(table.userId, table.organizationId),
@@ -9188,7 +9164,7 @@ async function registerClient(db, data) {
9188
9164
  userId: clients.userId,
9189
9165
  organizationId: clients.organizationId
9190
9166
  }).from(clients).where(eq(clients.id, data.clientId)).limit(1);
9191
- if (existing && existing.organizationId !== data.organizationId) throw new ClientOrgMismatchError(`Client "${data.clientId}" is bound to a different organization. Re-register as a new client under the current org.`);
9167
+ if (existing && existing.organizationId !== data.organizationId) throw new ClientOrgMismatchError$1(`Client "${data.clientId}" is bound to a different organization. Re-register as a new client under the current org.`);
9192
9168
  if (existing?.userId && existing.userId !== data.userId) throw new ForbiddenError(`Client "${data.clientId}" is already claimed by a different user. Pick a unique client_id.`);
9193
9169
  await db.insert(clients).values({
9194
9170
  id: data.clientId,
@@ -10059,9 +10035,10 @@ function memberAuthHook(db, jwtSecret) {
10059
10035
  id: members.id,
10060
10036
  organizationId: members.organizationId,
10061
10037
  role: members.role,
10062
- agentId: members.agentId
10038
+ agentId: members.agentId,
10039
+ status: members.status
10063
10040
  }).from(members).where(eq(members.id, payload.memberId)).limit(1);
10064
- if (!member) throw new UnauthorizedError("Membership not found");
10041
+ if (!member || member.status !== "active") throw new UnauthorizedError("Membership not found");
10065
10042
  request.member = {
10066
10043
  userId: user.id,
10067
10044
  memberId: member.id,
@@ -12750,7 +12727,7 @@ function clientWsRoutes(notifier, instanceId) {
12750
12727
  });
12751
12728
  } catch (err) {
12752
12729
  const message = err instanceof Error ? err.message : "client register failed";
12753
- const code = err instanceof ClientOrgMismatchError ? err.code : void 0;
12730
+ const code = err instanceof ClientOrgMismatchError$1 ? err.code : void 0;
12754
12731
  socket.send(JSON.stringify({
12755
12732
  type: "client:register:rejected",
12756
12733
  message,
@@ -13025,32 +13002,44 @@ const CONNECT_JTI_TTL_MS = 6e5;
13025
13002
  async function signToken(secret, payload, expiry) {
13026
13003
  return new SignJWT({ ...payload }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(expiry).sign(secret);
13027
13004
  }
13005
+ /**
13006
+ * Sign an `(access, refresh)` pair for the given member. Used by both the
13007
+ * legacy username/password login path and the SaaS GitHub OAuth callback,
13008
+ * so the issuance shape stays in one place.
13009
+ */
13010
+ async function signTokensForMember(jwtSecretKey, member) {
13011
+ const secret = new TextEncoder().encode(jwtSecretKey);
13012
+ const tokenBase = {
13013
+ sub: member.userId,
13014
+ memberId: member.memberId,
13015
+ organizationId: member.organizationId,
13016
+ role: member.role
13017
+ };
13018
+ return {
13019
+ accessToken: await signToken(secret, {
13020
+ ...tokenBase,
13021
+ type: "access"
13022
+ }, ACCESS_TOKEN_EXPIRY),
13023
+ refreshToken: await signToken(secret, {
13024
+ ...tokenBase,
13025
+ type: "refresh"
13026
+ }, REFRESH_TOKEN_EXPIRY)
13027
+ };
13028
+ }
13028
13029
  async function login(db, username, password, jwtSecretKey) {
13029
13030
  const [user] = await db.select().from(users).where(eq(users.username, username)).limit(1);
13030
13031
  if (!user || user.status !== "active") throw new UnauthorizedError("Invalid username or password");
13031
13032
  if (!await bcrypt.compare(password, user.passwordHash)) throw new UnauthorizedError("Invalid username or password");
13032
- const [member] = await db.select().from(members).where(eq(members.userId, user.id)).limit(1);
13033
+ const [member] = await db.select().from(members).where(and(eq(members.userId, user.id), eq(members.status, "active"))).limit(1);
13033
13034
  if (!member) throw new UnauthorizedError("No organization membership found");
13034
- const secret = new TextEncoder().encode(jwtSecretKey);
13035
- const tokenBase = {
13036
- sub: user.id,
13035
+ const tokens = await signTokensForMember(jwtSecretKey, {
13036
+ userId: user.id,
13037
13037
  memberId: member.id,
13038
13038
  organizationId: member.organizationId,
13039
13039
  role: member.role
13040
- };
13041
- const accessToken = await signToken(secret, {
13042
- ...tokenBase,
13043
- type: "access"
13044
- }, ACCESS_TOKEN_EXPIRY);
13045
- const refreshToken = await signToken(secret, {
13046
- ...tokenBase,
13047
- type: "refresh"
13048
- }, REFRESH_TOKEN_EXPIRY);
13040
+ });
13049
13041
  await db.update(users).set({ updatedAt: /* @__PURE__ */ new Date() }).where(eq(users.id, user.id));
13050
- return {
13051
- accessToken,
13052
- refreshToken
13053
- };
13042
+ return tokens;
13054
13043
  }
13055
13044
  async function refreshAccessToken(db, refreshToken, jwtSecretKey) {
13056
13045
  const secret = new TextEncoder().encode(jwtSecretKey);
@@ -13067,7 +13056,7 @@ async function refreshAccessToken(db, refreshToken, jwtSecretKey) {
13067
13056
  status: users.status
13068
13057
  }).from(users).where(eq(users.id, payload.sub)).limit(1);
13069
13058
  if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
13070
- const [member] = await db.select().from(members).where(eq(members.id, payload.memberId)).limit(1);
13059
+ const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
13071
13060
  if (!member) throw new UnauthorizedError("Membership not found");
13072
13061
  return { accessToken: await signToken(secret, {
13073
13062
  sub: user.id,
@@ -13081,18 +13070,25 @@ async function refreshAccessToken(db, refreshToken, jwtSecretKey) {
13081
13070
  * Generate a short-lived connect token for CLI authentication.
13082
13071
  * The connect token carries the member's identity and can be exchanged
13083
13072
  * for full access+refresh tokens via exchangeConnectToken().
13073
+ *
13074
+ * `iss` (when supplied) is stamped into the JWT so the CLI can derive
13075
+ * the hub URL with no additional argument. Production servers must
13076
+ * always pass it; dev callers may omit and the CLI will require an
13077
+ * explicit `--server-url` (legacy form).
13084
13078
  */
13085
- async function generateConnectToken(member, jwtSecretKey) {
13079
+ async function generateConnectToken(member, jwtSecretKey, iss) {
13086
13080
  const secret = new TextEncoder().encode(jwtSecretKey);
13087
13081
  const jti = randomUUID();
13082
+ const builder = new SignJWT({
13083
+ sub: member.userId,
13084
+ memberId: member.memberId,
13085
+ organizationId: member.organizationId,
13086
+ role: member.role,
13087
+ type: "connect"
13088
+ }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setJti(jti).setExpirationTime(CONNECT_TOKEN_EXPIRY);
13089
+ if (iss) builder.setIssuer(iss);
13088
13090
  return {
13089
- token: await new SignJWT({
13090
- sub: member.userId,
13091
- memberId: member.memberId,
13092
- organizationId: member.organizationId,
13093
- role: member.role,
13094
- type: "connect"
13095
- }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setJti(jti).setExpirationTime(CONNECT_TOKEN_EXPIRY).sign(secret),
13091
+ token: await builder.sign(secret),
13096
13092
  expiresIn: 600
13097
13093
  };
13098
13094
  }
@@ -13123,25 +13119,607 @@ async function exchangeConnectToken(db, connectToken, jwtSecretKey) {
13123
13119
  status: users.status
13124
13120
  }).from(users).where(eq(users.id, payload.sub)).limit(1);
13125
13121
  if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
13126
- const [member] = await db.select().from(members).where(eq(members.id, payload.memberId)).limit(1);
13122
+ const [member] = await db.select().from(members).where(and(eq(members.id, payload.memberId), eq(members.status, "active"))).limit(1);
13127
13123
  if (!member) throw new UnauthorizedError("Membership not found");
13128
- const tokenBase = {
13129
- sub: user.id,
13124
+ return signTokensForMember(jwtSecretKey, {
13125
+ userId: user.id,
13130
13126
  memberId: member.id,
13131
13127
  organizationId: member.organizationId,
13132
13128
  role: member.role
13129
+ });
13130
+ }
13131
+ /**
13132
+ * Third-party / local auth identities for a user. Models "how does this user
13133
+ * prove they are who they say they are". A single user MAY have multiple
13134
+ * identities (e.g. GitHub login + future email/password) but each
13135
+ * (provider, identifier) tuple maps to exactly one user.
13136
+ *
13137
+ * v1 supported shapes:
13138
+ * - GitHub OAuth: provider='github', identifier=<github numeric id>,
13139
+ * email=<primary>, credential_type=null
13140
+ * - Future Email + password: provider='email', identifier=<email>,
13141
+ * credential_type='password', credential_payload={ hash }
13142
+ * - Future Email + magic link: provider='email', identifier=<email>,
13143
+ * credential_type=null
13144
+ * - Future Webauthn / passkey: credential_type='webauthn',
13145
+ * credential_payload={ pubkey, counter }
13146
+ *
13147
+ * v1 explicitly does NOT support multi-factor on the same identity — the
13148
+ * (provider, identifier) UNIQUE constraint precludes two credential rows for
13149
+ * the same identifier. v2 splits credential_type / credential_payload into
13150
+ * a separate auth_credentials table; the migration is recorded in the
13151
+ * proposal so the upgrade path is unambiguous.
13152
+ *
13153
+ * The legacy `users.password_hash` column is preserved for backwards-compat
13154
+ * with self-host installs created before this milestone; new SaaS users get
13155
+ * a non-functional placeholder there and a real `auth_identities` row.
13156
+ */
13157
+ const authIdentities = pgTable("auth_identities", {
13158
+ id: text("id").primaryKey(),
13159
+ userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
13160
+ provider: text("provider").notNull(),
13161
+ identifier: text("identifier").notNull(),
13162
+ email: text("email"),
13163
+ verifiedAt: timestamp("verified_at", { withTimezone: true }),
13164
+ credentialType: text("credential_type"),
13165
+ credentialPayload: jsonb("credential_payload").$type(),
13166
+ metadata: jsonb("metadata").$type().notNull().default({}),
13167
+ createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
13168
+ updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
13169
+ }, (table) => [
13170
+ unique("uq_auth_identities_provider_identifier").on(table.provider, table.identifier),
13171
+ index("idx_auth_identities_user").on(table.userId),
13172
+ index("idx_auth_identities_email").on(table.email)
13173
+ ]);
13174
+ /**
13175
+ * Find or create the user backing a GitHub OAuth identity. Idempotent —
13176
+ * subsequent logins by the same `githubId` reuse the prior `user_id` row.
13177
+ *
13178
+ * SaaS users have no password. The legacy `users.password_hash` column is
13179
+ * NOT NULL (preserved for self-host), so we fill it with a non-functional
13180
+ * 32-byte random string. The bcrypt comparison in `authService.login`
13181
+ * treats it as a plain string and rejects every password — that's the
13182
+ * intended behaviour: SaaS users cannot fall back to password login.
13183
+ */
13184
+ async function findOrCreateUserFromGithub(db, profile) {
13185
+ const [existing] = await db.select({ userId: authIdentities.userId }).from(authIdentities).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId))).limit(1);
13186
+ if (existing) {
13187
+ if (profile.email) await db.update(authIdentities).set({
13188
+ email: profile.email,
13189
+ updatedAt: /* @__PURE__ */ new Date()
13190
+ }).where(and(eq(authIdentities.provider, "github"), eq(authIdentities.identifier, profile.githubId)));
13191
+ return { userId: existing.userId };
13192
+ }
13193
+ const userId = uuidv7();
13194
+ const baseUsername = profile.login.toLowerCase();
13195
+ const placeholderHash = `oauth:${randomBytes(32).toString("base64url")}`;
13196
+ await insertWithUsernameRetry(db, baseUsername, async (tx, username) => {
13197
+ await tx.insert(users).values({
13198
+ id: userId,
13199
+ username,
13200
+ passwordHash: placeholderHash,
13201
+ displayName: profile.displayName?.trim() || profile.login,
13202
+ avatarUrl: profile.avatarUrl ?? null
13203
+ });
13204
+ await tx.insert(authIdentities).values({
13205
+ id: uuidv7(),
13206
+ userId,
13207
+ provider: "github",
13208
+ identifier: profile.githubId,
13209
+ email: profile.email,
13210
+ verifiedAt: /* @__PURE__ */ new Date(),
13211
+ metadata: { login: profile.login }
13212
+ });
13213
+ });
13214
+ return { userId };
13215
+ }
13216
+ /** Postgres `unique_violation` SQLSTATE — emitted when a UNIQUE constraint trips. */
13217
+ const PG_UNIQUE_VIOLATION$1 = "23505";
13218
+ /**
13219
+ * Pick a candidate username, attempt the caller's INSERT in a transaction,
13220
+ * and retry under a fresh disambiguator if the UNIQUE(users.username)
13221
+ * constraint trips. Two concurrent OAuth sign-ins for the same GitHub
13222
+ * `login` would otherwise let one INSERT win and the other 500 — the
13223
+ * race window between the pre-check `SELECT` and the `INSERT` is small but
13224
+ * non-zero in production. Retry budget is small; pathological storms fall
13225
+ * back to a fully-random suffix.
13226
+ */
13227
+ async function insertWithUsernameRetry(db, base, insert) {
13228
+ const [hit] = await db.select({ id: users.id }).from(users).where(eq(users.username, base)).limit(1);
13229
+ let candidate = hit ? `${base}-${randomBytes(2).toString("hex")}` : base;
13230
+ for (let attempt = 0; attempt < 4; attempt += 1) try {
13231
+ await db.transaction(async (tx) => {
13232
+ await insert(tx, candidate);
13233
+ });
13234
+ return;
13235
+ } catch (err) {
13236
+ if ((err?.code ?? err?.cause?.code) !== PG_UNIQUE_VIOLATION$1) throw err;
13237
+ candidate = `${base}-${randomBytes(2).toString("hex")}`;
13238
+ }
13239
+ candidate = `${base}-${uuidv7().slice(0, 12)}`;
13240
+ await db.transaction(async (tx) => {
13241
+ await insert(tx, candidate);
13242
+ });
13243
+ }
13244
+ const TOKEN_URL = "https://github.com/login/oauth/access_token";
13245
+ const USER_URL = "https://api.github.com/user";
13246
+ const USER_EMAILS_URL = "https://api.github.com/user/emails";
13247
+ /**
13248
+ * Exchange an OAuth code for an access token + fetch the user profile.
13249
+ *
13250
+ * The default `fetch` is overridable via `opts.fetcher` so tests can mock
13251
+ * the GitHub round-trip without standing up a fake server. The contract
13252
+ * the test fake must honor:
13253
+ * - First call: POST `${TOKEN_URL}` → returns `{ access_token: string }`
13254
+ * - Then GET `${USER_URL}` with `Authorization: Bearer …`
13255
+ * - Then GET `${USER_EMAILS_URL}` (only if `/user` returned no email)
13256
+ */
13257
+ async function exchangeCodeForProfile(config, code, redirectUri, opts = {}) {
13258
+ const fetcher = opts.fetcher ?? fetch;
13259
+ const tokenRes = await fetcher(TOKEN_URL, {
13260
+ method: "POST",
13261
+ headers: {
13262
+ Accept: "application/json",
13263
+ "Content-Type": "application/json"
13264
+ },
13265
+ body: JSON.stringify({
13266
+ client_id: config.clientId,
13267
+ client_secret: config.clientSecret,
13268
+ code,
13269
+ redirect_uri: redirectUri
13270
+ })
13271
+ });
13272
+ if (!tokenRes.ok) throw new Error(`GitHub token exchange failed (${tokenRes.status})`);
13273
+ const tokenJson = await tokenRes.json();
13274
+ if (!tokenJson.access_token) throw new Error(tokenJson.error ?? "GitHub token exchange returned no access_token");
13275
+ const userRes = await fetcher(USER_URL, { headers: {
13276
+ Authorization: `Bearer ${tokenJson.access_token}`,
13277
+ Accept: "application/vnd.github+json"
13278
+ } });
13279
+ if (!userRes.ok) throw new Error(`GitHub user fetch failed (${userRes.status})`);
13280
+ const user = await userRes.json();
13281
+ let email = user.email ?? null;
13282
+ if (!email) {
13283
+ const emailsRes = await fetcher(USER_EMAILS_URL, { headers: {
13284
+ Authorization: `Bearer ${tokenJson.access_token}`,
13285
+ Accept: "application/vnd.github+json"
13286
+ } });
13287
+ if (emailsRes.ok) {
13288
+ const emails = await emailsRes.json();
13289
+ email = (emails.find((e) => e.primary && e.verified) ?? emails.find((e) => e.verified))?.email ?? null;
13290
+ }
13291
+ }
13292
+ return {
13293
+ githubId: String(user.id),
13294
+ login: user.login,
13295
+ email,
13296
+ displayName: user.name ?? null,
13297
+ avatarUrl: user.avatar_url ?? null
13133
13298
  };
13299
+ }
13300
+ /** Insert (or reactivate) a `members` row for `userId` in `organizationId`. */
13301
+ async function ensureMembership(db, data) {
13302
+ const [existing] = await db.select().from(members).where(and(eq(members.userId, data.userId), eq(members.organizationId, data.organizationId))).limit(1);
13303
+ if (existing) {
13304
+ if (existing.status === "left") {
13305
+ await db.update(members).set({ status: "active" }).where(eq(members.id, existing.id));
13306
+ return {
13307
+ ...existing,
13308
+ status: "active"
13309
+ };
13310
+ }
13311
+ return existing;
13312
+ }
13313
+ return db.transaction(async (tx) => {
13314
+ const memberId = uuidv7();
13315
+ const agentName = sanitizeAgentName(data.username);
13316
+ const inboxId = `inbox_${uuidv7()}`;
13317
+ const agentUuid = uuidv7();
13318
+ await tx.insert(agents).values({
13319
+ uuid: agentUuid,
13320
+ name: agentName,
13321
+ organizationId: data.organizationId,
13322
+ type: "human",
13323
+ displayName: data.displayName,
13324
+ inboxId,
13325
+ source: "oauth",
13326
+ visibility: "organization",
13327
+ managerId: memberId
13328
+ });
13329
+ const [row] = await tx.insert(members).values({
13330
+ id: memberId,
13331
+ userId: data.userId,
13332
+ organizationId: data.organizationId,
13333
+ agentId: agentUuid,
13334
+ role: data.role,
13335
+ status: "active"
13336
+ }).returning();
13337
+ if (!row) throw new Error("Unexpected: INSERT RETURNING produced no row");
13338
+ return row;
13339
+ });
13340
+ }
13341
+ function sanitizeAgentName(login) {
13342
+ return login.toLowerCase().replace(/[^a-z0-9_-]/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "user";
13343
+ }
13344
+ /**
13345
+ * Create a fresh "personal team" org for a brand-new user, plus the
13346
+ * matching admin membership + 1:1 human agent. Slug strategy:
13347
+ *
13348
+ * - First try: `${login}-personal`
13349
+ * - On collision: append a 4-char hex disambiguator
13350
+ *
13351
+ * The display name is `{user}'s Personal Team` so it reads sensibly in the
13352
+ * UI; the user can rename via Settings later (proposal §"Personal team
13353
+ * visual降级").
13354
+ */
13355
+ async function createPersonalTeam(db, input) {
13356
+ const baseSlug = sanitizeOrgSlug(`${input.loginSeed}-personal`);
13357
+ const displayName = `${input.userDisplayName}'s Personal Team`;
13358
+ const orgId = uuidv7();
13134
13359
  return {
13135
- accessToken: await signToken(secret, {
13136
- ...tokenBase,
13137
- type: "access"
13138
- }, ACCESS_TOKEN_EXPIRY),
13139
- refreshToken: await signToken(secret, {
13140
- ...tokenBase,
13141
- type: "refresh"
13142
- }, REFRESH_TOKEN_EXPIRY)
13360
+ organizationId: orgId,
13361
+ slug: await insertOrgWithSlugRetry(db, orgId, baseSlug, displayName),
13362
+ displayName,
13363
+ memberId: (await ensureMembership(db, {
13364
+ userId: input.userId,
13365
+ organizationId: orgId,
13366
+ role: "admin",
13367
+ displayName: input.userDisplayName,
13368
+ username: input.loginSeed
13369
+ })).id
13370
+ };
13371
+ }
13372
+ function sanitizeOrgSlug(raw) {
13373
+ return raw.toLowerCase().replace(/[^a-z0-9-]/g, "-").replace(/-+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "team";
13374
+ }
13375
+ /** Postgres `unique_violation` SQLSTATE — `organizations.name` UNIQUE tripping. */
13376
+ const PG_UNIQUE_VIOLATION = "23505";
13377
+ /**
13378
+ * Attempt INSERT into `organizations` with `base` slug, retrying with a
13379
+ * disambiguator on UNIQUE constraint violations. Two concurrent OAuth
13380
+ * sign-ins for the same GitHub `login` would race here without retry —
13381
+ * pre-check `SELECT` followed by `INSERT` has a TOCTOU window the unique
13382
+ * constraint catches but the catch path needs to exist. Returns the slug
13383
+ * actually used.
13384
+ */
13385
+ async function insertOrgWithSlugRetry(db, orgId, base, displayName) {
13386
+ const [existing] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, base)).limit(1);
13387
+ let candidate = existing ? `${base}-${randomBytes(2).toString("hex")}` : base;
13388
+ for (let attempt = 0; attempt < 4; attempt += 1) try {
13389
+ await db.insert(organizations).values({
13390
+ id: orgId,
13391
+ name: candidate,
13392
+ displayName
13393
+ });
13394
+ return candidate;
13395
+ } catch (err) {
13396
+ if ((err?.code ?? err?.cause?.code) !== PG_UNIQUE_VIOLATION) throw err;
13397
+ candidate = `${base}-${randomBytes(2).toString("hex")}`;
13398
+ }
13399
+ candidate = `${base}-${uuidv7().slice(0, 12)}`;
13400
+ await db.insert(organizations).values({
13401
+ id: orgId,
13402
+ name: candidate,
13403
+ displayName
13404
+ });
13405
+ return candidate;
13406
+ }
13407
+ /** List ACTIVE memberships (omit soft-deleted "left") for a user. */
13408
+ async function listActiveMemberships(db, userId) {
13409
+ return await db.select({
13410
+ memberId: members.id,
13411
+ organizationId: members.organizationId,
13412
+ role: members.role,
13413
+ orgName: organizations.name,
13414
+ orgDisplayName: organizations.displayName,
13415
+ createdAt: members.createdAt
13416
+ }).from(members).innerJoin(organizations, eq(members.organizationId, organizations.id)).where(and(eq(members.userId, userId), eq(members.status, "active"))).orderBy(desc(members.createdAt));
13417
+ }
13418
+ /**
13419
+ * Pick the most recently joined active membership — used after OAuth login
13420
+ * when the user already has at least one team but no `next` was specified.
13421
+ */
13422
+ async function pickPrimaryMembership(db, userId) {
13423
+ return (await listActiveMemberships(db, userId))[0] ?? null;
13424
+ }
13425
+ /**
13426
+ * Mark `members.status='left'` for the given member. v1 simplification:
13427
+ * no "must transfer admin" check — the proposal accepts the trade-off
13428
+ * (last admin allowed to leave, leaves an orphan team) and the cleanup is
13429
+ * a v2 sweep job.
13430
+ */
13431
+ async function leaveOrganization(db, memberId) {
13432
+ const [existing] = await db.select().from(members).where(eq(members.id, memberId)).limit(1);
13433
+ if (!existing) throw new NotFoundError(`Membership "${memberId}" not found`);
13434
+ if (existing.status === "left") return existing;
13435
+ await db.update(members).set({ status: "left" }).where(eq(members.id, memberId));
13436
+ return {
13437
+ ...existing,
13438
+ status: "left"
13439
+ };
13440
+ }
13441
+ /**
13442
+ * Self-service "create another team" (operator clicks "Create team" in the
13443
+ * org switcher). Caller is the new team's admin. Slug uniqueness is
13444
+ * enforced by the underlying organizations.name UNIQUE constraint.
13445
+ */
13446
+ async function selfCreateOrganization(db, data) {
13447
+ const [collision] = await db.select({ id: organizations.id }).from(organizations).where(eq(organizations.name, data.name)).limit(1);
13448
+ if (collision) throw new ConflictError(`Organization "${data.name}" already exists`);
13449
+ if (data.name === "default") throw new BadRequestError("\"default\" is a reserved organization name");
13450
+ const orgId = uuidv7();
13451
+ await db.insert(organizations).values({
13452
+ id: orgId,
13453
+ name: data.name,
13454
+ displayName: data.displayName
13455
+ });
13456
+ return {
13457
+ organizationId: orgId,
13458
+ memberId: (await ensureMembership(db, {
13459
+ userId: data.userId,
13460
+ organizationId: orgId,
13461
+ role: "admin",
13462
+ displayName: data.userDisplayName,
13463
+ username: data.username
13464
+ })).id,
13465
+ name: data.name,
13466
+ displayName: data.displayName
13467
+ };
13468
+ }
13469
+ /**
13470
+ * State-token signing for the GitHub OAuth dance.
13471
+ *
13472
+ * Flow:
13473
+ * 1. `/auth/github/start` mints a `state` JWT *and* an HttpOnly cookie
13474
+ * holding the same nonce. Both ride for ~10 minutes.
13475
+ * 2. GitHub redirects back to `/auth/github/callback?code=…&state=<jwt>`.
13476
+ * 3. Callback verifies the JWT (signature + expiry) AND that the cookie
13477
+ * nonce matches `payload.nonce`. The double check defeats the
13478
+ * classic login-CSRF where an attacker pre-signs a `start` with their
13479
+ * own GitHub account and tricks a victim's browser into completing
13480
+ * the callback under that identity.
13481
+ *
13482
+ * `next` rides inside the JWT so the caller's intended landing path can't
13483
+ * be tampered with mid-flight.
13484
+ */
13485
+ const STATE_EXPIRY = "10m";
13486
+ const NONCE_BYTES = 24;
13487
+ const OAUTH_STATE_COOKIE = "oauth_state_nonce";
13488
+ /**
13489
+ * Sign a fresh state token + return the matching cookie nonce. Caller is
13490
+ * responsible for setting the cookie (HttpOnly + Secure in prod).
13491
+ */
13492
+ async function signOAuthState(jwtSecret, next) {
13493
+ const nonce = randomBytes(NONCE_BYTES).toString("base64url");
13494
+ const secret = new TextEncoder().encode(jwtSecret);
13495
+ return {
13496
+ token: await new SignJWT({
13497
+ nonce,
13498
+ next
13499
+ }).setProtectedHeader({ alg: "HS256" }).setIssuedAt().setExpirationTime(STATE_EXPIRY).sign(secret),
13500
+ nonce
13143
13501
  };
13144
13502
  }
13503
+ /**
13504
+ * Verify a state token. Returns the carried `next` on success. Throws
13505
+ * `Error` with the verification failure mode on rejection so the route
13506
+ * layer can map to 401.
13507
+ *
13508
+ * Cookie/nonce double-submit is mandatory — this is the CSRF defense.
13509
+ * `/dev-callback` does NOT call this function; it bypasses state entirely
13510
+ * (see `api/auth/github.ts`) because the dev shortcut also bypasses the
13511
+ * github.com round-trip that would have set a state cookie.
13512
+ */
13513
+ async function verifyOAuthState(jwtSecret, token, cookieNonce) {
13514
+ const secret = new TextEncoder().encode(jwtSecret);
13515
+ let payload;
13516
+ try {
13517
+ const { payload: p } = await jwtVerify(token, secret);
13518
+ payload = p;
13519
+ } catch {
13520
+ throw new Error("Invalid or expired OAuth state");
13521
+ }
13522
+ if (typeof payload.nonce !== "string" || typeof payload.next !== "string") throw new Error("OAuth state payload malformed");
13523
+ if (!cookieNonce || cookieNonce !== payload.nonce) throw new Error("OAuth state nonce / cookie mismatch");
13524
+ return { next: payload.next };
13525
+ }
13526
+ /**
13527
+ * Resolve the hub's public-facing base URL.
13528
+ *
13529
+ * Precedence:
13530
+ * 1. `app.config.server.publicUrl` — explicit configuration. Required in
13531
+ * production (the boot check enforces it).
13532
+ * 2. The request's `Host` header (with `X-Forwarded-Proto` honored) —
13533
+ * dev fallback so local quickstart works without extra config.
13534
+ *
13535
+ * Result is normalized to drop trailing slashes so callers can append
13536
+ * paths with a single leading `/`.
13537
+ */
13538
+ function resolvePublicUrl(app, request) {
13539
+ const configured = app.config.server.publicUrl;
13540
+ if (configured && configured.length > 0) return configured.replace(/\/+$/, "");
13541
+ return `${pickHeader(request.headers["x-forwarded-proto"]) ?? request.protocol}://${pickHeader(request.headers["x-forwarded-host"]) ?? pickHeader(request.headers.host) ?? request.hostname}`.replace(/\/+$/, "");
13542
+ }
13543
+ function pickHeader(value) {
13544
+ if (Array.isArray(value)) return value[0];
13545
+ return value;
13546
+ }
13547
+ /**
13548
+ * Manual cookie helpers — we don't pull in `@fastify/cookie` because the
13549
+ * SaaS onboarding flow needs exactly one cookie (the OAuth state nonce).
13550
+ * Parser tolerates the standard `name=value; name2=value2` format.
13551
+ */
13552
+ function parseCookieHeader(header, name) {
13553
+ if (!header) return null;
13554
+ const raw = Array.isArray(header) ? header.join("; ") : header;
13555
+ for (const entry of raw.split(/;\s*/)) {
13556
+ const eq = entry.indexOf("=");
13557
+ if (eq < 0) continue;
13558
+ if (entry.slice(0, eq).trim() === name) return decodeURIComponent(entry.slice(eq + 1));
13559
+ }
13560
+ return null;
13561
+ }
13562
+ function buildCookie(opts) {
13563
+ const sameSite = opts.sameSite ?? "Lax";
13564
+ const parts = [
13565
+ `${opts.name}=${encodeURIComponent(opts.value)}`,
13566
+ "Path=/",
13567
+ "HttpOnly",
13568
+ `SameSite=${sameSite}`,
13569
+ `Max-Age=${opts.maxAge}`
13570
+ ];
13571
+ if (opts.secure) parts.push("Secure");
13572
+ if (opts.maxAge <= 0) parts.push("Expires=Thu, 01 Jan 1970 00:00:00 GMT");
13573
+ return parts.join("; ");
13574
+ }
13575
+ /**
13576
+ * GitHub OAuth surface. All routes are public (no member JWT required).
13577
+ *
13578
+ * Routes:
13579
+ * - GET /auth/github/start — sign state JWT + cookie + 302 to GitHub
13580
+ * - GET /auth/github/callback — verify state + exchange code → fragment
13581
+ * - GET /auth/github/dev-callback — dev-only stub (no GitHub round-trip)
13582
+ */
13583
+ async function githubOauthRoutes(app) {
13584
+ const oauthCfg = app.config.oauth?.github;
13585
+ if (!oauthCfg) app.log.info("GitHub OAuth not configured — /auth/github/start will return 503. Set FIRST_TREE_HUB_GITHUB_OAUTH_CLIENT_ID/_SECRET to enable.");
13586
+ app.get("/start", { config: { rateLimit: {
13587
+ max: 20,
13588
+ timeWindow: "1 minute"
13589
+ } } }, async (request, reply) => {
13590
+ const { next } = githubStartQuerySchema.parse(request.query);
13591
+ const safeNext = safeRedirectPath(next ?? null);
13592
+ if (!oauthCfg) return reply.status(503).send({ error: "GitHub OAuth is not configured on this hub" });
13593
+ const { token, nonce } = await signOAuthState(app.config.secrets.jwtSecret, safeNext);
13594
+ const isProd = process.env.NODE_ENV === "production";
13595
+ reply.header("Set-Cookie", buildCookie({
13596
+ name: OAUTH_STATE_COOKIE,
13597
+ value: nonce,
13598
+ maxAge: 600,
13599
+ secure: isProd
13600
+ }));
13601
+ const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
13602
+ const params = new URLSearchParams({
13603
+ client_id: oauthCfg.clientId,
13604
+ redirect_uri: redirectUri,
13605
+ state: token,
13606
+ scope: "read:user user:email",
13607
+ allow_signup: "true"
13608
+ });
13609
+ return reply.redirect(`https://github.com/login/oauth/authorize?${params}`, 302);
13610
+ });
13611
+ app.get("/callback", async (request, reply) => {
13612
+ if (!oauthCfg) return reply.status(503).send({ error: "GitHub OAuth is not configured on this hub" });
13613
+ const { code, state } = githubCallbackQuerySchema.parse(request.query);
13614
+ const cookieNonce = parseCookieHeader(request.headers.cookie, OAUTH_STATE_COOKIE);
13615
+ let next;
13616
+ try {
13617
+ next = (await verifyOAuthState(app.config.secrets.jwtSecret, state, cookieNonce)).next;
13618
+ } catch (err) {
13619
+ const msg = err instanceof Error ? err.message : "OAuth state rejected";
13620
+ return reply.status(401).send({ error: msg });
13621
+ }
13622
+ reply.header("Set-Cookie", buildCookie({
13623
+ name: OAUTH_STATE_COOKIE,
13624
+ value: "",
13625
+ maxAge: 0,
13626
+ secure: process.env.NODE_ENV === "production"
13627
+ }));
13628
+ const redirectUri = `${resolvePublicUrl(app, request)}/api/v1/auth/github/callback`;
13629
+ let profile;
13630
+ try {
13631
+ profile = await exchangeCodeForProfile({
13632
+ clientId: oauthCfg.clientId,
13633
+ clientSecret: oauthCfg.clientSecret
13634
+ }, code, redirectUri);
13635
+ } catch (err) {
13636
+ const msg = err instanceof Error ? err.message : "GitHub exchange failed";
13637
+ app.log.warn({ err }, "github oauth code exchange failed");
13638
+ return reply.status(401).send({ error: msg });
13639
+ }
13640
+ return completeOauthFlow(app, request, reply, profile, next);
13641
+ });
13642
+ app.get("/dev-callback", async (request, reply) => {
13643
+ const isProd = process.env.NODE_ENV === "production";
13644
+ const devEnabled = oauthCfg?.devCallbackEnabled === true;
13645
+ if (isProd || !devEnabled) return reply.status(404).send({ error: "Not found" });
13646
+ const params = githubDevCallbackQuerySchema.parse(request.query);
13647
+ const next = safeRedirectPath(params.next ?? null);
13648
+ return completeOauthFlow(app, request, reply, {
13649
+ githubId: params.githubId,
13650
+ login: params.login,
13651
+ email: params.email ?? null,
13652
+ displayName: params.displayName ?? params.login,
13653
+ avatarUrl: null
13654
+ }, next);
13655
+ });
13656
+ }
13657
+ async function completeOauthFlow(app, request, reply, profile, next) {
13658
+ const { userId } = await findOrCreateUserFromGithub(app.db, profile);
13659
+ let joinPath = "returning";
13660
+ const inviteMatch = /^\/invite\/([^/?#]+)/.exec(next);
13661
+ let memberInfo = null;
13662
+ if (inviteMatch?.[1]) {
13663
+ const token = inviteMatch[1];
13664
+ const inv = await findActiveByToken(app.db, token);
13665
+ if (!inv) return reply.status(404).send({ error: "Invitation not found or no longer valid" });
13666
+ const member = await ensureMembership(app.db, {
13667
+ userId,
13668
+ organizationId: inv.organizationId,
13669
+ role: inv.role === "admin" ? "admin" : "member",
13670
+ displayName: profile.displayName?.trim() || profile.login,
13671
+ username: profile.login
13672
+ });
13673
+ await recordRedemption(app.db, {
13674
+ invitationId: inv.id,
13675
+ userId,
13676
+ ip: request.ip,
13677
+ userAgent: request.headers["user-agent"] ?? null
13678
+ });
13679
+ memberInfo = {
13680
+ memberId: member.id,
13681
+ organizationId: member.organizationId,
13682
+ role: member.role === "admin" ? "admin" : "member"
13683
+ };
13684
+ joinPath = "invite";
13685
+ next = "/";
13686
+ } else {
13687
+ const primary = await pickPrimaryMembership(app.db, userId);
13688
+ if (primary) memberInfo = {
13689
+ memberId: primary.memberId,
13690
+ organizationId: primary.organizationId,
13691
+ role: primary.role === "admin" ? "admin" : "member"
13692
+ };
13693
+ else {
13694
+ const personal = await createPersonalTeam(app.db, {
13695
+ userId,
13696
+ loginSeed: profile.login,
13697
+ userDisplayName: profile.displayName?.trim() || profile.login
13698
+ });
13699
+ memberInfo = {
13700
+ memberId: personal.memberId,
13701
+ organizationId: personal.organizationId,
13702
+ role: "admin"
13703
+ };
13704
+ joinPath = "solo";
13705
+ next = "/";
13706
+ }
13707
+ }
13708
+ if (!memberInfo) return reply.status(500).send({ error: "Failed to resolve membership" });
13709
+ const tokens = await signTokensForMember(app.config.secrets.jwtSecret, {
13710
+ userId,
13711
+ memberId: memberInfo.memberId,
13712
+ organizationId: memberInfo.organizationId,
13713
+ role: memberInfo.role
13714
+ });
13715
+ const fragment = new URLSearchParams({
13716
+ access: tokens.accessToken,
13717
+ refresh: tokens.refreshToken,
13718
+ next,
13719
+ joinPath
13720
+ }).toString();
13721
+ return reply.redirect(`/auth/github/complete#${fragment}`, 302);
13722
+ }
13145
13723
  async function authRoutes(app) {
13146
13724
  const loginMax = app.config.rateLimit?.loginMax ?? 5;
13147
13725
  app.post("/login", { config: { rateLimit: {
@@ -13277,7 +13855,12 @@ async function healthzRoutes(app) {
13277
13855
  }
13278
13856
  });
13279
13857
  }
13280
- /** GET /me — returns current user + member + agent info. */
13858
+ /**
13859
+ * `/me` and self-service organization routes (mounted under the member
13860
+ * auth hook). The legacy `GET /me` shape is preserved + extended with
13861
+ * `wizard` and `inviteUrl` (admin only) so the web SPA can derive its
13862
+ * landing UI without an extra round-trip.
13863
+ */
13281
13864
  async function meRoutes(app) {
13282
13865
  app.get("/me", async (request) => {
13283
13866
  const m = requireMember(request);
@@ -13293,6 +13876,12 @@ async function meRoutes(app) {
13293
13876
  displayName: agents.displayName,
13294
13877
  inboxId: agents.inboxId
13295
13878
  }).from(agents).where(eq(agents.uuid, m.agentId)).limit(1);
13879
+ const wizardStep = await inferWizardStep(app, m);
13880
+ let inviteUrl = null;
13881
+ if (m.role === "admin") {
13882
+ const inv = await getActiveInvitation(app.db, m.organizationId);
13883
+ if (inv) inviteUrl = buildInviteUrl(resolvePublicUrl(app, request), inv.token);
13884
+ }
13296
13885
  return {
13297
13886
  user: user ?? null,
13298
13887
  member: {
@@ -13301,25 +13890,202 @@ async function meRoutes(app) {
13301
13890
  role: m.role,
13302
13891
  agentId: m.agentId
13303
13892
  },
13304
- agent: agent ?? null
13893
+ agent: agent ?? null,
13894
+ wizard: { step: wizardStep },
13895
+ inviteUrl
13305
13896
  };
13306
13897
  });
13307
13898
  /**
13308
13899
  * POST /connect-tokens — generate a short-lived connect token for CLI authentication.
13309
- * The token can be exchanged via POST /auth/connect-token for full credentials.
13900
+ * Stamped with `iss = server.publicUrl` (or the request host as a dev fallback)
13901
+ * so the CLI's `connect <token>` form can derive the hub URL with no extra arg.
13902
+ *
13903
+ * Rate-limited per-route at the same level as `/auth/login`: a "Copy
13904
+ * commands" double-click in the wizard mustn't burn through token slots,
13905
+ * but neither should a stolen access token mint unlimited connect tokens.
13310
13906
  */
13311
- app.post("/connect-tokens", async (request) => {
13907
+ const loginMax = app.config.rateLimit?.loginMax ?? 5;
13908
+ app.post("/connect-tokens", { config: { rateLimit: {
13909
+ max: loginMax,
13910
+ timeWindow: "1 minute"
13911
+ } } }, async (request) => {
13312
13912
  const m = requireMember(request);
13913
+ const issuer = resolvePublicUrl(app, request);
13313
13914
  const { token, expiresIn } = await generateConnectToken({
13314
13915
  userId: m.userId,
13315
13916
  memberId: m.memberId,
13316
13917
  organizationId: m.organizationId,
13317
13918
  role: m.role
13318
- }, app.config.secrets.jwtSecret);
13919
+ }, app.config.secrets.jwtSecret, issuer);
13319
13920
  return {
13320
13921
  token,
13321
13922
  expiresIn,
13322
- command: `first-tree-hub client connect ${`${request.headers["x-forwarded-proto"] ?? request.protocol}://${request.headers["x-forwarded-host"] ?? request.headers.host ?? request.hostname}`} --token ${token}`
13923
+ command: `first-tree-hub connect ${token}`
13924
+ };
13925
+ });
13926
+ app.get("/me/organizations", async (request) => {
13927
+ const m = requireMember(request);
13928
+ return (await listActiveMemberships(app.db, m.userId)).map((r) => ({
13929
+ id: r.organizationId,
13930
+ name: r.orgName,
13931
+ displayName: r.orgDisplayName,
13932
+ role: r.role
13933
+ }));
13934
+ });
13935
+ app.post("/me/organizations", async (request, reply) => {
13936
+ const m = requireMember(request);
13937
+ const body = createOrgFromMeSchema.parse(request.body);
13938
+ const [u] = await app.db.select({
13939
+ username: users.username,
13940
+ displayName: users.displayName
13941
+ }).from(users).where(eq(users.id, m.userId)).limit(1);
13942
+ if (!u) throw new NotFoundError("User not found");
13943
+ const created = await selfCreateOrganization(app.db, {
13944
+ userId: m.userId,
13945
+ userDisplayName: u.displayName,
13946
+ username: u.username,
13947
+ name: body.name,
13948
+ displayName: body.displayName
13949
+ });
13950
+ const tokens = await signTokensForMember(app.config.secrets.jwtSecret, {
13951
+ userId: m.userId,
13952
+ memberId: created.memberId,
13953
+ organizationId: created.organizationId,
13954
+ role: "admin"
13955
+ });
13956
+ return reply.status(201).send({
13957
+ organization: {
13958
+ id: created.organizationId,
13959
+ name: created.name,
13960
+ displayName: created.displayName,
13961
+ role: "admin"
13962
+ },
13963
+ tokens
13964
+ });
13965
+ });
13966
+ app.post("/me/organizations/join", { config: { rateLimit: {
13967
+ max: loginMax,
13968
+ timeWindow: "1 minute"
13969
+ } } }, async (request, reply) => {
13970
+ const m = requireMember(request);
13971
+ const body = joinByInvitationSchema.parse(request.body);
13972
+ const inv = await findActiveByToken(app.db, body.token);
13973
+ if (!inv) return reply.status(404).send({ error: "Invitation not found or no longer valid" });
13974
+ const [u] = await app.db.select({
13975
+ username: users.username,
13976
+ displayName: users.displayName
13977
+ }).from(users).where(eq(users.id, m.userId)).limit(1);
13978
+ if (!u) throw new NotFoundError("User not found");
13979
+ const member = await ensureMembership(app.db, {
13980
+ userId: m.userId,
13981
+ organizationId: inv.organizationId,
13982
+ role: inv.role === "admin" ? "admin" : "member",
13983
+ displayName: u.displayName,
13984
+ username: u.username
13985
+ });
13986
+ await recordRedemption(app.db, {
13987
+ invitationId: inv.id,
13988
+ userId: m.userId,
13989
+ ip: request.ip,
13990
+ userAgent: request.headers["user-agent"] ?? null
13991
+ });
13992
+ const tokens = await signTokensForMember(app.config.secrets.jwtSecret, {
13993
+ userId: m.userId,
13994
+ memberId: member.id,
13995
+ organizationId: member.organizationId,
13996
+ role: member.role
13997
+ });
13998
+ return reply.status(200).send({
13999
+ organizationId: member.organizationId,
14000
+ memberId: member.id,
14001
+ role: member.role,
14002
+ tokens
14003
+ });
14004
+ });
14005
+ app.post("/me/organizations/leave", async (request, reply) => {
14006
+ const m = requireMember(request);
14007
+ await leaveOrganization(app.db, m.memberId);
14008
+ return reply.status(204).send();
14009
+ });
14010
+ app.post("/auth/switch-org", async (request, reply) => {
14011
+ const m = requireMember(request);
14012
+ const body = switchOrgSchema.parse(request.body);
14013
+ const [target] = await app.db.select().from(members).where(and(eq(members.userId, m.userId), eq(members.organizationId, body.organizationId), eq(members.status, "active"))).limit(1);
14014
+ if (!target) throw new ForbiddenError("You do not belong to that organization");
14015
+ const tokens = await signTokensForMember(app.config.secrets.jwtSecret, {
14016
+ userId: m.userId,
14017
+ memberId: target.id,
14018
+ organizationId: target.organizationId,
14019
+ role: target.role
14020
+ });
14021
+ return reply.send(tokens);
14022
+ });
14023
+ }
14024
+ /**
14025
+ * Infer the wizard step from observable runtime state. Refer to
14026
+ * proposal §"Onboarding 状态推断" for the rationale.
14027
+ *
14028
+ * Note: we deliberately do NOT filter by `clients.status='connected'`
14029
+ * here. The original "fact-is-state" reading would have flapped between
14030
+ * `completed` and `connect` every time the user's client briefly went
14031
+ * offline — UX disaster (the onboarding modal would re-pop). "Ever
14032
+ * connected" (= a clients row exists at all for this user/org) is still
14033
+ * fact-derived: deleting the row really does rewind the wizard, and
14034
+ * that's the explicit reset path.
14035
+ */
14036
+ async function inferWizardStep(app, m) {
14037
+ const [hasClient] = await app.db.select({ id: clients.id }).from(clients).where(and(eq(clients.userId, m.userId), eq(clients.organizationId, m.organizationId))).limit(1);
14038
+ if (!hasClient) return "connect";
14039
+ const [hasAgent] = await app.db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.managerId, m.memberId), ne(agents.type, "human"), eq(agents.status, "active"))).limit(1);
14040
+ if (!hasAgent) return "create_agent";
14041
+ return "completed";
14042
+ }
14043
+ /**
14044
+ * Public route exported separately so it mounts BEFORE the member auth hook.
14045
+ * Just exposes the org's display name & slug for the unauthenticated `/invite/:token`
14046
+ * landing page.
14047
+ */
14048
+ async function publicInvitePreviewRoute(app) {
14049
+ const { previewInvitation } = await import("./invitation-BTlGMy0o-dIoR8JRj.mjs");
14050
+ app.get("/:token/preview", async (request, reply) => {
14051
+ if (!request.params.token) throw new UnauthorizedError("Token required");
14052
+ const preview = await previewInvitation(app.db, request.params.token);
14053
+ return reply.send(preview);
14054
+ });
14055
+ }
14056
+ /**
14057
+ * Admin-only invitation routes — mounted under `/admin/organizations/:id/invitations`.
14058
+ */
14059
+ async function adminInvitationRoutes(app) {
14060
+ app.get("/", async (request) => {
14061
+ const m = requireMember(request);
14062
+ if (m.role !== "admin") throw new ForbiddenError("Admin role required");
14063
+ if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot inspect invitations for another organization");
14064
+ const inv = await ensureActiveInvitation(app.db, m.organizationId, m.userId);
14065
+ return {
14066
+ id: inv.id,
14067
+ organizationId: inv.organizationId,
14068
+ token: inv.token,
14069
+ inviteUrl: buildInviteUrl(resolvePublicUrl(app, request), inv.token),
14070
+ role: inv.role,
14071
+ createdAt: inv.createdAt.toISOString(),
14072
+ expiresAt: inv.expiresAt ? inv.expiresAt.toISOString() : null
14073
+ };
14074
+ });
14075
+ app.post("/rotate", async (request) => {
14076
+ const m = requireMember(request);
14077
+ if (m.role !== "admin") throw new ForbiddenError("Admin role required");
14078
+ if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
14079
+ const { rotateInvitation } = await import("./invitation-BTlGMy0o-dIoR8JRj.mjs");
14080
+ const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
14081
+ return {
14082
+ id: inv.id,
14083
+ organizationId: inv.organizationId,
14084
+ token: inv.token,
14085
+ inviteUrl: buildInviteUrl(resolvePublicUrl(app, request), inv.token),
14086
+ role: inv.role,
14087
+ createdAt: inv.createdAt.toISOString(),
14088
+ expiresAt: inv.expiresAt ? inv.expiresAt.toISOString() : null
13323
14089
  };
13324
14090
  });
13325
14091
  }
@@ -13959,10 +14725,13 @@ var schema_exports = /* @__PURE__ */ __exportAll({
13959
14725
  agentConfigs: () => agentConfigs,
13960
14726
  agentPresence: () => agentPresence,
13961
14727
  agents: () => agents,
14728
+ authIdentities: () => authIdentities,
13962
14729
  chatParticipants: () => chatParticipants,
13963
14730
  chats: () => chats,
13964
14731
  clients: () => clients,
13965
14732
  inboxEntries: () => inboxEntries,
14733
+ invitationRedemptions: () => invitationRedemptions,
14734
+ invitations: () => invitations,
13966
14735
  members: () => members,
13967
14736
  messages: () => messages,
13968
14737
  notifications: () => notifications,
@@ -15217,6 +15986,8 @@ async function buildApp(config) {
15217
15986
  await api.register(healthRoutes);
15218
15987
  await api.register(githubWebhookRoutes, { prefix: "/webhooks" });
15219
15988
  await api.register(authRoutes, { prefix: "/auth" });
15989
+ await api.register(githubOauthRoutes, { prefix: "/auth/github" });
15990
+ await api.register(publicInvitePreviewRoute, { prefix: "/invite" });
15220
15991
  await api.register(contextTreeInfoRoutes, { prefix: "/context-tree" });
15221
15992
  await api.register(bootstrapConfigRoutes, { prefix: "/bootstrap" });
15222
15993
  await api.register(async (adminApp) => {
@@ -15277,6 +16048,10 @@ async function buildApp(config) {
15277
16048
  adminApp.addHook("onRequest", adminOnly);
15278
16049
  await adminApp.register(adminOrganizationRoutes);
15279
16050
  }, { prefix: "/admin/organizations" });
16051
+ await api.register(async (adminApp) => {
16052
+ adminApp.addHook("onRequest", memberAuth);
16053
+ await adminApp.register(adminInvitationRoutes);
16054
+ }, { prefix: "/admin/organizations/:id/invitations" });
15280
16055
  await api.register(async (adminApp) => {
15281
16056
  adminApp.addHook("onRequest", memberAuth);
15282
16057
  adminApp.addHook("onRequest", adminOnly);
@@ -15488,7 +16263,7 @@ async function startServer(options) {
15488
16263
  instanceId: `srv_${randomUUID().slice(0, 8)}`,
15489
16264
  commandVersion: COMMAND_VERSION
15490
16265
  };
15491
- const { initTelemetry, shutdownTelemetry } = await import("./observability-DDkJwSKv.mjs");
16266
+ const { initTelemetry, shutdownTelemetry } = await import("./observability-C08jUFsJ.mjs");
15492
16267
  await initTelemetry(serverConfig.observability.tracing, config.instanceId);
15493
16268
  const app = await buildApp(config);
15494
16269
  const shutdown = async () => {
@@ -15714,4 +16489,187 @@ function createExecuteUpdate({ managed }) {
15714
16489
  };
15715
16490
  }
15716
16491
  //#endregion
15717
- export { configureClientLoggerForService as $, installClientService as A, createOwner as B, checkNodeVersion as C, checkWebSocket as D, checkServerReachable as E, isDockerAvailable as F, setJsonMode as G, resolveReplyToFromEnv as H, stopPostgres as I, FirstTreeHubSDK as J, status as K, ClientRuntime as L, resolveCliInvocation as M, uninstallClientService as N, printResults as O, ensurePostgres as P, applyClientLoggerConfig as Q, handleClientOrgMismatch as R, checkDocker as S, checkServerHealth as T, blank as U, hasUser as V, print as W, SessionRegistry as X, SdkError as Y, cleanWorkspaces as Z, runMigrations as _, COMMAND_VERSION as a, checkClientConfig as b, promptMissingFields as c, onboardCheck as d, onboardCreate as f, migrateLocalAgentDirs as g, createApiNameResolver as h, startServer as i, isServiceSupported as j, getClientServiceStatus as k, formatCheckReport as l, runHomeMigration as m, declineUpdate as n, isInteractive as o, saveOnboardState as p, ClientOrgMismatchError$1 as q, promptUpdate as r, promptAddAgent as s, createExecuteUpdate as t, loadOnboardState as u, checkAgentConfigs as v, checkServerConfig as w, checkDatabase as x, checkBackgroundService as y, rotateClientIdWithBackup as z };
16492
+ //#region src/commands/saas-connect.ts
16493
+ /**
16494
+ * @internal
16495
+ * Decode a JWT payload without verifying its signature. Used only by the
16496
+ * CLI's account-switch prompt and the URL-derivation helper below. Not
16497
+ * re-exported from `packages/command/src/index.ts` — external consumers
16498
+ * should call `deriveHubUrlFromToken` instead.
16499
+ */
16500
+ function decodeJwtPayload(token) {
16501
+ try {
16502
+ const parts = token.split(".");
16503
+ if (parts.length !== 3 || !parts[1]) return null;
16504
+ const raw = Buffer.from(parts[1], "base64url").toString();
16505
+ const obj = JSON.parse(raw);
16506
+ if (typeof obj !== "object" || obj === null) return null;
16507
+ return obj;
16508
+ } catch {
16509
+ return null;
16510
+ }
16511
+ }
16512
+ var HubUrlDerivationError = class extends Error {
16513
+ constructor(code, message) {
16514
+ super(message);
16515
+ this.code = code;
16516
+ this.name = "HubUrlDerivationError";
16517
+ }
16518
+ };
16519
+ /**
16520
+ * Derive the hub URL from a connect token's `iss` claim. Throws
16521
+ * `HubUrlDerivationError` when the claim is missing or malformed — we
16522
+ * *never* fall back to a default URL because that would let a stale
16523
+ * connect token from one environment silently re-target another (prod →
16524
+ * staging foot-gun).
16525
+ *
16526
+ * The action handler maps the thrown error to a `fail()` exit so this
16527
+ * function stays unit-testable without spawning a subprocess.
16528
+ */
16529
+ function deriveHubUrlFromToken(token) {
16530
+ const payload = decodeJwtPayload(token);
16531
+ if (!payload) throw new HubUrlDerivationError("INVALID_TOKEN", "Connect token is not a valid JWT. Generate a new one from your Hub web console.");
16532
+ const iss = payload.iss;
16533
+ if (typeof iss !== "string" || iss.length === 0) throw new HubUrlDerivationError("TOKEN_MISSING_ISS", "Connect token does not carry an issuer (`iss` claim). Generate a new token from a Hub running v0.10+.");
16534
+ if (!/^https?:\/\//i.test(iss)) throw new HubUrlDerivationError("TOKEN_BAD_ISS", `Connect token issuer "${iss}" is not an http(s) URL. Generate a new token.`);
16535
+ return iss.replace(/\/+$/, "");
16536
+ }
16537
+ async function promptReplaceOrCancel(newMemberId) {
16538
+ const existing = loadCredentials();
16539
+ if (!existing) return "proceed";
16540
+ const existingPayload = decodeJwtPayload(existing.accessToken);
16541
+ const existingMemberId = typeof existingPayload?.memberId === "string" ? existingPayload.memberId : null;
16542
+ if (existingMemberId && existingMemberId === newMemberId) return "proceed";
16543
+ const existingMember = existingMemberId ? `member ${existingMemberId.slice(0, 8)}` : "unknown account";
16544
+ const serviceStatus = getClientServiceStatus();
16545
+ const serviceLine = serviceStatus.state === "active" ? `running (${serviceStatus.detail ?? "live"})` : serviceStatus.state === "inactive" ? `installed but not running${serviceStatus.detail ? ` — ${serviceStatus.detail}` : ""}` : "not installed";
16546
+ print.line("\n ⚠️ This computer is already connected under another account.\n\n");
16547
+ print.line(` Existing account: ${existingMember}\n`);
16548
+ print.line(` Server: ${existing.serverUrl}\n`);
16549
+ print.line(` Background service: ${serviceLine}\n\n`);
16550
+ print.line(" Replacing only affects THIS computer. Server-side data is untouched.\n\n");
16551
+ return await select({
16552
+ message: "How would you like to continue?",
16553
+ choices: [{
16554
+ name: "Replace — log out the other account and set up this one",
16555
+ value: "replace"
16556
+ }, {
16557
+ name: "Cancel — keep the existing setup",
16558
+ value: "cancel"
16559
+ }]
16560
+ }) === "replace" ? "proceed" : "cancel";
16561
+ }
16562
+ async function exchangeToken(url, token) {
16563
+ const res = await fetch(`${url}/api/v1/auth/connect-token`, {
16564
+ method: "POST",
16565
+ headers: { "Content-Type": "application/json" },
16566
+ body: JSON.stringify({ token }),
16567
+ signal: AbortSignal.timeout(1e4)
16568
+ });
16569
+ if (!res.ok) fail("AUTH_ERROR", (await res.json().catch(() => ({}))).error ?? `Token exchange failed (HTTP ${res.status})`, 1);
16570
+ return await res.json();
16571
+ }
16572
+ /**
16573
+ * Top-level `first-tree-hub connect <token>`. Single positional, no flags,
16574
+ * no env-var override — the connect token's `iss` claim carries the hub
16575
+ * URL so prod / staging / local environments are tagged at issuance and
16576
+ * the operator can never accidentally cross-target.
16577
+ */
16578
+ function registerSaaSConnectCommand(program) {
16579
+ program.command("connect <token>").description("Connect this computer to the Hub using a token from the web console").option("--no-service", "Skip background service install (runs inline until Ctrl+C)").action(async (token, options) => {
16580
+ try {
16581
+ let url;
16582
+ try {
16583
+ url = deriveHubUrlFromToken(token);
16584
+ } catch (err) {
16585
+ if (err instanceof HubUrlDerivationError) fail(err.code, err.message, 1);
16586
+ throw err;
16587
+ }
16588
+ const payload = decodeJwtPayload(token);
16589
+ const newMemberId = typeof payload?.memberId === "string" ? payload.memberId : null;
16590
+ if (newMemberId) {
16591
+ if (await promptReplaceOrCancel(newMemberId) === "cancel") {
16592
+ print.line("\n Cancelled. Existing setup untouched.\n");
16593
+ return;
16594
+ }
16595
+ }
16596
+ const tokens = await exchangeToken(url, token);
16597
+ setConfigValue(join(DEFAULT_CONFIG_DIR, "client.yaml"), "server.url", url);
16598
+ print.line(`\n ✓ Hub: ${url}\n`);
16599
+ saveCredentials({
16600
+ ...tokens,
16601
+ serverUrl: url
16602
+ });
16603
+ print.line(" ✓ Authenticated\n");
16604
+ resetConfig();
16605
+ resetConfigMeta();
16606
+ const config = await initConfig({
16607
+ schema: clientConfigSchema,
16608
+ role: "client"
16609
+ });
16610
+ print.line(` ✓ Computer registered (id: ${config.client.id})\n`);
16611
+ if (options.service !== false && isServiceSupported()) {
16612
+ const info = installClientService();
16613
+ print.line(` ✓ Background service installed (${info.platform}) — you may close this terminal.\n`);
16614
+ print.line(` Logs: ${info.logDir}\n\n`);
16615
+ return;
16616
+ }
16617
+ if (options.service === false) print.line(" (--no-service) running inline — Ctrl+C to stop\n");
16618
+ else print.line(` Background service not supported on ${process.platform}; running inline.\n`);
16619
+ const agentsDir = join(DEFAULT_CONFIG_DIR, "agents");
16620
+ try {
16621
+ await migrateLocalAgentDirs({
16622
+ agentsDir,
16623
+ workspacesDir: join(DEFAULT_DATA_DIR$1, "workspaces"),
16624
+ sessionsDir: join(DEFAULT_DATA_DIR$1, "sessions"),
16625
+ resolver: createApiNameResolver(config.server.url, () => ensureFreshAccessToken())
16626
+ });
16627
+ } catch (err) {
16628
+ const msg = err instanceof Error ? err.message : String(err);
16629
+ print.status("⚠️", `agent-dir migration skipped: ${msg}`);
16630
+ }
16631
+ const agents = loadAgents({
16632
+ schema: agentConfigSchema,
16633
+ agentsDir
16634
+ });
16635
+ const runtime = new ClientRuntime(config.server.url, config.client.id, {
16636
+ currentVersion: COMMAND_VERSION,
16637
+ update: {
16638
+ updateConfig: config.update,
16639
+ prompt: promptUpdate,
16640
+ executeUpdate: createExecuteUpdate({ managed: false })
16641
+ }
16642
+ });
16643
+ for (const [name, agentConfig] of agents) runtime.addAgent(name, agentConfig);
16644
+ await runtime.start();
16645
+ runtime.watchAgentsDir(agentsDir);
16646
+ const shutdown = async () => {
16647
+ print.line("\n Shutting down...\n");
16648
+ runtime.unwatchAgentsDir();
16649
+ await runtime.stop();
16650
+ process.exit(0);
16651
+ };
16652
+ process.on("SIGINT", () => void shutdown());
16653
+ process.on("SIGTERM", () => void shutdown());
16654
+ await new Promise(() => {});
16655
+ } catch (error) {
16656
+ if (error.name === "ExitPromptError") {
16657
+ print.line("\n Cancelled.\n");
16658
+ return;
16659
+ }
16660
+ if (error instanceof ClientOrgMismatchError) await handleClientOrgMismatch(error, {
16661
+ managed: false,
16662
+ configDir: DEFAULT_CONFIG_DIR,
16663
+ rerunCommand: "first-tree-hub connect <token>"
16664
+ });
16665
+ const msg = error instanceof Error ? error.message : String(error);
16666
+ print.line(` Error: ${msg}\n`);
16667
+ process.exit(1);
16668
+ } finally {
16669
+ resetConfig();
16670
+ resetConfigMeta();
16671
+ }
16672
+ });
16673
+ }
16674
+ //#endregion
16675
+ export { FirstTreeHubSDK as $, checkWebSocket as A, ClientRuntime as B, checkClientConfig as C, checkServerConfig as D, checkNodeVersion as E, resolveCliInvocation as F, resolveReplyToFromEnv as G, rotateClientIdWithBackup as H, uninstallClientService as I, blank as J, fail as K, ensurePostgres as L, getClientServiceStatus as M, installClientService as N, checkServerHealth as O, isServiceSupported as P, ClientOrgMismatchError as Q, isDockerAvailable as R, checkBackgroundService as S, checkDocker as T, createOwner as U, handleClientOrgMismatch as V, hasUser as W, setJsonMode as X, print as Y, status as Z, runHomeMigration as _, declineUpdate as a, runMigrations as b, COMMAND_VERSION as c, promptMissingFields as d, SdkError as et, formatCheckReport as f, saveOnboardState as g, onboardCreate as h, createExecuteUpdate as i, configureClientLoggerForService as it, printResults as j, checkServerReachable as k, isInteractive as l, onboardCheck as m, deriveHubUrlFromToken as n, cleanWorkspaces as nt, promptUpdate as o, loadOnboardState as p, success as q, registerSaaSConnectCommand as r, applyClientLoggerConfig as rt, startServer as s, HubUrlDerivationError as t, SessionRegistry as tt, promptAddAgent as u, createApiNameResolver as v, checkDatabase as w, checkAgentConfigs as x, migrateLocalAgentDirs as y, stopPostgres as z };