Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions backend/src/bots/owner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { BotRow } from "../db/schema";

/**
* Who may change a bot's setup (its standing rules, preset and routines): its
* creator. Every turn the bot takes runs that setup, a routine's unattended ones
* as their creator, so nobody else writes it. Others may still message it.
*/
export function botEditableBy(bot: Pick<BotRow, "createdBy">, userId: string | null): boolean {
return userId !== null && bot.createdBy === userId;
}

/** A routine someone other than its bot's creator added (before botEditableBy
* held): it would run as them with rules they do not set, so it does not fire. */
export class RoutineOwnerError extends Error {
readonly code = "routine_not_bot_owner" as const;

constructor(botName: string) {
super(`Only the creator of ${botName} can give it routines, so this one, added by someone else, no longer runs.`);
this.name = "RoutineOwnerError";
}
}
30 changes: 26 additions & 4 deletions backend/src/bots/repo.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { botHandoffs, bots, commands, gatewayApprovalRequests, providerEvents, r

type ScheduleRecord = typeof schedules.$inferSelect;
import { isMemoryScope, type MemoryScope } from "../memory/scope";
import { memberDisplayNames } from "../team/member-names";
import { botEditableBy } from "./owner";

const NAME_MAX = 60;
const TITLE_MAX = 80;
Expand Down Expand Up @@ -50,6 +52,10 @@ export interface BotView {
readonly handoffs: number;
/** Root run ids of those delegated threads, newest first. */
readonly handoffThreadIds: readonly string[];
/** Whether the viewer may change the bot's setup: only its creator (botEditableBy). */
readonly editable: boolean;
/** The creator as the member directory names them; null when it has no name for them. */
readonly ownerName: string | null;
}

export interface BotInput {
Expand Down Expand Up @@ -248,6 +254,17 @@ export async function updateBotRow(
return row ?? null;
}

/** Archive or restore a bot and nothing else: who may only stop a bot (an org
* admin) never rewrites its setup, whatever was read before their turn. */
export async function setBotArchived(orgId: string, id: string, archived: boolean): Promise<BotRow | null> {
const [row] = await db
.update(bots)
.set({ archived, updatedAt: new Date() })
.where(and(eq(bots.orgId, orgId), eq(bots.id, id)))
.returning();
return row ?? null;
}

/** The bot (archived or not) whose name matches ignoring case, other than `exceptId`. */
export async function findBotByName(orgId: string, name: string, exceptId?: string): Promise<BotRow | null> {
const rows = await db
Expand Down Expand Up @@ -455,6 +472,7 @@ function toView(
pending: number,
routines: number,
handoffs: { threadIds: readonly string[]; live: number },
owner: { readonly editable: boolean; readonly name: string | null },
): BotView {
return {
id: row.id,
Expand All @@ -480,20 +498,23 @@ function toView(
routines,
handoffs: handoffs.threadIds.length,
handoffThreadIds: [...handoffs.threadIds],
editable: owner.editable,
ownerName: owner.name,
};
}

/** Attach the derived fields for many bots with batched queries only. */
export async function describeBots(orgId: string, rows: readonly BotRow[]): Promise<BotView[]> {
export async function describeBots(orgId: string, rows: readonly BotRow[], viewerId: string | null): Promise<BotView[]> {
const botIds = rows.map((row) => row.id);
const handoffs = await handoffThreads(orgId, botIds);
const homeThreadIds = rows.flatMap((row) => (row.homeThreadId ? [row.homeThreadId] : []));
const allThreadIds = [...homeThreadIds, ...[...handoffs.values()].flat()];
const [heads, pending, pendingNative, routines] = await Promise.all([
const [heads, pending, pendingNative, routines, ownerNames] = await Promise.all([
threadHeads(orgId, allThreadIds),
pendingApprovalCounts(orgId, allThreadIds),
pendingNativeHumanInputCounts(orgId, allThreadIds),
routineCounts(orgId, botIds),
memberDisplayNames(orgId, rows.flatMap((row) => (row.createdBy ? [row.createdBy] : []))),
]);
return rows.map((row) => {
const delegated = handoffs.get(row.id) ?? [];
Expand All @@ -518,12 +539,13 @@ export async function describeBots(orgId: string, rows: readonly BotRow[]): Prom
pendingForBot,
routines.get(row.id) ?? 0,
{ threadIds: delegated, live },
{ editable: botEditableBy(row, viewerId), name: row.createdBy ? (ownerNames.get(row.createdBy) ?? null) : null },
);
});
}

export async function describeBot(orgId: string, row: BotRow): Promise<BotView> {
const [view] = await describeBots(orgId, [row]);
export async function describeBot(orgId: string, row: BotRow, viewerId: string | null): Promise<BotView> {
const [view] = await describeBots(orgId, [row], viewerId);
if (!view) throw new Error("describeBots returned no view");
return view;
}
Expand Down
44 changes: 35 additions & 9 deletions backend/src/bots/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,19 @@ import {
parseBotInput,
rowToInput,
attachRoutine,
setBotArchived,
updateBotRow,
} from "./repo";
import { botEditableBy } from "./owner";
import type { BotRow } from "../db/schema";
import { asOrgAdmin } from "../provider-connections/repo";
import { memberDisplayNames } from "../team/member-names";
import { botsEnabled } from "./rollout";
import { listFirings, markFired } from "../schedules/repo";
import {
createScheduleForOrg,
deleteScheduleForOrg,
fireScheduleForOrg,
runScheduleNow,
ScheduleServiceError,
updateScheduleForOrg,
} from "../schedules/service";
Expand Down Expand Up @@ -75,6 +80,17 @@ async function readBody(c: { req: { json: () => Promise<unknown> } }): Promise<R

type PresetProblem = { readonly status: 400 | 403 | 404; readonly body: Record<string, unknown> };

/**
* A bot's setup is its creator's (botEditableBy): only they change it or give
* it routines, and a routine, like every automation, answers to its creator
* (the schedules service). Who may message it is the command door's call.
*/
async function ownerOnlyRefusal(row: BotRow, userId: string | null): Promise<Response | null> {
if (botEditableBy(row, userId)) return null;
const name = (row.createdBy ? (await memberDisplayNames(row.orgId, [row.createdBy])).get(row.createdBy) : undefined) ?? "the bot's creator";
return Response.json({ error: "bot_owner_only", reason: `Only ${name} can change this bot's setup.` }, { status: 403 });
}

/** Names are unique per org ignoring case; the message names the bot that holds it. */
function nameTaken(name: string) {
return { error: "invalid_bot", field: "name", reason: `A bot named ${name} already exists. Choose another name.` };
Expand All @@ -97,7 +113,7 @@ async function checkPreset(orgId: string, input: BotInput, userId: string | null

botsRoutes.get("/", async (c) => {
const rows = await listBotRows(c.get("orgId"));
return c.json({ bots: await describeBots(c.get("orgId"), rows) });
return c.json({ bots: await describeBots(c.get("orgId"), rows, c.get("userId") ?? null) });
});

botsRoutes.post("/", async (c) => {
Expand All @@ -117,7 +133,7 @@ botsRoutes.post("/", async (c) => {
if (holder) return c.json(nameTaken(holder.name), 409);
try {
const row = await createBotRow(c.get("orgId"), parsed.input, c.get("userId"));
return c.json({ bot: await describeBot(c.get("orgId"), row) }, 201);
return c.json({ bot: await describeBot(c.get("orgId"), row, c.get("userId") ?? null) }, 201);
} catch (error) {
if (isUniqueViolation(error)) return c.json(nameTaken(parsed.input.name), 409);
throw error;
Expand All @@ -129,7 +145,7 @@ botsRoutes.get("/:id", async (c) => {
if (!id) return c.json({ error: "not_found" }, 404);
const row = await getBotRow(c.get("orgId"), id);
if (!row) return c.json({ error: "not_found" }, 404);
return c.json({ bot: await describeBot(c.get("orgId"), row) });
return c.json({ bot: await describeBot(c.get("orgId"), row, c.get("userId") ?? null) });
});

botsRoutes.patch("/:id", async (c) => {
Expand All @@ -148,6 +164,14 @@ botsRoutes.patch("/:id", async (c) => {
if (archived !== undefined && typeof archived !== "boolean") {
return c.json({ error: "invalid_bot", field: "archived", reason: "archived must be true or false" }, 400);
}
// An org owner or admin may archive or restore any bot, which runs nothing as anyone.
const userId = c.get("userId") ?? null;
if (archived !== undefined && Object.keys(body).every((key) => key === "archived") && userId && !botEditableBy(row, userId)) {
const done = await asOrgAdmin(c.get("orgId"), userId, () => setBotArchived(c.get("orgId"), row.id, archived));
if (done) return done.value ? c.json({ bot: await describeBot(c.get("orgId"), done.value, userId) }) : c.json({ error: "not_found" }, 404);
}
const notOwner = await ownerOnlyRefusal(row, userId);
if (notOwner) return notOwner;
// The home thread already runs on the stored preset; a changed engine would
// 400 every follow-up and changed skills/repos/scope would silently not apply.
const changed = changedPresetFields(base, parsed.input);
Expand All @@ -170,7 +194,7 @@ botsRoutes.patch("/:id", async (c) => {
try {
const updated = await updateBotRow(c.get("orgId"), row.id, parsed.input, archived);
if (!updated) return c.json({ error: "not_found" }, 404);
return c.json({ bot: await describeBot(c.get("orgId"), updated) });
return c.json({ bot: await describeBot(c.get("orgId"), updated, userId) });
} catch (error) {
if (isUniqueViolation(error)) return c.json(nameTaken(parsed.input.name), 409);
throw error;
Expand Down Expand Up @@ -278,6 +302,8 @@ botsRoutes.post("/:id/routines", async (c) => {
if (!id) return c.json({ error: "not_found" }, 404);
const row = await getBotRow(orgId, id);
if (!row) return c.json({ error: "not_found" }, 404);
const notOwner = await ownerOnlyRefusal(row, c.get("userId") ?? null);
if (notOwner) return notOwner;
const body = await readBody(c);
if (!body) return c.json({ error: "invalid_body" }, 400);
const skillId = row.skillIds[0];
Expand All @@ -297,7 +323,7 @@ botsRoutes.post("/:id/routines", async (c) => {
const created = await createScheduleForOrg({ orgId, userId: c.get("userId") }, draft);
await attachRoutine(orgId, created.id, row.id);
// Automations are created paused; a routine is on unless asked otherwise.
if (body.enabled !== false) await updateScheduleForOrg(orgId, created.id, { enabled: true });
if (body.enabled !== false) await updateScheduleForOrg(orgId, created.id, { enabled: true }, c.get("userId") ?? null);
const routine = await getBotRoutine(orgId, row.id, created.id);
if (!routine) return c.json({ error: "routine_not_attached" }, 500);
return c.json({ routine: routineView(routine) }, 201);
Expand All @@ -318,7 +344,7 @@ botsRoutes.patch("/:id/routines/:routineId", async (c) => {
const patch: Record<string, unknown> = {};
for (const field of ROUTINE_PATCH_FIELDS) if (body[field] !== undefined) patch[field] = body[field];
try {
await updateScheduleForOrg(orgId, routine.id, patch);
await updateScheduleForOrg(orgId, routine.id, patch, c.get("userId") ?? null);
const updated = await getBotRoutine(orgId, id, routineId);
return c.json({ routine: updated ? routineView(updated) : null });
} catch (error) {
Expand All @@ -334,7 +360,7 @@ botsRoutes.delete("/:id/routines/:routineId", async (c) => {
const routine = await getBotRoutine(orgId, id, routineId);
if (!routine) return c.json({ error: "not_found" }, 404);
try {
await deleteScheduleForOrg(orgId, routine.id);
await deleteScheduleForOrg(orgId, routine.id, c.get("userId") ?? null);
return c.body(null, 204);
} catch (error) {
return scheduleError(error) ?? Promise.reject(error);
Expand All @@ -351,7 +377,7 @@ botsRoutes.post("/:id/routines/:routineId/run-now", async (c) => {
const routine = await getBotRoutine(orgId, id, routineId);
if (!routine) return c.json({ error: "not_found" }, 404);
try {
const runId = await fireScheduleForOrg(routine, "manual");
const runId = await runScheduleNow(routine, c.get("userId") ?? null);
// A test run is a firing too: the row's "last run" must show it, not wait for cron.
await markFired(routine.id, new Date());
const fired = (await getBotRoutine(orgId, id, routineId)) ?? routine;
Expand Down
8 changes: 4 additions & 4 deletions backend/src/knowledge/gateway/automation-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ async function updateAutomation(
delete patch.confirmEnable;
delete patch.id;
try {
const schedule = await updateScheduleForOrg(claims.orgId, id, patch);
const schedule = await updateScheduleForOrg(claims.orgId, id, patch, claims.userId || null);
return textResult(`Updated automation ${schedule.name} (${schedule.id}).`, {
automation: automationSummary(schedule),
});
Expand Down Expand Up @@ -244,10 +244,10 @@ async function runAutomationNow(
// Keep the standalone tool gateway importable without loading the backend's
// command worker and authentication root. The execution graph is needed only
// for this explicit mutating call.
const { fireScheduleForOrg } = await import("../../schedules/service");
const { runScheduleNow } = await import("../../schedules/service");
let runId: string;
try {
runId = await fireScheduleForOrg(schedule, "manual");
runId = await runScheduleNow(schedule, claims.userId || null);
} catch (error) {
if (error instanceof ScheduleServiceError) return serviceError(error);
throw error;
Expand Down Expand Up @@ -280,7 +280,7 @@ async function deleteAutomation(
const id = automationId(args);
if (!id) return errorResult("automation_delete requires an automation id.");
try {
await deleteScheduleForOrg(claims.orgId, id);
await deleteScheduleForOrg(claims.orgId, id, claims.userId || null);
return textResult(`Deleted automation ${id}.`, { automation_id: id, deleted: true });
} catch (error) {
if (error instanceof ScheduleServiceError) return serviceError(error);
Expand Down
11 changes: 2 additions & 9 deletions backend/src/middleware/org.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { resolveSession, type IdentitySession } from "../auth/session";
import { db } from "../db/client";
import { member } from "../db/schema";
import { allowDevOrg } from "../env";
import { isOrgAdmin } from "../provider-connections/repo";
import type { AppEnv } from "../http";
import { parsePreviewViewPath } from "../runs/preview-capability";
import { firstOrgForUser, getDevContext } from "../seed";
Expand Down Expand Up @@ -147,14 +148,6 @@ export const orgAdminScope = createMiddleware<AppEnv>(async (c, next) => {
const orgId = c.get("orgId");
const userId = c.get("userId");
if (!orgId || !userId) return c.json({ error: "forbidden" }, 403);

const [membership] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, orgId), eq(member.userId, userId)))
.limit(1);
if (membership?.role !== "owner" && membership?.role !== "admin") {
return c.json({ error: "organization_admin_required" }, 403);
}
if (!(await isOrgAdmin(orgId, userId))) return c.json({ error: "organization_admin_required" }, 403);
return next();
});
21 changes: 20 additions & 1 deletion backend/src/provider-connections/repo.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { and, asc, eq, sql } from "drizzle-orm";
import { db } from "../db/client";
import { db, type Executor } from "../db/client";
import { withOrgLock } from "../org-lock";
import {
member,
providerConnections,
providerConnectionThreads,
type ProviderConnectionAuthMethod,
Expand All @@ -11,6 +13,23 @@ import {

export type ProviderConnectionRecord = typeof providerConnections.$inferSelect;

/** Whether the person is an owner or admin of the organization (exact roles). */
export async function isOrgAdmin(orgId: string, userId: string, exec: Executor = db): Promise<boolean> {
const [row] = await exec
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, orgId), eq(member.userId, userId)))
.limit(1);
return row?.role === "owner" || row?.role === "admin";
}

/** `act` for an org owner or admin, authorized in the organisation's turn
* (org-lock.ts) as role changes are, so one demoted meanwhile gets nothing
* done; null for anyone else. */
export function asOrgAdmin<T>(orgId: string, userId: string, act: () => Promise<T>): Promise<{ readonly value: T } | null> {
return withOrgLock(orgId, async () => ((await isOrgAdmin(orgId, userId)) ? { value: await act() } : null));
}

export interface ProviderConnectionScope {
orgId: string;
userId: string;
Expand Down
7 changes: 6 additions & 1 deletion backend/src/schedules/fire.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ import {
resolveRunIntake,
} from "../resources/run-intake";
import { resolveExecutableSkillPin } from "../skills/pins";
import { botFiringTarget } from "../bots/repo";
import { botFiringTarget, getBotRow } from "../bots/repo";
import { botEditableBy, RoutineOwnerError } from "../bots/owner";
import { BotsDisabledError, botsEnabled } from "../bots/rollout";
import { acceptExistingThreadFollowup } from "../runs/thread-followups";
import type { MemoryScope } from "../memory/scope";
Expand Down Expand Up @@ -100,6 +101,10 @@ export async function fireScheduleWithOutcome(
const target = schedule.botId
? await botFiringTarget(schedule.orgId, schedule.botId)
: null;
// Judged on the bot itself, archived or not: archiving must not turn a routine
// someone else added into a plain automation that fires.
const bot = schedule.botId ? (target?.bot ?? (await getBotRow(schedule.orgId, schedule.botId))) : null;
if (bot && !botEditableBy(bot, schedule.userId)) throw new RoutineOwnerError(bot.name);
const home = target?.head ?? null;
const threadId = home ? home.threadId : runId;
const memoryScope: MemoryScope = target ? target.bot.memoryScope : "org";
Expand Down
8 changes: 4 additions & 4 deletions backend/src/schedules/routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import {
import {
createScheduleForOrg,
deleteScheduleForOrg,
fireScheduleForOrg,
runScheduleNow,
ScheduleServiceError,
updateScheduleForOrg,
} from "./service";
Expand Down Expand Up @@ -59,7 +59,7 @@ schedulesRoutes.patch("/:id", runCreateBodyLimit, async (c) => {
}

try {
return c.json(await updateScheduleForOrg(c.get("orgId"), id, body));
return c.json(await updateScheduleForOrg(c.get("orgId"), id, body, c.get("userId") ?? null));
} catch (error) {
if (error instanceof ScheduleServiceError) return c.json(error.body, error.status);
throw error;
Expand All @@ -71,7 +71,7 @@ schedulesRoutes.post("/:id/run-now", async (c) => {
const schedule = await getScheduleForOrg(c.get("orgId"), c.req.param("id"));
if (!schedule) return c.json({ error: "schedule not found" }, 404);
try {
const runId = await fireScheduleForOrg(schedule, "manual");
const runId = await runScheduleNow(schedule, c.get("userId") ?? null);
return c.json({ run_id: runId }, 201);
} catch (error) {
if (error instanceof ScheduleServiceError) return c.json(error.body, error.status);
Expand All @@ -90,7 +90,7 @@ schedulesRoutes.get("/:id/history", async (c) => {
// remain in the durable run log.
schedulesRoutes.delete("/:id", async (c) => {
try {
await deleteScheduleForOrg(c.get("orgId"), c.req.param("id"));
await deleteScheduleForOrg(c.get("orgId"), c.req.param("id"), c.get("userId") ?? null);
return c.body(null, 204);
} catch (error) {
if (error instanceof ScheduleServiceError) return c.json(error.body, error.status);
Expand Down
Loading
Loading