Make bot types an extension of the bot's kind

Coordinator and worker come from bots.kind; bot_types now only holds
workspace_admin (was admin), each row naming its workspace, with no scope
field. Rows from v0.2.8 and earlier are fixed at boot by migrateBotTypes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
2026-10-26 06:05:18 +01:00
co-authored by Claude Opus 5.5
parent 657a76d868
commit 926bad6bea
29 changed files with 168 additions and 175 deletions
+12 -3
View File
@@ -1,9 +1,12 @@
import type { BotKind } from "@/src/dict/bot-dict";
import { BotTypeTargetMatrix } from "@/src/dict/bot-types-dict";
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
import typeAssignment from "./type-assignment";
type Params = {
actor_kind?: BotKind | "" | null;
actor_bot_types?: BotTypeRow[] | null;
target_kind?: BotKind | "" | null;
target_bot_types?: BotTypeRow[] | null;
/**
* Where they're working: types count there.
@@ -16,9 +19,15 @@ type Params = {
* where they're working, through `BotTypeTargetMatrix`: the coordinator
* acts on anyone, a workspace admin on the workers there.
*/
export default function botCanTarget({ actor_bot_types, target_bot_types, workspace_id }: Params): boolean {
const actor_types = botTypesIn({ bot_types: actor_bot_types, workspace_id });
const target_types = botTypesIn({ bot_types: target_bot_types, workspace_id });
export default function botCanTarget({
actor_kind,
actor_bot_types,
target_kind,
target_bot_types,
workspace_id,
}: Params): boolean {
const actor_types = botTypesIn({ kind: actor_kind, bot_types: actor_bot_types, workspace_id });
const target_types = botTypesIn({ kind: target_kind, bot_types: target_bot_types, workspace_id });
return typeAssignment({
actor_types,
+5 -3
View File
@@ -1,8 +1,10 @@
import { BotTypePermissionMatrix } from "@/src/dict/bot-types-dict";
import type { BotKind } from "@/src/dict/bot-dict";
import type { BotPermission } from "@/src/types";
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
type Params = {
kind?: BotKind | "" | null;
bot_types?: BotTypeRow[] | null;
permission: BotPermission;
/**
@@ -13,12 +15,12 @@ type Params = {
};
/**
* Whether a bot's types let it do `permission` where it's working
* Whether a bot's kind and types let it do `permission` where it's working
* (`BotTypePermissionMatrix`). This is the bot's side only: the user the
* run works for must still be allowed to do it.
*/
export default function botCan({ bot_types, permission, workspace_id }: Params): boolean {
return botTypesIn({ bot_types, workspace_id }).some((bt) =>
export default function botCan({ kind, bot_types, permission, workspace_id }: Params): boolean {
return botTypesIn({ kind, bot_types, workspace_id }).some((bt) =>
BotTypePermissionMatrix[bt].includes(permission),
);
}
+11 -7
View File
@@ -1,4 +1,5 @@
import type { BUN_SQLITE_COBBLE_USER_TYPES } from "@/db/types/db";
import type { BotKind } from "@/src/dict/bot-dict";
import { BotTypeAssignmentMatrix, BotTypeDeletionMatrix } from "@/src/dict/bot-types-dict";
import type { BotType } from "@/src/types";
import botTypesIn, { type BotTypeRow } from "./bot-types-in";
@@ -12,10 +13,13 @@ type Params = {
*/
actor_user_types?: Pick<BUN_SQLITE_COBBLE_USER_TYPES, "user_type">[] | null;
/**
* A bot granting it from chat: its types. Only install-wide ones count,
* so a workspace admin never grants types.
* A bot granting it from chat: its kind and types. Only its kind counts
* (no workspace), so a workspace admin never grants types.
*/
actor_bot_types?: BotTypeRow[] | null;
actor_bot?: {
kind?: BotKind | "" | null;
bot_types?: BotTypeRow[] | null;
} | null;
target_bot_type: BotType;
action: "assign" | "delete";
};
@@ -27,17 +31,17 @@ type Params = {
*/
export default function botTypeAssignment({
actor_user_types,
actor_bot_types,
actor_bot,
target_bot_type,
action,
}: Params): boolean {
const actor_types = actor_bot_types
? botTypesIn({ bot_types: actor_bot_types })
const actor_types = actor_bot
? botTypesIn({ kind: actor_bot.kind, bot_types: actor_bot.bot_types })
: (actor_user_types || []).map((ut) => ut.user_type).filter((ut): ut is string => Boolean(ut));
return typeAssignment({
actor_types,
pass_through: actor_bot_types ? false : isPassThroughUser({ user_types: actor_user_types as never }),
pass_through: actor_bot ? false : isPassThroughUser({ user_types: actor_user_types as never }),
target_type: target_bot_type,
action,
assignment_matrix: BotTypeAssignmentMatrix,
+18 -13
View File
@@ -1,14 +1,19 @@
import type { BUN_SQLITE_COBBLE_BOT_TYPES } from "@/db/types/db";
import type { BotType } from "@/src/types";
import type { BotKind } from "@/src/dict/bot-dict";
import type { BotRole } from "@/src/types";
export type BotTypeRow = Pick<BUN_SQLITE_COBBLE_BOT_TYPES, "bot_type"> & {
/**
* Null (or empty) for an install-wide type.
* The workspace the type holds in.
*/
workspace_id?: number | "" | null;
};
type Params = {
/**
* The bot's kind (`bots.kind`): a worker when missing.
*/
kind?: BotKind | "" | null;
bot_types?: BotTypeRow[] | null;
/**
* Where the bot is working: its own chat (null) or a workspace.
@@ -20,25 +25,25 @@ type Params = {
* Strongest first, so a bot holding several is judged by the one that
* reaches furthest.
*/
const BotTypeRank: BotType[] = ["coordinator", "admin", "worker"];
const BotRoleRank: BotRole[] = ["coordinator", "workspace_admin", "worker"];
/**
* The bot types that hold where the bot is working: install-wide rows
* everywhere, workspace rows only in their workspace. Strongest first,
* `worker` when nothing else holds.
* The roles a bot holds where it's working: the coordinator kind
* everywhere, a type only in its workspace. Strongest first, `worker` when
* nothing else holds.
*/
export default function botTypesIn({ bot_types, workspace_id }: Params): BotType[] {
const held = new Set<BotType>();
export default function botTypesIn({ kind, bot_types, workspace_id }: Params): BotRole[] {
const held = new Set<BotRole>();
if (kind == "coordinator") held.add("coordinator");
for (const row of bot_types || []) {
if (!row.bot_type) continue;
if (!row.bot_type || !row.workspace_id || !workspace_id) continue;
const applies = !row.workspace_id || (workspace_id && row.workspace_id == Number(workspace_id));
if (applies) held.add(row.bot_type);
if (row.workspace_id == Number(workspace_id)) held.add(row.bot_type);
}
if (!held.size) held.add("worker");
return BotTypeRank.filter((bt) => held.has(bt));
return BotRoleRank.filter((role) => held.has(role));
}
+25 -23
View File
@@ -4,22 +4,29 @@ import botCanTarget from "./bot-can-target";
import botTypeAssignment from "./bot-type-assignment";
import botTypesIn from "./bot-types-in";
const coordinator = [{ bot_type: "coordinator" as const, workspace_id: null }];
const admin_of_3 = [{ bot_type: "admin" as const, workspace_id: 3 }];
const admin_of_3 = [{ bot_type: "workspace_admin" as const, workspace_id: 3 }];
describe("botTypesIn", () => {
test("a bot with no rows is a worker", () => {
expect(botTypesIn({ bot_types: [] })).toEqual(["worker"]);
expect(botTypesIn({ kind: "worker", bot_types: [] })).toEqual(["worker"]);
});
test("a workspace type only holds in its workspace", () => {
expect(botTypesIn({ bot_types: admin_of_3, workspace_id: 3 })).toEqual(["admin"]);
expect(botTypesIn({ bot_types: admin_of_3, workspace_id: 3 })).toEqual(["workspace_admin"]);
expect(botTypesIn({ bot_types: admin_of_3, workspace_id: 4 })).toEqual(["worker"]);
expect(botTypesIn({ bot_types: admin_of_3 })).toEqual(["worker"]);
});
test("an install-wide type holds everywhere", () => {
expect(botTypesIn({ bot_types: coordinator, workspace_id: 9 })).toEqual(["coordinator"]);
test("a type row without a workspace holds nowhere", () => {
expect(botTypesIn({ bot_types: [{ bot_type: "workspace_admin", workspace_id: null }], workspace_id: 3 })).toEqual([
"worker",
]);
});
test("the coordinator kind holds everywhere", () => {
expect(botTypesIn({ kind: "coordinator", workspace_id: 9 })).toEqual(["coordinator"]);
expect(botTypesIn({ kind: "coordinator" })).toEqual(["coordinator"]);
});
});
@@ -35,12 +42,12 @@ describe("botCan", () => {
});
test("the coordinator can do everything", () => {
expect(botCan({ bot_types: coordinator, permission: "manage_extensions" })).toBe(true);
expect(botCan({ bot_types: coordinator, permission: "delegate_task", workspace_id: 3 })).toBe(true);
expect(botCan({ kind: "coordinator", permission: "manage_extensions" })).toBe(true);
expect(botCan({ kind: "coordinator", permission: "delegate_task", workspace_id: 3 })).toBe(true);
});
test("a worker can't delegate", () => {
expect(botCan({ bot_types: [], permission: "delegate_task", workspace_id: 3 })).toBe(false);
expect(botCan({ kind: "worker", bot_types: [], permission: "delegate_task", workspace_id: 3 })).toBe(false);
});
});
@@ -51,22 +58,22 @@ describe("botCanTarget", () => {
test("a workspace admin doesn't direct another admin or the coordinator", () => {
expect(botCanTarget({ actor_bot_types: admin_of_3, target_bot_types: admin_of_3, workspace_id: 3 })).toBe(false);
expect(botCanTarget({ actor_bot_types: admin_of_3, target_bot_types: coordinator, workspace_id: 3 })).toBe(false);
expect(botCanTarget({ actor_bot_types: admin_of_3, target_kind: "coordinator", workspace_id: 3 })).toBe(false);
});
test("an admin elsewhere is a worker here", () => {
expect(botCanTarget({ actor_bot_types: admin_of_3, target_bot_types: [], workspace_id: 4 })).toBe(false);
expect(botCanTarget({
actor_bot_types: admin_of_3,
target_bot_types: [{ bot_type: "admin", workspace_id: 8 }],
target_bot_types: [{ bot_type: "workspace_admin", workspace_id: 8 }],
workspace_id: 3,
})).toBe(true);
});
test("the coordinator directs workers and admins, never itself", () => {
expect(botCanTarget({ actor_bot_types: coordinator, target_bot_types: [], workspace_id: 3 })).toBe(true);
expect(botCanTarget({ actor_bot_types: coordinator, target_bot_types: admin_of_3, workspace_id: 3 })).toBe(true);
expect(botCanTarget({ actor_bot_types: coordinator, target_bot_types: coordinator })).toBe(false);
expect(botCanTarget({ actor_kind: "coordinator", target_bot_types: [], workspace_id: 3 })).toBe(true);
expect(botCanTarget({ actor_kind: "coordinator", target_bot_types: admin_of_3, workspace_id: 3 })).toBe(true);
expect(botCanTarget({ actor_kind: "coordinator", target_kind: "coordinator" })).toBe(false);
});
test("a worker directs no one", () => {
@@ -79,19 +86,14 @@ describe("botTypeAssignment", () => {
test("super admins and admins grant and remove workspace admin", () => {
for (const action of ["assign", "delete"] as const) {
expect(botTypeAssignment({ actor_user_types: user("super_admin"), target_bot_type: "admin", action })).toBe(true);
expect(botTypeAssignment({ actor_user_types: user("admin"), target_bot_type: "admin", action })).toBe(true);
expect(botTypeAssignment({ actor_user_types: user("member"), target_bot_type: "admin", action })).toBe(false);
expect(botTypeAssignment({ actor_user_types: user("super_admin"), target_bot_type: "workspace_admin", action })).toBe(true);
expect(botTypeAssignment({ actor_user_types: user("admin"), target_bot_type: "workspace_admin", action })).toBe(true);
expect(botTypeAssignment({ actor_user_types: user("member"), target_bot_type: "workspace_admin", action })).toBe(false);
}
});
test("only the super admin passes for the coordinator type", () => {
expect(botTypeAssignment({ actor_user_types: user("admin"), target_bot_type: "coordinator", action: "assign" })).toBe(false);
});
test("the coordinator bot grants workspace admin, a workspace admin doesn't", () => {
expect(botTypeAssignment({ actor_bot_types: coordinator, target_bot_type: "admin", action: "assign" })).toBe(true);
expect(botTypeAssignment({ actor_bot_types: admin_of_3, target_bot_type: "admin", action: "assign" })).toBe(false);
expect(botTypeAssignment({ actor_bot_types: coordinator, target_bot_type: "coordinator", action: "assign" })).toBe(false);
expect(botTypeAssignment({ actor_bot: { kind: "coordinator" }, target_bot_type: "workspace_admin", action: "assign" })).toBe(true);
expect(botTypeAssignment({ actor_bot: { kind: "worker", bot_types: admin_of_3 }, target_bot_type: "workspace_admin", action: "assign" })).toBe(false);
});
});