Team mode stores one disk per Space. computer/readFile and computer/files only verified the caller owns the entry bot; an explicit bots/<otherBotId>/… path let one member read another member's workspace. Wire existing path guards into the API router and executor file/shell tools, and add a shared-Space authorization regression test. Co-authored-by: cynarAI <cynarAI@users.noreply.github.com>
2008 lines
73 KiB
TypeScript
2008 lines
73 KiB
TypeScript
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
import { ComposioEmulator, LocalAgentHomeStore, teamBotWorkspaceDirectory } from "@rakazo/adapters";
|
|
import type { AiConsentStatus, appContract, Space, SpaceNavigation } from "@rakazo/contracts";
|
|
import {
|
|
claimEmptySpaceDeletionForMember,
|
|
computerHomeKey,
|
|
deleteEmptySpaceForMember,
|
|
releaseSpaceDeletionClaim,
|
|
renewSpaceDeletionClaim,
|
|
} from "@rakazo/db";
|
|
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
|
import type { createApp } from "../../../apps/api/src/app.ts";
|
|
import { sessionCookieHeader } from "./index.js";
|
|
|
|
type App = { request: (input: string, init?: RequestInit) => Response | Promise<Response> };
|
|
type AppHandles = Awaited<ReturnType<typeof createApp>>;
|
|
type RpcPath<T, Prefix extends string = ""> = T extends { "~orpc": unknown }
|
|
? Prefix
|
|
: T extends object
|
|
? {
|
|
[Key in keyof T & string]: RpcPath<T[Key], Prefix extends "" ? Key : `${Prefix}/${Key}`>;
|
|
}[keyof T & string]
|
|
: never;
|
|
type ProtectedRpcPath = Exclude<RpcPath<typeof appContract>, "health">;
|
|
|
|
process.env.WAKEUP_DRIVER = "memory";
|
|
process.env.SANDBOX_PROVIDER = "fake";
|
|
process.env.AGENT_RUNTIME = "scripted";
|
|
|
|
const hasDb = process.env.VERIFY_DATABASE === "1" && Boolean(process.env.DATABASE_URL);
|
|
const describeWithDatabase = hasDb ? describe : describe.skip;
|
|
|
|
describeWithDatabase("API authorization and resource isolation", () => {
|
|
let handles: AppHandles;
|
|
let app: App;
|
|
const stamp = `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
const dataDir = mkdtempSync(path.join(tmpdir(), "rakazo-authz-"));
|
|
|
|
beforeAll(async () => {
|
|
const { createApp } = await import("../../../apps/api/src/app.ts");
|
|
handles = await createApp({
|
|
databaseUrl: process.env.DATABASE_URL!,
|
|
dataDir,
|
|
sandboxProvider: "fake",
|
|
agentRuntime: "scripted",
|
|
wakeupDriver: "memory",
|
|
signupsEnabled: "true",
|
|
composio: new ComposioEmulator(),
|
|
});
|
|
app = handles.app;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await handles?.stop();
|
|
rmSync(dataDir, { recursive: true, force: true });
|
|
});
|
|
|
|
it("rejects unauthenticated calls to every protected RPC family", async () => {
|
|
const calls = exhaustiveProtectedCalls([
|
|
["me"],
|
|
["deployment/get"],
|
|
["deployment/update", { signupsEnabled: true }],
|
|
["updater/status"],
|
|
["updater/check", {}],
|
|
["updater/apply", {}],
|
|
["models/list"],
|
|
["models/credentials"],
|
|
["models/connect", { provider: "test", apiKey: "not-a-real-key" }],
|
|
["models/beginOAuth", { provider: "openai-codex" }],
|
|
["models/submitOAuthCode", { loginId: "missing-login", code: "fake-code" }],
|
|
["models/completeOAuth", { loginId: "missing-login" }],
|
|
["models/finishOAuth", { loginId: "missing-login" }],
|
|
["models/cancelOAuth", { loginId: "missing-login" }],
|
|
["models/setDefault", { provider: "test", modelId: "test/model" }],
|
|
["spaces/list"],
|
|
["spaces/create", { name: "Nope" }],
|
|
["spaces/remove", { spaceId: "missing-space" }],
|
|
["bots/list"],
|
|
["bots/listArchived"],
|
|
["bots/get", { botId: "missing-bot" }],
|
|
["bots/create", botInput("Unauthenticated")],
|
|
["bots/duplicate", { botId: "missing-bot" }],
|
|
["bots/update", { botId: "missing-bot", name: "Nope" }],
|
|
["bots/archive", { botId: "missing-bot" }],
|
|
["bots/restore", { botId: "missing-bot" }],
|
|
["bots/remove", { botId: "missing-bot" }],
|
|
["groups/create", { name: "Nope", botIds: ["missing-a", "missing-b"] }],
|
|
["groups/list"],
|
|
["groups/get", { groupId: "missing-group" }],
|
|
["groups/update", { groupId: "missing-group", name: "Nope" }],
|
|
["groups/remove", { groupId: "missing-group" }],
|
|
["botSections/list"],
|
|
["botSections/create", { botId: "missing-bot", name: "Planning" }],
|
|
["botSections/update", { sectionId: "missing-section", name: "Planning" }],
|
|
["threads/get", { botId: "missing-bot" }],
|
|
["threads/get", { groupId: "missing-group" }],
|
|
["threads/messages", { botId: "missing-bot", before: 1 }],
|
|
["threads/subscribe", { botId: "missing-bot", cursor: -1 }],
|
|
["threads/send", { botId: "missing-bot", text: "Nope" }],
|
|
["threads/send", { groupId: "missing-group", text: "Nope" }],
|
|
["threads/stop", { botId: "missing-bot" }],
|
|
["threads/clear", { botId: "missing-bot" }],
|
|
["threads/followUp", { botId: "missing-bot", text: "Nope" }],
|
|
[
|
|
"threads/answer",
|
|
{
|
|
botId: "missing-bot",
|
|
runId: "missing-run",
|
|
messageId: "missing-message",
|
|
answer: "Nope",
|
|
},
|
|
],
|
|
["threads/markRead", { botId: "missing-bot" }],
|
|
["threads/markUnread", { botId: "missing-bot" }],
|
|
["computer/status", { botId: "missing-bot" }],
|
|
["computer/boot", { botId: "missing-bot" }],
|
|
["computer/stop", { botId: "missing-bot" }],
|
|
["computer/takeover", { botId: "missing-bot" }],
|
|
["computer/release", { botId: "missing-bot" }],
|
|
["computer/input", { botId: "missing-bot", kind: "key", payload: { key: "A" } }],
|
|
["computer/files", { botId: "missing-bot", path: "/" }],
|
|
["computer/readFile", { botId: "missing-bot", path: "MEMORY.md" }],
|
|
["computer/screenUrl", { botId: "missing-bot" }],
|
|
["computer/heartbeat", { botId: "missing-bot" }],
|
|
["memory/list", {}],
|
|
["memory/update", { documentId: "missing-memory", content: "Nope" }],
|
|
["memory/exportMarkdown", {}],
|
|
["memory/providerConfig"],
|
|
[
|
|
"memory/connectProvider",
|
|
{ provider: "test", settings: {}, credentials: {}, defaultMemoryScope: "isolated" },
|
|
],
|
|
["memory/setDefaultScope", { defaultMemoryScope: "shared" }],
|
|
["memory/disconnectProvider"],
|
|
["routines/list", { botId: "missing-bot" }],
|
|
["routines/create", routineInput("missing-bot")],
|
|
["routines/update", { routineId: "missing-routine", name: "Nope" }],
|
|
["routines/remove", { routineId: "missing-routine" }],
|
|
["routines/testRun", { routineId: "missing-routine" }],
|
|
["scratchpad/list", { botId: "missing-bot" }],
|
|
["scratchpad/create", { botId: "missing-bot", title: "Nope" }],
|
|
["scratchpad/update", { itemId: "missing-item", title: "Nope" }],
|
|
["scratchpad/remove", { itemId: "missing-item" }],
|
|
["skills/list", { botId: "missing-bot" }],
|
|
["skills/get", { skillId: "missing-skill" }],
|
|
["skills/start", { botId: "missing-bot", goal: "Demonstrate export" }],
|
|
[
|
|
"skills/appendEvent",
|
|
{
|
|
skillId: "missing-skill",
|
|
event: { at: new Date().toISOString(), kind: "key", key: "a" },
|
|
},
|
|
],
|
|
["skills/snapshot", { skillId: "missing-skill" }],
|
|
["skills/stop", { skillId: "missing-skill" }],
|
|
["skills/updateDraft", { skillId: "missing-skill", playbook: skillPlaybookInput() }],
|
|
["skills/save", { skillId: "missing-skill" }],
|
|
["skills/testRun", { skillId: "missing-skill" }],
|
|
["skills/remove", { skillId: "missing-skill" }],
|
|
["agentSkills/list"],
|
|
["agentSkills/get", { skillId: "missing-skill" }],
|
|
["agentSkills/create", { name: "Unauthenticated", description: "Nope", body: "Steps" }],
|
|
["agentSkills/update", { skillId: "missing-skill", description: "Nope" }],
|
|
["agentSkills/remove", { skillId: "missing-skill" }],
|
|
["capabilities/list"],
|
|
["capabilities/install", capabilityInput("Unauthenticated")],
|
|
["capabilities/remove", { id: "missing-capability" }],
|
|
["connections/catalog", {}],
|
|
["connections/list"],
|
|
["connections/begin", connectionInput("Unauthenticated")],
|
|
["connections/complete", { connectionId: "missing-connection" }],
|
|
["connections/revoke", { connectionId: "missing-connection" }],
|
|
["approvalRules/list"],
|
|
[
|
|
"approvalRules/set",
|
|
{ effect: "require_approval", matchKind: "category", matchValue: "email" },
|
|
],
|
|
["approvalRules/remove", { id: "missing-rule" }],
|
|
["artifacts/list", { botId: "missing-bot" }],
|
|
["usage/list"],
|
|
["usage/summary"],
|
|
["export/bot", { botId: "missing-bot" }],
|
|
["notifications/registerPush", { token: "ExponentPushToken[not-real]" }],
|
|
["search/query", { q: "anything" }],
|
|
["voice/catalog"],
|
|
["voice/status"],
|
|
["voice/credentials"],
|
|
["voice/connect", { provider: "elevenlabs", apiKey: "not-a-real-key" }],
|
|
["voice/disconnect", { provider: "elevenlabs" }],
|
|
["voice/setVoice", { voiceId: "missing-voice" }],
|
|
["voice/voices", {}],
|
|
["voice/prepare", { text: "Nope" }],
|
|
]);
|
|
|
|
const results = await Promise.all(
|
|
calls.map(async ([procedure, input]) => ({
|
|
procedure,
|
|
response: await raw(app, "", procedure, input),
|
|
})),
|
|
);
|
|
|
|
for (const { procedure, response } of results) {
|
|
expect(response.status, procedure).toBe(401);
|
|
}
|
|
});
|
|
|
|
it("prevents one user from reading or mutating another user's resources", async () => {
|
|
const owner = await signup(app, `owner-authz-${stamp}@rakazo.test`, "Authorization Owner");
|
|
const intruder = await signup(
|
|
app,
|
|
`intruder-authz-${stamp}@rakazo.test`,
|
|
"Authorization Intruder",
|
|
);
|
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
|
const intruderActor = await rpc<Actor>(app, intruder, "me");
|
|
expect(ownerActor.spaceId).not.toBe(intruderActor.spaceId);
|
|
|
|
const ownerBot = await rpc<Bot>(app, owner, "bots/create", botInput("Owner Bot"));
|
|
const intruderBot = await rpc<Bot>(app, intruder, "bots/create", botInput("Intruder Bot"));
|
|
const ownerRoutine = await rpc<{ id: string }>(
|
|
app,
|
|
owner,
|
|
"routines/create",
|
|
routineInput(ownerBot.id),
|
|
);
|
|
const ownerScratchpad = await rpc<{ id: string }>(app, owner, "scratchpad/create", {
|
|
botId: ownerBot.id,
|
|
title: "Owner open work",
|
|
notes: "private",
|
|
});
|
|
const ownerSkill = await handles.prisma.taughtSkill.create({
|
|
data: {
|
|
spaceId: ownerActor.spaceId,
|
|
botId: ownerBot.id,
|
|
userId: ownerActor.userId,
|
|
name: "Owner Skill",
|
|
goal: "Owner-only skill",
|
|
status: "saved",
|
|
playbook: skillPlaybookInput(),
|
|
recording: { events: [], snapshots: [] },
|
|
},
|
|
});
|
|
const ownerAgentSkill = await rpc<{ id: string }>(app, owner, "agentSkills/create", {
|
|
name: "Owner Recipe",
|
|
description: "Owner-only shared skill",
|
|
body: "1. Do the private thing.",
|
|
});
|
|
const ownerCapability = await rpc<{ id: string }>(
|
|
app,
|
|
owner,
|
|
"capabilities/install",
|
|
capabilityInput("Owner Capability"),
|
|
);
|
|
const ownerConnection = await rpc<{ connectionId: string }>(
|
|
app,
|
|
owner,
|
|
"connections/begin",
|
|
connectionInput("Owner Connection"),
|
|
);
|
|
const ownerMemory = await handles.prisma.memoryDocument.create({
|
|
data: {
|
|
spaceId: ownerActor.spaceId,
|
|
userId: ownerActor.userId,
|
|
botId: ownerBot.id,
|
|
scope: "bot",
|
|
path: "PRIVATE.md",
|
|
content: "owner-only-memory",
|
|
},
|
|
});
|
|
const ownerArtifact = await handles.prisma.artifact.create({
|
|
data: {
|
|
spaceId: ownerActor.spaceId,
|
|
userId: ownerActor.userId,
|
|
botId: ownerBot.id,
|
|
name: "owner-secret.txt",
|
|
mimeType: "text/plain",
|
|
size: 17,
|
|
hash: "sha256:not-a-real-hash",
|
|
storageKey: "test/owner-secret.txt",
|
|
},
|
|
});
|
|
const ownerThread = await handles.prisma.thread.findUniqueOrThrow({
|
|
where: { botId: ownerBot.id },
|
|
});
|
|
const ownerTask = await handles.prisma.task.create({
|
|
data: {
|
|
spaceId: ownerActor.spaceId,
|
|
userId: ownerActor.userId,
|
|
botId: ownerBot.id,
|
|
threadId: ownerThread.id,
|
|
prompt: "owner prompt",
|
|
status: "waiting_input",
|
|
},
|
|
});
|
|
const ownerRun = await handles.prisma.run.create({
|
|
data: {
|
|
spaceId: ownerActor.spaceId,
|
|
userId: ownerActor.userId,
|
|
botId: ownerBot.id,
|
|
threadId: ownerThread.id,
|
|
taskId: ownerTask.id,
|
|
status: "waiting_input",
|
|
trigger: "user",
|
|
},
|
|
});
|
|
|
|
const botIdCalls: Array<[string, unknown]> = [
|
|
["bots/get", { botId: ownerBot.id }],
|
|
["bots/duplicate", { botId: ownerBot.id }],
|
|
["bots/update", { botId: ownerBot.id, name: "Stolen Bot" }],
|
|
["bots/archive", { botId: ownerBot.id }],
|
|
["bots/restore", { botId: ownerBot.id }],
|
|
["threads/get", { botId: ownerBot.id }],
|
|
["threads/messages", { botId: ownerBot.id, before: 1 }],
|
|
["threads/subscribe", { botId: ownerBot.id, cursor: -1 }],
|
|
["threads/send", { botId: ownerBot.id, text: "intruder message" }],
|
|
["threads/send", { botId: ownerBot.id, artifactIds: [ownerArtifact.id] }],
|
|
["threads/stop", { botId: ownerBot.id }],
|
|
["threads/clear", { botId: ownerBot.id }],
|
|
["threads/followUp", { botId: ownerBot.id, text: "intruder follow-up" }],
|
|
[
|
|
"threads/answer",
|
|
{
|
|
botId: ownerBot.id,
|
|
runId: ownerRun.id,
|
|
messageId: "missing-message",
|
|
answer: "intruder answer",
|
|
},
|
|
],
|
|
["threads/markRead", { botId: ownerBot.id }],
|
|
["threads/markUnread", { botId: ownerBot.id }],
|
|
["computer/status", { botId: ownerBot.id }],
|
|
["computer/boot", { botId: ownerBot.id }],
|
|
["computer/stop", { botId: ownerBot.id }],
|
|
["computer/takeover", { botId: ownerBot.id }],
|
|
["computer/release", { botId: ownerBot.id }],
|
|
["computer/input", { botId: ownerBot.id, kind: "key", payload: { key: "A" } }],
|
|
["computer/files", { botId: ownerBot.id, path: "/" }],
|
|
["computer/readFile", { botId: ownerBot.id, path: "PRIVATE.md" }],
|
|
["computer/screenUrl", { botId: ownerBot.id }],
|
|
["computer/heartbeat", { botId: ownerBot.id }],
|
|
["routines/list", { botId: ownerBot.id }],
|
|
["routines/create", routineInput(ownerBot.id)],
|
|
["scratchpad/list", { botId: ownerBot.id }],
|
|
["scratchpad/create", { botId: ownerBot.id, title: "Stolen item" }],
|
|
["skills/list", { botId: ownerBot.id }],
|
|
["skills/start", { botId: ownerBot.id, goal: "Intruder demo" }],
|
|
["artifacts/list", { botId: ownerBot.id }],
|
|
[
|
|
"artifacts/create",
|
|
{
|
|
botId: ownerBot.id,
|
|
name: "intruder.txt",
|
|
mimeType: "text/plain",
|
|
contentBase64: Buffer.from("nope").toString("base64"),
|
|
},
|
|
],
|
|
["artifacts/get", { botId: ownerBot.id, artifactId: ownerArtifact.id }],
|
|
["export/bot", { botId: ownerBot.id }],
|
|
["voice/prepare", { text: "stolen speech", botId: ownerBot.id }],
|
|
];
|
|
await Promise.all(
|
|
botIdCalls.map(([procedure, input]) => expectDenied(app, intruder, procedure, input)),
|
|
);
|
|
|
|
const ownerBot2 = await rpc<Bot>(app, owner, "bots/create", botInput("Owner Bot Two"));
|
|
const ownerGroup = await rpc<{ id: string }>(app, owner, "groups/create", {
|
|
name: "Owner Group",
|
|
botIds: [ownerBot.id, ownerBot2.id],
|
|
});
|
|
const groupCalls: Array<[string, unknown]> = [
|
|
["groups/get", { groupId: ownerGroup.id }],
|
|
["groups/update", { groupId: ownerGroup.id, name: "Stolen Group" }],
|
|
["groups/remove", { groupId: ownerGroup.id }],
|
|
["threads/get", { groupId: ownerGroup.id }],
|
|
["threads/messages", { groupId: ownerGroup.id, before: 1 }],
|
|
["threads/subscribe", { groupId: ownerGroup.id, cursor: -1 }],
|
|
["threads/send", { groupId: ownerGroup.id, text: "intruder group message" }],
|
|
["threads/stop", { groupId: ownerGroup.id }],
|
|
["threads/followUp", { groupId: ownerGroup.id, text: "intruder follow-up" }],
|
|
["threads/markRead", { groupId: ownerGroup.id }],
|
|
["threads/markUnread", { groupId: ownerGroup.id }],
|
|
];
|
|
await Promise.all(
|
|
groupCalls.map(([procedure, input]) => expectDenied(app, intruder, procedure, input)),
|
|
);
|
|
|
|
// A caller cannot pair their own bot with another workspace's run ID.
|
|
await expectDenied(app, intruder, "threads/answer", {
|
|
botId: intruderBot.id,
|
|
runId: ownerRun.id,
|
|
messageId: "missing-message",
|
|
answer: "mixed-resource attack",
|
|
});
|
|
|
|
const resourceIdCalls = [
|
|
["routines/update", { routineId: ownerRoutine.id, name: "Stolen Routine" }],
|
|
["routines/remove", { routineId: ownerRoutine.id }],
|
|
["routines/testRun", { routineId: ownerRoutine.id }],
|
|
["scratchpad/update", { itemId: ownerScratchpad.id, title: "Stolen item" }],
|
|
["scratchpad/remove", { itemId: ownerScratchpad.id }],
|
|
["skills/get", { skillId: ownerSkill.id }],
|
|
[
|
|
"skills/appendEvent",
|
|
{ skillId: ownerSkill.id, event: { at: new Date().toISOString(), kind: "key", key: "x" } },
|
|
],
|
|
["skills/snapshot", { skillId: ownerSkill.id }],
|
|
["skills/stop", { skillId: ownerSkill.id }],
|
|
["skills/updateDraft", { skillId: ownerSkill.id, playbook: skillPlaybookInput() }],
|
|
["skills/save", { skillId: ownerSkill.id }],
|
|
["skills/testRun", { skillId: ownerSkill.id }],
|
|
["skills/remove", { skillId: ownerSkill.id }],
|
|
["agentSkills/get", { skillId: ownerAgentSkill.id }],
|
|
["agentSkills/update", { skillId: ownerAgentSkill.id, description: "Stolen" }],
|
|
["agentSkills/remove", { skillId: ownerAgentSkill.id }],
|
|
["memory/update", { documentId: ownerMemory.id, content: "stolen" }],
|
|
["connections/complete", { connectionId: ownerConnection.connectionId }],
|
|
] satisfies Array<[string, unknown]>;
|
|
await Promise.all(
|
|
resourceIdCalls.map(([procedure, input]) => expectDenied(app, intruder, procedure, input)),
|
|
);
|
|
|
|
expect(await rpc<unknown[]>(app, intruder, "memory/list", { botId: ownerBot.id })).toEqual([]);
|
|
expect(await rpc<string>(app, intruder, "memory/exportMarkdown", { botId: ownerBot.id })).toBe(
|
|
"",
|
|
);
|
|
expect(await rpc<Array<{ id: string }>>(app, intruder, "capabilities/list")).not.toContainEqual(
|
|
expect.objectContaining({ id: ownerCapability.id }),
|
|
);
|
|
expect(await rpc<Array<{ id: string }>>(app, intruder, "agentSkills/list")).not.toContainEqual(
|
|
expect.objectContaining({ id: ownerAgentSkill.id }),
|
|
);
|
|
expect(await rpc<Array<{ id: string }>>(app, intruder, "connections/list")).not.toContainEqual(
|
|
expect.objectContaining({ id: ownerConnection.connectionId }),
|
|
);
|
|
expect(
|
|
await rpc<{ hits: unknown[] }>(app, intruder, "search/query", { q: ownerBot.name }),
|
|
).toEqual({ hits: [] });
|
|
|
|
// These endpoints are deliberately idempotent for unknown IDs. Success must not mutate
|
|
// a row in a different workspace or disclose whether it exists.
|
|
await rpc(app, intruder, "capabilities/remove", { id: ownerCapability.id });
|
|
await rpc(app, intruder, "connections/revoke", {
|
|
connectionId: ownerConnection.connectionId,
|
|
});
|
|
expect(
|
|
await handles.prisma.capabilityInstall.findUnique({ where: { id: ownerCapability.id } }),
|
|
).not.toBeNull();
|
|
expect(
|
|
await handles.prisma.connection.findUniqueOrThrow({
|
|
where: { id: ownerConnection.connectionId },
|
|
}),
|
|
).toMatchObject({ status: "connected", userId: ownerActor.userId });
|
|
|
|
const ownerBotAfter = await handles.prisma.bot.findUniqueOrThrow({
|
|
where: { id: ownerBot.id },
|
|
});
|
|
expect(ownerBotAfter.name).toBe("Owner Bot");
|
|
expect(
|
|
await handles.prisma.routine.findUniqueOrThrow({ where: { id: ownerRoutine.id } }),
|
|
).toMatchObject({ name: "Owner Routine" });
|
|
expect(
|
|
await handles.prisma.scratchpadItem.findUniqueOrThrow({ where: { id: ownerScratchpad.id } }),
|
|
).toMatchObject({ title: "Owner open work", notes: "private" });
|
|
expect(
|
|
await handles.prisma.memoryDocument.findUniqueOrThrow({ where: { id: ownerMemory.id } }),
|
|
).toMatchObject({ content: "owner-only-memory" });
|
|
|
|
// Destructive bot removal is checked last so an authorization regression is unmistakable.
|
|
await expectDenied(app, intruder, "bots/remove", {
|
|
botId: ownerBot.id,
|
|
deleteMemories: true,
|
|
});
|
|
expect(await handles.prisma.bot.findUnique({ where: { id: ownerBot.id } })).not.toBeNull();
|
|
});
|
|
|
|
it("prevents one shared-space member from hijacking another member's Team Computer takeover", async () => {
|
|
const owner = await signup(app, `takeover-owner-${stamp}@rakazo.test`, "Takeover Owner");
|
|
const member = await signup(app, `takeover-member-${stamp}@rakazo.test`, "Takeover Member");
|
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
|
const memberActor = await rpc<Actor>(app, member, "me");
|
|
const sharedSpaceId = ownerActor.spaceId;
|
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: sharedSpaceId },
|
|
select: { organizationId: true },
|
|
});
|
|
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `takeover-org-member-${stamp}`,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `takeover-space-member-${stamp}`,
|
|
spaceId: sharedSpaceId,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const ownerBot = await rpc<Bot>(
|
|
app,
|
|
owner,
|
|
"bots/create",
|
|
botInput("Owner Bot"),
|
|
sharedSpaceId,
|
|
);
|
|
const memberBot = await rpc<Bot>(
|
|
app,
|
|
member,
|
|
"bots/create",
|
|
botInput("Member Bot"),
|
|
sharedSpaceId,
|
|
);
|
|
const ownerBotRow = await handles.prisma.bot.findUniqueOrThrow({ where: { id: ownerBot.id } });
|
|
const memberBotRow = await handles.prisma.bot.findUniqueOrThrow({
|
|
where: { id: memberBot.id },
|
|
});
|
|
expect(ownerBotRow.computerId).toBe(memberBotRow.computerId);
|
|
|
|
await rpc(app, owner, "computer/boot", { botId: ownerBot.id }, sharedSpaceId);
|
|
await rpc(app, owner, "computer/takeover", { botId: ownerBot.id }, sharedSpaceId);
|
|
|
|
const memberTakeover = await raw(
|
|
app,
|
|
member,
|
|
"computer/takeover",
|
|
{ botId: memberBot.id },
|
|
sharedSpaceId,
|
|
);
|
|
expect(memberTakeover.status).toBe(409);
|
|
expect(await memberTakeover.text()).toMatch(/Another member holds control/i);
|
|
|
|
const computer = await handles.prisma.computer.findUniqueOrThrow({
|
|
where: { id: ownerBotRow.computerId! },
|
|
select: { controlBotId: true },
|
|
});
|
|
expect(computer.controlBotId).toBe(ownerBot.id);
|
|
});
|
|
|
|
it("prevents one shared-space member from deleting another member's routine", async () => {
|
|
const owner = await signup(app, `routine-owner-${stamp}@rakazo.test`, "Routine Owner");
|
|
const member = await signup(app, `routine-member-${stamp}@rakazo.test`, "Routine Member");
|
|
const memberActor = await rpc<Actor>(app, member, "me");
|
|
|
|
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Shared routines" });
|
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: shared.id },
|
|
select: { organizationId: true },
|
|
});
|
|
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `routine-member-org-${stamp}`,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `routine-member-space-${stamp}`,
|
|
spaceId: shared.id,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const ownerBot = await rpc<Bot>(app, owner, "bots/create", botInput("Owner Bot"), shared.id);
|
|
const ownerRoutine = await rpc<{ id: string }>(
|
|
app,
|
|
owner,
|
|
"routines/create",
|
|
routineInput(ownerBot.id),
|
|
shared.id,
|
|
);
|
|
|
|
await expectDenied(app, member, "routines/remove", { routineId: ownerRoutine.id }, shared.id);
|
|
expect(
|
|
await handles.prisma.routine.findUnique({ where: { id: ownerRoutine.id } }),
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it("keeps Team Computer bot homes private to each member in a shared Space", async () => {
|
|
const owner = await signup(app, `team-home-owner-${stamp}@rakazo.test`, "Team Home Owner");
|
|
const member = await signup(app, `team-home-member-${stamp}@rakazo.test`, "Team Home Member");
|
|
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Shared Team Computer" });
|
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: shared.id },
|
|
select: { organizationId: true },
|
|
});
|
|
const memberActor = await rpc<Actor>(app, member, "me");
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `team-home-org-member-${stamp}`,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `team-home-space-member-${stamp}`,
|
|
spaceId: shared.id,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const ownerBot = await rpc<Bot>(
|
|
app,
|
|
owner,
|
|
"bots/create",
|
|
botInput("Owner Team Bot"),
|
|
shared.id,
|
|
);
|
|
const ownerBotTwo = await rpc<Bot>(
|
|
app,
|
|
owner,
|
|
"bots/create",
|
|
botInput("Owner Team Bot Two"),
|
|
shared.id,
|
|
);
|
|
const memberBot = await rpc<Bot>(
|
|
app,
|
|
member,
|
|
"bots/create",
|
|
botInput("Member Team Bot"),
|
|
shared.id,
|
|
);
|
|
const secret = "owner-only-workspace-secret";
|
|
const ownerNotesDir = path.join(
|
|
dataDir,
|
|
"homes",
|
|
`team-${shared.id}`,
|
|
"bots",
|
|
ownerBot.id,
|
|
"notes",
|
|
);
|
|
mkdirSync(ownerNotesDir, { recursive: true });
|
|
writeFileSync(path.join(ownerNotesDir, "secret.txt"), secret);
|
|
|
|
await expectForbidden(
|
|
app,
|
|
member,
|
|
"computer/readFile",
|
|
{ botId: memberBot.id, path: `bots/${ownerBot.id}/notes/secret.txt` },
|
|
shared.id,
|
|
);
|
|
const memberNotesDir = path.join(
|
|
dataDir,
|
|
"homes",
|
|
`team-${shared.id}`,
|
|
"bots",
|
|
memberBot.id,
|
|
"notes",
|
|
);
|
|
mkdirSync(memberNotesDir, { recursive: true });
|
|
writeFileSync(path.join(memberNotesDir, "mine.txt"), "member-only");
|
|
const botListing = await rpc<Array<{ path: string }>>(
|
|
app,
|
|
member,
|
|
"computer/files",
|
|
{ botId: memberBot.id, path: "bots" },
|
|
shared.id,
|
|
);
|
|
expect(botListing.map((entry) => entry.path)).toEqual([`bots/${memberBot.id}`]);
|
|
|
|
const ownerCrossBot = await rpc<{ content: string }>(
|
|
app,
|
|
owner,
|
|
"computer/readFile",
|
|
{ botId: ownerBotTwo.id, path: `bots/${ownerBot.id}/notes/secret.txt` },
|
|
shared.id,
|
|
);
|
|
expect(ownerCrossBot.content).toBe(secret);
|
|
});
|
|
|
|
it("scopes Team Computer export to the exporting member's bot workspace in a shared Space", async () => {
|
|
const owner = await signup(app, `export-owner-${stamp}@rakazo.test`, "Export Owner");
|
|
const member = await signup(app, `export-member-${stamp}@rakazo.test`, "Export Member");
|
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
|
const memberActor = await rpc<Actor>(app, member, "me");
|
|
|
|
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Shared export" });
|
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: shared.id },
|
|
select: { organizationId: true },
|
|
});
|
|
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `export-member-org-${stamp}`,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `export-member-space-${stamp}`,
|
|
spaceId: shared.id,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const ownerBot = await rpc<Bot>(app, owner, "bots/create", botInput("Owner Bot"), shared.id);
|
|
const memberBot = await rpc<Bot>(app, member, "bots/create", botInput("Member Bot"), shared.id);
|
|
const home = new LocalAgentHomeStore(dataDir);
|
|
const homeKey = computerHomeKey("team", shared.id);
|
|
const homeContext = {
|
|
operationId: "test",
|
|
traceId: "test",
|
|
spaceId: shared.id,
|
|
userId: ownerActor.userId,
|
|
signal: new AbortController().signal,
|
|
};
|
|
await home.writeFile(
|
|
homeKey,
|
|
`${teamBotWorkspaceDirectory(ownerBot.id)}/PRIVATE.md`,
|
|
"owner-workspace-secret",
|
|
homeContext,
|
|
);
|
|
await home.writeFile(
|
|
homeKey,
|
|
`${teamBotWorkspaceDirectory(memberBot.id)}/notes.txt`,
|
|
"member-notes",
|
|
homeContext,
|
|
);
|
|
|
|
const manifest = await rpc<{ files: Array<{ path: string; content: string }> }>(
|
|
app,
|
|
member,
|
|
"export/bot",
|
|
{ botId: memberBot.id },
|
|
shared.id,
|
|
);
|
|
|
|
expect(manifest.files.some((file) => file.content.includes("owner-workspace-secret"))).toBe(
|
|
false,
|
|
);
|
|
expect(manifest.files.some((file) => file.content.includes("member-notes"))).toBe(true);
|
|
});
|
|
|
|
it("keeps approval rules private to each user in a shared Space", async () => {
|
|
const owner = await signup(app, `approval-owner-${stamp}@rakazo.test`, "Approval Owner");
|
|
const member = await signup(app, `approval-member-${stamp}@rakazo.test`, "Approval Member");
|
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
|
const memberActor = await rpc<Actor>(app, member, "me");
|
|
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `approval-member-${stamp}`,
|
|
organizationId: ownerActor.spaceId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
const ownerRule = await rpc<{ id: string }>(app, owner, "approvalRules/set", {
|
|
effect: "always_allow",
|
|
matchKind: "tool",
|
|
matchValue: "destination.write",
|
|
});
|
|
expect(await rpc<unknown[]>(app, member, "approvalRules/list")).toEqual([]);
|
|
|
|
const memberRule = await rpc<{ id: string }>(app, member, "approvalRules/set", {
|
|
effect: "require_approval",
|
|
matchKind: "category",
|
|
matchValue: "email",
|
|
});
|
|
expect(await rpc<Array<{ id: string }>>(app, owner, "approvalRules/list")).toEqual([
|
|
expect.objectContaining({ id: ownerRule.id }),
|
|
]);
|
|
expect(await rpc<Array<{ id: string }>>(app, member, "approvalRules/list")).toEqual([
|
|
expect.objectContaining({ id: memberRule.id }),
|
|
]);
|
|
|
|
await rpc(app, member, "approvalRules/remove", { id: ownerRule.id });
|
|
expect(
|
|
await handles.prisma.actionApprovalRule.findUnique({ where: { id: ownerRule.id } }),
|
|
).not.toBeNull();
|
|
});
|
|
|
|
it("redacts memory provider details in aiConsent/status for non-owner members", async () => {
|
|
const owner = await signup(app, `ai-consent-owner-${stamp}@rakazo.test`, "Consent Owner");
|
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
|
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Consent shared" });
|
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: shared.id },
|
|
select: { organizationId: true },
|
|
});
|
|
const secret = await handles.prisma.secret.create({
|
|
data: {
|
|
userId: ownerActor.userId,
|
|
spaceId: shared.id,
|
|
kind: "memory-provider",
|
|
ciphertext: "encrypted-test-key",
|
|
},
|
|
});
|
|
await handles.prisma.spaceMemoryConfig.create({
|
|
data: {
|
|
spaceId: shared.id,
|
|
userId: ownerActor.userId,
|
|
provider: "supermemory",
|
|
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
|
secretId: secret.id,
|
|
defaultMemoryScope: "isolated",
|
|
},
|
|
});
|
|
|
|
const member = await signup(app, `ai-consent-member-${stamp}@rakazo.test`, "Consent Member");
|
|
const memberActor = await rpc<Actor>(app, member, "me");
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `ai-consent-org-member-${stamp}`,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `ai-consent-space-member-${stamp}`,
|
|
spaceId: shared.id,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const ownerStatus = await rpc<AiConsentStatus>(
|
|
app,
|
|
owner,
|
|
"aiConsent/status",
|
|
{ uses: ["memory"] },
|
|
shared.id,
|
|
);
|
|
const memberStatus = await rpc<AiConsentStatus>(
|
|
app,
|
|
member,
|
|
"aiConsent/status",
|
|
{ uses: ["memory"] },
|
|
shared.id,
|
|
);
|
|
const ownerMemory = ownerStatus.recipients.find((recipient) => recipient.use === "memory");
|
|
const memberMemory = memberStatus.recipients.find((recipient) => recipient.use === "memory");
|
|
expect(ownerMemory?.name).toContain("127.0.0.1:8123");
|
|
expect(memberMemory?.name).toBe("Space memory");
|
|
expect(JSON.stringify(memberStatus.recipients)).not.toContain("127.0.0.1");
|
|
expect(JSON.stringify(memberStatus.recipients)).not.toMatch(/supermemory/i);
|
|
});
|
|
|
|
it("keeps space data and computers behind the selected space boundary", async () => {
|
|
const cookie = await signup(app, `spaces-${stamp}@rakazo.test`, "Space Owner");
|
|
const original = await rpc<Actor>(app, cookie, "me");
|
|
const originalBot = await rpc<Bot>(app, cookie, "bots/create", botInput("Open source"));
|
|
const support = await rpc<Space>(app, cookie, "spaces/create", {
|
|
name: "Customer support",
|
|
});
|
|
const storedSpaces = await handles.prisma.space.findMany({
|
|
where: { id: { in: [original.spaceId, support.id] } },
|
|
select: { id: true, organizationId: true },
|
|
});
|
|
expect(new Set(storedSpaces.map((space) => space.organizationId))).toEqual(
|
|
new Set([original.spaceId]),
|
|
);
|
|
const otherOrganizationId = `spaces-other-org-${stamp}`;
|
|
const otherWorkspaceId = `spaces-other-workspace-${stamp}`;
|
|
await handles.prisma.$transaction([
|
|
handles.prisma.organization.create({
|
|
data: {
|
|
id: otherOrganizationId,
|
|
name: "Other company",
|
|
slug: otherOrganizationId,
|
|
createdAt: new Date(),
|
|
},
|
|
}),
|
|
handles.prisma.member.create({
|
|
data: {
|
|
id: `spaces-other-member-${stamp}`,
|
|
organizationId: otherOrganizationId,
|
|
userId: original.userId,
|
|
role: "owner",
|
|
createdAt: new Date(),
|
|
},
|
|
}),
|
|
handles.prisma.space.create({
|
|
data: {
|
|
id: otherWorkspaceId,
|
|
organizationId: otherOrganizationId,
|
|
name: "Other company space",
|
|
},
|
|
}),
|
|
handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `spaces-other-space-member-${stamp}`,
|
|
spaceId: otherWorkspaceId,
|
|
organizationId: otherOrganizationId,
|
|
userId: original.userId,
|
|
createdAt: new Date(),
|
|
},
|
|
}),
|
|
]);
|
|
|
|
const supportMe = await rpc<Actor>(app, cookie, "me", {}, support.id);
|
|
expect(supportMe.spaceId).toBe(support.id);
|
|
const supportBot = await rpc<Bot>(app, cookie, "bots/create", botInput("Support"), support.id);
|
|
|
|
expect(await rpc<Array<{ id: string }>>(app, cookie, "bots/list")).toEqual([
|
|
expect.objectContaining({ id: originalBot.id }),
|
|
]);
|
|
expect(await rpc<Array<{ id: string }>>(app, cookie, "bots/list", {}, support.id)).toEqual([
|
|
expect.objectContaining({ id: supportBot.id }),
|
|
]);
|
|
await expectDenied(app, cookie, "bots/get", { botId: supportBot.id });
|
|
await expectDenied(app, cookie, "bots/get", { botId: originalBot.id }, support.id);
|
|
|
|
const navigation = await rpc<SpaceNavigation>(app, cookie, "spaces/list");
|
|
expect(navigation.current).toEqual(
|
|
expect.objectContaining({
|
|
id: original.spaceId,
|
|
bots: [expect.objectContaining({ id: originalBot.id })],
|
|
}),
|
|
);
|
|
expect(navigation.spaces).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
id: original.spaceId,
|
|
bots: [expect.objectContaining({ id: originalBot.id, notifyOnFinish: false })],
|
|
}),
|
|
expect.objectContaining({
|
|
id: support.id,
|
|
name: "Customer support",
|
|
bots: [expect.objectContaining({ id: supportBot.id, notifyOnFinish: false })],
|
|
}),
|
|
]),
|
|
);
|
|
expect(navigation.spaces).not.toEqual(
|
|
expect.arrayContaining([expect.objectContaining({ id: otherWorkspaceId })]),
|
|
);
|
|
const supportNavigation = await rpc<SpaceNavigation>(
|
|
app,
|
|
cookie,
|
|
"spaces/list",
|
|
{},
|
|
support.id,
|
|
);
|
|
expect(supportNavigation.current).toEqual(
|
|
expect.objectContaining({
|
|
id: support.id,
|
|
bots: [expect.objectContaining({ id: supportBot.id })],
|
|
}),
|
|
);
|
|
const otherOrganizationNavigation = await rpc<SpaceNavigation>(
|
|
app,
|
|
cookie,
|
|
"spaces/list",
|
|
{},
|
|
otherWorkspaceId,
|
|
);
|
|
expect(otherOrganizationNavigation.spaces.map((space) => space.id)).toEqual([otherWorkspaceId]);
|
|
|
|
const storedBots = await handles.prisma.bot.findMany({
|
|
where: { id: { in: [originalBot.id, supportBot.id] } },
|
|
select: { id: true, spaceId: true, computerId: true },
|
|
});
|
|
const storedOriginal = storedBots.find((bot) => bot.id === originalBot.id);
|
|
const storedSupport = storedBots.find((bot) => bot.id === supportBot.id);
|
|
expect(storedOriginal?.spaceId).toBe(original.spaceId);
|
|
expect(storedSupport?.spaceId).toBe(support.id);
|
|
expect(storedOriginal?.computerId).not.toBe(storedSupport?.computerId);
|
|
|
|
const intruder = await signup(app, `spaces-intruder-${stamp}@rakazo.test`, "Intruder");
|
|
await expectDenied(app, intruder, "bots/list", {}, support.id);
|
|
});
|
|
|
|
it("restricts memory provider configuration to Space owners", async () => {
|
|
const owner = await signup(app, `memory-provider-owner-${stamp}@rakazo.test`, "Memory Owner");
|
|
const member = await signup(
|
|
app,
|
|
`memory-provider-member-${stamp}@rakazo.test`,
|
|
"Memory Member",
|
|
);
|
|
const memberActor = await rpc<Actor>(app, member, "me");
|
|
|
|
const shared = await rpc<Space>(app, owner, "spaces/create", { name: "Shared memory config" });
|
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: shared.id },
|
|
select: { organizationId: true },
|
|
});
|
|
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `memory-provider-member-org-${stamp}`,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `memory-provider-member-space-${stamp}`,
|
|
spaceId: shared.id,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
|
|
const ownerActor = await rpc<Actor>(app, owner, "me", {}, shared.id);
|
|
const secret = await handles.prisma.secret.create({
|
|
data: {
|
|
userId: ownerActor.userId,
|
|
spaceId: shared.id,
|
|
kind: "memory-provider",
|
|
ciphertext: "encrypted-memory-provider-key",
|
|
},
|
|
});
|
|
await handles.prisma.spaceMemoryConfig.create({
|
|
data: {
|
|
spaceId: shared.id,
|
|
userId: ownerActor.userId,
|
|
provider: "supermemory",
|
|
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
|
secretId: secret.id,
|
|
defaultMemoryScope: "isolated",
|
|
},
|
|
});
|
|
|
|
await expectForbidden(app, member, "memory/providerConfig", {}, shared.id);
|
|
await expectForbidden(
|
|
app,
|
|
member,
|
|
"memory/connectProvider",
|
|
{
|
|
provider: "supermemory",
|
|
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
|
credentials: { apiKey: "not-a-real-key" },
|
|
defaultMemoryScope: "isolated",
|
|
},
|
|
shared.id,
|
|
);
|
|
await expectForbidden(
|
|
app,
|
|
member,
|
|
"memory/setDefaultScope",
|
|
{ defaultMemoryScope: "shared" },
|
|
shared.id,
|
|
);
|
|
await expectForbidden(app, member, "memory/disconnectProvider", {}, shared.id);
|
|
|
|
expect(await rpc(app, owner, "memory/providerConfig", {}, shared.id)).toEqual({
|
|
provider: "supermemory",
|
|
settings: { mode: "local", baseUrl: "http://127.0.0.1:8123" },
|
|
defaultMemoryScope: "isolated",
|
|
updatedAt: expect.any(String),
|
|
});
|
|
});
|
|
|
|
it("enforces the space limit across concurrent creation requests", async () => {
|
|
const cookie = await signup(app, `space-limit-${stamp}@rakazo.test`, "Space Limit");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const currentSpace = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: actor.spaceId },
|
|
select: { organizationId: true },
|
|
});
|
|
const extraSpaces = Array.from({ length: 30 }, (_, index) => ({
|
|
id: `limit-space-${stamp}-${index}`,
|
|
organizationId: currentSpace.organizationId,
|
|
name: `Limit space ${index}`,
|
|
createdAt: new Date(),
|
|
}));
|
|
await handles.prisma.space.createMany({ data: extraSpaces });
|
|
await handles.prisma.spaceMember.createMany({
|
|
data: extraSpaces.map((space, index) => ({
|
|
id: `limit-member-${stamp}-${index}`,
|
|
spaceId: space.id,
|
|
organizationId: space.organizationId,
|
|
userId: actor.userId,
|
|
createdAt: space.createdAt,
|
|
})),
|
|
});
|
|
|
|
const results = await Promise.allSettled([
|
|
rpc<Space>(app, cookie, "spaces/create", { name: "Concurrent A" }),
|
|
rpc<Space>(app, cookie, "spaces/create", { name: "Concurrent B" }),
|
|
]);
|
|
|
|
expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
|
expect(results.filter((result) => result.status === "rejected")).toHaveLength(1);
|
|
await expect(
|
|
handles.prisma.spaceMember.count({
|
|
where: { userId: actor.userId, organizationId: currentSpace.organizationId },
|
|
}),
|
|
).resolves.toBe(32);
|
|
});
|
|
|
|
it("reuses provider credentials and copies their selections into a new Space", async () => {
|
|
const cookie = await signup(app, `space-provider-copy-${stamp}@rakazo.test`, "Provider Copy");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const model = await rpc<ModelCredential>(app, cookie, "models/connect", {
|
|
provider: "copy-provider",
|
|
apiKey: "fake-copy-model-key",
|
|
label: "Copy provider",
|
|
modelId: "copy/model",
|
|
});
|
|
const voice = await rpc<{ id: string; voiceId: string }>(app, cookie, "voice/connect", {
|
|
provider: "scripted",
|
|
apiKey: "fake-copy-voice-key",
|
|
});
|
|
|
|
const created = await rpc<Space>(app, cookie, "spaces/create", { name: "Copied defaults" });
|
|
const [modelPreference, voicePreference] = await Promise.all([
|
|
handles.prisma.spaceModelPreference.findUnique({
|
|
where: {
|
|
spaceId_userId_credentialId: {
|
|
spaceId: created.id,
|
|
userId: actor.userId,
|
|
credentialId: model.id,
|
|
},
|
|
},
|
|
}),
|
|
handles.prisma.spaceVoicePreference.findUnique({
|
|
where: {
|
|
spaceId_userId_credentialId: {
|
|
spaceId: created.id,
|
|
userId: actor.userId,
|
|
credentialId: voice.id,
|
|
},
|
|
},
|
|
}),
|
|
]);
|
|
|
|
expect(modelPreference).toMatchObject({ modelId: "copy/model", isDefault: true });
|
|
expect(voicePreference).toMatchObject({ voiceId: voice.voiceId, isDefault: true });
|
|
await rpc(
|
|
app,
|
|
cookie,
|
|
"voice/setVoice",
|
|
{ provider: "scripted", voiceId: "space-specific-voice" },
|
|
created.id,
|
|
);
|
|
await expect(
|
|
handles.prisma.spaceVoicePreference.findUniqueOrThrow({
|
|
where: {
|
|
spaceId_userId_credentialId: {
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
credentialId: voice.id,
|
|
},
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({ voiceId: voice.voiceId, isDefault: true });
|
|
await expect(
|
|
handles.prisma.spaceVoicePreference.findUniqueOrThrow({
|
|
where: {
|
|
spaceId_userId_credentialId: {
|
|
spaceId: created.id,
|
|
userId: actor.userId,
|
|
credentialId: voice.id,
|
|
},
|
|
},
|
|
}),
|
|
).resolves.toMatchObject({ voiceId: "space-specific-voice", isDefault: true });
|
|
await expect(
|
|
handles.prisma.userModelCredential.count({ where: { userId: actor.userId } }),
|
|
).resolves.toBe(1);
|
|
await expect(
|
|
handles.prisma.userVoiceCredential.count({ where: { userId: actor.userId } }),
|
|
).resolves.toBe(1);
|
|
});
|
|
|
|
it("shares model credentials while keeping defaults private to each space", async () => {
|
|
const cookie = await signup(app, `model-defaults-${stamp}@rakazo.test`, "Model Defaults");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const support = await rpc<Space>(app, cookie, "spaces/create", { name: "Support models" });
|
|
const expectSpaceModelDefault = async (spaceId: string, provider: string, modelId: string) => {
|
|
const preference = await handles.prisma.spaceModelPreference.findFirst({
|
|
where: { userId: actor.userId, spaceId, isDefault: true },
|
|
include: { credential: true },
|
|
});
|
|
expect(preference).toMatchObject({ modelId, credential: { provider } });
|
|
};
|
|
|
|
const connectedA = await rpc<ModelCredential>(app, cookie, "models/connect", {
|
|
provider: "provider-a",
|
|
apiKey: "fake-provider-a-key",
|
|
label: "Provider A",
|
|
modelId: "a/one",
|
|
});
|
|
expect(connectedA.isDefault).toBe(true);
|
|
const providerABeforeRotation = await handles.prisma.userModelCredential.findUniqueOrThrow({
|
|
where: { id: connectedA.id },
|
|
});
|
|
|
|
const rotatedA = await rpc<ModelCredential>(app, cookie, "models/connect", {
|
|
provider: "provider-a",
|
|
apiKey: "fake-provider-a-replacement-key",
|
|
label: "Provider A rotated",
|
|
modelId: "a/rotated",
|
|
});
|
|
const providerAAfterRotation = await handles.prisma.userModelCredential.findUniqueOrThrow({
|
|
where: { id: connectedA.id },
|
|
});
|
|
expect(rotatedA.id).toBe(connectedA.id);
|
|
expect(providerAAfterRotation.secretId).not.toBe(providerABeforeRotation.secretId);
|
|
expect(
|
|
await handles.prisma.secret.findUnique({ where: { id: providerABeforeRotation.secretId } }),
|
|
).toBeNull();
|
|
await expect(
|
|
handles.prisma.secret.findUniqueOrThrow({ where: { id: providerAAfterRotation.secretId } }),
|
|
).resolves.toMatchObject({ userId: actor.userId, spaceId: null, kind: "model" });
|
|
expect(
|
|
await handles.prisma.userModelCredential.count({
|
|
where: { userId: actor.userId, provider: "provider-a" },
|
|
}),
|
|
).toBe(1);
|
|
|
|
const supportCredentials = await rpc<ModelCredential[]>(
|
|
app,
|
|
cookie,
|
|
"models/credentials",
|
|
{},
|
|
support.id,
|
|
);
|
|
expect(supportCredentials).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: connectedA.id, provider: "provider-a", isDefault: false }),
|
|
]),
|
|
);
|
|
await rpc(
|
|
app,
|
|
cookie,
|
|
"models/setDefault",
|
|
{ provider: "provider-a", modelId: "a/support" },
|
|
support.id,
|
|
);
|
|
await expectSpaceModelDefault(support.id, "provider-a", "a/support");
|
|
await expectSpaceModelDefault(actor.spaceId, "provider-a", "a/rotated");
|
|
|
|
const connectedB = await rpc<ModelCredential>(app, cookie, "models/connect", {
|
|
provider: "provider-b",
|
|
apiKey: "fake-provider-b-key",
|
|
label: "Provider B",
|
|
modelId: "b/one",
|
|
});
|
|
expect(connectedB.isDefault).toBe(true);
|
|
await expectSpaceModelDefault(actor.spaceId, "provider-b", "b/one");
|
|
|
|
await rpc(app, cookie, "models/setDefault", { provider: "provider-a", modelId: "a/two" });
|
|
await expectSpaceModelDefault(actor.spaceId, "provider-a", "a/two");
|
|
|
|
await rpc(app, cookie, "models/setDefault", { provider: "provider-b", modelId: "b/two" });
|
|
await expectSpaceModelDefault(actor.spaceId, "provider-b", "b/two");
|
|
|
|
await rpc(app, cookie, "models/setDefault", { provider: "provider-a", modelId: "a/three" });
|
|
await expectSpaceModelDefault(actor.spaceId, "provider-a", "a/three");
|
|
await expectSpaceModelDefault(support.id, "provider-a", "a/support");
|
|
const listed = await rpc<ModelCredential[]>(app, cookie, "models/credentials");
|
|
expect(JSON.stringify(listed)).not.toContain("fake-provider-a-key");
|
|
expect(JSON.stringify(listed)).not.toContain("fake-provider-b-key");
|
|
|
|
const missing = await raw(app, cookie, "models/setDefault", {
|
|
provider: "missing-provider",
|
|
modelId: "missing/model",
|
|
});
|
|
expect(missing.status).toBeGreaterThanOrEqual(400);
|
|
expect(await missing.text()).toMatch(/credential/i);
|
|
});
|
|
|
|
it("deletes only empty, non-default spaces", async () => {
|
|
const cookie = await signup(app, `space-delete-${stamp}@rakazo.test`, "Space Delete");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const empty = await rpc<Space>(app, cookie, "spaces/create", { name: "Temporary" });
|
|
const busy = await rpc<Space>(app, cookie, "spaces/create", { name: "Busy" });
|
|
const busyBot = await rpc<Bot>(app, cookie, "bots/create", botInput("Busy bot"), busy.id);
|
|
|
|
await expect(raw(app, cookie, "spaces/remove", { spaceId: busy.id })).resolves.toMatchObject({
|
|
status: 400,
|
|
});
|
|
await expect(
|
|
handles.prisma.space.findUnique({ where: { id: busy.id } }),
|
|
).resolves.not.toBeNull();
|
|
await rpc(app, cookie, "bots/archive", { botId: busyBot.id }, busy.id);
|
|
expect(
|
|
(await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, busy.id)).spaces.find(
|
|
(space) => space.id === busy.id,
|
|
),
|
|
).toMatchObject({ hasContent: true, canDelete: false });
|
|
await expect(raw(app, cookie, "spaces/remove", { spaceId: busy.id })).resolves.toMatchObject({
|
|
status: 400,
|
|
});
|
|
await expect(
|
|
raw(app, cookie, "spaces/remove", { spaceId: actor.spaceId }),
|
|
).resolves.toMatchObject({ status: 400 });
|
|
await expect(
|
|
raw(app, cookie, "spaces/remove", { spaceId: "missing-space" }),
|
|
).resolves.toMatchObject({ status: 404 });
|
|
|
|
const removed = await rpc<{ ok: true; activeSpaceId: string }>(app, cookie, "spaces/remove", {
|
|
spaceId: empty.id,
|
|
});
|
|
expect(removed).toEqual({ ok: true, activeSpaceId: actor.spaceId });
|
|
await expect(handles.prisma.space.findUnique({ where: { id: empty.id } })).resolves.toBeNull();
|
|
const navigation = await rpc<SpaceNavigation>(app, cookie, "spaces/list");
|
|
expect(navigation.spaces.map((space) => space.id)).not.toContain(empty.id);
|
|
|
|
const intruder = await signup(app, `space-delete-intruder-${stamp}@rakazo.test`, "Intruder");
|
|
await expect(raw(app, intruder, "spaces/remove", { spaceId: busy.id })).resolves.toMatchObject({
|
|
status: 404,
|
|
});
|
|
|
|
// Shared-space members must not delete; only the SpaceMember owner may.
|
|
const shared = await rpc<Space>(app, cookie, "spaces/create", { name: "Shared empty" });
|
|
expect(shared.canDelete).toBe(true);
|
|
const sharedRow = await handles.prisma.space.findUniqueOrThrow({
|
|
where: { id: shared.id },
|
|
select: { organizationId: true },
|
|
});
|
|
const memberCookie = await signup(
|
|
app,
|
|
`space-delete-member-${stamp}@rakazo.test`,
|
|
"Space Member",
|
|
);
|
|
const memberActor = await rpc<Actor>(app, memberCookie, "me");
|
|
await handles.prisma.member.deleteMany({ where: { userId: memberActor.userId } });
|
|
await handles.prisma.member.create({
|
|
data: {
|
|
id: `space-delete-org-member-${stamp}`,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await handles.prisma.spaceMember.create({
|
|
data: {
|
|
id: `space-delete-space-member-${stamp}`,
|
|
spaceId: shared.id,
|
|
organizationId: sharedRow.organizationId,
|
|
userId: memberActor.userId,
|
|
role: "member",
|
|
createdAt: new Date(),
|
|
},
|
|
});
|
|
await expect(
|
|
raw(app, memberCookie, "spaces/remove", { spaceId: shared.id }, shared.id),
|
|
).resolves.toMatchObject({ status: 403 });
|
|
await expect(
|
|
handles.prisma.space.findUnique({ where: { id: shared.id } }),
|
|
).resolves.not.toBeNull();
|
|
|
|
// Another member's bot still counts as content for onboarding emptiness.
|
|
const ownerBot = await rpc<Bot>(
|
|
app,
|
|
cookie,
|
|
"bots/create",
|
|
botInput("Owner shared bot"),
|
|
shared.id,
|
|
);
|
|
const ownerNavigation = await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, shared.id);
|
|
expect(ownerNavigation.spaces).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: shared.id, hasContent: true, canDelete: false }),
|
|
]),
|
|
);
|
|
const memberNavigation = await rpc<SpaceNavigation>(
|
|
app,
|
|
memberCookie,
|
|
"spaces/list",
|
|
{},
|
|
shared.id,
|
|
);
|
|
expect(memberNavigation.spaces).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ id: shared.id, hasContent: true, canDelete: false }),
|
|
]),
|
|
);
|
|
|
|
await rpc(app, cookie, "bots/remove", { botId: ownerBot.id, deleteMemories: true }, shared.id);
|
|
expect(
|
|
(await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, shared.id)).spaces.find(
|
|
(space) => space.id === shared.id,
|
|
)?.canDelete,
|
|
).toBe(true);
|
|
const ownerRemoved = await rpc<{ ok: true; activeSpaceId: string }>(
|
|
app,
|
|
cookie,
|
|
"spaces/remove",
|
|
{
|
|
spaceId: shared.id,
|
|
},
|
|
);
|
|
expect(ownerRemoved.ok).toBe(true);
|
|
await expect(handles.prisma.space.findUnique({ where: { id: shared.id } })).resolves.toBeNull();
|
|
});
|
|
|
|
it("blocks bot creation after empty space deletion is claimed", async () => {
|
|
const cookie = await signup(app, `space-race-${stamp}@rakazo.test`, "Space Race");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const space = await rpc<Space>(app, cookie, "spaces/create", { name: "Concurrent" });
|
|
await handles.prisma.computer.create({
|
|
data: {
|
|
spaceId: space.id,
|
|
userId: actor.userId,
|
|
scopeKey: `space-race-scope-${stamp}`,
|
|
homeKey: `space-race-home-${stamp}`,
|
|
kind: "fake",
|
|
providerRef: `space-race-provider-${stamp}`,
|
|
},
|
|
});
|
|
const deleteInput = {
|
|
currentSpaceId: space.id,
|
|
userId: actor.userId,
|
|
spaceId: space.id,
|
|
};
|
|
|
|
const firstClaim = await claimEmptySpaceDeletionForMember(handles.prisma, deleteInput);
|
|
expect(firstClaim.recovered).toBe(false);
|
|
expect(firstClaim.computers).toEqual([
|
|
expect.objectContaining({ providerRef: `space-race-provider-${stamp}` }),
|
|
]);
|
|
await expect(
|
|
renewSpaceDeletionClaim(handles.prisma, { ...deleteInput, claimId: firstClaim.claimId }),
|
|
).resolves.toBe(true);
|
|
const claimedNavigation = await rpc<SpaceNavigation>(app, cookie, "spaces/list", {}, space.id);
|
|
expect(claimedNavigation.spaces.find((item) => item.id === space.id)?.canDelete).toBe(false);
|
|
const blockedCreate = await raw(app, cookie, "bots/create", botInput("Racing bot"), space.id);
|
|
expect(blockedCreate.ok).toBe(false);
|
|
expect(blockedCreate.status).toBe(409);
|
|
expect(await handles.prisma.bot.count({ where: { spaceId: space.id } })).toBe(0);
|
|
expect(
|
|
await handles.prisma.computer.findFirst({
|
|
where: { spaceId: space.id },
|
|
select: { providerRef: true },
|
|
}),
|
|
).toEqual({ providerRef: `space-race-provider-${stamp}` });
|
|
|
|
await handles.prisma.space.update({
|
|
where: { id: space.id },
|
|
data: { deletingAt: new Date(Date.now() - 6 * 60_000) },
|
|
});
|
|
const replacementClaim = await claimEmptySpaceDeletionForMember(handles.prisma, deleteInput);
|
|
expect(replacementClaim.recovered).toBe(true);
|
|
await expect(
|
|
deleteEmptySpaceForMember(handles.prisma, { ...deleteInput, claimId: firstClaim.claimId }),
|
|
).rejects.toThrow("Space deletion is already in progress");
|
|
await releaseSpaceDeletionClaim(handles.prisma, {
|
|
...deleteInput,
|
|
claimId: firstClaim.claimId,
|
|
});
|
|
expect(
|
|
await handles.prisma.space.findUnique({
|
|
where: { id: space.id },
|
|
select: { deletionClaimId: true },
|
|
}),
|
|
).toEqual({ deletionClaimId: replacementClaim.claimId });
|
|
await releaseSpaceDeletionClaim(handles.prisma, {
|
|
...deleteInput,
|
|
claimId: replacementClaim.claimId,
|
|
});
|
|
const created = await raw(app, cookie, "bots/create", botInput("After retry"), space.id);
|
|
expect(created.ok).toBe(true);
|
|
});
|
|
|
|
it("keeps a space claimed after ambiguous sandbox teardown failure", async () => {
|
|
const cookie = await signup(app, `space-teardown-${stamp}@rakazo.test`, "Teardown");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const space = await rpc<Space>(app, cookie, "spaces/create", { name: "Teardown" });
|
|
await handles.prisma.computer.create({
|
|
data: {
|
|
spaceId: space.id,
|
|
userId: actor.userId,
|
|
scopeKey: `space-teardown-scope-${stamp}`,
|
|
homeKey: `space-teardown-home-${stamp}`,
|
|
kind: "fake",
|
|
providerRef: `space-teardown-provider-${stamp}`,
|
|
},
|
|
});
|
|
const destroy = vi
|
|
.spyOn(handles.sandbox, "destroy")
|
|
.mockRejectedValueOnce(new Error("ambiguous provider failure"));
|
|
|
|
try {
|
|
const removed = await raw(app, cookie, "spaces/remove", { spaceId: space.id }, space.id);
|
|
expect(removed.status).toBe(500);
|
|
const lifecycle = await handles.prisma.space.findUnique({
|
|
where: { id: space.id },
|
|
select: { deletingAt: true, deletionClaimId: true },
|
|
});
|
|
expect(lifecycle?.deletingAt).toBeInstanceOf(Date);
|
|
expect(lifecycle?.deletionClaimId).toBeTruthy();
|
|
expect(
|
|
await handles.prisma.computer.findFirst({
|
|
where: { spaceId: space.id },
|
|
select: { providerRef: true },
|
|
}),
|
|
).toEqual({ providerRef: `space-teardown-provider-${stamp}` });
|
|
const blockedCreate = await raw(
|
|
app,
|
|
cookie,
|
|
"bots/create",
|
|
botInput("Blocked after teardown"),
|
|
space.id,
|
|
);
|
|
expect(blockedCreate.ok).toBe(false);
|
|
} finally {
|
|
destroy.mockRestore();
|
|
const lifecycle = await handles.prisma.space.findUnique({
|
|
where: { id: space.id },
|
|
select: { deletionClaimId: true },
|
|
});
|
|
if (lifecycle?.deletionClaimId) {
|
|
await releaseSpaceDeletionClaim(handles.prisma, {
|
|
currentSpaceId: space.id,
|
|
spaceId: space.id,
|
|
userId: actor.userId,
|
|
claimId: lifecycle.deletionClaimId,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
it("times out a hung sandbox teardown without unblocking the space", async () => {
|
|
const cookie = await signup(app, `space-deadline-${stamp}@rakazo.test`, "Deadline");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const space = await rpc<Space>(app, cookie, "spaces/create", { name: "Deadline" });
|
|
await handles.prisma.computer.create({
|
|
data: {
|
|
spaceId: space.id,
|
|
userId: actor.userId,
|
|
scopeKey: `space-deadline-scope-${stamp}`,
|
|
homeKey: `space-deadline-home-${stamp}`,
|
|
kind: "fake",
|
|
providerRef: `space-deadline-provider-${stamp}`,
|
|
},
|
|
});
|
|
process.env.SPACE_TEARDOWN_TIMEOUT_MS = "50";
|
|
const destroy = vi
|
|
.spyOn(handles.sandbox, "destroy")
|
|
.mockImplementation(() => new Promise(() => undefined));
|
|
|
|
try {
|
|
const removed = await raw(app, cookie, "spaces/remove", { spaceId: space.id }, space.id);
|
|
expect(removed.status).toBe(500);
|
|
const lifecycle = await handles.prisma.space.findUnique({
|
|
where: { id: space.id },
|
|
select: { deletingAt: true, deletionClaimId: true },
|
|
});
|
|
expect(lifecycle?.deletingAt).toBeInstanceOf(Date);
|
|
expect(lifecycle?.deletionClaimId).toBeTruthy();
|
|
const blockedCreate = await raw(
|
|
app,
|
|
cookie,
|
|
"bots/create",
|
|
botInput("Blocked during teardown"),
|
|
space.id,
|
|
);
|
|
expect(blockedCreate.status).toBe(409);
|
|
} finally {
|
|
destroy.mockRestore();
|
|
delete process.env.SPACE_TEARDOWN_TIMEOUT_MS;
|
|
const lifecycle = await handles.prisma.space.findUnique({
|
|
where: { id: space.id },
|
|
select: { deletionClaimId: true },
|
|
});
|
|
if (lifecycle?.deletionClaimId) {
|
|
await releaseSpaceDeletionClaim(handles.prisma, {
|
|
currentSpaceId: space.id,
|
|
spaceId: space.id,
|
|
userId: actor.userId,
|
|
claimId: lifecycle.deletionClaimId,
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|
|
it("validates custom thinking against the saved connection capability", async () => {
|
|
const cookie = await signup(app, `custom-thinking-${stamp}@rakazo.test`, "Custom Thinking");
|
|
const bot = await rpc<Bot>(app, cookie, "bots/create", botInput("Thinking Bot"));
|
|
const connection = {
|
|
provider: "openai-compatible",
|
|
modelId: "arbitrary-model",
|
|
baseUrl: "http://localhost:8000/v1",
|
|
};
|
|
await rpc(app, cookie, "models/connect", {
|
|
...connection,
|
|
apiKey: "fake-saved-key",
|
|
reasoning: false,
|
|
});
|
|
await rpc(app, cookie, "models/connect", { ...connection, reasoning: true });
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
expect(
|
|
await handles.executor.resolveModel({
|
|
userId: actor.userId,
|
|
spaceId: actor.spaceId,
|
|
botId: bot.id,
|
|
}),
|
|
).toMatchObject({ apiKey: "fake-saved-key", reasoning: true });
|
|
const update = {
|
|
botId: bot.id,
|
|
modelProvider: connection.provider,
|
|
modelId: connection.modelId,
|
|
};
|
|
expect(
|
|
await rpc(app, cookie, "bots/update", { ...update, thinkingLevel: "low" }),
|
|
).toMatchObject({ thinkingLevel: "low" });
|
|
expect(
|
|
(await raw(app, cookie, "bots/update", { ...update, thinkingLevel: "xhigh" })).status,
|
|
).toBe(400);
|
|
await rpc(app, cookie, "models/connect", { ...connection, reasoning: false });
|
|
expect(
|
|
(await raw(app, cookie, "bots/update", { ...update, thinkingLevel: "low" })).status,
|
|
).toBe(400);
|
|
expect(
|
|
await rpc(app, cookie, "bots/update", { ...update, thinkingLevel: "off" }),
|
|
).toMatchObject({ thinkingLevel: "off" });
|
|
});
|
|
|
|
it("validates per-bot model overrides against connected providers and catalog", async () => {
|
|
const cookie = await signup(app, `bot-model-${stamp}@rakazo.test`, "Bot Model");
|
|
const bot = await rpc<
|
|
Bot & {
|
|
modelProvider: string | null;
|
|
modelId: string | null;
|
|
thinkingLevel: string | null;
|
|
}
|
|
>(app, cookie, "bots/create", botInput("Model Bot"));
|
|
await rpc(app, cookie, "models/connect", {
|
|
provider: "xai",
|
|
apiKey: "fake-xai-key-not-real",
|
|
label: "xAI",
|
|
modelId: "grok-4.6",
|
|
});
|
|
|
|
const updated = await rpc<
|
|
Bot & {
|
|
modelProvider: string | null;
|
|
modelId: string | null;
|
|
thinkingLevel: string | null;
|
|
}
|
|
>(app, cookie, "bots/update", {
|
|
botId: bot.id,
|
|
modelProvider: "xai",
|
|
modelId: "grok-4.6",
|
|
thinkingLevel: "high",
|
|
});
|
|
expect(updated).toMatchObject({
|
|
modelProvider: "xai",
|
|
modelId: "grok-4.6",
|
|
thinkingLevel: "high",
|
|
});
|
|
|
|
const unknown = await raw(app, cookie, "bots/update", {
|
|
botId: bot.id,
|
|
modelProvider: "xai",
|
|
modelId: "not-a-real-grok",
|
|
});
|
|
expect(unknown.status).toBeGreaterThanOrEqual(400);
|
|
expect(await unknown.text()).toMatch(/unknown model/i);
|
|
|
|
const disconnected = await raw(app, cookie, "bots/update", {
|
|
botId: bot.id,
|
|
modelProvider: "anthropic",
|
|
modelId: "claude-opus-4-6",
|
|
});
|
|
expect(disconnected.status).toBeGreaterThanOrEqual(400);
|
|
expect(await disconnected.text()).toMatch(/connect/i);
|
|
|
|
const partialClear = await raw(app, cookie, "bots/update", {
|
|
botId: bot.id,
|
|
modelId: null,
|
|
});
|
|
expect(partialClear.status).toBeGreaterThanOrEqual(400);
|
|
expect(await partialClear.text()).toMatch(/both be set or both cleared/i);
|
|
});
|
|
|
|
it("chooses the newest duplicate provider credential when selecting a default", async () => {
|
|
const cookie = await signup(app, `model-duplicates-${stamp}@rakazo.test`, "Model Duplicates");
|
|
const actor = await rpc<Actor>(app, cookie, "me");
|
|
const olderSecret = await handles.prisma.secret.create({
|
|
data: {
|
|
userId: actor.userId,
|
|
spaceId: null,
|
|
kind: "model",
|
|
ciphertext: "encrypted-older-key",
|
|
},
|
|
});
|
|
const newerSecret = await handles.prisma.secret.create({
|
|
data: {
|
|
userId: actor.userId,
|
|
spaceId: null,
|
|
kind: "model",
|
|
ciphertext: "encrypted-newer-key",
|
|
},
|
|
});
|
|
const older = await handles.prisma.userModelCredential.create({
|
|
data: {
|
|
userId: actor.userId,
|
|
provider: "duplicate-provider",
|
|
label: "Older",
|
|
secretId: olderSecret.id,
|
|
createdAt: new Date("2026-01-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-01-02T00:00:00.000Z"),
|
|
},
|
|
});
|
|
const newer = await handles.prisma.userModelCredential.create({
|
|
data: {
|
|
userId: actor.userId,
|
|
provider: "duplicate-provider",
|
|
label: "Newer",
|
|
secretId: newerSecret.id,
|
|
createdAt: new Date("2026-02-01T00:00:00.000Z"),
|
|
updatedAt: new Date("2026-02-02T00:00:00.000Z"),
|
|
},
|
|
});
|
|
await handles.prisma.spaceModelPreference.create({
|
|
data: {
|
|
spaceId: actor.spaceId,
|
|
userId: actor.userId,
|
|
credentialId: older.id,
|
|
modelId: "older/model",
|
|
isDefault: true,
|
|
},
|
|
});
|
|
|
|
await rpc(app, cookie, "models/setDefault", {
|
|
provider: "duplicate-provider",
|
|
modelId: "newer/selected",
|
|
});
|
|
|
|
const preferences = await handles.prisma.spaceModelPreference.findMany({
|
|
where: { userId: actor.userId, spaceId: actor.spaceId },
|
|
});
|
|
expect(preferences.filter((row) => row.isDefault).map((row) => row.credentialId)).toEqual([
|
|
newer.id,
|
|
]);
|
|
expect(preferences.find((row) => row.credentialId === newer.id)).toMatchObject({
|
|
isDefault: true,
|
|
modelId: "newer/selected",
|
|
});
|
|
expect(preferences.find((row) => row.credentialId === older.id)).toMatchObject({
|
|
isDefault: false,
|
|
modelId: "older/model",
|
|
});
|
|
const listed = await rpc<ModelCredential[]>(app, cookie, "models/credentials");
|
|
expect(
|
|
listed.filter((row) => row.provider === "duplicate-provider").map((row) => row.id),
|
|
).toEqual([newer.id, older.id]);
|
|
});
|
|
|
|
it("restricts deployment settings to the deployment owner", async () => {
|
|
const owner = await signup(app, `deployment-owner-${stamp}@rakazo.test`, "Deployment Owner");
|
|
const other = await signup(app, `deployment-other-${stamp}@rakazo.test`, "Deployment Other");
|
|
const ownerActor = await rpc<Actor>(app, owner, "me");
|
|
const otherActor = await rpc<Actor>(app, other, "me");
|
|
// This test changes a live allowlist; the operator has already proved
|
|
// ownership of the mailbox. Endpoint verification has offline auth tests.
|
|
await handles.prisma.user.update({
|
|
where: { id: ownerActor.userId },
|
|
data: { emailVerified: true },
|
|
});
|
|
await handles.prisma.deploymentSettings.update({
|
|
where: { id: "default" },
|
|
data: {
|
|
ownerUserId: ownerActor.userId,
|
|
signupsEnabled: true,
|
|
signupAllowlist: "",
|
|
},
|
|
});
|
|
|
|
expect(otherActor.userId).not.toBe(ownerActor.userId);
|
|
|
|
await rpc(app, owner, "deployment/get");
|
|
await expectDenied(app, other, "deployment/get", {});
|
|
await expectDenied(app, other, "deployment/update", {
|
|
signupsEnabled: false,
|
|
signupAllowlist: ["attacker@example.test"],
|
|
});
|
|
await expectForbidden(app, other, "updater/status", {});
|
|
await expectForbidden(app, other, "updater/check", {});
|
|
await expectForbidden(app, other, "updater/apply", {});
|
|
expect(
|
|
await handles.prisma.deploymentSettings.findUniqueOrThrow({ where: { id: "default" } }),
|
|
).toMatchObject({ signupsEnabled: true, signupAllowlist: "" });
|
|
|
|
try {
|
|
await rpc(app, owner, "deployment/update", { signupsEnabled: false });
|
|
const closedSignup = await app.request("/api/auth/sign-up/email", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
email: `closed-${stamp}@rakazo.test`,
|
|
password: "password123",
|
|
name: "Closed Signup",
|
|
}),
|
|
});
|
|
expect(closedSignup.status).toBe(400);
|
|
expect(await closedSignup.text()).toContain("Registration is closed");
|
|
|
|
const approvedEmail = `approved-${stamp}@example.test`;
|
|
await rpc(app, owner, "deployment/update", {
|
|
signupsEnabled: true,
|
|
signupAllowlist: [approvedEmail],
|
|
});
|
|
const disallowedSignup = await app.request("/api/auth/sign-up/email", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
email: `not-approved-${stamp}@rakazo.test`,
|
|
password: "password123",
|
|
name: "Disallowed Signup",
|
|
}),
|
|
});
|
|
expect(disallowedSignup.status).toBe(400);
|
|
expect(await disallowedSignup.text()).toContain("Email is not allowed to register");
|
|
const unverifiedSignup = await app.request("/api/auth/sign-up/email", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify({
|
|
email: approvedEmail,
|
|
password: "password123",
|
|
name: "Approved Signup",
|
|
}),
|
|
});
|
|
expect(unverifiedSignup.status).toBe(400);
|
|
expect(await unverifiedSignup.text()).toContain("Registration requires email delivery");
|
|
} finally {
|
|
await rpc(app, owner, "deployment/update", {
|
|
signupsEnabled: true,
|
|
signupAllowlist: [],
|
|
});
|
|
}
|
|
});
|
|
});
|
|
|
|
function botInput(name: string) {
|
|
return {
|
|
name,
|
|
title: "",
|
|
description: "",
|
|
instructions: "",
|
|
notifyOnFinish: false,
|
|
};
|
|
}
|
|
|
|
function routineInput(botId: string) {
|
|
return {
|
|
botId,
|
|
name: "Owner Routine",
|
|
prompt: "owner-only prompt",
|
|
crons: ["0 9 * * 1"],
|
|
timezone: "UTC",
|
|
notify: false,
|
|
active: false,
|
|
};
|
|
}
|
|
|
|
function skillPlaybookInput() {
|
|
return {
|
|
whenToUse: "When needed",
|
|
inputs: ["example"],
|
|
steps: ["Do the thing"],
|
|
howToCheck: "Verify result",
|
|
whatToReturn: "Summary",
|
|
approvalBoundaries: "Ask first",
|
|
failureHandling: "Stop and ask",
|
|
};
|
|
}
|
|
|
|
function capabilityInput(name: string) {
|
|
return { kind: "skill", name, source: "test://authorization", config: {} };
|
|
}
|
|
|
|
function connectionInput(displayName: string) {
|
|
return { provider: "test-provider", displayName };
|
|
}
|
|
|
|
async function signup(app: App, email: string, name: string) {
|
|
const response = await app.request("/api/auth/sign-up/email", {
|
|
method: "POST",
|
|
headers: { "content-type": "application/json", origin: "http://127.0.0.1:5173" },
|
|
body: JSON.stringify({ email, password: "password12", name }),
|
|
});
|
|
if (response.status >= 400) {
|
|
throw new Error(`signup failed ${response.status}: ${await response.text()}`);
|
|
}
|
|
return sessionCookieHeader(response);
|
|
}
|
|
|
|
async function raw(
|
|
app: App,
|
|
cookie: string,
|
|
procedure: string,
|
|
body: unknown = {},
|
|
spaceId?: string,
|
|
) {
|
|
return app.request(`/rpc/${procedure}`, {
|
|
method: "POST",
|
|
headers: {
|
|
"content-type": "application/json",
|
|
...(cookie ? { cookie } : {}),
|
|
...(spaceId ? { "x-rakazo-space-id": spaceId } : {}),
|
|
origin: "http://127.0.0.1:5173",
|
|
},
|
|
body: JSON.stringify({ json: body ?? {} }),
|
|
});
|
|
}
|
|
|
|
async function rpc<T>(
|
|
app: App,
|
|
cookie: string,
|
|
procedure: string,
|
|
body: unknown = {},
|
|
spaceId?: string,
|
|
): Promise<T> {
|
|
const response = await raw(app, cookie, procedure, body, spaceId);
|
|
const text = await response.text();
|
|
const payload = JSON.parse(text) as { json?: T; error?: { message?: string } };
|
|
if (response.status >= 400 || payload.error) {
|
|
throw new Error(`${procedure} ${response.status}: ${payload.error?.message ?? text}`);
|
|
}
|
|
return payload.json as T;
|
|
}
|
|
|
|
async function expectDenied(
|
|
app: App,
|
|
cookie: string,
|
|
procedure: string,
|
|
body: unknown,
|
|
spaceId?: string,
|
|
) {
|
|
const response = await raw(app, cookie, procedure, body, spaceId);
|
|
if (procedure === "threads/subscribe" && response.status === 200) {
|
|
// Streaming transports commit the HTTP 200 before advancing the async iterator. The
|
|
// ownership error is therefore encoded in the iterator response instead of the status.
|
|
expect(await response.text(), procedure).toMatch(/error|forbidden|not.found|unauthorized/i);
|
|
return;
|
|
}
|
|
expect(response.status, procedure).toBeGreaterThanOrEqual(400);
|
|
}
|
|
|
|
async function expectForbidden(
|
|
app: App,
|
|
cookie: string,
|
|
procedure: string,
|
|
body: unknown,
|
|
spaceId?: string,
|
|
) {
|
|
const response = await raw(app, cookie, procedure, body, spaceId);
|
|
expect(response.status, procedure).toBe(403);
|
|
expect(await response.text(), procedure).toMatch(/forbidden/i);
|
|
}
|
|
|
|
interface Actor {
|
|
userId: string;
|
|
spaceId: string;
|
|
}
|
|
|
|
interface ModelCredential {
|
|
id: string;
|
|
provider: string;
|
|
label: string;
|
|
hasKey: boolean;
|
|
isDefault: boolean;
|
|
}
|
|
|
|
interface Bot {
|
|
id: string;
|
|
}
|
|
|
|
function exhaustiveProtectedCalls<
|
|
const Calls extends ReadonlyArray<readonly [ProtectedRpcPath, unknown?]>,
|
|
>(calls: Calls & (ProtectedRpcPath extends Calls[number][0] ? unknown : never)): Calls {
|
|
return calls;
|
|
}
|