@agent-team-foundation/first-tree-hub 0.10.0 → 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.
- package/dist/{bootstrap-CtVqQA8a.mjs → bootstrap-Ca5Fiqz6.mjs} +10 -1
- package/dist/cli/index.mjs +9 -19
- package/dist/{feishu-DEmwoNn_.mjs → dist-CLiN7cVS.mjs} +88 -51
- package/dist/drizzle/0026_saas_onboarding.sql +153 -0
- package/dist/drizzle/meta/_journal.json +7 -0
- package/dist/feishu-FTWnoOsc.mjs +52 -0
- package/dist/{getMachineId-bsd-BB-fnFLA.mjs → getMachineId-bsd-D0w3uAZa.mjs} +1 -1
- package/dist/{getMachineId-darwin-DAYWNsYK.mjs → getMachineId-darwin-DOoYFb2_.mjs} +1 -1
- package/dist/{getMachineId-win-H5RT49ov.mjs → getMachineId-win-B6hY8edq.mjs} +1 -1
- package/dist/index.mjs +7 -5
- package/dist/invitation-BTlGMy0o-dIoR8JRj.mjs +3 -0
- package/dist/invitation-C_zAhB8x-8Khychlu.mjs +258 -0
- package/dist/{observability-DDkJwSKv.mjs → observability-C08jUFsJ.mjs} +1 -1
- package/dist/{observability-DV_fQKqV-oxfXX6Z2.mjs → observability-DPyf745N-BSc8QNcR.mjs} +6 -6
- package/dist/{core-BgiFGT7Y.mjs → saas-connect-idjpoPTk.mjs} +1111 -153
- package/dist/web/assets/index-CEAPwdg7.js +377 -0
- package/dist/web/assets/index-CzWeWItA.css +1 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-DStPeqrX.css +0 -1
- package/dist/web/assets/index-Wgxk3V_m.js +0 -371
- /package/dist/{execAsync-CP8iWV5b.mjs → execAsync-XMc-nFn-.mjs} +0 -0
- /package/dist/{getMachineId-linux-BU7Fi6S0.mjs → getMachineId-linux-MlY63Zsw.mjs} +0 -0
- /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-
|
|
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-
|
|
4
|
-
import { $ as
|
|
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
|
|
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
|
|
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-
|
|
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-
|
|
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
|
|
13035
|
-
|
|
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
|
|
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
|
-
|
|
13129
|
-
|
|
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
|
-
|
|
13136
|
-
|
|
13137
|
-
|
|
13138
|
-
|
|
13139
|
-
|
|
13140
|
-
|
|
13141
|
-
|
|
13142
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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.
|
|
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
|
|
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-
|
|
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
|
-
|
|
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 };
|