@agent-team-foundation/first-tree-hub 0.6.0 → 0.6.1
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-BnlTKa0H.mjs → bootstrap-Dq_k_6ZD.mjs} +32 -8
- package/dist/cli/index.mjs +12 -8
- package/dist/{core-B9bH7EjM.mjs → core-Dt3yNBTm.mjs} +454 -167
- package/dist/drizzle/0018_agent_visibility.sql +13 -0
- package/dist/drizzle/meta/0018_snapshot.json +1938 -0
- package/dist/drizzle/meta/_journal.json +7 -0
- package/dist/index.mjs +3 -3
- package/dist/web/assets/index-D7-5shxZ.js +310 -0
- package/dist/web/index.html +1 -1
- package/package.json +1 -1
- package/dist/web/assets/index-C_FKYVro.js +0 -310
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { D as setConfigValue, E as serverConfigSchema, T as resolveConfigReadonly, _ as clientConfigSchema, b as initConfig, d as saveAgentConfig, g as agentConfigSchema, h as DEFAULT_HOME_DIR$1, m as DEFAULT_DATA_DIR$1, o as getGitHubUsername, p as DEFAULT_CONFIG_DIR, t as bootstrapToken$1, u as resolveServerUrl, v as collectMissingPrompts, x as loadAgents } from "./bootstrap-Dq_k_6ZD.mjs";
|
|
2
2
|
import { copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, renameSync, rmSync, statSync, watch, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, join, resolve } from "node:path";
|
|
4
4
|
import { ZodError, z } from "zod";
|
|
@@ -10,7 +10,7 @@ import WebSocket from "ws";
|
|
|
10
10
|
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
11
11
|
import { execFileSync, execSync } from "node:child_process";
|
|
12
12
|
import bcrypt from "bcrypt";
|
|
13
|
-
import { and, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, sql } from "drizzle-orm";
|
|
13
|
+
import { and, count, desc, eq, gt, inArray, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm";
|
|
14
14
|
import { drizzle } from "drizzle-orm/postgres-js";
|
|
15
15
|
import postgres from "postgres";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
@@ -816,7 +816,7 @@ You are running inside **Agent Hub**, a messaging platform for agent teams.
|
|
|
816
816
|
- Each message includes a \`[From: sender-id]\` header so you know who sent it
|
|
817
817
|
- **Your final text response is automatically delivered** to the chat — just respond normally
|
|
818
818
|
- For **proactive communication** (sending to other agents, other chats, or structured data),
|
|
819
|
-
use the
|
|
819
|
+
use the \`first-tree-hub\` CLI below
|
|
820
820
|
- **Use your judgment about when to respond.** Not every message requires a reply.
|
|
821
821
|
Your role and responsibilities (defined in your profile above) guide your behavior
|
|
822
822
|
|
|
@@ -831,23 +831,30 @@ These are injected automatically when the agent process starts:
|
|
|
831
831
|
| \`FIRST_TREE_HUB_CHAT_ID\` | Current chat context ID |
|
|
832
832
|
| \`FIRST_TREE_HUB_AGENT_ID\` | Your agent ID |
|
|
833
833
|
|
|
834
|
+
The \`first-tree-hub\` CLI reads these automatically — no extra setup needed.
|
|
835
|
+
|
|
834
836
|
## Sending Messages
|
|
835
837
|
|
|
836
|
-
Use
|
|
838
|
+
Use the \`first-tree-hub agent send\` CLI:
|
|
837
839
|
|
|
838
840
|
\`\`\`bash
|
|
839
|
-
#
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
841
|
+
# Send to another agent (target = agent ID)
|
|
842
|
+
first-tree-hub agent send <agentId> "your message"
|
|
843
|
+
|
|
844
|
+
# Send to a chat (target = chat ID)
|
|
845
|
+
first-tree-hub agent send --chat <chatId> "your message"
|
|
846
|
+
|
|
847
|
+
# Send markdown (default format is text)
|
|
848
|
+
first-tree-hub agent send <agentId> -f markdown "**bold** message"
|
|
844
849
|
|
|
845
|
-
#
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
+
# Reply to a specific message
|
|
851
|
+
first-tree-hub agent send <agentId> --reply-to <messageId> "reply content"
|
|
852
|
+
|
|
853
|
+
# Pipe long content via stdin (recommended for special characters)
|
|
854
|
+
echo "long message body" | first-tree-hub agent send <agentId>
|
|
850
855
|
\`\`\`
|
|
856
|
+
|
|
857
|
+
For content with quotes, \`$\`, backticks, or newlines, prefer stdin to avoid shell escaping issues.
|
|
851
858
|
`;
|
|
852
859
|
}
|
|
853
860
|
/**
|
|
@@ -2710,7 +2717,7 @@ async function onboardCheck(args) {
|
|
|
2710
2717
|
key: "server",
|
|
2711
2718
|
label: "Server URL",
|
|
2712
2719
|
status: "missing_required",
|
|
2713
|
-
hint: "Provide via --server,
|
|
2720
|
+
hint: "Provide via --server, FIRST_TREE_HUB_SERVER_URL, or config"
|
|
2714
2721
|
});
|
|
2715
2722
|
}
|
|
2716
2723
|
if (args.id) items.push({
|
|
@@ -3052,6 +3059,11 @@ const agentTypeSchema = z.enum([
|
|
|
3052
3059
|
"personal_assistant",
|
|
3053
3060
|
"autonomous_agent"
|
|
3054
3061
|
]);
|
|
3062
|
+
const AGENT_VISIBILITY = {
|
|
3063
|
+
PRIVATE: "private",
|
|
3064
|
+
ORGANIZATION: "organization"
|
|
3065
|
+
};
|
|
3066
|
+
const agentVisibilitySchema = z.enum(["private", "organization"]);
|
|
3055
3067
|
const AGENT_STATUSES = {
|
|
3056
3068
|
ACTIVE: "active",
|
|
3057
3069
|
SUSPENDED: "suspended",
|
|
@@ -3076,7 +3088,7 @@ const createAgentSchema = z.object({
|
|
|
3076
3088
|
profile: z.string().optional(),
|
|
3077
3089
|
organizationId: z.string().max(100).optional(),
|
|
3078
3090
|
source: agentSourceSchema.optional(),
|
|
3079
|
-
|
|
3091
|
+
visibility: agentVisibilitySchema.optional(),
|
|
3080
3092
|
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
3081
3093
|
managerId: z.string().optional()
|
|
3082
3094
|
});
|
|
@@ -3085,6 +3097,7 @@ const updateAgentSchema = z.object({
|
|
|
3085
3097
|
displayName: z.string().max(200).nullable().optional(),
|
|
3086
3098
|
delegateMention: z.string().max(100).nullable().optional(),
|
|
3087
3099
|
profile: z.string().nullable().optional(),
|
|
3100
|
+
visibility: agentVisibilitySchema.optional(),
|
|
3088
3101
|
metadata: z.record(z.string(), z.unknown()).optional()
|
|
3089
3102
|
});
|
|
3090
3103
|
z.object({
|
|
@@ -3099,7 +3112,7 @@ z.object({
|
|
|
3099
3112
|
status: z.string(),
|
|
3100
3113
|
source: z.string().nullable().optional(),
|
|
3101
3114
|
cloudUserId: z.string().nullable().optional(),
|
|
3102
|
-
|
|
3115
|
+
visibility: agentVisibilitySchema,
|
|
3103
3116
|
metadata: z.record(z.string(), z.unknown()),
|
|
3104
3117
|
managerId: z.string().nullable(),
|
|
3105
3118
|
presenceStatus: presenceStatusSchema.optional(),
|
|
@@ -3536,7 +3549,7 @@ z.object({
|
|
|
3536
3549
|
password: z.string().min(8).max(200).optional()
|
|
3537
3550
|
});
|
|
3538
3551
|
//#endregion
|
|
3539
|
-
//#region ../server/dist/app-
|
|
3552
|
+
//#region ../server/dist/app-DjL7uA1H.mjs
|
|
3540
3553
|
var __defProp = Object.defineProperty;
|
|
3541
3554
|
var __exportAll = (all, no_symbols) => {
|
|
3542
3555
|
let target = {};
|
|
@@ -3571,7 +3584,7 @@ const agents = pgTable("agents", {
|
|
|
3571
3584
|
status: text("status").notNull().default("active"),
|
|
3572
3585
|
source: text("source"),
|
|
3573
3586
|
cloudUserId: text("cloud_user_id"),
|
|
3574
|
-
|
|
3587
|
+
visibility: text("visibility").notNull().default("private"),
|
|
3575
3588
|
metadata: jsonb("metadata").$type().notNull().default({}),
|
|
3576
3589
|
managerId: text("manager_id"),
|
|
3577
3590
|
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
@@ -3579,6 +3592,7 @@ const agents = pgTable("agents", {
|
|
|
3579
3592
|
}, (table) => [
|
|
3580
3593
|
index("idx_agents_org").on(table.organizationId),
|
|
3581
3594
|
index("idx_agents_manager").on(table.managerId),
|
|
3595
|
+
index("idx_agents_visibility_org").on(table.organizationId, table.visibility),
|
|
3582
3596
|
unique("uq_agents_org_name").on(table.organizationId, table.name)
|
|
3583
3597
|
]);
|
|
3584
3598
|
/** Maps external user identities to internal Agents. */
|
|
@@ -3649,6 +3663,96 @@ const chatParticipants = pgTable("chat_participants", {
|
|
|
3649
3663
|
mode: text("mode").notNull().default("full"),
|
|
3650
3664
|
joinedAt: timestamp("joined_at", { withTimezone: true }).notNull().defaultNow()
|
|
3651
3665
|
}, (table) => [primaryKey({ columns: [table.chatId, table.agentId] }), index("idx_participants_agent").on(table.agentId)]);
|
|
3666
|
+
function requireAgent(request) {
|
|
3667
|
+
const agent = request.agent;
|
|
3668
|
+
if (!agent) throw new UnauthorizedError("Agent authentication required");
|
|
3669
|
+
return agent;
|
|
3670
|
+
}
|
|
3671
|
+
function requireMember(request) {
|
|
3672
|
+
const member = request.member;
|
|
3673
|
+
if (!member) throw new UnauthorizedError("Member authentication required");
|
|
3674
|
+
return member;
|
|
3675
|
+
}
|
|
3676
|
+
/**
|
|
3677
|
+
* Centralized access control for member-facing APIs.
|
|
3678
|
+
*
|
|
3679
|
+
* Three independent decisions:
|
|
3680
|
+
* 1. assertAgentVisible — can this member see this agent?
|
|
3681
|
+
* 2. assertChatAccess — can this member access this chat?
|
|
3682
|
+
* 3. assertCanManage — can this member manage (configure/delete) this agent?
|
|
3683
|
+
*
|
|
3684
|
+
* Plus a SQL condition builder for list queries:
|
|
3685
|
+
* agentVisibilityCondition — WHERE clause for "agents visible to this member"
|
|
3686
|
+
*
|
|
3687
|
+
* Rules:
|
|
3688
|
+
* - Visibility is role-independent: admin sees the same agents as member.
|
|
3689
|
+
* Admin privilege is expressed through manageability, not visibility.
|
|
3690
|
+
* This means an admin cannot see private agents they don't manage —
|
|
3691
|
+
* this is intentional and matches the design principle
|
|
3692
|
+
* "roster is transparent, workspace is private".
|
|
3693
|
+
* - Manageability distinguishes roles: admin can manage all, member only their own.
|
|
3694
|
+
* - All conditions include organizationId scoping to prevent cross-org access.
|
|
3695
|
+
*/
|
|
3696
|
+
/** Extract MemberScope from an authenticated request. Single definition, used by all routes. */
|
|
3697
|
+
function memberScope(request) {
|
|
3698
|
+
const m = requireMember(request);
|
|
3699
|
+
return {
|
|
3700
|
+
memberId: m.memberId,
|
|
3701
|
+
humanAgentId: m.agentId,
|
|
3702
|
+
organizationId: m.organizationId,
|
|
3703
|
+
role: m.role
|
|
3704
|
+
};
|
|
3705
|
+
}
|
|
3706
|
+
/**
|
|
3707
|
+
* SQL WHERE conditions for agents visible to a member.
|
|
3708
|
+
* Visibility is the same for all roles:
|
|
3709
|
+
* same org + not deleted + (organization-visible OR managerId = self)
|
|
3710
|
+
*/
|
|
3711
|
+
function agentVisibilityCondition(scope) {
|
|
3712
|
+
return and(eq(agents.organizationId, scope.organizationId), ne(agents.status, AGENT_STATUSES.DELETED), or(eq(agents.visibility, AGENT_VISIBILITY.ORGANIZATION), eq(agents.managerId, scope.memberId)));
|
|
3713
|
+
}
|
|
3714
|
+
/**
|
|
3715
|
+
* Assert a single agent is visible to the member.
|
|
3716
|
+
* Single query — returns 404 for both "not found" and "not visible"
|
|
3717
|
+
* to prevent UUID enumeration.
|
|
3718
|
+
*/
|
|
3719
|
+
async function assertAgentVisible(db, scope, agentUuid) {
|
|
3720
|
+
const [row] = await db.select({ uuid: agents.uuid }).from(agents).where(and(eq(agents.uuid, agentUuid), agentVisibilityCondition(scope))).limit(1);
|
|
3721
|
+
if (!row) throw new NotFoundError(`Agent "${agentUuid}" not found`);
|
|
3722
|
+
}
|
|
3723
|
+
/**
|
|
3724
|
+
* Assert the member can access a chat (read detail, read messages).
|
|
3725
|
+
* Verifies chat exists (404 if not), then checks:
|
|
3726
|
+
* - The member's human agent is a participant, OR
|
|
3727
|
+
* - Any agent managed by this member is a participant (supervision)
|
|
3728
|
+
* Returns 404 for inaccessible chats to prevent enumeration.
|
|
3729
|
+
*/
|
|
3730
|
+
async function assertChatAccess(db, scope, chatId) {
|
|
3731
|
+
const [chat] = await db.select({ id: chats.id }).from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
3732
|
+
if (!chat) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
3733
|
+
const [direct] = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, scope.humanAgentId))).limit(1);
|
|
3734
|
+
if (direct) return;
|
|
3735
|
+
const participantRows = await db.select({ agentId: chatParticipants.agentId }).from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
3736
|
+
if (participantRows.length === 0) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
3737
|
+
const participantIds = participantRows.map((p) => p.agentId);
|
|
3738
|
+
const [managed] = await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, participantIds), eq(agents.managerId, scope.memberId))).limit(1);
|
|
3739
|
+
if (!managed) throw new NotFoundError(`Chat "${chatId}" not found`);
|
|
3740
|
+
}
|
|
3741
|
+
/**
|
|
3742
|
+
* Assert the member can manage (update/delete/token/suspend) an agent.
|
|
3743
|
+
* Admin can manage all agents in their org. Non-admin can only manage agents where managerId = self.
|
|
3744
|
+
* Always verifies the agent exists and belongs to the same org (throws 404 if not).
|
|
3745
|
+
*/
|
|
3746
|
+
async function assertCanManage(db, scope, agentUuid) {
|
|
3747
|
+
const [agent] = await db.select({
|
|
3748
|
+
uuid: agents.uuid,
|
|
3749
|
+
managerId: agents.managerId,
|
|
3750
|
+
organizationId: agents.organizationId
|
|
3751
|
+
}).from(agents).where(and(eq(agents.uuid, agentUuid), ne(agents.status, AGENT_STATUSES.DELETED))).limit(1);
|
|
3752
|
+
if (!agent || agent.organizationId !== scope.organizationId) throw new NotFoundError(`Agent "${agentUuid}" not found`);
|
|
3753
|
+
if (scope.role === "admin") return;
|
|
3754
|
+
if (agent.managerId !== scope.memberId) throw new NotFoundError(`Agent "${agentUuid}" not found`);
|
|
3755
|
+
}
|
|
3652
3756
|
/**
|
|
3653
3757
|
* Maps internal Chats to external IM platform channels.
|
|
3654
3758
|
* NOTE: The unique constraint uses COALESCE(thread_id, '') which cannot be
|
|
@@ -3960,6 +4064,8 @@ async function adminAdapterMappingRoutes(app) {
|
|
|
3960
4064
|
});
|
|
3961
4065
|
app.post("/", async (request, reply) => {
|
|
3962
4066
|
const body = createAdapterMappingSchema.parse(request.body);
|
|
4067
|
+
const scope = memberScope(request);
|
|
4068
|
+
await assertCanManage(app.db, scope, body.agentId);
|
|
3963
4069
|
const [agent] = await app.db.select({
|
|
3964
4070
|
id: agents.uuid,
|
|
3965
4071
|
type: agents.type,
|
|
@@ -3986,8 +4092,11 @@ async function adminAdapterMappingRoutes(app) {
|
|
|
3986
4092
|
});
|
|
3987
4093
|
app.delete("/:id", async (request, reply) => {
|
|
3988
4094
|
const id = parseId$1(request.params.id);
|
|
3989
|
-
const [
|
|
3990
|
-
if (!
|
|
4095
|
+
const [existing] = await app.db.select().from(adapterAgentMappings).where(eq(adapterAgentMappings.id, id)).limit(1);
|
|
4096
|
+
if (!existing) throw new NotFoundError(`Adapter mapping "${id}" not found`);
|
|
4097
|
+
const scope = memberScope(request);
|
|
4098
|
+
await assertCanManage(app.db, scope, existing.agentId);
|
|
4099
|
+
await app.db.delete(adapterAgentMappings).where(eq(adapterAgentMappings.id, id));
|
|
3991
4100
|
return reply.status(204).send();
|
|
3992
4101
|
});
|
|
3993
4102
|
}
|
|
@@ -4139,6 +4248,8 @@ async function adminAdapterRoutes(app) {
|
|
|
4139
4248
|
});
|
|
4140
4249
|
app.post("/", async (request, reply) => {
|
|
4141
4250
|
const body = createAdapterConfigSchema.parse(request.body);
|
|
4251
|
+
const scope = memberScope(request);
|
|
4252
|
+
await assertCanManage(app.db, scope, body.agentId);
|
|
4142
4253
|
const config = await createAdapterConfig(app.db, body, app.config.secrets.encryptionKey);
|
|
4143
4254
|
app.adapterManager.reload().catch((err) => app.log.error(err, "Adapter reload failed after create"));
|
|
4144
4255
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
@@ -4160,6 +4271,9 @@ async function adminAdapterRoutes(app) {
|
|
|
4160
4271
|
app.patch("/:id", async (request) => {
|
|
4161
4272
|
const body = updateAdapterConfigSchema.parse(request.body);
|
|
4162
4273
|
const id = parseId(request.params.id);
|
|
4274
|
+
const scope = memberScope(request);
|
|
4275
|
+
const existing = await getAdapterConfig(app.db, id);
|
|
4276
|
+
await assertCanManage(app.db, scope, existing.agentId);
|
|
4163
4277
|
const config = await updateAdapterConfig(app.db, id, body, app.config.secrets.encryptionKey);
|
|
4164
4278
|
app.adapterManager.reload().catch((err) => app.log.error(err, "Adapter reload failed after update"));
|
|
4165
4279
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
@@ -4171,22 +4285,15 @@ async function adminAdapterRoutes(app) {
|
|
|
4171
4285
|
});
|
|
4172
4286
|
app.delete("/:id", async (request, reply) => {
|
|
4173
4287
|
const id = parseId(request.params.id);
|
|
4288
|
+
const scope = memberScope(request);
|
|
4289
|
+
const existing = await getAdapterConfig(app.db, id);
|
|
4290
|
+
await assertCanManage(app.db, scope, existing.agentId);
|
|
4174
4291
|
await deleteAdapterConfig(app.db, id);
|
|
4175
4292
|
app.adapterManager.reload().catch((err) => app.log.error(err, "Adapter reload failed after delete"));
|
|
4176
4293
|
app.notifier.notifyConfigChange("adapter_configs").catch(() => {});
|
|
4177
4294
|
return reply.status(204).send();
|
|
4178
4295
|
});
|
|
4179
4296
|
}
|
|
4180
|
-
function requireAgent(request) {
|
|
4181
|
-
const agent = request.agent;
|
|
4182
|
-
if (!agent) throw new UnauthorizedError("Agent authentication required");
|
|
4183
|
-
return agent;
|
|
4184
|
-
}
|
|
4185
|
-
function requireMember(request) {
|
|
4186
|
-
const member = request.member;
|
|
4187
|
-
if (!member) throw new UnauthorizedError("Member authentication required");
|
|
4188
|
-
return member;
|
|
4189
|
-
}
|
|
4190
4297
|
/** Client connections. A client is a single SDK process (AgentRuntime) that may host multiple agents. */
|
|
4191
4298
|
const clients = pgTable("clients", {
|
|
4192
4299
|
id: text("id").primaryKey(),
|
|
@@ -4240,6 +4347,15 @@ function serializeDate(d) {
|
|
|
4240
4347
|
* real account.
|
|
4241
4348
|
*/
|
|
4242
4349
|
const RESERVED_AGENT_NAME_PREFIX = "__";
|
|
4350
|
+
/** Default visibility per agent type. */
|
|
4351
|
+
function defaultVisibility(type) {
|
|
4352
|
+
switch (type) {
|
|
4353
|
+
case "human":
|
|
4354
|
+
case "autonomous_agent": return AGENT_VISIBILITY.ORGANIZATION;
|
|
4355
|
+
case "personal_assistant": return AGENT_VISIBILITY.PRIVATE;
|
|
4356
|
+
default: return AGENT_VISIBILITY.PRIVATE;
|
|
4357
|
+
}
|
|
4358
|
+
}
|
|
4243
4359
|
async function createAgent(db, data) {
|
|
4244
4360
|
const uuid = uuidv7();
|
|
4245
4361
|
const name = data.name ?? null;
|
|
@@ -4261,7 +4377,7 @@ async function createAgent(db, data) {
|
|
|
4261
4377
|
profile: data.profile ?? null,
|
|
4262
4378
|
inboxId,
|
|
4263
4379
|
source: data.source ?? null,
|
|
4264
|
-
|
|
4380
|
+
visibility: data.visibility ?? defaultVisibility(data.type),
|
|
4265
4381
|
metadata: data.metadata ?? {},
|
|
4266
4382
|
managerId: data.managerId ?? null
|
|
4267
4383
|
}).returning();
|
|
@@ -4282,8 +4398,12 @@ async function getAgentByName(db, orgId, name) {
|
|
|
4282
4398
|
if (!agent) throw new NotFoundError(`Agent "${name}" not found in organization "${orgId}"`);
|
|
4283
4399
|
return agent;
|
|
4284
4400
|
}
|
|
4285
|
-
|
|
4286
|
-
|
|
4401
|
+
/**
|
|
4402
|
+
* List agents visible to a specific member.
|
|
4403
|
+
* Uses agentVisibilityCondition from access-control (same rules for all roles).
|
|
4404
|
+
*/
|
|
4405
|
+
async function listAgentsForMember(db, scope, limit, cursor, type) {
|
|
4406
|
+
const conditions = [agentVisibilityCondition(scope)];
|
|
4287
4407
|
if (cursor) conditions.push(lt(agents.createdAt, new Date(cursor)));
|
|
4288
4408
|
if (type) conditions.push(eq(agents.type, type));
|
|
4289
4409
|
const where = and(...conditions);
|
|
@@ -4298,7 +4418,7 @@ async function listAgents(db, orgId, limit, cursor, type) {
|
|
|
4298
4418
|
inboxId: agents.inboxId,
|
|
4299
4419
|
status: agents.status,
|
|
4300
4420
|
cloudUserId: agents.cloudUserId,
|
|
4301
|
-
|
|
4421
|
+
visibility: agents.visibility,
|
|
4302
4422
|
metadata: agents.metadata,
|
|
4303
4423
|
managerId: agents.managerId,
|
|
4304
4424
|
createdAt: agents.createdAt,
|
|
@@ -4324,6 +4444,7 @@ async function updateAgent(db, uuid, data) {
|
|
|
4324
4444
|
if (data.displayName !== void 0) updates.displayName = data.displayName;
|
|
4325
4445
|
if (data.delegateMention !== void 0) updates.delegateMention = data.delegateMention;
|
|
4326
4446
|
if (data.profile !== void 0) updates.profile = data.profile;
|
|
4447
|
+
if (data.visibility !== void 0) updates.visibility = data.visibility;
|
|
4327
4448
|
if (data.metadata !== void 0) updates.metadata = data.metadata;
|
|
4328
4449
|
const [updated] = await db.update(agents).set(updates).where(eq(agents.uuid, agent.uuid)).returning();
|
|
4329
4450
|
if (!updated) throw new Error("Unexpected: UPDATE RETURNING produced no row");
|
|
@@ -4563,6 +4684,111 @@ async function removeParticipant(db, chatId, requesterId, targetAgentId) {
|
|
|
4563
4684
|
if (!removed) throw new NotFoundError(`Agent "${targetAgentId}" is not a participant of this chat`);
|
|
4564
4685
|
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
4565
4686
|
}
|
|
4687
|
+
/**
|
|
4688
|
+
* List chats visible to a member, grouped by agent.
|
|
4689
|
+
* A member sees chats where:
|
|
4690
|
+
* 1. Their human agent is a participant, OR
|
|
4691
|
+
* 2. Any agent they manage (managerId = memberId) is a participant (supervision)
|
|
4692
|
+
*/
|
|
4693
|
+
async function listChatsForMember(db, memberId, humanAgentId) {
|
|
4694
|
+
const managedAgents = await db.select({
|
|
4695
|
+
uuid: agents.uuid,
|
|
4696
|
+
name: agents.name,
|
|
4697
|
+
type: agents.type,
|
|
4698
|
+
displayName: agents.displayName
|
|
4699
|
+
}).from(agents).where(eq(agents.managerId, memberId));
|
|
4700
|
+
const agentMap = /* @__PURE__ */ new Map();
|
|
4701
|
+
for (const a of managedAgents) agentMap.set(a.uuid, a);
|
|
4702
|
+
if (!agentMap.has(humanAgentId)) {
|
|
4703
|
+
const [ha] = await db.select({
|
|
4704
|
+
uuid: agents.uuid,
|
|
4705
|
+
name: agents.name,
|
|
4706
|
+
type: agents.type,
|
|
4707
|
+
displayName: agents.displayName
|
|
4708
|
+
}).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
|
|
4709
|
+
if (ha) agentMap.set(ha.uuid, ha);
|
|
4710
|
+
}
|
|
4711
|
+
const agentIds = [...agentMap.keys()];
|
|
4712
|
+
if (agentIds.length === 0) return [];
|
|
4713
|
+
const participations = await db.select({
|
|
4714
|
+
chatId: chatParticipants.chatId,
|
|
4715
|
+
agentId: chatParticipants.agentId,
|
|
4716
|
+
role: chatParticipants.role,
|
|
4717
|
+
mode: chatParticipants.mode
|
|
4718
|
+
}).from(chatParticipants).where(inArray(chatParticipants.agentId, agentIds));
|
|
4719
|
+
if (participations.length === 0) return [];
|
|
4720
|
+
const chatIds = [...new Set(participations.map((p) => p.chatId))];
|
|
4721
|
+
const agentChatMap = /* @__PURE__ */ new Map();
|
|
4722
|
+
for (const p of participations) {
|
|
4723
|
+
const list = agentChatMap.get(p.agentId) ?? [];
|
|
4724
|
+
list.push(p.chatId);
|
|
4725
|
+
agentChatMap.set(p.agentId, list);
|
|
4726
|
+
}
|
|
4727
|
+
const chatRows = await db.select({
|
|
4728
|
+
id: chats.id,
|
|
4729
|
+
type: chats.type,
|
|
4730
|
+
topic: chats.topic,
|
|
4731
|
+
metadata: chats.metadata,
|
|
4732
|
+
createdAt: chats.createdAt,
|
|
4733
|
+
updatedAt: chats.updatedAt,
|
|
4734
|
+
participantCount: sql`(SELECT count(*)::int FROM chat_participants WHERE chat_id = ${chats.id})`
|
|
4735
|
+
}).from(chats).where(inArray(chats.id, chatIds)).orderBy(desc(chats.updatedAt));
|
|
4736
|
+
const chatMap = new Map(chatRows.map((c) => [c.id, c]));
|
|
4737
|
+
const humanParticipantChatIds = new Set(participations.filter((p) => p.agentId === humanAgentId).map((p) => p.chatId));
|
|
4738
|
+
const result = [];
|
|
4739
|
+
for (const [agentId, agentChatIds] of agentChatMap) {
|
|
4740
|
+
const agentInfo = agentMap.get(agentId);
|
|
4741
|
+
if (!agentInfo) continue;
|
|
4742
|
+
const agentChats = agentChatIds.map((chatId) => {
|
|
4743
|
+
const chat = chatMap.get(chatId);
|
|
4744
|
+
if (!chat) return null;
|
|
4745
|
+
const isSupervisionOnly = agentId !== humanAgentId && !humanParticipantChatIds.has(chatId);
|
|
4746
|
+
return {
|
|
4747
|
+
id: chat.id,
|
|
4748
|
+
type: chat.type,
|
|
4749
|
+
topic: chat.topic,
|
|
4750
|
+
participantCount: chat.participantCount,
|
|
4751
|
+
isSupervisionOnly,
|
|
4752
|
+
createdAt: chat.createdAt.toISOString(),
|
|
4753
|
+
updatedAt: chat.updatedAt.toISOString()
|
|
4754
|
+
};
|
|
4755
|
+
}).filter((c) => c !== null);
|
|
4756
|
+
if (agentChats.length > 0) result.push({
|
|
4757
|
+
agent: agentInfo,
|
|
4758
|
+
chats: agentChats
|
|
4759
|
+
});
|
|
4760
|
+
}
|
|
4761
|
+
return result;
|
|
4762
|
+
}
|
|
4763
|
+
/**
|
|
4764
|
+
* Manager joins a chat. Adds their human agent as a participant.
|
|
4765
|
+
* Requires the member to have supervision rights (manages at least one existing participant).
|
|
4766
|
+
*/
|
|
4767
|
+
async function joinChat(db, chatId, memberId, humanAgentId) {
|
|
4768
|
+
const chat = await getChat(db, chatId);
|
|
4769
|
+
const participantAgentIds = (await db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId))).map((p) => p.agentId);
|
|
4770
|
+
if (participantAgentIds.length === 0) throw new NotFoundError("Chat has no participants");
|
|
4771
|
+
if (participantAgentIds.includes(humanAgentId)) throw new ConflictError("Already a participant in this chat");
|
|
4772
|
+
if ((await db.select({ uuid: agents.uuid }).from(agents).where(and(inArray(agents.uuid, participantAgentIds), eq(agents.managerId, memberId)))).length === 0) throw new ForbiddenError("You can only join chats where you manage at least one participant");
|
|
4773
|
+
const [humanAgent] = await db.select({ organizationId: agents.organizationId }).from(agents).where(eq(agents.uuid, humanAgentId)).limit(1);
|
|
4774
|
+
if (!humanAgent || humanAgent.organizationId !== chat.organizationId) throw new BadRequestError("Agent does not belong to the same organization as the chat");
|
|
4775
|
+
await db.insert(chatParticipants).values({
|
|
4776
|
+
chatId,
|
|
4777
|
+
agentId: humanAgentId,
|
|
4778
|
+
role: "member",
|
|
4779
|
+
mode: "full"
|
|
4780
|
+
});
|
|
4781
|
+
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
4782
|
+
}
|
|
4783
|
+
/**
|
|
4784
|
+
* Manager leaves a chat. Removes their human agent from participants.
|
|
4785
|
+
* Only allowed if the human agent is a participant.
|
|
4786
|
+
*/
|
|
4787
|
+
async function leaveChat(db, chatId, humanAgentId) {
|
|
4788
|
+
const [removed] = await db.delete(chatParticipants).where(and(eq(chatParticipants.chatId, chatId), eq(chatParticipants.agentId, humanAgentId))).returning();
|
|
4789
|
+
if (!removed) throw new NotFoundError("Not a participant of this chat");
|
|
4790
|
+
return db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
4791
|
+
}
|
|
4566
4792
|
async function findOrCreateDirectChat(db, agentAId, agentBId) {
|
|
4567
4793
|
const aChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentAId));
|
|
4568
4794
|
const bChats = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(eq(chatParticipants.agentId, agentBId));
|
|
@@ -5154,11 +5380,8 @@ async function adminAgentRoutes(app) {
|
|
|
5154
5380
|
app.get("/", async (request) => {
|
|
5155
5381
|
const query = paginationQuerySchema.parse(request.query);
|
|
5156
5382
|
const { type } = listAgentsFilterSchema.parse(request.query);
|
|
5157
|
-
const
|
|
5158
|
-
|
|
5159
|
-
if (orgParam) org = (await resolveOrganization(app.db, orgParam)).id;
|
|
5160
|
-
else org = await resolveDefaultOrgId(app.db);
|
|
5161
|
-
const result = await listAgents(app.db, org, query.limit, query.cursor, type);
|
|
5383
|
+
const scope = memberScope(request);
|
|
5384
|
+
const result = await listAgentsForMember(app.db, scope, query.limit, query.cursor, type);
|
|
5162
5385
|
return {
|
|
5163
5386
|
items: result.items.map((a) => ({
|
|
5164
5387
|
...a,
|
|
@@ -5175,8 +5398,9 @@ async function adminAgentRoutes(app) {
|
|
|
5175
5398
|
};
|
|
5176
5399
|
});
|
|
5177
5400
|
app.post("/", async (request, reply) => {
|
|
5401
|
+
const scope = memberScope(request);
|
|
5178
5402
|
const body = createAgentSchema.parse(request.body);
|
|
5179
|
-
const managerId =
|
|
5403
|
+
const managerId = scope.role === "admin" ? body.managerId ?? scope.memberId : scope.memberId;
|
|
5180
5404
|
const agent = await createAgent(app.db, {
|
|
5181
5405
|
...body,
|
|
5182
5406
|
source: body.source ?? "admin-api",
|
|
@@ -5189,6 +5413,8 @@ async function adminAgentRoutes(app) {
|
|
|
5189
5413
|
});
|
|
5190
5414
|
});
|
|
5191
5415
|
app.patch("/:uuid", async (request) => {
|
|
5416
|
+
const scope = memberScope(request);
|
|
5417
|
+
await assertCanManage(app.db, scope, request.params.uuid);
|
|
5192
5418
|
const body = updateAgentSchema.parse(request.body);
|
|
5193
5419
|
const agent = await updateAgent(app.db, request.params.uuid, body);
|
|
5194
5420
|
return {
|
|
@@ -5198,6 +5424,8 @@ async function adminAgentRoutes(app) {
|
|
|
5198
5424
|
};
|
|
5199
5425
|
});
|
|
5200
5426
|
app.get("/:uuid", async (request) => {
|
|
5427
|
+
const scope = memberScope(request);
|
|
5428
|
+
await assertAgentVisible(app.db, scope, request.params.uuid);
|
|
5201
5429
|
const agent = await getAgent(app.db, request.params.uuid);
|
|
5202
5430
|
return {
|
|
5203
5431
|
...agent,
|
|
@@ -5206,6 +5434,8 @@ async function adminAgentRoutes(app) {
|
|
|
5206
5434
|
};
|
|
5207
5435
|
});
|
|
5208
5436
|
app.post("/:uuid/tokens", async (request, reply) => {
|
|
5437
|
+
const scope = memberScope(request);
|
|
5438
|
+
await assertCanManage(app.db, scope, request.params.uuid);
|
|
5209
5439
|
const body = createAgentTokenSchema.parse(request.body);
|
|
5210
5440
|
const result = await createToken(app.db, request.params.uuid, body);
|
|
5211
5441
|
return reply.status(201).send({
|
|
@@ -5217,6 +5447,8 @@ async function adminAgentRoutes(app) {
|
|
|
5217
5447
|
});
|
|
5218
5448
|
});
|
|
5219
5449
|
app.get("/:uuid/tokens", async (request) => {
|
|
5450
|
+
const scope = memberScope(request);
|
|
5451
|
+
await assertCanManage(app.db, scope, request.params.uuid);
|
|
5220
5452
|
return (await listTokens(app.db, request.params.uuid)).map((t) => ({
|
|
5221
5453
|
...t,
|
|
5222
5454
|
expiresAt: serializeDate(t.expiresAt),
|
|
@@ -5226,22 +5458,25 @@ async function adminAgentRoutes(app) {
|
|
|
5226
5458
|
}));
|
|
5227
5459
|
});
|
|
5228
5460
|
app.delete("/:uuid/tokens/:tokenId", async (request, reply) => {
|
|
5461
|
+
const scope = memberScope(request);
|
|
5462
|
+
await assertCanManage(app.db, scope, request.params.uuid);
|
|
5229
5463
|
await revokeToken(app.db, request.params.uuid, request.params.tokenId);
|
|
5230
5464
|
return reply.status(204).send();
|
|
5231
5465
|
});
|
|
5232
5466
|
app.post("/:uuid/disconnect", async (request, reply) => {
|
|
5233
5467
|
const { uuid } = request.params;
|
|
5234
|
-
|
|
5468
|
+
const scope = memberScope(request);
|
|
5469
|
+
await assertCanManage(app.db, scope, uuid);
|
|
5235
5470
|
const wasConnected = forceDisconnect(uuid);
|
|
5236
5471
|
await setOffline(app.db, uuid);
|
|
5237
5472
|
return reply.status(200).send({ disconnected: wasConnected });
|
|
5238
5473
|
});
|
|
5239
5474
|
app.post("/:uuid/provision", async (request, reply) => {
|
|
5240
5475
|
const { uuid } = request.params;
|
|
5241
|
-
const
|
|
5476
|
+
const scope = memberScope(request);
|
|
5477
|
+
await assertCanManage(app.db, scope, uuid);
|
|
5242
5478
|
const body = z.object({ clientId: z.string().min(1) }).parse(request.body);
|
|
5243
5479
|
const agent = await getAgent(app.db, uuid);
|
|
5244
|
-
if (agent.organizationId !== member.organizationId) throw new ForbiddenError("Agent does not belong to your organization");
|
|
5245
5480
|
if (!hasClientConnection(body.clientId)) return reply.status(409).send({ error: "Client is not connected" });
|
|
5246
5481
|
const tokenResult = await createToken(app.db, uuid, { name: "provision" });
|
|
5247
5482
|
if (!sendToClient(body.clientId, {
|
|
@@ -5256,6 +5491,8 @@ async function adminAgentRoutes(app) {
|
|
|
5256
5491
|
});
|
|
5257
5492
|
});
|
|
5258
5493
|
app.post("/:uuid/suspend", async (request) => {
|
|
5494
|
+
const scope = memberScope(request);
|
|
5495
|
+
await assertCanManage(app.db, scope, request.params.uuid);
|
|
5259
5496
|
const agent = await suspendAgent(app.db, request.params.uuid);
|
|
5260
5497
|
return {
|
|
5261
5498
|
...agent,
|
|
@@ -5264,6 +5501,8 @@ async function adminAgentRoutes(app) {
|
|
|
5264
5501
|
};
|
|
5265
5502
|
});
|
|
5266
5503
|
app.post("/:uuid/reactivate", async (request) => {
|
|
5504
|
+
const scope = memberScope(request);
|
|
5505
|
+
await assertCanManage(app.db, scope, request.params.uuid);
|
|
5267
5506
|
const agent = await reactivateAgent(app.db, request.params.uuid);
|
|
5268
5507
|
return {
|
|
5269
5508
|
...agent,
|
|
@@ -5272,12 +5511,16 @@ async function adminAgentRoutes(app) {
|
|
|
5272
5511
|
};
|
|
5273
5512
|
});
|
|
5274
5513
|
app.delete("/:uuid", async (request, reply) => {
|
|
5514
|
+
const scope = memberScope(request);
|
|
5515
|
+
await assertCanManage(app.db, scope, request.params.uuid);
|
|
5275
5516
|
await deleteAgent(app.db, request.params.uuid);
|
|
5276
5517
|
return reply.status(204).send();
|
|
5277
5518
|
});
|
|
5278
5519
|
app.post("/:uuid/test", async (request, reply) => {
|
|
5279
5520
|
const { uuid } = request.params;
|
|
5280
|
-
const
|
|
5521
|
+
const scope = memberScope(request);
|
|
5522
|
+
await assertCanManage(app.db, scope, uuid);
|
|
5523
|
+
const presence = await getPresence(app.db, uuid);
|
|
5281
5524
|
const wsConnected = hasActiveConnection(uuid);
|
|
5282
5525
|
const clientId = getAgentClientId(uuid) ?? presence?.clientId ?? null;
|
|
5283
5526
|
const STALE_THRESHOLD_MS = 6e4;
|
|
@@ -5359,8 +5602,9 @@ async function adminAgentRoutes(app) {
|
|
|
5359
5602
|
/** POST /admin/agents/:uuid/chats — create a new workspace chat with the target agent */
|
|
5360
5603
|
app.post("/:uuid/chats", async (request, reply) => {
|
|
5361
5604
|
const { uuid: targetAgentId } = request.params;
|
|
5605
|
+
const scope = memberScope(request);
|
|
5606
|
+
await assertAgentVisible(app.db, scope, targetAgentId);
|
|
5362
5607
|
const member = requireMember(request);
|
|
5363
|
-
if ((await getAgent(app.db, targetAgentId)).organizationId !== member.organizationId) throw new ForbiddenError("Agent does not belong to your organization");
|
|
5364
5608
|
const result = await createChat(app.db, member.agentId, {
|
|
5365
5609
|
type: "direct",
|
|
5366
5610
|
participantIds: [targetAgentId]
|
|
@@ -5379,9 +5623,74 @@ async function adminAgentRoutes(app) {
|
|
|
5379
5623
|
});
|
|
5380
5624
|
});
|
|
5381
5625
|
}
|
|
5626
|
+
/** User accounts. Passwords are stored as bcrypt hashes. */
|
|
5627
|
+
const users = pgTable("users", {
|
|
5628
|
+
id: text("id").primaryKey(),
|
|
5629
|
+
username: text("username").unique().notNull(),
|
|
5630
|
+
passwordHash: text("password_hash").notNull(),
|
|
5631
|
+
displayName: text("display_name").notNull(),
|
|
5632
|
+
avatarUrl: text("avatar_url"),
|
|
5633
|
+
status: text("status").notNull().default("active"),
|
|
5634
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
5635
|
+
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
5636
|
+
});
|
|
5637
|
+
/** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
|
|
5638
|
+
const members = pgTable("members", {
|
|
5639
|
+
id: text("id").primaryKey(),
|
|
5640
|
+
userId: text("user_id").notNull().references(() => users.id),
|
|
5641
|
+
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
5642
|
+
agentId: text("agent_id").unique().notNull().references(() => agents.uuid),
|
|
5643
|
+
role: text("role").notNull(),
|
|
5644
|
+
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
5645
|
+
}, (table) => [
|
|
5646
|
+
unique("uq_members_user_org").on(table.userId, table.organizationId),
|
|
5647
|
+
index("idx_members_user").on(table.userId),
|
|
5648
|
+
index("idx_members_org").on(table.organizationId)
|
|
5649
|
+
]);
|
|
5650
|
+
function memberAuthHook(db, jwtSecret) {
|
|
5651
|
+
const secret = new TextEncoder().encode(jwtSecret);
|
|
5652
|
+
return async (request, _reply) => {
|
|
5653
|
+
const header = request.headers.authorization;
|
|
5654
|
+
if (!header?.startsWith("Bearer ")) throw new UnauthorizedError("Missing or invalid Authorization header");
|
|
5655
|
+
const token = header.slice(7);
|
|
5656
|
+
let payload;
|
|
5657
|
+
try {
|
|
5658
|
+
const { payload: p } = await jwtVerify(token, secret);
|
|
5659
|
+
payload = p;
|
|
5660
|
+
} catch {
|
|
5661
|
+
throw new UnauthorizedError("Invalid or expired token");
|
|
5662
|
+
}
|
|
5663
|
+
if (payload.type !== "access" || !payload.sub || !payload.memberId) throw new UnauthorizedError("Invalid token type");
|
|
5664
|
+
const [user] = await db.select({
|
|
5665
|
+
id: users.id,
|
|
5666
|
+
status: users.status
|
|
5667
|
+
}).from(users).where(eq(users.id, payload.sub)).limit(1);
|
|
5668
|
+
if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
|
|
5669
|
+
const [member] = await db.select({
|
|
5670
|
+
id: members.id,
|
|
5671
|
+
organizationId: members.organizationId,
|
|
5672
|
+
role: members.role,
|
|
5673
|
+
agentId: members.agentId
|
|
5674
|
+
}).from(members).where(eq(members.id, payload.memberId)).limit(1);
|
|
5675
|
+
if (!member) throw new UnauthorizedError("Membership not found");
|
|
5676
|
+
request.member = {
|
|
5677
|
+
userId: user.id,
|
|
5678
|
+
memberId: member.id,
|
|
5679
|
+
organizationId: member.organizationId,
|
|
5680
|
+
role: member.role,
|
|
5681
|
+
agentId: member.agentId
|
|
5682
|
+
};
|
|
5683
|
+
};
|
|
5684
|
+
}
|
|
5685
|
+
/** Additional hook that requires admin role. Use after memberAuthHook. */
|
|
5686
|
+
function requireAdminRoleHook() {
|
|
5687
|
+
return async (request, _reply) => {
|
|
5688
|
+
if (request.member?.role !== "admin") throw new ForbiddenError("Admin role required");
|
|
5689
|
+
};
|
|
5690
|
+
}
|
|
5382
5691
|
async function adminChatRoutes(app) {
|
|
5383
|
-
/** List chats
|
|
5384
|
-
app.get("/", async (request) => {
|
|
5692
|
+
/** List all chats in org (admin-only, for audit). Members should use GET /mine. */
|
|
5693
|
+
app.get("/", { preHandler: requireAdminRoleHook() }, async (request) => {
|
|
5385
5694
|
const query = paginationQuerySchema.parse(request.query);
|
|
5386
5695
|
const orgParam = request.query.org;
|
|
5387
5696
|
let orgId;
|
|
@@ -5422,11 +5731,13 @@ async function adminChatRoutes(app) {
|
|
|
5422
5731
|
nextCursor
|
|
5423
5732
|
};
|
|
5424
5733
|
});
|
|
5425
|
-
/** Get chat detail with participants */
|
|
5734
|
+
/** Get chat detail with participants (requires participation or supervision) */
|
|
5426
5735
|
app.get("/:chatId", async (request) => {
|
|
5427
5736
|
const { chatId } = request.params;
|
|
5737
|
+
const scope = memberScope(request);
|
|
5738
|
+
await assertChatAccess(app.db, scope, chatId);
|
|
5428
5739
|
const [chat] = await app.db.select().from(chats).where(eq(chats.id, chatId)).limit(1);
|
|
5429
|
-
if (!chat) throw new
|
|
5740
|
+
if (!chat) throw new Error("Unexpected: chat missing after access check");
|
|
5430
5741
|
const participants = await app.db.select().from(chatParticipants).where(eq(chatParticipants.chatId, chatId));
|
|
5431
5742
|
return {
|
|
5432
5743
|
...chat,
|
|
@@ -5440,9 +5751,11 @@ async function adminChatRoutes(app) {
|
|
|
5440
5751
|
}))
|
|
5441
5752
|
};
|
|
5442
5753
|
});
|
|
5443
|
-
/** List messages in a chat with delivery status (
|
|
5754
|
+
/** List messages in a chat with delivery status (requires participation or supervision) */
|
|
5444
5755
|
app.get("/:chatId/messages", async (request) => {
|
|
5445
5756
|
const { chatId } = request.params;
|
|
5757
|
+
const scope = memberScope(request);
|
|
5758
|
+
await assertChatAccess(app.db, scope, chatId);
|
|
5446
5759
|
const query = paginationQuerySchema.parse(request.query);
|
|
5447
5760
|
const where = query.cursor ? and(eq(messages.chatId, chatId), lt(messages.createdAt, new Date(query.cursor))) : eq(messages.chatId, chatId);
|
|
5448
5761
|
const rows = await app.db.select({
|
|
@@ -5488,12 +5801,51 @@ async function adminChatRoutes(app) {
|
|
|
5488
5801
|
nextCursor
|
|
5489
5802
|
};
|
|
5490
5803
|
});
|
|
5491
|
-
/**
|
|
5804
|
+
/**
|
|
5805
|
+
* GET /admin/chats/mine — member-scoped chat listing grouped by agent.
|
|
5806
|
+
* Returns only chats where the member's agents participate or are supervised.
|
|
5807
|
+
*/
|
|
5808
|
+
app.get("/mine", async (request) => {
|
|
5809
|
+
const member = requireMember(request);
|
|
5810
|
+
return listChatsForMember(app.db, member.memberId, member.agentId);
|
|
5811
|
+
});
|
|
5812
|
+
/** POST /admin/chats/:chatId/join — manager joins a chat (adds human agent as participant) */
|
|
5813
|
+
app.post("/:chatId/join", async (request, reply) => {
|
|
5814
|
+
const { chatId } = request.params;
|
|
5815
|
+
const member = requireMember(request);
|
|
5816
|
+
const participants = await joinChat(app.db, chatId, member.memberId, member.agentId);
|
|
5817
|
+
return reply.status(200).send({
|
|
5818
|
+
chatId,
|
|
5819
|
+
participants: participants.map((p) => ({
|
|
5820
|
+
agentId: p.agentId,
|
|
5821
|
+
role: p.role,
|
|
5822
|
+
mode: p.mode,
|
|
5823
|
+
joinedAt: p.joinedAt.toISOString()
|
|
5824
|
+
}))
|
|
5825
|
+
});
|
|
5826
|
+
});
|
|
5827
|
+
/** POST /admin/chats/:chatId/leave — manager leaves a chat (removes human agent from participants) */
|
|
5828
|
+
app.post("/:chatId/leave", async (request, reply) => {
|
|
5829
|
+
const { chatId } = request.params;
|
|
5830
|
+
const member = requireMember(request);
|
|
5831
|
+
const participants = await leaveChat(app.db, chatId, member.agentId);
|
|
5832
|
+
return reply.status(200).send({
|
|
5833
|
+
chatId,
|
|
5834
|
+
participants: participants.map((p) => ({
|
|
5835
|
+
agentId: p.agentId,
|
|
5836
|
+
role: p.role,
|
|
5837
|
+
mode: p.mode,
|
|
5838
|
+
joinedAt: p.joinedAt.toISOString()
|
|
5839
|
+
}))
|
|
5840
|
+
});
|
|
5841
|
+
});
|
|
5842
|
+
/** POST /admin/chats/:chatId/messages — member sends a message as their linked human agent */
|
|
5492
5843
|
app.post("/:chatId/messages", async (request, reply) => {
|
|
5493
5844
|
const { chatId } = request.params;
|
|
5494
|
-
const
|
|
5495
|
-
|
|
5845
|
+
const scope = memberScope(request);
|
|
5846
|
+
const member = requireMember(request);
|
|
5496
5847
|
const body = sendMessageSchema.parse(request.body);
|
|
5848
|
+
await assertChatAccess(app.db, scope, chatId);
|
|
5497
5849
|
await ensureParticipant(app.db, chatId, member.agentId);
|
|
5498
5850
|
const result = await sendMessage(app.db, chatId, member.agentId, {
|
|
5499
5851
|
...body,
|
|
@@ -5577,8 +5929,26 @@ async function getActivityOverview(db) {
|
|
|
5577
5929
|
clients: clientCounts?.count ?? 0
|
|
5578
5930
|
};
|
|
5579
5931
|
}
|
|
5580
|
-
|
|
5581
|
-
|
|
5932
|
+
/**
|
|
5933
|
+
* List agents with active runtime state.
|
|
5934
|
+
* When scope is provided, filters to agents visible to the member.
|
|
5935
|
+
*/
|
|
5936
|
+
async function listAgentsWithRuntime(db, scope) {
|
|
5937
|
+
if (!scope) return db.select().from(agentPresence).where(isNotNull(agentPresence.runtimeState));
|
|
5938
|
+
return db.select({
|
|
5939
|
+
agentId: agentPresence.agentId,
|
|
5940
|
+
status: agentPresence.status,
|
|
5941
|
+
instanceId: agentPresence.instanceId,
|
|
5942
|
+
connectedAt: agentPresence.connectedAt,
|
|
5943
|
+
lastSeenAt: agentPresence.lastSeenAt,
|
|
5944
|
+
clientId: agentPresence.clientId,
|
|
5945
|
+
runtimeType: agentPresence.runtimeType,
|
|
5946
|
+
runtimeVersion: agentPresence.runtimeVersion,
|
|
5947
|
+
runtimeState: agentPresence.runtimeState,
|
|
5948
|
+
activeSessions: agentPresence.activeSessions,
|
|
5949
|
+
totalSessions: agentPresence.totalSessions,
|
|
5950
|
+
runtimeUpdatedAt: agentPresence.runtimeUpdatedAt
|
|
5951
|
+
}).from(agentPresence).innerJoin(agents, eq(agentPresence.agentId, agents.uuid)).where(and(isNotNull(agentPresence.runtimeState), agentVisibilityCondition(scope)));
|
|
5582
5952
|
}
|
|
5583
5953
|
/**
|
|
5584
5954
|
* Clean up stale session rows from agent_chat_sessions.
|
|
@@ -5634,9 +6004,10 @@ async function adminClientRoutes(app) {
|
|
|
5634
6004
|
});
|
|
5635
6005
|
}
|
|
5636
6006
|
async function adminActivityRoutes(app) {
|
|
5637
|
-
app.get("/", async () => {
|
|
6007
|
+
app.get("/", async (request) => {
|
|
6008
|
+
const scope = memberScope(request);
|
|
5638
6009
|
const overview = await getActivityOverview(app.db);
|
|
5639
|
-
const runningAgents = await listAgentsWithRuntime(app.db);
|
|
6010
|
+
const runningAgents = await listAgentsWithRuntime(app.db, scope);
|
|
5640
6011
|
return {
|
|
5641
6012
|
...overview,
|
|
5642
6013
|
agents: runningAgents.map((a) => ({
|
|
@@ -5995,6 +6366,18 @@ async function listAllSessions(db, limit, cursor, filters) {
|
|
|
5995
6366
|
};
|
|
5996
6367
|
}
|
|
5997
6368
|
/**
|
|
6369
|
+
* Filter sessions to only those where the given agent is also a participant in the chat.
|
|
6370
|
+
* Used when a non-manager views sessions of an org-visible agent — they should only see
|
|
6371
|
+
* sessions for chats they participate in.
|
|
6372
|
+
*/
|
|
6373
|
+
async function filterSessionsByParticipant(db, sessions, participantAgentId) {
|
|
6374
|
+
if (sessions.length === 0) return [];
|
|
6375
|
+
const chatIds = sessions.map((s) => s.chatId);
|
|
6376
|
+
const participantRows = await db.select({ chatId: chatParticipants.chatId }).from(chatParticipants).where(and(inArray(chatParticipants.chatId, chatIds), eq(chatParticipants.agentId, participantAgentId)));
|
|
6377
|
+
const allowedChatIds = new Set(participantRows.map((r) => r.chatId));
|
|
6378
|
+
return sessions.filter((s) => allowedChatIds.has(s.chatId));
|
|
6379
|
+
}
|
|
6380
|
+
/**
|
|
5998
6381
|
* Session outputs — aggregated text output from agent sessions.
|
|
5999
6382
|
* One row per (agent, chat) session. Content is appended as the agent works.
|
|
6000
6383
|
* Cleaned up when the session is evicted.
|
|
@@ -6056,11 +6439,6 @@ const globalSessionFilterSchema = paginationQuerySchema.extend({
|
|
|
6056
6439
|
]).optional(),
|
|
6057
6440
|
agentId: z.string().optional()
|
|
6058
6441
|
});
|
|
6059
|
-
/** Verify the agent belongs to the caller's organization. */
|
|
6060
|
-
async function assertAgentInOrg(app, request, agentId) {
|
|
6061
|
-
const member = requireMember(request);
|
|
6062
|
-
if ((await getAgent(app.db, agentId)).organizationId !== member.organizationId) throw new ForbiddenError("Agent does not belong to your organization");
|
|
6063
|
-
}
|
|
6064
6442
|
async function adminSessionRoutes(app) {
|
|
6065
6443
|
/** GET /admin/sessions — global session list, scoped to caller's org */
|
|
6066
6444
|
app.get("/", async (request) => {
|
|
@@ -6072,20 +6450,25 @@ async function adminSessionRoutes(app) {
|
|
|
6072
6450
|
organizationId: member.organizationId
|
|
6073
6451
|
});
|
|
6074
6452
|
});
|
|
6075
|
-
/** GET /admin/sessions/agents/:agentId — sessions for a specific agent
|
|
6453
|
+
/** GET /admin/sessions/agents/:agentId — sessions for a specific agent.
|
|
6454
|
+
* Manager sees all sessions. Non-manager sees only sessions where their human agent is also a participant. */
|
|
6076
6455
|
app.get("/agents/:agentId", async (request) => {
|
|
6077
|
-
|
|
6456
|
+
const scope = memberScope(request);
|
|
6457
|
+
await assertAgentVisible(app.db, scope, request.params.agentId);
|
|
6078
6458
|
const filters = sessionFilterSchema.parse(request.query);
|
|
6079
|
-
|
|
6459
|
+
const isManager = (await getAgent(app.db, request.params.agentId)).managerId === scope.memberId;
|
|
6460
|
+
const sessions = await listAgentSessions(app.db, request.params.agentId, filters);
|
|
6461
|
+
if (isManager) return sessions;
|
|
6462
|
+
return filterSessionsByParticipant(app.db, sessions, scope.humanAgentId);
|
|
6080
6463
|
});
|
|
6081
6464
|
/** GET /admin/sessions/agents/:agentId/:chatId — single session detail */
|
|
6082
6465
|
app.get("/agents/:agentId/:chatId", async (request) => {
|
|
6083
|
-
await
|
|
6466
|
+
await assertAgentVisible(app.db, memberScope(request), request.params.agentId);
|
|
6084
6467
|
return getSession(app.db, request.params.agentId, request.params.chatId);
|
|
6085
6468
|
});
|
|
6086
6469
|
/** GET /admin/sessions/agents/:agentId/:chatId/output — session output text */
|
|
6087
6470
|
app.get("/agents/:agentId/:chatId/output", async (request) => {
|
|
6088
|
-
await
|
|
6471
|
+
await assertAgentVisible(app.db, memberScope(request), request.params.agentId);
|
|
6089
6472
|
return await getOutput(app.db, request.params.agentId, request.params.chatId) ?? {
|
|
6090
6473
|
content: "",
|
|
6091
6474
|
updatedAt: null
|
|
@@ -6094,7 +6477,7 @@ async function adminSessionRoutes(app) {
|
|
|
6094
6477
|
/** POST /admin/sessions/agents/:agentId/:chatId/suspend — suspend a session */
|
|
6095
6478
|
app.post("/agents/:agentId/:chatId/suspend", async (request, reply) => {
|
|
6096
6479
|
const { agentId, chatId } = request.params;
|
|
6097
|
-
await
|
|
6480
|
+
await assertCanManage(app.db, memberScope(request), agentId);
|
|
6098
6481
|
if (!sendToAgent$1(agentId, {
|
|
6099
6482
|
type: "session:suspend",
|
|
6100
6483
|
chatId
|
|
@@ -6109,7 +6492,7 @@ async function adminSessionRoutes(app) {
|
|
|
6109
6492
|
/** POST /admin/sessions/agents/:agentId/:chatId/resume — resume a session */
|
|
6110
6493
|
app.post("/agents/:agentId/:chatId/resume", async (request, reply) => {
|
|
6111
6494
|
const { agentId, chatId } = request.params;
|
|
6112
|
-
await
|
|
6495
|
+
await assertCanManage(app.db, memberScope(request), agentId);
|
|
6113
6496
|
if (!sendToAgent$1(agentId, {
|
|
6114
6497
|
type: "session:resume",
|
|
6115
6498
|
chatId
|
|
@@ -6124,7 +6507,7 @@ async function adminSessionRoutes(app) {
|
|
|
6124
6507
|
/** POST /admin/sessions/agents/:agentId/:chatId/terminate — terminate a session */
|
|
6125
6508
|
app.post("/agents/:agentId/:chatId/terminate", async (request, reply) => {
|
|
6126
6509
|
const { agentId, chatId } = request.params;
|
|
6127
|
-
await
|
|
6510
|
+
await assertCanManage(app.db, memberScope(request), agentId);
|
|
6128
6511
|
if (!sendToAgent$1(agentId, {
|
|
6129
6512
|
type: "session:terminate",
|
|
6130
6513
|
chatId
|
|
@@ -7408,30 +7791,6 @@ function clientWsRoutes(notifier, instanceId) {
|
|
|
7408
7791
|
});
|
|
7409
7792
|
};
|
|
7410
7793
|
}
|
|
7411
|
-
/** User accounts. Passwords are stored as bcrypt hashes. */
|
|
7412
|
-
const users = pgTable("users", {
|
|
7413
|
-
id: text("id").primaryKey(),
|
|
7414
|
-
username: text("username").unique().notNull(),
|
|
7415
|
-
passwordHash: text("password_hash").notNull(),
|
|
7416
|
-
displayName: text("display_name").notNull(),
|
|
7417
|
-
avatarUrl: text("avatar_url"),
|
|
7418
|
-
status: text("status").notNull().default("active"),
|
|
7419
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(),
|
|
7420
|
-
updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow()
|
|
7421
|
-
});
|
|
7422
|
-
/** Organization membership. Links a user to an org with a role and a 1:1 human agent. */
|
|
7423
|
-
const members = pgTable("members", {
|
|
7424
|
-
id: text("id").primaryKey(),
|
|
7425
|
-
userId: text("user_id").notNull().references(() => users.id),
|
|
7426
|
-
organizationId: text("organization_id").notNull().references(() => organizations.id),
|
|
7427
|
-
agentId: text("agent_id").unique().notNull().references(() => agents.uuid),
|
|
7428
|
-
role: text("role").notNull(),
|
|
7429
|
-
createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
|
7430
|
-
}, (table) => [
|
|
7431
|
-
unique("uq_members_user_org").on(table.userId, table.organizationId),
|
|
7432
|
-
index("idx_members_user").on(table.userId),
|
|
7433
|
-
index("idx_members_org").on(table.organizationId)
|
|
7434
|
-
]);
|
|
7435
7794
|
const ACCESS_TOKEN_EXPIRY = "30m";
|
|
7436
7795
|
const REFRESH_TOKEN_EXPIRY = "7d";
|
|
7437
7796
|
const CONNECT_TOKEN_EXPIRY = "10m";
|
|
@@ -7876,36 +8235,6 @@ async function memberRoutes(app) {
|
|
|
7876
8235
|
return reply.status(204).send();
|
|
7877
8236
|
});
|
|
7878
8237
|
}
|
|
7879
|
-
/** Public agent discovery — returns only agents with public=true. No auth required. */
|
|
7880
|
-
async function publicAgentRoutes(app) {
|
|
7881
|
-
app.get("/", async (request) => {
|
|
7882
|
-
const query = paginationQuerySchema.parse(request.query);
|
|
7883
|
-
const orgParam = request.query.org;
|
|
7884
|
-
const conditions = [eq(agents.public, true), eq(agents.status, "active")];
|
|
7885
|
-
if (orgParam) {
|
|
7886
|
-
const resolved = await resolveOrganization(app.db, orgParam);
|
|
7887
|
-
conditions.push(eq(agents.organizationId, resolved.id));
|
|
7888
|
-
}
|
|
7889
|
-
if (query.cursor) conditions.push(lt(agents.createdAt, new Date(query.cursor)));
|
|
7890
|
-
const where = and(...conditions);
|
|
7891
|
-
const rows = await app.db.select({
|
|
7892
|
-
uuid: agents.uuid,
|
|
7893
|
-
name: agents.name,
|
|
7894
|
-
organizationId: agents.organizationId,
|
|
7895
|
-
type: agents.type,
|
|
7896
|
-
displayName: agents.displayName,
|
|
7897
|
-
profile: agents.profile,
|
|
7898
|
-
createdAt: agents.createdAt
|
|
7899
|
-
}).from(agents).where(where).orderBy(desc(agents.createdAt)).limit(query.limit + 1);
|
|
7900
|
-
const hasMore = rows.length > query.limit;
|
|
7901
|
-
const items = hasMore ? rows.slice(0, query.limit) : rows;
|
|
7902
|
-
const last = items[items.length - 1];
|
|
7903
|
-
return {
|
|
7904
|
-
items,
|
|
7905
|
-
nextCursor: hasMore && last ? last.createdAt.toISOString() : null
|
|
7906
|
-
};
|
|
7907
|
-
});
|
|
7908
|
-
}
|
|
7909
8238
|
const GITHUB_ADAPTER_ID = "github-adapter";
|
|
7910
8239
|
function verifySignature(secret, rawBody, signatureHeader) {
|
|
7911
8240
|
const expected = `sha256=${createHmac("sha256", secret).update(rawBody).digest("hex")}`;
|
|
@@ -8450,47 +8779,6 @@ function githubAuthHook() {
|
|
|
8450
8779
|
request.githubUser = { username: data.login };
|
|
8451
8780
|
};
|
|
8452
8781
|
}
|
|
8453
|
-
function memberAuthHook(db, jwtSecret) {
|
|
8454
|
-
const secret = new TextEncoder().encode(jwtSecret);
|
|
8455
|
-
return async (request, _reply) => {
|
|
8456
|
-
const header = request.headers.authorization;
|
|
8457
|
-
if (!header?.startsWith("Bearer ")) throw new UnauthorizedError("Missing or invalid Authorization header");
|
|
8458
|
-
const token = header.slice(7);
|
|
8459
|
-
let payload;
|
|
8460
|
-
try {
|
|
8461
|
-
const { payload: p } = await jwtVerify(token, secret);
|
|
8462
|
-
payload = p;
|
|
8463
|
-
} catch {
|
|
8464
|
-
throw new UnauthorizedError("Invalid or expired token");
|
|
8465
|
-
}
|
|
8466
|
-
if (payload.type !== "access" || !payload.sub || !payload.memberId) throw new UnauthorizedError("Invalid token type");
|
|
8467
|
-
const [user] = await db.select({
|
|
8468
|
-
id: users.id,
|
|
8469
|
-
status: users.status
|
|
8470
|
-
}).from(users).where(eq(users.id, payload.sub)).limit(1);
|
|
8471
|
-
if (!user || user.status !== "active") throw new UnauthorizedError("User not found or suspended");
|
|
8472
|
-
const [member] = await db.select({
|
|
8473
|
-
id: members.id,
|
|
8474
|
-
organizationId: members.organizationId,
|
|
8475
|
-
role: members.role,
|
|
8476
|
-
agentId: members.agentId
|
|
8477
|
-
}).from(members).where(eq(members.id, payload.memberId)).limit(1);
|
|
8478
|
-
if (!member) throw new UnauthorizedError("Membership not found");
|
|
8479
|
-
request.member = {
|
|
8480
|
-
userId: user.id,
|
|
8481
|
-
memberId: member.id,
|
|
8482
|
-
organizationId: member.organizationId,
|
|
8483
|
-
role: member.role,
|
|
8484
|
-
agentId: member.agentId
|
|
8485
|
-
};
|
|
8486
|
-
};
|
|
8487
|
-
}
|
|
8488
|
-
/** Additional hook that requires admin role. Use after memberAuthHook. */
|
|
8489
|
-
function requireAdminRoleHook() {
|
|
8490
|
-
return async (request, _reply) => {
|
|
8491
|
-
if (request.member?.role !== "admin") throw new ForbiddenError("Admin role required");
|
|
8492
|
-
};
|
|
8493
|
-
}
|
|
8494
8782
|
const PROXY_ENV_KEYS = [
|
|
8495
8783
|
"HTTP_PROXY",
|
|
8496
8784
|
"HTTPS_PROXY",
|
|
@@ -9384,7 +9672,6 @@ async function buildApp(config) {
|
|
|
9384
9672
|
adminApp.addHook("onRequest", memberAuth);
|
|
9385
9673
|
await adminApp.register(adminNotificationRoutes);
|
|
9386
9674
|
}, { prefix: "/admin/notifications" });
|
|
9387
|
-
await api.register(publicAgentRoutes, { prefix: "/public/agents" });
|
|
9388
9675
|
await api.register(async (agentApp) => {
|
|
9389
9676
|
agentApp.addHook("onRequest", agentAuth);
|
|
9390
9677
|
await agentApp.register(agentMeRoutes);
|