@agent-team-foundation/first-tree-hub 0.10.6 → 0.10.8
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/cli/index.mjs +101 -14
- package/dist/{dist-CbX9mUVH.mjs → dist-DSr_I5Ia.mjs} +12 -0
- package/dist/{feishu-DvjRZMdZ.mjs → feishu-eynC54km.mjs} +1 -1
- package/dist/index.mjs +4 -4
- package/dist/{invitation-BljIolbO-DLeHfURd.mjs → invitation-B1pjAyOz-BaCA9PII.mjs} +20 -6
- package/dist/invitation-CBnQyB7o-DVOpS_Ts.mjs +3 -0
- package/dist/{saas-connect-vLyx73kJ.mjs → saas-connect-D-fy3xu-.mjs} +470 -172
- package/dist/web/assets/index-B33n1w2k.js +388 -0
- package/dist/web/assets/index-BR-pWR44.css +1 -0
- package/dist/web/assets/{index-FAu90ACE.js → index-DIgiOalZ.js} +4 -9
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/invitation-D3feYxet-366MNOor.mjs +0 -3
- package/dist/web/assets/index-5490TxIp.css +0 -1
- package/dist/web/assets/index-CJEdMYWd.js +0 -387
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { m as __toESM } from "./esm-CYu4tXXn.mjs";
|
|
2
2
|
import { _ as withSpan, a as endWsConnectionSpan, b as require_pino, c as messageAttrs, d as rootLogger$1, g as startWsConnectionSpan, i as currentTraceId, n as applyLoggerConfig, o as getFastifyOtelPlugin, p as setWsConnectionAttrs, r as createLogger$1, t as adapterAttrs, u as observabilityPlugin, v as withWsMessageSpan, y as FIRST_TREE_HUB_ATTR } from "./observability-DPyf745N-BSc8QNcR.mjs";
|
|
3
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-jx5nN1qZ.mjs";
|
|
4
|
-
import { $ as refreshTokenSchema, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as isReservedAgentName$1, H as inboxDeliverFrameSchema$1, I as extractMentions, J as loginSchema, K as joinByInvitationSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as rebindAgentSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, St as wsAuthFrameSchema, T as createAdapterConfigSchema, U as inboxPollQuerySchema, V as inboxAckFrameSchema, W as isRedactedEnvValue, X as notificationQuerySchema, Y as messageSourceSchema$1, Z as paginationQuerySchema, _ as adminUpdateTaskSchema, _t as updateClientCapabilitiesSchema, a as AGENT_STATUSES, at as sendToAgentSchema, b as agentRuntimeConfigPayloadSchema$1, bt as updateSystemConfigSchema, ct as sessionEventSchema$1, d as TASK_HEALTH_SIGNALS, dt as switchOrgSchema, et as runtimeStateMessageSchema, f as TASK_STATUSES, ft as taskListQuerySchema, g as adminCreateTaskSchema, gt as updateChatSchema, h as addParticipantSchema, ht as updateAgentSchema, i as AGENT_SOURCES, it as sendMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as sessionReconcileRequestSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateAgentRuntimeConfigSchema, n as AGENT_NAME_REGEX$1, nt as scanMentionTokens, o as AGENT_TYPES, ot as sessionCompletionMessageSchema, p as TASK_TERMINAL_STATUSES, pt as updateAdapterConfigSchema, q as linkTaskChatSchema, r as AGENT_SELECTOR_HEADER$1, rt as selfServiceFeishuBotSchema, s as AGENT_VISIBILITY, st as sessionEventMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as safeRedirectPath, u as TASK_CREATOR_TYPES, ut as sessionStateMessageSchema, v as agentBindRequestSchema, vt as updateMemberSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, xt as updateTaskStatusSchema, y as agentPinnedMessageSchema$1, yt as updateOrganizationSchema, z as githubStartQuerySchema } from "./dist-
|
|
5
|
-
import { a as
|
|
4
|
+
import { $ as refreshTokenSchema, A as createOrgFromMeSchema, B as imageInlineContentSchema, C as clientRegisterSchema, D as createAgentSchema, E as createAdapterMappingSchema, F as dryRunAgentRuntimeConfigSchema, G as isReservedAgentName$1, H as inboxDeliverFrameSchema$1, I as extractMentions, J as loginSchema, K as joinByInvitationSchema, L as githubCallbackQuerySchema, M as createTaskSchema, N as defaultRuntimeConfigPayload, O as createChatSchema, P as delegateFeishuUserSchema, Q as rebindAgentSchema, R as githubDevCallbackQuerySchema, S as clientCapabilitiesSchema$1, St as wsAuthFrameSchema, T as createAdapterConfigSchema, U as inboxPollQuerySchema, V as inboxAckFrameSchema, W as isRedactedEnvValue, X as notificationQuerySchema, Y as messageSourceSchema$1, Z as paginationQuerySchema, _ as adminUpdateTaskSchema, _t as updateClientCapabilitiesSchema, a as AGENT_STATUSES, at as sendToAgentSchema, b as agentRuntimeConfigPayloadSchema$1, bt as updateSystemConfigSchema, ct as sessionEventSchema$1, d as TASK_HEALTH_SIGNALS, dt as switchOrgSchema, et as runtimeStateMessageSchema, f as TASK_STATUSES, ft as taskListQuerySchema, g as adminCreateTaskSchema, gt as updateChatSchema, h as addParticipantSchema, ht as updateAgentSchema, i as AGENT_SOURCES, it as sendMessageSchema, j as createOrganizationSchema, k as createMemberSchema, l as SYSTEM_CONFIG_DEFAULTS, lt as sessionReconcileRequestSchema, m as WS_AUTH_FRAME_TIMEOUT_MS, mt as updateAgentRuntimeConfigSchema, n as AGENT_NAME_REGEX$1, nt as scanMentionTokens, o as AGENT_TYPES, ot as sessionCompletionMessageSchema, p as TASK_TERMINAL_STATUSES, pt as updateAdapterConfigSchema, q as linkTaskChatSchema, r as AGENT_SELECTOR_HEADER$1, rt as selfServiceFeishuBotSchema, s as AGENT_VISIBILITY, st as sessionEventMessageSchema, t as AGENT_BIND_REJECT_REASONS, tt as safeRedirectPath, u as TASK_CREATOR_TYPES, ut as sessionStateMessageSchema, v as agentBindRequestSchema, vt as updateMemberSchema, w as connectTokenExchangeSchema, x as agentTypeSchema$1, xt as updateTaskStatusSchema, y as agentPinnedMessageSchema$1, yt as updateOrganizationSchema, z as githubStartQuerySchema } from "./dist-DSr_I5Ia.mjs";
|
|
5
|
+
import { _ as recordRedemption, a as ConflictError, b as uuidv7, c as UnauthorizedError, d as findActiveByToken, f as getActiveInvitation, h as organizations, i as ClientUserMismatchError$1, l as buildInviteUrl, m as invitations, n as BadRequestError, o as ForbiddenError, p as invitationRedemptions, r as ClientOrgMismatchError$1, s as NotFoundError, t as AppError, u as ensureActiveInvitation, y as users } from "./invitation-B1pjAyOz-BaCA9PII.mjs";
|
|
6
6
|
import { createRequire } from "node:module";
|
|
7
7
|
import { ZodError, z } from "zod";
|
|
8
8
|
import { delimiter, dirname, isAbsolute, join, resolve } from "node:path";
|
|
@@ -770,6 +770,11 @@ z.object({
|
|
|
770
770
|
sdkVersion: z.string().max(50).optional(),
|
|
771
771
|
wireCapabilities: clientWireCapabilitiesSchema.optional()
|
|
772
772
|
});
|
|
773
|
+
z.object({
|
|
774
|
+
clientId: z.string(),
|
|
775
|
+
previousUserId: z.string().nullable(),
|
|
776
|
+
unpinnedAgentCount: z.number().int().nonnegative()
|
|
777
|
+
});
|
|
773
778
|
const capabilityStateSchema = z.enum([
|
|
774
779
|
"ok",
|
|
775
780
|
"missing",
|
|
@@ -1037,6 +1042,13 @@ z.object({
|
|
|
1037
1042
|
displayName: z.string().min(1).max(200)
|
|
1038
1043
|
});
|
|
1039
1044
|
z.object({ organizationId: z.string().min(1) });
|
|
1045
|
+
z.object({
|
|
1046
|
+
id: z.string(),
|
|
1047
|
+
organizationId: z.string(),
|
|
1048
|
+
organizationName: z.string(),
|
|
1049
|
+
role: z.enum(["admin", "member"]),
|
|
1050
|
+
agentId: z.string()
|
|
1051
|
+
});
|
|
1040
1052
|
const memberRoleSchema = z.enum(["admin", "member"]);
|
|
1041
1053
|
const memberSchema = z.object({
|
|
1042
1054
|
id: z.string(),
|
|
@@ -1761,8 +1773,8 @@ var SdkError = class extends Error {
|
|
|
1761
1773
|
/**
|
|
1762
1774
|
* Thrown (emitted on `error` and rejected from `connect()`) when the server
|
|
1763
1775
|
* refuses a `client:register` because the local clientId is bound to a
|
|
1764
|
-
* different organization.
|
|
1765
|
-
*
|
|
1776
|
+
* different organization. Retained for wire compatibility — the read paths
|
|
1777
|
+
* that produced this code were retired in decouple-client-from-identity §4.1.
|
|
1766
1778
|
*/
|
|
1767
1779
|
var ClientOrgMismatchError = class extends Error {
|
|
1768
1780
|
code = "CLIENT_ORG_MISMATCH";
|
|
@@ -1771,6 +1783,21 @@ var ClientOrgMismatchError = class extends Error {
|
|
|
1771
1783
|
this.name = "ClientOrgMismatchError";
|
|
1772
1784
|
}
|
|
1773
1785
|
};
|
|
1786
|
+
/**
|
|
1787
|
+
* Thrown when the server refuses `client:register` because the local
|
|
1788
|
+
* client.yaml is owned by a different user. The CLI detects this via
|
|
1789
|
+
* `instanceof` and guides the operator to run
|
|
1790
|
+
* `first-tree-hub client claim --confirm` to take ownership (which unpins
|
|
1791
|
+
* the previous owner's agents from this machine). See decouple-client-from-
|
|
1792
|
+
* identity §4.4.
|
|
1793
|
+
*/
|
|
1794
|
+
var ClientUserMismatchError = class extends Error {
|
|
1795
|
+
code = "CLIENT_USER_MISMATCH";
|
|
1796
|
+
constructor(message = "Client belongs to a different user") {
|
|
1797
|
+
super(message);
|
|
1798
|
+
this.name = "ClientUserMismatchError";
|
|
1799
|
+
}
|
|
1800
|
+
};
|
|
1774
1801
|
const RECONNECT_BASE_MS = 1e3;
|
|
1775
1802
|
const RECONNECT_MAX_MS = 3e4;
|
|
1776
1803
|
const WS_CONNECT_TIMEOUT_MS = 1e4;
|
|
@@ -2121,7 +2148,7 @@ var ClientConnection = class extends EventEmitter {
|
|
|
2121
2148
|
const code = typeof msg.code === "string" ? msg.code : void 0;
|
|
2122
2149
|
const message = typeof msg.message === "string" ? msg.message : "unknown";
|
|
2123
2150
|
this.closing = true;
|
|
2124
|
-
const err = code === "CLIENT_ORG_MISMATCH" ? new ClientOrgMismatchError(message) : /* @__PURE__ */ new Error(`client:register rejected: ${message}`);
|
|
2151
|
+
const err = code === "CLIENT_USER_MISMATCH" ? new ClientUserMismatchError(message) : code === "CLIENT_ORG_MISMATCH" ? new ClientOrgMismatchError(message) : /* @__PURE__ */ new Error(`client:register rejected: ${message}`);
|
|
2125
2152
|
this.lastHandshakeError = err;
|
|
2126
2153
|
this.wsLogger.error({
|
|
2127
2154
|
code,
|
|
@@ -7556,7 +7583,7 @@ async function onboardCreate(args) {
|
|
|
7556
7583
|
}
|
|
7557
7584
|
const runtimeAgent = args.type === "human" ? args.assistant : args.id;
|
|
7558
7585
|
if (args.feishuBotAppId && args.feishuBotAppSecret) {
|
|
7559
|
-
const { bindFeishuBot } = await import("./feishu-
|
|
7586
|
+
const { bindFeishuBot } = await import("./feishu-eynC54km.mjs").then((n) => n.r);
|
|
7560
7587
|
const targetAgentUuid = args.type === "human" ? assistantUuid : primary.uuid;
|
|
7561
7588
|
if (!targetAgentUuid) print.line(`Warning: Cannot bind Feishu bot — no runtime agent available for "${args.id}".\n`);
|
|
7562
7589
|
else {
|
|
@@ -8536,7 +8563,7 @@ function createFeedbackHandler(config) {
|
|
|
8536
8563
|
return { handle };
|
|
8537
8564
|
}
|
|
8538
8565
|
//#endregion
|
|
8539
|
-
//#region ../server/dist/app-
|
|
8566
|
+
//#region ../server/dist/app-XRPDwwkj.mjs
|
|
8540
8567
|
var __defProp = Object.defineProperty;
|
|
8541
8568
|
var __exportAll = (all, no_symbols) => {
|
|
8542
8569
|
let target = {};
|
|
@@ -8631,6 +8658,20 @@ const chatParticipants = pgTable("chat_participants", {
|
|
|
8631
8658
|
mode: text("mode").notNull().default("full"),
|
|
8632
8659
|
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
|
|
8633
8660
|
}, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_participants_agent").on(table.agentId)]);
|
|
8661
|
+
/** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
|
|
8662
|
+
const members = pgTable("members", {
|
|
8663
|
+
id: text("id").primaryKey(),
|
|
8664
|
+
userId: text("user_id").notNull().references(() => users.id),
|
|
8665
|
+
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
8666
|
+
agentId: text("agent_id").unique().notNull().references(() => agents.uuid),
|
|
8667
|
+
role: text("role").notNull(),
|
|
8668
|
+
status: text("status").notNull().default("active"),
|
|
8669
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
8670
|
+
}, (table) => [
|
|
8671
|
+
unique("uq_members_user_org").on(table.userId, table.organizationId),
|
|
8672
|
+
index("idx_members_user").on(table.userId),
|
|
8673
|
+
index("idx_members_org").on(table.organizationId)
|
|
8674
|
+
]);
|
|
8634
8675
|
function requireAgent(request) {
|
|
8635
8676
|
const agent = request.agent;
|
|
8636
8677
|
if (!agent) throw new UnauthorizedError("Agent authentication required");
|
|
@@ -8675,18 +8716,41 @@ function memberScope(request) {
|
|
|
8675
8716
|
/**
|
|
8676
8717
|
* SQL WHERE conditions for agents visible to a member.
|
|
8677
8718
|
* Visibility is the same for all roles:
|
|
8678
|
-
*
|
|
8719
|
+
* target org + not deleted + (organization-visible OR managerId = caller's member)
|
|
8720
|
+
*
|
|
8721
|
+
* Takes explicit `orgId` + `memberId` rather than reading them from
|
|
8722
|
+
* MemberScope: an admin viewing a non-default org passes
|
|
8723
|
+
* `requireMemberInOrg(db, request, orgId).memberId` to derive the right
|
|
8724
|
+
* memberId for that org (decouple-client-from-identity §4.5.1).
|
|
8679
8725
|
*/
|
|
8680
|
-
function agentVisibilityCondition(
|
|
8681
|
-
return and(eq(agents.organizationId,
|
|
8726
|
+
function agentVisibilityCondition(orgId, memberId) {
|
|
8727
|
+
return and(eq(agents.organizationId, orgId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, memberId)));
|
|
8682
8728
|
}
|
|
8683
8729
|
/**
|
|
8684
|
-
* Assert a single agent is visible to the
|
|
8685
|
-
*
|
|
8686
|
-
*
|
|
8730
|
+
* Assert a single agent is visible to the caller.
|
|
8731
|
+
*
|
|
8732
|
+
* The agent is identified by UUID, so its organization is *intrinsic to the
|
|
8733
|
+
* row itself* — we don't gate on the JWT default org. Instead we resolve the
|
|
8734
|
+
* caller's active membership in `agent.organizationId` and reuse the same
|
|
8735
|
+
* visibility rule (organization-visible OR managerId = caller's member in
|
|
8736
|
+
* that org). This lets a multi-org user hit `/admin/agents/:uuid` for an
|
|
8737
|
+
* agent in a non-default org without re-issuing the JWT — `/auth/switch-org`
|
|
8738
|
+
* now returns 204 only and the web client carries the selected org in
|
|
8739
|
+
* localStorage (decouple-client-from-identity §C.2 / §D fix).
|
|
8740
|
+
*
|
|
8741
|
+
* Returns 404 for both "not found" and "not visible" to prevent UUID
|
|
8742
|
+
* enumeration.
|
|
8687
8743
|
*/
|
|
8688
8744
|
async function assertAgentVisible(db, scope, agentUuid) {
|
|
8689
|
-
const [
|
|
8745
|
+
const [agent] = await db.select({
|
|
8746
|
+
uuid: agents.uuid,
|
|
8747
|
+
organizationId: agents.organizationId,
|
|
8748
|
+
status: agents.status
|
|
8749
|
+
}).from(agents).where(eq(agents.uuid, agentUuid)).limit(1);
|
|
8750
|
+
if (!agent || agent.status === AGENT_STATUSES.DELETED) throw new NotFoundError(`Agent "${agentUuid}" not found`);
|
|
8751
|
+
const [member] = await db.select({ id: members.id }).from(members).where(and(eq(members.userId, scope.userId), eq(members.organizationId, agent.organizationId), eq(members.status, "active"))).limit(1);
|
|
8752
|
+
if (!member) throw new NotFoundError(`Agent "${agentUuid}" not found`);
|
|
8753
|
+
const [row] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.uuid, agentUuid), agentVisibilityCondition(agent.organizationId, member.id))).limit(1);
|
|
8690
8754
|
if (!row) throw new NotFoundError(`Agent "${agentUuid}" not found`);
|
|
8691
8755
|
}
|
|
8692
8756
|
/**
|
|
@@ -8708,9 +8772,22 @@ async function assertChatAccess(db, scope, chatId) {
|
|
|
8708
8772
|
if (!managed) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
8709
8773
|
}
|
|
8710
8774
|
/**
|
|
8711
|
-
* Assert the
|
|
8712
|
-
*
|
|
8713
|
-
*
|
|
8775
|
+
* Assert the caller can manage (update/delete/token/suspend) an agent.
|
|
8776
|
+
*
|
|
8777
|
+
* Manageability is anchored on the agent's *own* organization, not on the
|
|
8778
|
+
* JWT default org. We resolve the caller's active membership in
|
|
8779
|
+
* `agent.organizationId` and grant manage access if either:
|
|
8780
|
+
* - that membership is `admin` (admin in the agent's org), or
|
|
8781
|
+
* - the agent's `managerId` equals the caller's memberId *in that org*.
|
|
8782
|
+
*
|
|
8783
|
+
* This is the cross-org switch-org fix: with `/auth/switch-org` now 204 the
|
|
8784
|
+
* scope.organizationId / scope.memberId are JWT defaults, so the previous
|
|
8785
|
+
* `agent.organizationId !== scope.organizationId` short-circuit was 404'ing
|
|
8786
|
+
* every `:uuid` route as soon as the user looked at a non-default org. We
|
|
8787
|
+
* authorize against the agent's actual org instead.
|
|
8788
|
+
*
|
|
8789
|
+
* Returns 404 for "not found", "not a member of agent's org", or "not
|
|
8790
|
+
* authorized" — same shape as before, to prevent UUID enumeration.
|
|
8714
8791
|
*/
|
|
8715
8792
|
async function assertCanManage(db, scope, agentUuid) {
|
|
8716
8793
|
const [agent] = await db.select({
|
|
@@ -8718,9 +8795,85 @@ async function assertCanManage(db, scope, agentUuid) {
|
|
|
8718
8795
|
managerId: agents.managerId,
|
|
8719
8796
|
organizationId: agents.organizationId
|
|
8720
8797
|
}).from(agents).where(and(eq(agents.uuid, agentUuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
|
|
8721
|
-
if (!agent
|
|
8722
|
-
|
|
8723
|
-
|
|
8798
|
+
if (!agent) throw new NotFoundError(`Agent "${agentUuid}" not found`);
|
|
8799
|
+
const [memberRow] = await db.select({
|
|
8800
|
+
id: members.id,
|
|
8801
|
+
role: members.role
|
|
8802
|
+
}).from(members).where(and(eq(members.userId, scope.userId), eq(members.organizationId, agent.organizationId), eq(members.status, "active"))).limit(1);
|
|
8803
|
+
if (!memberRow) throw new NotFoundError(`Agent "${agentUuid}" not found`);
|
|
8804
|
+
if (memberRow.role === "admin") return;
|
|
8805
|
+
if (agent.managerId === memberRow.id) return;
|
|
8806
|
+
throw new NotFoundError(`Agent "${agentUuid}" not found`);
|
|
8807
|
+
}
|
|
8808
|
+
/**
|
|
8809
|
+
* Assert the request's authenticated user has an active membership in
|
|
8810
|
+
* `orgId` and return its `(memberId, role)`. Used by admin routes that
|
|
8811
|
+
* accept an explicit `organizationId` from the request (query / body /
|
|
8812
|
+
* path) and must verify role realtime — JWT `organizationId` and `role`
|
|
8813
|
+
* claims are hints, not authoritative (decouple-client-from-identity §4.5).
|
|
8814
|
+
*
|
|
8815
|
+
* Throws {@link ForbiddenError} if the user is not an active member of the
|
|
8816
|
+
* target org.
|
|
8817
|
+
*/
|
|
8818
|
+
async function requireMemberInOrg(db, request, orgId) {
|
|
8819
|
+
const m = requireMember(request);
|
|
8820
|
+
const [row] = await db.select({
|
|
8821
|
+
id: members.id,
|
|
8822
|
+
role: members.role
|
|
8823
|
+
}).from(members).where(and(eq(members.userId, m.userId), eq(members.organizationId, orgId), eq(members.status, "active"))).limit(1);
|
|
8824
|
+
if (!row) throw new ForbiddenError("Not an active member of the target organization");
|
|
8825
|
+
return {
|
|
8826
|
+
memberId: row.id,
|
|
8827
|
+
role: row.role
|
|
8828
|
+
};
|
|
8829
|
+
}
|
|
8830
|
+
/**
|
|
8831
|
+
* Resolve the `(organizationId, memberId, role)` an admin route should
|
|
8832
|
+
* operate against, based on the unified PR-D scoping rule:
|
|
8833
|
+
* - If the request supplies `?organizationId=…` (query) or it appears in
|
|
8834
|
+
* the body, verify the caller is an active member there and return
|
|
8835
|
+
* that membership realtime.
|
|
8836
|
+
* - Otherwise fall back to the JWT default org (the existing
|
|
8837
|
+
* `MemberScope` from `memberScope(request)`).
|
|
8838
|
+
*
|
|
8839
|
+
* All admin listing routes call this so that the cross-org switch — driven
|
|
8840
|
+
* entirely client-side via `localStorage.selectedOrganizationId` after
|
|
8841
|
+
* `/auth/switch-org` returns 204 — funnels through one consistent gate
|
|
8842
|
+
* (decouple-client-from-identity §4.5 / §D, codex P1 #2 fix).
|
|
8843
|
+
*
|
|
8844
|
+
* @returns the effective scope for the rest of the route to read from.
|
|
8845
|
+
*/
|
|
8846
|
+
async function resolveAdminScope(db, request, scope, requestedOrganizationId) {
|
|
8847
|
+
if (!requestedOrganizationId || requestedOrganizationId === scope.organizationId) return scope;
|
|
8848
|
+
const probe = await requireMemberInOrg(db, request, requestedOrganizationId);
|
|
8849
|
+
return {
|
|
8850
|
+
...scope,
|
|
8851
|
+
memberId: probe.memberId,
|
|
8852
|
+
organizationId: requestedOrganizationId,
|
|
8853
|
+
role: probe.role
|
|
8854
|
+
};
|
|
8855
|
+
}
|
|
8856
|
+
/**
|
|
8857
|
+
* Cross-org listing helper for "agents I personally manage". Used by the
|
|
8858
|
+
* CLI `agent list` view (decouple-client-from-identity §4.5.1 case (b)) —
|
|
8859
|
+
* the web roster, by contrast, stays org-scoped via
|
|
8860
|
+
* {@link agentVisibilityCondition}.
|
|
8861
|
+
*
|
|
8862
|
+
* Returns every active agent whose manager is an active member of the
|
|
8863
|
+
* caller. JOINs `agents → members.id` and filters by `members.user_id`.
|
|
8864
|
+
*/
|
|
8865
|
+
async function listAgentsManagedByUser(db, userId) {
|
|
8866
|
+
return db.select({
|
|
8867
|
+
uuid: agents.uuid,
|
|
8868
|
+
name: agents.name,
|
|
8869
|
+
displayName: agents.displayName,
|
|
8870
|
+
type: agents.type,
|
|
8871
|
+
organizationId: agents.organizationId,
|
|
8872
|
+
inboxId: agents.inboxId,
|
|
8873
|
+
visibility: agents.visibility,
|
|
8874
|
+
runtimeProvider: agents.runtimeProvider,
|
|
8875
|
+
clientId: agents.clientId
|
|
8876
|
+
}).from(agents).innerJoin(members, eq(agents.managerId, members.id)).where(and(eq(members.userId, userId), eq(members.status, "active"), ne(agents.status, AGENT_STATUSES.DELETED)));
|
|
8724
8877
|
}
|
|
8725
8878
|
/**
|
|
8726
8879
|
* Maps internal Chats to external IM platform channels.
|
|
@@ -8997,6 +9150,7 @@ async function findExternalMessageByInternalId(db, platform, messageId) {
|
|
|
8997
9150
|
}).from(adapterMessageReferences).where(and(eq(adapterMessageReferences.platform, platform), eq(adapterMessageReferences.messageId, messageId))).limit(1);
|
|
8998
9151
|
return row ?? null;
|
|
8999
9152
|
}
|
|
9153
|
+
const orgQuerySchema$3 = z.object({ organizationId: z.string().min(1).optional() });
|
|
9000
9154
|
function parseId$1(raw) {
|
|
9001
9155
|
const id = Number(raw);
|
|
9002
9156
|
if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid mapping ID: "${raw}"`);
|
|
@@ -9005,8 +9159,11 @@ function parseId$1(raw) {
|
|
|
9005
9159
|
async function adminAdapterMappingRoutes(app) {
|
|
9006
9160
|
app.get("/", async (request) => {
|
|
9007
9161
|
const scope = memberScope(request);
|
|
9008
|
-
const
|
|
9009
|
-
|
|
9162
|
+
const { organizationId } = orgQuerySchema$3.parse(request.query);
|
|
9163
|
+
const targetOrgId = organizationId ?? scope.organizationId;
|
|
9164
|
+
const probe = await requireMemberInOrg(app.db, request, targetOrgId);
|
|
9165
|
+
const conditions = [eq(agents.organizationId, targetOrgId)];
|
|
9166
|
+
if (probe.role !== "admin") conditions.push(eq(agents.managerId, probe.memberId));
|
|
9010
9167
|
return (await app.db.select({
|
|
9011
9168
|
id: adapterAgentMappings.id,
|
|
9012
9169
|
platform: adapterAgentMappings.platform,
|
|
@@ -9073,11 +9230,15 @@ const adapterConfigs = pgTable("adapter_configs", {
|
|
|
9073
9230
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
9074
9231
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
9075
9232
|
}, (t) => [unique("uq_adapter_configs_agent_platform").on(t.agentId, t.platform)]);
|
|
9233
|
+
const orgQuerySchema$2 = z.object({ organizationId: z.string().min(1).optional() });
|
|
9076
9234
|
async function adminAdapterStatusRoutes(app) {
|
|
9077
9235
|
app.get("/", async (request) => {
|
|
9078
9236
|
const scope = memberScope(request);
|
|
9079
|
-
const
|
|
9080
|
-
|
|
9237
|
+
const { organizationId } = orgQuerySchema$2.parse(request.query);
|
|
9238
|
+
const targetOrgId = organizationId ?? scope.organizationId;
|
|
9239
|
+
const probe = await requireMemberInOrg(app.db, request, targetOrgId);
|
|
9240
|
+
const conditions = [eq(agents.organizationId, targetOrgId), ne(agents.status, "deleted")];
|
|
9241
|
+
if (probe.role !== "admin") conditions.push(eq(agents.managerId, probe.memberId));
|
|
9081
9242
|
const visibleRows = await app.db.select({ id: adapterConfigs.id }).from(adapterConfigs).innerJoin(agents, eq(agents.uuid, adapterConfigs.agentId)).where(and(...conditions));
|
|
9082
9243
|
const visibleIds = new Set(visibleRows.map((r) => r.id));
|
|
9083
9244
|
if (visibleIds.size === 0) return [];
|
|
@@ -9264,6 +9425,7 @@ async function deleteAdapterConfig(db, id) {
|
|
|
9264
9425
|
if (!row) throw new NotFoundError(`Adapter config "${id}" not found`);
|
|
9265
9426
|
}
|
|
9266
9427
|
const log$5 = createLogger$1("AdminAdapters");
|
|
9428
|
+
const orgQuerySchema$1 = z.object({ organizationId: z.string().min(1).optional() });
|
|
9267
9429
|
function parseId(raw) {
|
|
9268
9430
|
const id = Number(raw);
|
|
9269
9431
|
if (!Number.isInteger(id) || id <= 0) throw new BadRequestError(`Invalid adapter ID: "${raw}"`);
|
|
@@ -9272,7 +9434,9 @@ function parseId(raw) {
|
|
|
9272
9434
|
async function adminAdapterRoutes(app) {
|
|
9273
9435
|
app.get("/", async (request) => {
|
|
9274
9436
|
const scope = memberScope(request);
|
|
9275
|
-
|
|
9437
|
+
const { organizationId } = orgQuerySchema$1.parse(request.query);
|
|
9438
|
+
const effective = await resolveAdminScope(app.db, request, scope, organizationId);
|
|
9439
|
+
return (await listAdapterConfigsForMember(app.db, effective)).map((c) => ({
|
|
9276
9440
|
...c,
|
|
9277
9441
|
createdAt: c.createdAt.toISOString(),
|
|
9278
9442
|
updatedAt: c.updatedAt.toISOString()
|
|
@@ -9401,20 +9565,6 @@ const agentConfigs = pgTable("agent_configs", {
|
|
|
9401
9565
|
updatedBy: text("updated_by").notNull(),
|
|
9402
9566
|
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
9403
9567
|
});
|
|
9404
|
-
/** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
|
|
9405
|
-
const members = pgTable("members", {
|
|
9406
|
-
id: text("id").primaryKey(),
|
|
9407
|
-
userId: text("user_id").notNull().references(() => users.id),
|
|
9408
|
-
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
9409
|
-
agentId: text("agent_id").unique().notNull().references(() => agents.uuid),
|
|
9410
|
-
role: text("role").notNull(),
|
|
9411
|
-
status: text("status").notNull().default("active"),
|
|
9412
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
9413
|
-
}, (table) => [
|
|
9414
|
-
unique("uq_members_user_org").on(table.userId, table.organizationId),
|
|
9415
|
-
index("idx_members_user").on(table.userId),
|
|
9416
|
-
index("idx_members_org").on(table.organizationId)
|
|
9417
|
-
]);
|
|
9418
9568
|
/**
|
|
9419
9569
|
* Names beginning with `__` are reserved for Hub-internal pseudo agents
|
|
9420
9570
|
* (e.g. the task notifier). User-facing creation must not be able to
|
|
@@ -9506,19 +9656,14 @@ async function resolveAgentClient(db, data) {
|
|
|
9506
9656
|
return null;
|
|
9507
9657
|
}
|
|
9508
9658
|
if (!data.clientId) return null;
|
|
9509
|
-
const [manager] = await db.select({
|
|
9510
|
-
userId: members.userId,
|
|
9511
|
-
organizationId: members.organizationId
|
|
9512
|
-
}).from(members).where(eq(members.id, data.managerId)).limit(1);
|
|
9659
|
+
const [manager] = await db.select({ userId: members.userId }).from(members).where(eq(members.id, data.managerId)).limit(1);
|
|
9513
9660
|
if (!manager) throw new BadRequestError(`Manager "${data.managerId}" not found`);
|
|
9514
9661
|
const [client] = await db.select({
|
|
9515
9662
|
id: clients.id,
|
|
9516
|
-
userId: clients.userId
|
|
9517
|
-
organizationId: clients.organizationId
|
|
9663
|
+
userId: clients.userId
|
|
9518
9664
|
}).from(clients).where(eq(clients.id, data.clientId)).limit(1);
|
|
9519
9665
|
if (!client) throw new BadRequestError(`Client "${data.clientId}" not found`);
|
|
9520
9666
|
if (!client.userId) throw new BadRequestError(`Client "${data.clientId}" has not been claimed by a user yet. Have the operator run \`first-tree-hub client connect\` on that machine before pinning an agent to it.`);
|
|
9521
|
-
if (client.organizationId !== manager.organizationId) throw new ForbiddenError(`Client "${data.clientId}" belongs to a different organization — pick a client registered in the manager's org.`);
|
|
9522
9667
|
if (client.userId !== manager.userId) throw new ForbiddenError(`Client "${data.clientId}" is not owned by the manager's user — pick a client belonging to that user.`);
|
|
9523
9668
|
return client.id;
|
|
9524
9669
|
}
|
|
@@ -9662,7 +9807,7 @@ async function listAgentsForAdmin(db, scope, limit, cursor) {
|
|
|
9662
9807
|
* Uses agentVisibilityCondition from access-control (same rules for all roles).
|
|
9663
9808
|
*/
|
|
9664
9809
|
async function listAgentsForMember(db, scope, limit, cursor, type) {
|
|
9665
|
-
const conditions = [agentVisibilityCondition(scope)];
|
|
9810
|
+
const conditions = [agentVisibilityCondition(scope.organizationId, scope.memberId)];
|
|
9666
9811
|
if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
|
|
9667
9812
|
if (type) conditions.push(eq(agents.type, type));
|
|
9668
9813
|
const where = and(...conditions);
|
|
@@ -10249,54 +10394,40 @@ async function cleanupStalePresence(db, staleSeconds = 60) {
|
|
|
10249
10394
|
}
|
|
10250
10395
|
/**
|
|
10251
10396
|
* Assert the caller can act on this client. Throws 404 for both "not found"
|
|
10252
|
-
* and "not yours" to prevent UUID enumeration
|
|
10253
|
-
*
|
|
10254
|
-
*
|
|
10255
|
-
*
|
|
10256
|
-
* - member: row.user_id == scope.userId AND row.organization_id == scope.organizationId.
|
|
10257
|
-
* - admin: row.organization_id == scope.organizationId AND the owner is a
|
|
10258
|
-
* member of that same org (defense in depth).
|
|
10259
|
-
*
|
|
10260
|
-
* Same user across two orgs has two distinct client rows; operating on one
|
|
10261
|
-
* while logged into the other is refused by the org filter.
|
|
10397
|
+
* and "not yours" to prevent UUID enumeration. The client is owned by exactly
|
|
10398
|
+
* one user; cross-user admin access is no longer supported by this code path
|
|
10399
|
+
* (see decouple-client-from-identity-design §4.10.5 option A). Cross-user
|
|
10400
|
+
* ownership transfer goes through `claimClient` in PR-B.
|
|
10262
10401
|
*/
|
|
10263
10402
|
async function assertClientOwner(db, clientId, scope) {
|
|
10264
10403
|
const [row] = await db.select({
|
|
10265
10404
|
id: clients.id,
|
|
10266
|
-
userId: clients.userId
|
|
10267
|
-
organizationId: clients.organizationId
|
|
10405
|
+
userId: clients.userId
|
|
10268
10406
|
}).from(clients).where(eq(clients.id, clientId)).limit(1);
|
|
10269
|
-
if (!row) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
10270
|
-
if (row.organizationId !== scope.organizationId) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
10271
|
-
if (row.userId === scope.userId) return;
|
|
10272
|
-
if (scope.role === "admin" && row.userId !== null) {
|
|
10273
|
-
const [sibling] = await db.select({ id: members.id }).from(members).where(and(eq(members.userId, row.userId), eq(members.organizationId, scope.organizationId))).limit(1);
|
|
10274
|
-
if (sibling) return;
|
|
10275
|
-
}
|
|
10276
|
-
throw new NotFoundError(`Client "${clientId}" not found`);
|
|
10407
|
+
if (!row || row.userId !== scope.userId) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
10277
10408
|
}
|
|
10278
10409
|
/**
|
|
10279
10410
|
* Upsert the clients row for a given `client_id` under an authenticated user.
|
|
10280
10411
|
*
|
|
10281
|
-
* Claim semantics (
|
|
10282
|
-
* - New client_id → INSERT with the authenticated user_id
|
|
10283
|
-
*
|
|
10284
|
-
*
|
|
10285
|
-
*
|
|
10286
|
-
*
|
|
10287
|
-
*
|
|
10288
|
-
*
|
|
10289
|
-
*
|
|
10412
|
+
* Claim semantics (decouple-client-from-identity §4.1.1):
|
|
10413
|
+
* - New client_id → INSERT with the authenticated user_id. `organization_id`
|
|
10414
|
+
* is written as a placeholder (NOT NULL legacy column; no longer consumed
|
|
10415
|
+
* by any read path) sourced from the caller-supplied JWT default org.
|
|
10416
|
+
* - Existing row with the same user_id → refresh runtime columns.
|
|
10417
|
+
* `organization_id` is **not** updated on conflict, so the placeholder set
|
|
10418
|
+
* at first insert sticks for the row's lifetime.
|
|
10419
|
+
* - Existing row with a different user_id → raises
|
|
10420
|
+
* {@link ClientUserMismatchError} (WS close 4403). The CLI guides the
|
|
10421
|
+
* operator through `first-tree-hub client claim --confirm` to take
|
|
10422
|
+
* ownership, which unpins the previous owner's agents from the machine.
|
|
10290
10423
|
*/
|
|
10291
10424
|
async function registerClient(db, data) {
|
|
10292
10425
|
const now = /* @__PURE__ */ new Date();
|
|
10293
10426
|
const [existing] = await db.select({
|
|
10294
10427
|
id: clients.id,
|
|
10295
|
-
userId: clients.userId
|
|
10296
|
-
organizationId: clients.organizationId
|
|
10428
|
+
userId: clients.userId
|
|
10297
10429
|
}).from(clients).where(eq(clients.id, data.clientId)).limit(1);
|
|
10298
|
-
if (existing && existing.
|
|
10299
|
-
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.`);
|
|
10430
|
+
if (existing?.userId && existing.userId !== data.userId) throw new ClientUserMismatchError$1(`Client "${data.clientId}" is owned by a different user. Run \`first-tree-hub client claim --confirm\` to transfer ownership.`);
|
|
10300
10431
|
await db.insert(clients).values({
|
|
10301
10432
|
id: data.clientId,
|
|
10302
10433
|
userId: data.userId,
|
|
@@ -10322,6 +10453,54 @@ async function registerClient(db, data) {
|
|
|
10322
10453
|
}
|
|
10323
10454
|
});
|
|
10324
10455
|
}
|
|
10456
|
+
/**
|
|
10457
|
+
* Transfer ownership of a client row to a new user, unpinning any agents
|
|
10458
|
+
* whose manager belonged to the previous owner. Atomic: caller is guaranteed
|
|
10459
|
+
* either a fully-applied ownership flip + bulk unpin, or no change. Idempotent
|
|
10460
|
+
* when `newUserId` already owns the row.
|
|
10461
|
+
*
|
|
10462
|
+
* Manager → user resolution goes through the members JOIN (the agents table
|
|
10463
|
+
* carries only `manager_id`); cross-org agents under the same previous owner
|
|
10464
|
+
* are unpinned together (decouple-client-from-identity §4.4).
|
|
10465
|
+
*
|
|
10466
|
+
* Caller is responsible for the caller-side authorization (the new owner must
|
|
10467
|
+
* be the authenticated request's user). The structured log
|
|
10468
|
+
* `event: client.owner_transfer` is emitted by the caller after the
|
|
10469
|
+
* transaction commits, using the returned `previousUserId` /
|
|
10470
|
+
* `unpinnedAgentIds`.
|
|
10471
|
+
*/
|
|
10472
|
+
async function claimClient(db, clientId, newUserId) {
|
|
10473
|
+
return db.transaction(async (tx) => {
|
|
10474
|
+
const [locked] = await tx.execute(sql`SELECT id, user_id FROM clients WHERE id = ${clientId} FOR UPDATE`);
|
|
10475
|
+
if (!locked) throw new NotFoundError(`Client "${clientId}" not found`);
|
|
10476
|
+
const previousUserId = locked.user_id;
|
|
10477
|
+
if (previousUserId === newUserId) return {
|
|
10478
|
+
previousUserId,
|
|
10479
|
+
unpinnedAgentIds: []
|
|
10480
|
+
};
|
|
10481
|
+
let unpinnedAgentIds = [];
|
|
10482
|
+
if (previousUserId !== null) {
|
|
10483
|
+
unpinnedAgentIds = (await tx.select({ uuid: agents.uuid }).from(agents).innerJoin(members, eq(agents.managerId, members.id)).where(and(eq(agents.clientId, clientId), eq(members.userId, previousUserId)))).map((r) => r.uuid);
|
|
10484
|
+
if (unpinnedAgentIds.length > 0) {
|
|
10485
|
+
const now = /* @__PURE__ */ new Date();
|
|
10486
|
+
await tx.update(agents).set({
|
|
10487
|
+
clientId: null,
|
|
10488
|
+
updatedAt: now
|
|
10489
|
+
}).where(inArray(agents.uuid, unpinnedAgentIds));
|
|
10490
|
+
await tx.update(agentPresence).set({
|
|
10491
|
+
status: "offline",
|
|
10492
|
+
clientId: null,
|
|
10493
|
+
...runtimeFieldsReset(now)
|
|
10494
|
+
}).where(inArray(agentPresence.agentId, unpinnedAgentIds));
|
|
10495
|
+
}
|
|
10496
|
+
}
|
|
10497
|
+
await tx.update(clients).set({ userId: newUserId }).where(eq(clients.id, clientId));
|
|
10498
|
+
return {
|
|
10499
|
+
previousUserId,
|
|
10500
|
+
unpinnedAgentIds
|
|
10501
|
+
};
|
|
10502
|
+
});
|
|
10503
|
+
}
|
|
10325
10504
|
async function disconnectClient(db, clientId) {
|
|
10326
10505
|
const now = /* @__PURE__ */ new Date();
|
|
10327
10506
|
await db.update(agentPresence).set({
|
|
@@ -10360,16 +10539,17 @@ async function listActiveAgentsPinnedToClient(db, clientId) {
|
|
|
10360
10539
|
}).from(agents).where(and(eq(agents.clientId, clientId), ne(agents.status, "deleted")));
|
|
10361
10540
|
}
|
|
10362
10541
|
/**
|
|
10363
|
-
* Member-scoped: every active agent pinned to a client owned by this user
|
|
10364
|
-
*
|
|
10365
|
-
*
|
|
10542
|
+
* Member-scoped: every active agent pinned to a client owned by this user.
|
|
10543
|
+
* Used by client startup to reconcile its local YAML against the authoritative
|
|
10544
|
+
* `agents.runtime_provider`. Cross-org by design — a client is owned by a
|
|
10545
|
+
* user, not an org (decouple-client-from-identity §4.1).
|
|
10366
10546
|
*/
|
|
10367
10547
|
async function listMyPinnedAgents(db, scope) {
|
|
10368
10548
|
return (await db.select({
|
|
10369
10549
|
agentId: agents.uuid,
|
|
10370
10550
|
clientId: agents.clientId,
|
|
10371
10551
|
runtimeProvider: agents.runtimeProvider
|
|
10372
|
-
}).from(agents).innerJoin(clients, eq(agents.clientId, clients.id)).where(and(eq(clients.userId, scope.userId),
|
|
10552
|
+
}).from(agents).innerJoin(clients, eq(agents.clientId, clients.id)).where(and(eq(clients.userId, scope.userId), ne(agents.status, "deleted")))).filter((r) => r.clientId !== null).map((r) => ({
|
|
10373
10553
|
agentId: r.agentId,
|
|
10374
10554
|
clientId: r.clientId,
|
|
10375
10555
|
runtimeProvider: r.runtimeProvider
|
|
@@ -10394,17 +10574,28 @@ async function updateClientCapabilities(db, clientId, capabilities) {
|
|
|
10394
10574
|
await db.update(clients).set({ metadata: merged }).where(eq(clients.id, clientId));
|
|
10395
10575
|
}
|
|
10396
10576
|
/**
|
|
10397
|
-
* Scope-aware client listing.
|
|
10398
|
-
*
|
|
10399
|
-
*
|
|
10400
|
-
* — protects against a user listing their own clients registered under a
|
|
10401
|
-
* different org when they're logged into this one.
|
|
10402
|
-
* - admin: every row in `scope.organizationId`, regardless of owner.
|
|
10577
|
+
* Scope-aware client listing. Returns the caller's own clients (cross-org —
|
|
10578
|
+
* a client is owned by a user, not an org). The admin route adds a separate
|
|
10579
|
+
* `?organizationId=` cross-user view via {@link listClientsForOrgAdmin}.
|
|
10403
10580
|
*/
|
|
10404
10581
|
async function listClients(db, scope) {
|
|
10405
|
-
|
|
10582
|
+
return attachAgentCounts(db, await db.select().from(clients).where(eq(clients.userId, scope.userId)));
|
|
10583
|
+
}
|
|
10584
|
+
/**
|
|
10585
|
+
* Admin-only cross-user listing: every client owned by an active member of
|
|
10586
|
+
* `orgId`. Joining `clients → members.user_id` instead of `clients.organization_id`
|
|
10587
|
+
* keeps the read path consistent with the rule that connection has no
|
|
10588
|
+
* runtime relationship to organization (decouple-client-from-identity §A).
|
|
10589
|
+
*
|
|
10590
|
+
* The caller must verify admin role realtime via `requireMemberInOrg` before
|
|
10591
|
+
* invoking this function — the service does not re-check, so it is
|
|
10592
|
+
* unsafe to expose without that gate.
|
|
10593
|
+
*/
|
|
10594
|
+
async function listClientsForOrgAdmin(db, orgId) {
|
|
10595
|
+
return attachAgentCounts(db, await db.select({
|
|
10406
10596
|
id: clients.id,
|
|
10407
10597
|
userId: clients.userId,
|
|
10598
|
+
organizationId: clients.organizationId,
|
|
10408
10599
|
status: clients.status,
|
|
10409
10600
|
sdkVersion: clients.sdkVersion,
|
|
10410
10601
|
hostname: clients.hostname,
|
|
@@ -10413,7 +10604,9 @@ async function listClients(db, scope) {
|
|
|
10413
10604
|
connectedAt: clients.connectedAt,
|
|
10414
10605
|
lastSeenAt: clients.lastSeenAt,
|
|
10415
10606
|
metadata: clients.metadata
|
|
10416
|
-
}).from(clients).
|
|
10607
|
+
}).from(clients).innerJoin(members, eq(members.userId, clients.userId)).where(and(eq(members.organizationId, orgId), eq(members.status, "active"))));
|
|
10608
|
+
}
|
|
10609
|
+
async function attachAgentCounts(db, rows) {
|
|
10417
10610
|
const counts = await db.select({
|
|
10418
10611
|
clientId: agents.clientId,
|
|
10419
10612
|
count: sql`count(*)::int`
|
|
@@ -10723,7 +10916,7 @@ async function listAgentsWithRuntime(db, scope) {
|
|
|
10723
10916
|
totalSessions: agentPresence.totalSessions,
|
|
10724
10917
|
runtimeUpdatedAt: agentPresence.runtimeUpdatedAt,
|
|
10725
10918
|
type: agents.type
|
|
10726
|
-
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
|
|
10919
|
+
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope.organizationId, scope.memberId)));
|
|
10727
10920
|
}
|
|
10728
10921
|
const log$4 = createLogger$1("message");
|
|
10729
10922
|
async function sendMessage(db, chatId, senderId, data, options = {}) {
|
|
@@ -11089,12 +11282,26 @@ async function adminAgentRoutes(app) {
|
|
|
11089
11282
|
}
|
|
11090
11283
|
sendToClient(agent.clientId, parsed.data);
|
|
11091
11284
|
}
|
|
11092
|
-
const listAgentsFilterSchema = z.object({
|
|
11285
|
+
const listAgentsFilterSchema = z.object({
|
|
11286
|
+
type: agentTypeSchema$1.optional(),
|
|
11287
|
+
organizationId: z.string().min(1).optional()
|
|
11288
|
+
});
|
|
11093
11289
|
app.get("/", async (request) => {
|
|
11094
11290
|
const query = paginationQuerySchema.parse(request.query);
|
|
11095
|
-
const { type } = listAgentsFilterSchema.parse(request.query);
|
|
11291
|
+
const { type, organizationId } = listAgentsFilterSchema.parse(request.query);
|
|
11096
11292
|
const scope = memberScope(request);
|
|
11097
|
-
const
|
|
11293
|
+
const targetOrgId = organizationId ?? scope.organizationId;
|
|
11294
|
+
let queryScope = scope;
|
|
11295
|
+
if (targetOrgId !== scope.organizationId) {
|
|
11296
|
+
const probe = await requireMemberInOrg(app.db, request, targetOrgId);
|
|
11297
|
+
queryScope = {
|
|
11298
|
+
...scope,
|
|
11299
|
+
memberId: probe.memberId,
|
|
11300
|
+
organizationId: targetOrgId,
|
|
11301
|
+
role: probe.role
|
|
11302
|
+
};
|
|
11303
|
+
}
|
|
11304
|
+
const result = await listAgentsForMember(app.db, queryScope, query.limit, query.cursor, type);
|
|
11098
11305
|
return {
|
|
11099
11306
|
items: result.items.map((a) => ({
|
|
11100
11307
|
...a,
|
|
@@ -11119,9 +11326,18 @@ async function adminAgentRoutes(app) {
|
|
|
11119
11326
|
*/
|
|
11120
11327
|
app.get("/all", async (request) => {
|
|
11121
11328
|
const scope = memberScope(request);
|
|
11122
|
-
if (scope.role !== "admin") throw new ForbiddenError("Admin role required");
|
|
11123
11329
|
const query = paginationQuerySchema.parse(request.query);
|
|
11124
|
-
const
|
|
11330
|
+
const { organizationId } = listAgentsFilterSchema.parse(request.query);
|
|
11331
|
+
const targetOrgId = organizationId ?? scope.organizationId;
|
|
11332
|
+
const probe = await requireMemberInOrg(app.db, request, targetOrgId);
|
|
11333
|
+
if (probe.role !== "admin") throw new ForbiddenError("Admin role required");
|
|
11334
|
+
const adminScope = {
|
|
11335
|
+
...scope,
|
|
11336
|
+
memberId: probe.memberId,
|
|
11337
|
+
organizationId: targetOrgId,
|
|
11338
|
+
role: probe.role
|
|
11339
|
+
};
|
|
11340
|
+
const result = await listAgentsForAdmin(app.db, adminScope, query.limit, query.cursor);
|
|
11125
11341
|
return {
|
|
11126
11342
|
items: result.items.map((a) => ({
|
|
11127
11343
|
...a,
|
|
@@ -11152,9 +11368,12 @@ async function adminAgentRoutes(app) {
|
|
|
11152
11368
|
app.post("/", async (request, reply) => {
|
|
11153
11369
|
const scope = memberScope(request);
|
|
11154
11370
|
const body = createAgentSchema.parse(request.body);
|
|
11155
|
-
const
|
|
11371
|
+
const targetOrgId = body.organizationId ?? scope.organizationId;
|
|
11372
|
+
const probe = await requireMemberInOrg(app.db, request, targetOrgId);
|
|
11373
|
+
const managerId = probe.role === "admin" ? body.managerId ?? probe.memberId : probe.memberId;
|
|
11156
11374
|
const agent = await createAgent(app.db, {
|
|
11157
11375
|
...body,
|
|
11376
|
+
organizationId: targetOrgId,
|
|
11158
11377
|
source: body.source ?? "admin-api",
|
|
11159
11378
|
managerId
|
|
11160
11379
|
});
|
|
@@ -11461,7 +11680,8 @@ async function adminChatRoutes(app) {
|
|
|
11461
11680
|
/** List all chats in org (admin-only, for audit). Members should use GET /mine. */
|
|
11462
11681
|
app.get("/", { preHandler: requireAdminRoleHook() }, async (request) => {
|
|
11463
11682
|
const query = paginationQuerySchema.parse(request.query);
|
|
11464
|
-
const
|
|
11683
|
+
const rawQuery = request.query;
|
|
11684
|
+
const orgParam = rawQuery.organizationId ?? rawQuery.org;
|
|
11465
11685
|
let orgId;
|
|
11466
11686
|
if (orgParam) orgId = (await resolveOrganization(app.db, orgParam)).id;
|
|
11467
11687
|
else orgId = await resolveDefaultOrgId(app.db);
|
|
@@ -11655,14 +11875,15 @@ async function adminChatRoutes(app) {
|
|
|
11655
11875
|
function serializeDate(d) {
|
|
11656
11876
|
return d ? d.toISOString() : null;
|
|
11657
11877
|
}
|
|
11878
|
+
const listClientsQuerySchema = z.object({ organizationId: z.string().min(1).optional() });
|
|
11658
11879
|
async function adminClientRoutes(app) {
|
|
11659
11880
|
app.get("/", async (request) => {
|
|
11660
11881
|
const scope = memberScope(request);
|
|
11661
|
-
|
|
11662
|
-
|
|
11663
|
-
|
|
11664
|
-
|
|
11665
|
-
})).map((c) => ({
|
|
11882
|
+
const { organizationId } = listClientsQuerySchema.parse(request.query);
|
|
11883
|
+
return (organizationId ? await (async () => {
|
|
11884
|
+
if ((await requireMemberInOrg(app.db, request, organizationId)).role !== "admin") throw new ForbiddenError("Admin role required");
|
|
11885
|
+
return listClientsForOrgAdmin(app.db, organizationId);
|
|
11886
|
+
})() : await listClients(app.db, { userId: scope.userId })).map((c) => ({
|
|
11666
11887
|
id: c.id,
|
|
11667
11888
|
userId: c.userId,
|
|
11668
11889
|
status: c.status,
|
|
@@ -11676,10 +11897,7 @@ async function adminClientRoutes(app) {
|
|
|
11676
11897
|
});
|
|
11677
11898
|
app.get("/me/agents", async (request) => {
|
|
11678
11899
|
const scope = memberScope(request);
|
|
11679
|
-
return await listMyPinnedAgents(app.db, {
|
|
11680
|
-
userId: scope.userId,
|
|
11681
|
-
organizationId: scope.organizationId
|
|
11682
|
-
});
|
|
11900
|
+
return await listMyPinnedAgents(app.db, { userId: scope.userId });
|
|
11683
11901
|
});
|
|
11684
11902
|
app.patch("/:clientId/capabilities", async (request, reply) => {
|
|
11685
11903
|
const scope = memberScope(request);
|
|
@@ -12014,25 +12232,33 @@ async function pushToWebhook(db, notification) {
|
|
|
12014
12232
|
});
|
|
12015
12233
|
} catch {}
|
|
12016
12234
|
}
|
|
12235
|
+
const orgQuerySchema = z.object({ organizationId: z.string().min(1).optional() });
|
|
12017
12236
|
async function adminNotificationRoutes(app) {
|
|
12018
12237
|
/**
|
|
12019
|
-
* GET /admin/notifications — list notifications visible to the caller
|
|
12238
|
+
* GET /admin/notifications — list notifications visible to the caller in
|
|
12239
|
+
* the **selected** organization. The web client passes
|
|
12240
|
+
* `?organizationId=<selectedOrgId>` so a non-default org doesn't reuse
|
|
12241
|
+
* the JWT default org's bell feed (codex P1 #2). Defaults to JWT org
|
|
12242
|
+
* when omitted.
|
|
12020
12243
|
*
|
|
12021
|
-
*
|
|
12022
|
-
*
|
|
12023
|
-
*
|
|
12024
|
-
*
|
|
12025
|
-
* WebSocket route enforces on live pushes — REST and WS stay in sync.
|
|
12244
|
+
* Per-agent visibility filter: the member only sees notifications whose
|
|
12245
|
+
* agentId is visible to them in that org (organization-visible agents or
|
|
12246
|
+
* agents they manage), plus org-wide system notifications with no
|
|
12247
|
+
* agentId. This mirrors the admin-WS push gate so REST and WS stay in sync.
|
|
12026
12248
|
*/
|
|
12027
12249
|
app.get("/", async (request) => {
|
|
12028
|
-
const
|
|
12250
|
+
const scope = memberScope(request);
|
|
12251
|
+
const { organizationId } = orgQuerySchema.parse(request.query);
|
|
12252
|
+
const effective = await resolveAdminScope(app.db, request, scope, organizationId);
|
|
12029
12253
|
const query = notificationQuerySchema.parse(request.query);
|
|
12030
|
-
return listNotifications(app.db,
|
|
12254
|
+
return listNotifications(app.db, effective.organizationId, effective.memberId, query);
|
|
12031
12255
|
});
|
|
12032
12256
|
/** POST /admin/notifications/:id/read — mark a single notification as read */
|
|
12033
12257
|
app.post("/:id/read", async (request) => {
|
|
12034
|
-
const
|
|
12035
|
-
const
|
|
12258
|
+
const scope = memberScope(request);
|
|
12259
|
+
const { organizationId } = orgQuerySchema.parse(request.query);
|
|
12260
|
+
const effective = await resolveAdminScope(app.db, request, scope, organizationId);
|
|
12261
|
+
const result = await markRead(app.db, request.params.id, effective.organizationId, effective.memberId);
|
|
12036
12262
|
if (!result) throw new NotFoundError(`Notification "${request.params.id}" not found`);
|
|
12037
12263
|
return {
|
|
12038
12264
|
...result,
|
|
@@ -12041,8 +12267,10 @@ async function adminNotificationRoutes(app) {
|
|
|
12041
12267
|
});
|
|
12042
12268
|
/** POST /admin/notifications/read-all — mark all visible notifications as read */
|
|
12043
12269
|
app.post("/read-all", async (request) => {
|
|
12044
|
-
const
|
|
12045
|
-
|
|
12270
|
+
const scope = memberScope(request);
|
|
12271
|
+
const { organizationId } = orgQuerySchema.parse(request.query);
|
|
12272
|
+
const effective = await resolveAdminScope(app.db, request, scope, organizationId);
|
|
12273
|
+
await markAllRead(app.db, effective.organizationId, effective.memberId);
|
|
12046
12274
|
return { status: "ok" };
|
|
12047
12275
|
});
|
|
12048
12276
|
}
|
|
@@ -12094,7 +12322,8 @@ async function adminOrganizationRoutes(app) {
|
|
|
12094
12322
|
}
|
|
12095
12323
|
async function adminOverviewRoutes(app) {
|
|
12096
12324
|
app.get("/", async (request) => {
|
|
12097
|
-
const
|
|
12325
|
+
const q = request.query ?? {};
|
|
12326
|
+
const orgParam = q.organizationId ?? q.org;
|
|
12098
12327
|
let orgId;
|
|
12099
12328
|
if (orgParam) orgId = (await resolveOrganization(app.db, orgParam)).id;
|
|
12100
12329
|
else orgId = await resolveDefaultOrgId(app.db);
|
|
@@ -12409,17 +12638,21 @@ const globalSessionFilterSchema = paginationQuerySchema.extend({
|
|
|
12409
12638
|
"suspended",
|
|
12410
12639
|
"evicted"
|
|
12411
12640
|
]).optional(),
|
|
12412
|
-
agentId: z.string().optional()
|
|
12641
|
+
agentId: z.string().optional(),
|
|
12642
|
+
organizationId: z.string().min(1).optional()
|
|
12413
12643
|
});
|
|
12414
12644
|
async function adminSessionRoutes(app) {
|
|
12415
|
-
/** GET /admin/sessions — global session list, scoped to
|
|
12645
|
+
/** GET /admin/sessions — global session list, scoped to the selected
|
|
12646
|
+
* org. The web client passes `?organizationId=<selectedOrgId>` after a
|
|
12647
|
+
* /auth/switch-org 204 (codex P1 #2); falls back to the JWT default. */
|
|
12416
12648
|
app.get("/", async (request) => {
|
|
12417
|
-
const
|
|
12649
|
+
const scope = memberScope(request);
|
|
12418
12650
|
const query = globalSessionFilterSchema.parse(request.query);
|
|
12651
|
+
const effective = await resolveAdminScope(app.db, request, scope, query.organizationId);
|
|
12419
12652
|
return listAllSessions(app.db, query.limit, query.cursor, {
|
|
12420
12653
|
state: query.state,
|
|
12421
12654
|
agentId: query.agentId,
|
|
12422
|
-
organizationId:
|
|
12655
|
+
organizationId: effective.organizationId
|
|
12423
12656
|
});
|
|
12424
12657
|
});
|
|
12425
12658
|
/** GET /admin/sessions/agents/:agentId — sessions for a specific agent.
|
|
@@ -13113,11 +13346,11 @@ function adminWsRoutes(notifier, jwtSecret) {
|
|
|
13113
13346
|
endWsConnectionSpan(socket, 4001);
|
|
13114
13347
|
return;
|
|
13115
13348
|
}
|
|
13349
|
+
let userId;
|
|
13116
13350
|
let organizationId;
|
|
13117
|
-
let memberId;
|
|
13118
13351
|
try {
|
|
13119
13352
|
const { payload } = await jwtVerify(token, secret);
|
|
13120
|
-
if (payload.type !== "access" ||
|
|
13353
|
+
if (payload.type !== "access" || typeof payload.sub !== "string" || typeof payload.organizationId !== "string") {
|
|
13121
13354
|
socket.send(JSON.stringify({
|
|
13122
13355
|
type: "error",
|
|
13123
13356
|
message: "Invalid token type"
|
|
@@ -13126,8 +13359,8 @@ function adminWsRoutes(notifier, jwtSecret) {
|
|
|
13126
13359
|
endWsConnectionSpan(socket, 4001);
|
|
13127
13360
|
return;
|
|
13128
13361
|
}
|
|
13362
|
+
userId = payload.sub;
|
|
13129
13363
|
organizationId = payload.organizationId;
|
|
13130
|
-
memberId = payload.memberId;
|
|
13131
13364
|
} catch {
|
|
13132
13365
|
socket.send(JSON.stringify({
|
|
13133
13366
|
type: "error",
|
|
@@ -13137,6 +13370,20 @@ function adminWsRoutes(notifier, jwtSecret) {
|
|
|
13137
13370
|
endWsConnectionSpan(socket, 4001);
|
|
13138
13371
|
return;
|
|
13139
13372
|
}
|
|
13373
|
+
const [memberRow] = await app.db.select({
|
|
13374
|
+
id: members.id,
|
|
13375
|
+
role: members.role
|
|
13376
|
+
}).from(members).where(and(eq(members.userId, userId), eq(members.organizationId, organizationId), eq(members.status, "active"))).limit(1);
|
|
13377
|
+
if (!memberRow) {
|
|
13378
|
+
socket.send(JSON.stringify({
|
|
13379
|
+
type: "error",
|
|
13380
|
+
message: "Not an active member of this organization"
|
|
13381
|
+
}));
|
|
13382
|
+
socket.close(4403, "Not a member");
|
|
13383
|
+
endWsConnectionSpan(socket, 4403);
|
|
13384
|
+
return;
|
|
13385
|
+
}
|
|
13386
|
+
const memberId = memberRow.id;
|
|
13140
13387
|
setWsConnectionAttrs(socket, {
|
|
13141
13388
|
organizationId,
|
|
13142
13389
|
memberId
|
|
@@ -13965,6 +14212,7 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
13965
14212
|
}, async (socket) => {
|
|
13966
14213
|
startWsConnectionSpan(socket);
|
|
13967
14214
|
let session = null;
|
|
14215
|
+
let jwtDefaultOrgId = null;
|
|
13968
14216
|
let clientId = null;
|
|
13969
14217
|
let authExpiryTimer = null;
|
|
13970
14218
|
const boundAgents = /* @__PURE__ */ new Map();
|
|
@@ -14195,28 +14443,15 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
14195
14443
|
try {
|
|
14196
14444
|
const { payload } = await jwtVerify(authParsed.data.token, jwtSecretBytes);
|
|
14197
14445
|
const claims = payload;
|
|
14198
|
-
if (claims.type !== "access" || !claims.sub
|
|
14446
|
+
if (claims.type !== "access" || !claims.sub) throw new Error("Invalid token claims");
|
|
14199
14447
|
const [user] = await app.db.select({
|
|
14200
14448
|
id: users.id,
|
|
14201
14449
|
status: users.status
|
|
14202
14450
|
}).from(users).where(eq(users.id, claims.sub)).limit(1);
|
|
14203
14451
|
if (!user || user.status !== "active") throw new Error("User not found or suspended");
|
|
14204
|
-
|
|
14205
|
-
|
|
14206
|
-
|
|
14207
|
-
role: members.role
|
|
14208
|
-
}).from(members).where(eq(members.id, claims.memberId)).limit(1);
|
|
14209
|
-
if (!member) throw new Error("Membership not found");
|
|
14210
|
-
session = {
|
|
14211
|
-
userId: user.id,
|
|
14212
|
-
memberId: member.id,
|
|
14213
|
-
organizationId: member.organizationId,
|
|
14214
|
-
role: member.role
|
|
14215
|
-
};
|
|
14216
|
-
setWsConnectionAttrs(socket, {
|
|
14217
|
-
"organization.id": member.organizationId,
|
|
14218
|
-
"member.id": member.id
|
|
14219
|
-
});
|
|
14452
|
+
session = { userId: user.id };
|
|
14453
|
+
jwtDefaultOrgId = typeof claims.organizationId === "string" ? claims.organizationId : null;
|
|
14454
|
+
setWsConnectionAttrs(socket, { "user.id": user.id });
|
|
14220
14455
|
clearTimeout(authTimeout);
|
|
14221
14456
|
scheduleAuthExpiry(claims.exp);
|
|
14222
14457
|
socket.send(JSON.stringify({ type: "auth:ok" }));
|
|
@@ -14242,11 +14477,19 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
14242
14477
|
if (type === "client:register") {
|
|
14243
14478
|
const data = clientRegisterSchema.parse(msg);
|
|
14244
14479
|
clientWantsWsInboxDeliver = data.wireCapabilities?.wsInboxDeliver === true;
|
|
14480
|
+
if (!jwtDefaultOrgId) {
|
|
14481
|
+
socket.send(JSON.stringify({
|
|
14482
|
+
type: "client:register:rejected",
|
|
14483
|
+
message: "JWT missing organizationId claim"
|
|
14484
|
+
}));
|
|
14485
|
+
socket.close(4401, "client register rejected");
|
|
14486
|
+
return;
|
|
14487
|
+
}
|
|
14245
14488
|
try {
|
|
14246
14489
|
await registerClient(app.db, {
|
|
14247
14490
|
clientId: data.clientId,
|
|
14248
14491
|
userId: session.userId,
|
|
14249
|
-
organizationId:
|
|
14492
|
+
organizationId: jwtDefaultOrgId,
|
|
14250
14493
|
instanceId,
|
|
14251
14494
|
hostname: data.hostname,
|
|
14252
14495
|
os: data.os,
|
|
@@ -14254,7 +14497,7 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
14254
14497
|
});
|
|
14255
14498
|
} catch (err) {
|
|
14256
14499
|
const message = err instanceof Error ? err.message : "client register failed";
|
|
14257
|
-
const code = err instanceof ClientOrgMismatchError$1 ? err.code : void 0;
|
|
14500
|
+
const code = err instanceof ClientUserMismatchError$1 ? err.code : err instanceof ClientOrgMismatchError$1 ? err.code : void 0;
|
|
14258
14501
|
socket.send(JSON.stringify({
|
|
14259
14502
|
type: "client:register:rejected",
|
|
14260
14503
|
message,
|
|
@@ -14317,25 +14560,24 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
14317
14560
|
clientId: agents.clientId,
|
|
14318
14561
|
runtimeProvider: agents.runtimeProvider,
|
|
14319
14562
|
clientUserId: clients.userId,
|
|
14320
|
-
managerUserId: members.userId
|
|
14563
|
+
managerUserId: members.userId,
|
|
14564
|
+
managerMemberStatus: members.status
|
|
14321
14565
|
}).from(agents).leftJoin(clients, eq(agents.clientId, clients.id)).leftJoin(members, eq(agents.managerId, members.id)).where(and(eq(agents.uuid, bindRequest.agentId))).limit(1);
|
|
14322
14566
|
if (!agent) {
|
|
14323
14567
|
sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.UNKNOWN_AGENT);
|
|
14324
14568
|
return;
|
|
14325
14569
|
}
|
|
14326
|
-
if (agent.organizationId !== session.organizationId) {
|
|
14327
|
-
sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.WRONG_ORG);
|
|
14328
|
-
return;
|
|
14329
|
-
}
|
|
14330
14570
|
if (agent.status !== "active") {
|
|
14331
14571
|
sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.AGENT_SUSPENDED);
|
|
14332
14572
|
return;
|
|
14333
14573
|
}
|
|
14574
|
+
const ownerOk = agent.managerUserId !== null && agent.managerUserId === session.userId;
|
|
14575
|
+
const membershipActive = agent.managerMemberStatus === "active";
|
|
14576
|
+
if (!ownerOk || !membershipActive) {
|
|
14577
|
+
sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.NOT_OWNED);
|
|
14578
|
+
return;
|
|
14579
|
+
}
|
|
14334
14580
|
if (agent.clientId === null) {
|
|
14335
|
-
if (!agent.managerUserId || agent.managerUserId !== session.userId) {
|
|
14336
|
-
sendRejected(socket, ref, AGENT_BIND_REJECT_REASONS.NOT_OWNED);
|
|
14337
|
-
return;
|
|
14338
|
-
}
|
|
14339
14581
|
if ((await app.db.update(agents).set({
|
|
14340
14582
|
clientId,
|
|
14341
14583
|
updatedAt: /* @__PURE__ */ new Date()
|
|
@@ -14363,7 +14605,8 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
14363
14605
|
bindAgentToClient(clientId, agent.id);
|
|
14364
14606
|
boundAgents.set(agent.id, {
|
|
14365
14607
|
agentId: agent.id,
|
|
14366
|
-
inboxId: agent.inboxId
|
|
14608
|
+
inboxId: agent.inboxId,
|
|
14609
|
+
organizationId: agent.organizationId
|
|
14367
14610
|
});
|
|
14368
14611
|
const wsPushActive = pushUseWsDataPlane();
|
|
14369
14612
|
if (wsPushActive) notifier.subscribe(agent.inboxId, socket, makeInboxPushHandler(agent.id, agent.inboxId));
|
|
@@ -14423,7 +14666,9 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
14423
14666
|
}, "session:state rejected — stale client wire");
|
|
14424
14667
|
return;
|
|
14425
14668
|
}
|
|
14426
|
-
|
|
14669
|
+
const boundAgentInfo = boundAgents.get(agentId);
|
|
14670
|
+
if (!boundAgentInfo) return;
|
|
14671
|
+
await upsertSessionState(app.db, agentId, payloadResult.data.chatId, payloadResult.data.state, boundAgentInfo.organizationId, notifier);
|
|
14427
14672
|
} else if (type === "session:reconcile") {
|
|
14428
14673
|
const agentId = parsed.data.agentId;
|
|
14429
14674
|
if (!agentId || !boundAgents.has(agentId)) {
|
|
@@ -14460,8 +14705,10 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
14460
14705
|
return;
|
|
14461
14706
|
}
|
|
14462
14707
|
const payload = runtimeStateMessageSchema.parse(msg);
|
|
14708
|
+
const boundAgentInfo = boundAgents.get(agentId);
|
|
14709
|
+
if (!boundAgentInfo) return;
|
|
14463
14710
|
await setRuntimeState(app.db, agentId, payload.runtimeState, {
|
|
14464
|
-
organizationId:
|
|
14711
|
+
organizationId: boundAgentInfo.organizationId,
|
|
14465
14712
|
notifier
|
|
14466
14713
|
});
|
|
14467
14714
|
if (payload.runtimeState === "error" && shouldNotify(agentId, "agent_error")) notifyAgentEvent(app.db, agentId, "agent_error", "high").catch(() => {});
|
|
@@ -14983,6 +15230,7 @@ async function listActiveMemberships(db, userId) {
|
|
|
14983
15230
|
memberId: members.id,
|
|
14984
15231
|
organizationId: members.organizationId,
|
|
14985
15232
|
role: members.role,
|
|
15233
|
+
agentId: members.agentId,
|
|
14986
15234
|
orgName: organizations.name,
|
|
14987
15235
|
orgDisplayName: organizations.displayName,
|
|
14988
15236
|
createdAt: members.createdAt
|
|
@@ -15324,6 +15572,36 @@ async function bootstrapConfigRoutes(app) {
|
|
|
15324
15572
|
return { allowedOrg: app.config.github.allowedOrg ?? null };
|
|
15325
15573
|
});
|
|
15326
15574
|
}
|
|
15575
|
+
/**
|
|
15576
|
+
* Member-scoped client routes.
|
|
15577
|
+
*
|
|
15578
|
+
* Mounted at `/me/clients` to keep them off the admin surface (the legacy
|
|
15579
|
+
* admin clients router lives at `/clients`). The only operation here is
|
|
15580
|
+
* `POST /:clientId/claim`: ownership transfer driven by the operator running
|
|
15581
|
+
* `first-tree-hub client claim --confirm` after a 4403 handshake mismatch
|
|
15582
|
+
* (decouple-client-from-identity §4.4).
|
|
15583
|
+
*/
|
|
15584
|
+
async function memberClientRoutes(app) {
|
|
15585
|
+
app.post("/:clientId/claim", async (request, reply) => {
|
|
15586
|
+
const m = requireMember(request);
|
|
15587
|
+
const { clientId } = request.params;
|
|
15588
|
+
const result = await claimClient(app.db, clientId, m.userId);
|
|
15589
|
+
const droppedAgentIds = forceDisconnectClient(clientId);
|
|
15590
|
+
request.log.info({
|
|
15591
|
+
event: "client.owner_transfer",
|
|
15592
|
+
clientId,
|
|
15593
|
+
fromUserId: result.previousUserId,
|
|
15594
|
+
toUserId: m.userId,
|
|
15595
|
+
unpinnedAgentCount: result.unpinnedAgentIds.length,
|
|
15596
|
+
droppedSocketAgentCount: droppedAgentIds.length
|
|
15597
|
+
}, "client ownership transferred via /me/clients/:clientId/claim");
|
|
15598
|
+
return reply.status(200).send({
|
|
15599
|
+
clientId,
|
|
15600
|
+
previousUserId: result.previousUserId,
|
|
15601
|
+
unpinnedAgentCount: result.unpinnedAgentIds.length
|
|
15602
|
+
});
|
|
15603
|
+
});
|
|
15604
|
+
}
|
|
15327
15605
|
async function contextTreeInfoRoutes(app) {
|
|
15328
15606
|
/** Public endpoint — returns Context Tree repo metadata for CLI auto-discovery. */
|
|
15329
15607
|
app.get("/info", async () => {
|
|
@@ -15453,6 +15731,7 @@ async function meRoutes(app) {
|
|
|
15453
15731
|
const inv = await getActiveInvitation(app.db, m.organizationId);
|
|
15454
15732
|
if (inv) inviteUrl = buildInviteUrl(resolvePublicUrl(app, request), inv.token);
|
|
15455
15733
|
}
|
|
15734
|
+
const memberships = await listActiveMemberships(app.db, m.userId);
|
|
15456
15735
|
return {
|
|
15457
15736
|
user: user ?? null,
|
|
15458
15737
|
member: {
|
|
@@ -15461,6 +15740,13 @@ async function meRoutes(app) {
|
|
|
15461
15740
|
role: m.role,
|
|
15462
15741
|
agentId: m.agentId
|
|
15463
15742
|
},
|
|
15743
|
+
memberships: memberships.map((mb) => ({
|
|
15744
|
+
id: mb.memberId,
|
|
15745
|
+
organizationId: mb.organizationId,
|
|
15746
|
+
organizationName: mb.orgDisplayName,
|
|
15747
|
+
role: mb.role,
|
|
15748
|
+
agentId: mb.agentId
|
|
15749
|
+
})),
|
|
15464
15750
|
agent: agent ?? null,
|
|
15465
15751
|
wizard: { step: wizardStep },
|
|
15466
15752
|
inviteUrl
|
|
@@ -15494,6 +15780,20 @@ async function meRoutes(app) {
|
|
|
15494
15780
|
command: `first-tree-hub connect ${token}`
|
|
15495
15781
|
};
|
|
15496
15782
|
});
|
|
15783
|
+
app.get("/me/managed-agents", async (request) => {
|
|
15784
|
+
const m = requireMember(request);
|
|
15785
|
+
return (await listAgentsManagedByUser(app.db, m.userId)).map((r) => ({
|
|
15786
|
+
uuid: r.uuid,
|
|
15787
|
+
name: r.name,
|
|
15788
|
+
displayName: r.displayName,
|
|
15789
|
+
type: r.type,
|
|
15790
|
+
organizationId: r.organizationId,
|
|
15791
|
+
inboxId: r.inboxId,
|
|
15792
|
+
visibility: r.visibility,
|
|
15793
|
+
runtimeProvider: r.runtimeProvider,
|
|
15794
|
+
clientId: r.clientId
|
|
15795
|
+
}));
|
|
15796
|
+
});
|
|
15497
15797
|
app.get("/me/organizations", async (request) => {
|
|
15498
15798
|
const m = requireMember(request);
|
|
15499
15799
|
return (await listActiveMemberships(app.db, m.userId)).map((r) => ({
|
|
@@ -15581,15 +15881,9 @@ async function meRoutes(app) {
|
|
|
15581
15881
|
app.post("/auth/switch-org", async (request, reply) => {
|
|
15582
15882
|
const m = requireMember(request);
|
|
15583
15883
|
const body = switchOrgSchema.parse(request.body);
|
|
15584
|
-
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);
|
|
15884
|
+
const [target] = await app.db.select({ id: members.id }).from(members).where(and(eq(members.userId, m.userId), eq(members.organizationId, body.organizationId), eq(members.status, "active"))).limit(1);
|
|
15585
15885
|
if (!target) throw new ForbiddenError("You do not belong to that organization");
|
|
15586
|
-
|
|
15587
|
-
userId: m.userId,
|
|
15588
|
-
memberId: target.id,
|
|
15589
|
-
organizationId: target.organizationId,
|
|
15590
|
-
role: target.role
|
|
15591
|
-
});
|
|
15592
|
-
return reply.send(tokens);
|
|
15886
|
+
return reply.status(204).send();
|
|
15593
15887
|
});
|
|
15594
15888
|
}
|
|
15595
15889
|
/**
|
|
@@ -15605,7 +15899,7 @@ async function meRoutes(app) {
|
|
|
15605
15899
|
* that's the explicit reset path.
|
|
15606
15900
|
*/
|
|
15607
15901
|
async function inferWizardStep(app, m) {
|
|
15608
|
-
const [hasClient] = await app.db.select({ id: clients.id }).from(clients).where(
|
|
15902
|
+
const [hasClient] = await app.db.select({ id: clients.id }).from(clients).where(eq(clients.userId, m.userId)).limit(1);
|
|
15609
15903
|
if (!hasClient) return "connect";
|
|
15610
15904
|
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);
|
|
15611
15905
|
if (!hasAgent) return "create_agent";
|
|
@@ -15617,7 +15911,7 @@ async function inferWizardStep(app, m) {
|
|
|
15617
15911
|
* landing page.
|
|
15618
15912
|
*/
|
|
15619
15913
|
async function publicInvitePreviewRoute(app) {
|
|
15620
|
-
const { previewInvitation } = await import("./invitation-
|
|
15914
|
+
const { previewInvitation } = await import("./invitation-CBnQyB7o-DVOpS_Ts.mjs");
|
|
15621
15915
|
app.get("/:token/preview", async (request, reply) => {
|
|
15622
15916
|
if (!request.params.token) throw new UnauthorizedError("Token required");
|
|
15623
15917
|
const preview = await previewInvitation(app.db, request.params.token);
|
|
@@ -15647,7 +15941,7 @@ async function adminInvitationRoutes(app) {
|
|
|
15647
15941
|
const m = requireMember(request);
|
|
15648
15942
|
if (m.role !== "admin") throw new ForbiddenError("Admin role required");
|
|
15649
15943
|
if (request.params.id !== m.organizationId) throw new ForbiddenError("Cannot rotate invitations for another organization");
|
|
15650
|
-
const { rotateInvitation } = await import("./invitation-
|
|
15944
|
+
const { rotateInvitation } = await import("./invitation-CBnQyB7o-DVOpS_Ts.mjs");
|
|
15651
15945
|
const inv = await rotateInvitation(app.db, m.organizationId, m.userId);
|
|
15652
15946
|
return {
|
|
15653
15947
|
id: inv.id,
|
|
@@ -17629,6 +17923,10 @@ async function buildApp(config) {
|
|
|
17629
17923
|
adminApp.addHook("onRequest", memberAuth);
|
|
17630
17924
|
await adminApp.register(adminClientRoutes);
|
|
17631
17925
|
}, { prefix: "/clients" });
|
|
17926
|
+
await api.register(async (memberApp) => {
|
|
17927
|
+
memberApp.addHook("onRequest", memberAuth);
|
|
17928
|
+
await memberApp.register(memberClientRoutes);
|
|
17929
|
+
}, { prefix: "/me/clients" });
|
|
17632
17930
|
await api.register(async (adminApp) => {
|
|
17633
17931
|
adminApp.addHook("onRequest", memberAuth);
|
|
17634
17932
|
await adminApp.register(adminActivityRoutes);
|
|
@@ -18262,4 +18560,4 @@ function registerSaaSConnectCommand(program) {
|
|
|
18262
18560
|
});
|
|
18263
18561
|
}
|
|
18264
18562
|
//#endregion
|
|
18265
|
-
export { status as $, checkServerHealth as A, isDockerAvailable as B, checkAgentConfigs as C, checkDocker as D, checkDatabase as E, installClientService as F, createOwner as G, ClientRuntime as H, isServiceSupported as I, fail as J, hasUser as K, resolveCliInvocation as L, checkWebSocket as M, printResults as N, checkNodeVersion as O, getClientServiceStatus as P, setJsonMode as Q, uninstallClientService as R, runMigrations as S, checkClientConfig as T, handleClientOrgMismatch as U, stopPostgres as V, rotateClientIdWithBackup as W, blank as X, success as Y, print as Z, onboardCreate as _, declineUpdate as a,
|
|
18563
|
+
export { status as $, checkServerHealth as A, isDockerAvailable as B, checkAgentConfigs as C, checkDocker as D, checkDatabase as E, installClientService as F, createOwner as G, ClientRuntime as H, isServiceSupported as I, fail as J, hasUser as K, resolveCliInvocation as L, checkWebSocket as M, printResults as N, checkNodeVersion as O, getClientServiceStatus as P, setJsonMode as Q, uninstallClientService as R, runMigrations as S, checkClientConfig as T, handleClientOrgMismatch as U, stopPostgres as V, rotateClientIdWithBackup as W, blank as X, success as Y, print as Z, onboardCreate as _, declineUpdate as a, cleanWorkspaces as at, createApiNameResolver as b, COMMAND_VERSION as c, configureClientLoggerForService as ct, isInteractive as d, ClientOrgMismatchError as et, promptAddAgent as f, onboardCheck as g, loadOnboardState as h, createExecuteUpdate as i, SessionRegistry as it, checkServerReachable as j, checkServerConfig as k, reconcileLocalRuntimeProviders as l, formatCheckReport as m, deriveHubUrlFromToken as n, FirstTreeHubSDK as nt, promptUpdate as o, probeCapabilities as ot, promptMissingFields as p, resolveReplyToFromEnv as q, registerSaaSConnectCommand as r, SdkError as rt, startServer as s, applyClientLoggerConfig as st, HubUrlDerivationError as t, ClientUserMismatchError as tt, uploadClientCapabilities as u, saveOnboardState as v, checkBackgroundService as w, migrateLocalAgentDirs as x, runHomeMigration as y, ensurePostgres as z };
|