From 68e962f588752c8f7dfa8350950eef82bb7630ff Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:10:04 +0300 Subject: [PATCH 01/54] feat: add isolated private spaces --- apps/api/src/app.ts | 7 +- apps/api/src/router.ts | 149 ++++++++++++++- apps/mobile/app/_layout.tsx | 9 + apps/mobile/app/index.tsx | 156 +++++++++++---- apps/mobile/app/new-space.tsx | 103 ++++++++++ apps/mobile/lib/api.ts | 43 ++++- apps/mobile/lib/inbox.ts | 7 +- apps/web/e2e/private-spaces.spec.ts | 43 +++++ apps/web/src/lib/rpc.ts | 27 ++- apps/web/src/pages/Auth.tsx | 2 + apps/web/src/pages/Shell.tsx | 211 ++++++++++++++++++--- packages/auth/src/index.ts | 43 ++--- packages/contracts/src/domain.ts | 56 ++++++ packages/contracts/src/rpc.ts | 8 + packages/db/src/groups.ts | 35 ++-- packages/db/src/index.ts | 1 + packages/db/src/repos.ts | 106 +++++++---- packages/db/src/scope.test.ts | 47 +++++ packages/db/src/scope.ts | 12 +- packages/db/src/workspaces.ts | 59 ++++++ packages/testkit/src/authorization.test.ts | 102 +++++++++- 21 files changed, 1053 insertions(+), 173 deletions(-) create mode 100644 apps/mobile/app/new-space.tsx create mode 100644 apps/web/e2e/private-spaces.spec.ts create mode 100644 packages/db/src/scope.test.ts create mode 100644 packages/db/src/workspaces.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 5c8e7bfdc8..55ceb52691 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -303,8 +303,9 @@ export async function createApp( }); app.use("/rpc/*", async (c, next) => { const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) }); + const requestedWorkspaceId = c.req.header("x-rakazo-workspace-id"); const actor = session?.user - ? await requireMembership(prisma, session.user.id).catch(() => null) + ? await requireMembership(prisma, session.user.id, requestedWorkspaceId).catch(() => null) : null; const { matched, response } = await rpc.handle(c.req.raw, { prefix: "/rpc", @@ -316,7 +317,9 @@ export async function createApp( mountVoiceHttpRoutes(app, { prisma, secrets }, async (c) => { const session = await auth.api.getSession({ headers: sessionHeaders(c.req.raw) }); if (!session?.user) return null; - return requireMembership(prisma, session.user.id).catch(() => null); + return requireMembership(prisma, session.user.id, c.req.header("x-rakazo-workspace-id")).catch( + () => null, + ); }); mountWebhookHttpRoutes(app, { prisma, secrets, events, jobs }); diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 8a66ef7e72..9ccc9d4b20 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -74,6 +74,7 @@ import { type McpServer, type Me, OPENAI_COMPATIBLE_PROVIDER_ID, + type PrivateSpaceNavigation, } from "@rakazo/contracts"; import { ACTIVE_RUN_STATUSES, @@ -87,8 +88,10 @@ import { import { appendEventInTransaction, createGroupRepos, + createOwnedWorkspace, createRepos, createThreadMessageInTransaction, + createWorkspaceDefaults, findDefaultModelCredential, findDefaultVoiceCredential, findWorkspaceMemoryConfig, @@ -363,15 +366,50 @@ export function createRouter(deps: RouterDeps) { return meDto(deps, context.actor); }), }, + privateSpaces: { + list: authed.privateSpaces.list.handler(async ({ context }) => + privateSpaceNavigationDto(deps, context.actor), + ), + create: authed.privateSpaces.create.handler(async ({ context, input }) => { + const count = await deps.prisma.member.count({ where: { userId: context.actor.userId } }); + if (count >= 32) { + throw new ORPCError("BAD_REQUEST", { message: "Private space limit reached" }); + } + const workspaceId = randomUUID(); + const createdAt = new Date(); + await deps.prisma.$transaction(async (tx) => { + await createOwnedWorkspace(tx, { + workspaceId, + membershipId: randomUUID(), + userId: context.actor.userId, + name: input.name, + slug: `space-${randomUUID()}`, + createdAt, + }); + await createWorkspaceDefaults(tx, { + workspaceId, + userId: context.actor.userId, + memoryContent: "# Space memory\n\n", + }); + }); + return { + id: workspaceId, + name: input.name, + bots: [], + groups: [], + botSections: [], + }; + }), + }, bootstrap: authed.bootstrap.handler(async ({ context, input }) => { const actor = context.actor; - const [me, bots, botSections, archivedBots, archivedGroups] = await Promise.all([ + const [me, navigation, archivedBots, archivedGroups] = await Promise.all([ meDto(deps, actor), - repos.listBots(actor), - repos.listBotSections(actor), + privateSpaceNavigationDto(deps, actor), repos.listBots(actor, { archived: true }), groupRepos.listGroups(actor, { archived: true }), ]); + const { bots, groups, botSections } = navigation.current; const active = bots.find((bot) => bot.id === input.botId) ?? bots[0]; const [thread, routines] = active ? await Promise.all([ @@ -381,7 +419,17 @@ export function createRouter(deps: RouterDeps) { listRoutinesDto(deps, actor, active.id), ]) : [null, []]; - return { me, bots, botSections, archivedBots, archivedGroups, thread, routines }; + return { + me, + bots, + groups, + botSections, + archivedBots, + archivedGroups, + thread, + routines, + privateSpaces: navigation.privateSpaces, + }; }), deployment: { get: authed.deployment.get.handler(async ({ context }) => { @@ -3150,6 +3198,99 @@ function mapUpdaterError(error: unknown): never { }); } +async function privateSpaceNavigationDto( + deps: RouterDeps, + actor: Actor, +): Promise { + const memberships = await deps.prisma.member.findMany({ + where: { userId: actor.userId }, + select: { + organizationId: true, + organization: { select: { name: true } }, + }, + orderBy: { createdAt: "asc" }, + }); + const repos = createRepos(deps.prisma); + const groupRepos = createGroupRepos(deps.prisma); + const workspaceIds = memberships.map((membership) => membership.organizationId); + const [bots, groups, botSections] = await Promise.all([ + repos.listBotsForWorkspaces(actor, workspaceIds), + groupRepos.listGroupsForWorkspaces(actor, workspaceIds), + repos.listBotSectionsForWorkspaces(actor, workspaceIds), + ]); + const currentMembership = memberships.find( + (membership) => membership.organizationId === actor.workspaceId, + ); + if (!currentMembership) throw new IsolationError(); + const botsByWorkspace = partitionByWorkspace(bots); + const groupsByWorkspace = partitionByWorkspace(groups); + const sectionsByWorkspace = partitionByWorkspace(botSections); + const botsFor = (workspaceId: string) => botsByWorkspace.get(workspaceId) ?? []; + const groupsFor = (workspaceId: string) => groupsByWorkspace.get(workspaceId) ?? []; + const sectionsFor = (workspaceId: string) => sectionsByWorkspace.get(workspaceId) ?? []; + const currentBots = botsFor(actor.workspaceId); + const currentGroups = groupsFor(actor.workspaceId); + + return { + current: { + id: actor.workspaceId, + name: currentMembership.organization.name, + bots: currentBots, + groups: currentGroups, + botSections: sectionsFor(actor.workspaceId), + }, + privateSpaces: memberships.map((membership) => { + const workspaceBots = + membership.organizationId === actor.workspaceId + ? currentBots + : botsFor(membership.organizationId); + const workspaceGroups = + membership.organizationId === actor.workspaceId + ? currentGroups + : groupsFor(membership.organizationId); + return { + id: membership.organizationId, + name: membership.organization.name, + bots: workspaceBots.map((bot) => ({ + id: bot.id, + workspaceId: bot.workspaceId, + name: bot.name, + title: bot.title, + color: bot.color, + pinned: bot.pinned, + sectionId: bot.sectionId, + unread: bot.unread, + preview: bot.preview, + status: bot.status, + updatedAt: bot.updatedAt, + })), + groups: workspaceGroups.map((group) => ({ + id: group.id, + workspaceId: group.workspaceId, + name: group.name, + pinned: group.pinned, + sectionId: group.sectionId, + members: group.members, + preview: group.preview, + unread: group.unread, + updatedAt: group.updatedAt, + })), + botSections: sectionsFor(membership.organizationId), + }; + }), + }; +} + +function partitionByWorkspace(rows: T[]): Map { + const partitioned = new Map(); + for (const row of rows) { + const workspaceRows = partitioned.get(row.workspaceId) ?? []; + workspaceRows.push(row); + partitioned.set(row.workspaceId, workspaceRows); + } + return partitioned; +} + async function loadAutoReviewSettings(deps: RouterDeps, actor: Actor) { const [preference, credentials] = await Promise.all([ deps.prisma.actionAutoReviewPreference.findUnique({ diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index 4e32748ef4..9c154b960c 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -54,6 +54,15 @@ export default function Layout() { gestureEnabled: true, }} /> + diff --git a/apps/mobile/app/index.tsx b/apps/mobile/app/index.tsx index 249ffb0c31..627222f995 100644 --- a/apps/mobile/app/index.tsx +++ b/apps/mobile/app/index.tsx @@ -1,4 +1,9 @@ -import type { RunActivityRow, SearchHit } from "@rakazo/contracts"; +import type { + PrivateSpaceBot, + PrivateSpaceGroup, + RunActivityRow, + SearchHit, +} from "@rakazo/contracts"; import { groupBotsForSidebar } from "@rakazo/core"; import { Redirect, useFocusEffect, useRouter } from "expo-router"; import { type ReactNode, useCallback, useEffect, useMemo, useRef, useState } from "react"; @@ -30,7 +35,10 @@ import { type MobileBotSection, type MobileGroup, type MobileMe, + type MobilePrivateSpace, + type MobilePrivateSpaceNavigation, rpc, + selectPrivateSpace, } from "../lib/api"; import { botTag, filterBots, formatThreadTime, userInitials } from "../lib/inbox"; import { native } from "../lib/native"; @@ -42,15 +50,21 @@ import { mobileSearchDestination } from "../lib/search-destination"; const FALLBACK_COLOR = "#9B5CF6"; type InboxItem = - | { type: "bot"; bot: MobileBot } - | { type: "group"; group: MobileGroup } + | { type: "bot"; bot: MobileBot | PrivateSpaceBot } + | { type: "group"; group: MobileGroup | PrivateSpaceGroup } | { type: "search"; hit: SearchHit } | { type: "heading"; key: string; title: string }; +async function openMobilePrivateSpace(workspaceId: string | undefined, open: () => void) { + if (workspaceId) await selectPrivateSpace(workspaceId); + open(); +} + export default function Home() { const [bots, setBots] = useState([]); const [groups, setGroups] = useState([]); const [botSections, setBotSections] = useState([]); + const [privateSpaces, setPrivateSpaces] = useState([]); const [me, setMe] = useState(null); const [error, setError] = useState(null); const [ready, setReady] = useState(false); @@ -86,14 +100,15 @@ export default function Home() { const loadBots = useCallback(async () => { setError(null); try { - const [nextBots, nextSections, nextGroups] = await Promise.all([ - rpc("bots/list"), - rpc("botSections/list"), - rpc("groups/list"), + const [navigation, nextMe] = await Promise.all([ + rpc("privateSpaces/list"), + rpc("me"), ]); - setBots(nextBots); - setBotSections(nextSections); - setGroups(nextGroups); + setBots(navigation.current.bots); + setBotSections(navigation.current.botSections); + setGroups(navigation.current.groups); + setPrivateSpaces(navigation.privateSpaces); + setMe(nextMe); } catch (err) { setError(err instanceof Error ? err.message : "Could not load bots"); } @@ -118,9 +133,6 @@ export default function Home() { useEffect(() => { if (!hasSession) return; void registerPushToken().catch(() => undefined); - void rpc("me") - .then(setMe) - .catch(() => undefined); }, [hasSession]); useFocusEffect( @@ -207,15 +219,52 @@ export default function Home() { if (query.trim() && searching) { return searchHits.map((hit) => ({ type: "search", hit })); } - const chats = [ - ...visible.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })), - ...visibleGroups.map((chat) => ({ type: "group" as const, group: chat, ...chat })), - ]; - return groupBotsForSidebar(chats, botSections).flatMap((group) => [ - ...(group.title ? [{ type: "heading" as const, key: group.key, title: group.title }] : []), - ...group.bots, - ]); - }, [botSections, query, searching, searchHits, visible, visibleGroups]); + const spaces = + privateSpaces.length > 0 + ? privateSpaces.map((space) => + space.id === me?.workspaceId + ? { ...space, bots: visible, groups: visibleGroups, botSections } + : { + ...space, + bots: filterBots(space.bots, query), + groups: space.groups.filter((group) => + `${group.name} ${group.preview}`.toLowerCase().includes(query.toLowerCase()), + ), + }, + ) + : me + ? [ + { + id: me.workspaceId, + name: "Personal", + bots: visible, + groups: visibleGroups, + botSections, + }, + ] + : []; + const showSpaceNames = spaces.length > 1; + return spaces.flatMap((space) => { + const chats = [ + ...space.bots.map((chat) => ({ type: "bot" as const, bot: chat, ...chat })), + ...space.groups.map((chat) => ({ type: "group" as const, group: chat, ...chat })), + ]; + return groupBotsForSidebar(chats, space.botSections).flatMap((group) => [ + ...(group.title || showSpaceNames + ? [ + { + type: "heading" as const, + key: `${space.id}:${group.key}`, + title: showSpaceNames + ? `🔒 ${space.name}${group.title ? ` · ${group.title}` : ""}` + : (group.title ?? ""), + }, + ] + : []), + ...group.bots, + ]); + }); + }, [botSections, me, privateSpaces, query, searching, searchHits, visible, visibleGroups]); const initials = userInitials(me?.name ?? ""); const organizeChat = organizeTarget ? organizeTarget.kind === "bot" @@ -272,6 +321,7 @@ export default function Home() { Alert.alert("Create", undefined, [ { text: "New bot", onPress: () => router.push("/new") }, { text: "New group", onPress: () => router.push("/new-group") }, + { text: "New private space", onPress: () => router.push("/new-space") }, { text: "Cancel", style: "cancel" }, ]) } @@ -360,12 +410,36 @@ export default function Home() { ) : item.type === "group" ? ( setOrganizeTarget({ kind: "group", id: item.group.id })} + onPress={() => { + void openMobilePrivateSpace(item.group.workspaceId, () => + router.push({ + pathname: "/group-thread", + params: { groupId: item.group.id, name: item.group.name }, + }), + ); + }} + onLongPress={ + item.group.workspaceId === me?.workspaceId + ? () => setOrganizeTarget({ kind: "group", id: item.group.id }) + : undefined + } /> ) : ( setOrganizeTarget({ kind: "bot", id: item.bot.id })} + onPress={() => { + void openMobilePrivateSpace(item.bot.workspaceId, () => + router.push({ + pathname: "/thread", + params: { botId: item.bot.id, name: item.bot.name }, + }), + ); + }} + onLongPress={ + item.bot.workspaceId === me?.workspaceId + ? () => setOrganizeTarget({ kind: "bot", id: item.bot.id }) + : undefined + } /> ) } @@ -515,8 +589,15 @@ function SearchRow({ hit, onPress }: { hit: SearchHit; onPress: () => void }) { ); } -function BotRow({ bot, onLongPress }: { bot: MobileBot; onLongPress: () => void }) { - const router = useRouter(); +function BotRow({ + bot, + onPress, + onLongPress, +}: { + bot: MobileBot | PrivateSpaceBot; + onPress: () => void; + onLongPress?: () => void; +}) { const preview = previewSnippet(bot.preview, 40) || bot.title || "No messages yet"; const time = bot.updatedAt ? formatThreadTime(bot.updatedAt) : ""; const tag = botTag(bot.title, bot.name); @@ -527,10 +608,8 @@ function BotRow({ bot, onLongPress }: { bot: MobileBot; onLongPress: () => void return ( - router.push({ pathname: "/thread", params: { botId: bot.id, name: bot.name } }) - } + accessibilityHint={onLongPress ? "Long press to pin or move to a section" : undefined} + onPress={onPress} onLongPress={onLongPress} style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} > @@ -566,8 +645,15 @@ function BotRow({ bot, onLongPress }: { bot: MobileBot; onLongPress: () => void ); } -function GroupRow({ group, onLongPress }: { group: MobileGroup; onLongPress: () => void }) { - const router = useRouter(); +function GroupRow({ + group, + onPress, + onLongPress, +}: { + group: MobileGroup | PrivateSpaceGroup; + onPress: () => void; + onLongPress?: () => void; +}) { const preview = previewSnippet(group.preview, 40) || group.members.map((member) => member.name).join(", "); const time = group.updatedAt ? formatThreadTime(group.updatedAt) : ""; @@ -576,11 +662,9 @@ function GroupRow({ group, onLongPress }: { group: MobileGroup; onLongPress: () accessibilityLabel={[group.name, group.unread ? "unread" : null, time, preview] .filter(Boolean) .join(", ")} - onPress={() => - router.push({ pathname: "/group-thread", params: { groupId: group.id, name: group.name } }) - } + onPress={onPress} onLongPress={onLongPress} - accessibilityHint="Long press to pin or move to a section" + accessibilityHint={onLongPress ? "Long press to pin or move to a section" : undefined} style={({ pressed }) => [styles.row, pressed && styles.rowPressed]} > diff --git a/apps/mobile/app/new-space.tsx b/apps/mobile/app/new-space.tsx new file mode 100644 index 0000000000..423849ceb6 --- /dev/null +++ b/apps/mobile/app/new-space.tsx @@ -0,0 +1,103 @@ +import type { PrivateSpace } from "@rakazo/contracts"; +import { Stack, useRouter } from "expo-router"; +import { useState } from "react"; +import { Pressable, ScrollView, Text, TextInput, View } from "react-native"; +import { rpc, selectPrivateSpace } from "../lib/api"; + +export default function NewPrivateSpace() { + const router = useRouter(); + const [name, setName] = useState(""); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + + async function create() { + const trimmed = name.trim(); + if (!trimmed || pending) return; + setPending(true); + setError(null); + try { + const space = await rpc("privateSpaces/create", { name: trimmed }); + await selectPrivateSpace(space.id); + router.dismissAll(); + router.replace("/"); + } catch (reason) { + setError(reason instanceof Error ? reason.message : "Could not create private space"); + setPending(false); + } + } + + return ( + <> + ( + router.back()} + hitSlop={12} + accessibilityRole="button" + accessibilityLabel="Cancel" + > + Cancel + + ), + }} + /> + + + Private space + + Computers, memory, integrations, files, and chats stay inside this space. + + Name + void create()} + placeholder="Customer support" + placeholderTextColor="#6C6C70" + returnKeyType="done" + style={{ + marginTop: 8, + backgroundColor: "#101012", + borderRadius: 11, + padding: 14, + color: "#ECECEE", + fontSize: 16, + }} + /> + {error ? {error} : null} + void create()} + disabled={!name.trim() || pending} + style={{ + marginTop: 20, + backgroundColor: "#8B5CF6", + borderRadius: 999, + padding: 14, + alignItems: "center", + opacity: !name.trim() || pending ? 0.4 : 1, + }} + > + + {pending ? "Creating…" : "Create space"} + + + + + + ); +} diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 3407d1e9d9..e87759d147 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -7,6 +7,10 @@ import type { MessageBlock, ModelCatalogEntry, ModelCredential, + PrivateSpace, + PrivateSpaceBot, + PrivateSpaceGroup, + PrivateSpaceNavigation, } from "@rakazo/contracts"; import { isRunTerminalEvent, @@ -27,8 +31,10 @@ import { } from "./session"; const ENDPOINT_KEY = "rakazo.api_base"; +const PRIVATE_SPACE_KEY = "rakazo.private_space_id"; let cachedApiBase: string | undefined; +let cachedPrivateSpaceId = ""; function responseErrorMessage(body: unknown, fallback: string): string { return typeof body === "object" && body && "message" in body @@ -41,6 +47,11 @@ export function currentApiBase() { } export async function loadApiBase() { + try { + cachedPrivateSpaceId = (await SecureStore.getItemAsync(PRIVATE_SPACE_KEY)) ?? ""; + } catch { + cachedPrivateSpaceId = ""; + } try { const stored = await SecureStore.getItemAsync(ENDPOINT_KEY); if (stored) { @@ -57,6 +68,20 @@ export async function loadApiBase() { return cachedApiBase; } +export async function selectPrivateSpace(id: string) { + cachedPrivateSpaceId = id; + await SecureStore.setItemAsync(PRIVATE_SPACE_KEY, id); +} + +async function clearPrivateSpace() { + cachedPrivateSpaceId = ""; + try { + await SecureStore.deleteItemAsync(PRIVATE_SPACE_KEY); + } catch { + // Keep sign-out and account deletion reliable when secure storage is unavailable. + } +} + export async function saveApiBase(input: string): Promise { const parsed = normalizeApiBase(input); if (!parsed.ok) return parsed; @@ -83,7 +108,10 @@ export async function resetApiBase(): Promise { async function authHeaders(): Promise> { const token = await loadSessionToken(); - return token ? { authorization: `Bearer ${token}` } : {}; + return { + ...(token ? { authorization: `Bearer ${token}` } : {}), + ...(cachedPrivateSpaceId ? { "x-rakazo-workspace-id": cachedPrivateSpaceId } : {}), + }; } export async function signIn(email: string, password: string) { @@ -98,6 +126,7 @@ export async function signIn(email: string, password: string) { } const token = tokenFromAuthResponse(res, body); if (!token) throw new Error("Sign-in did not return a session"); + await clearPrivateSpace(); await saveSessionToken(token); } @@ -108,6 +137,7 @@ export async function signOut() { headers: { "content-type": "application/json", origin: "rakazo://", ...headers }, }).catch(() => undefined); await clearSessionToken(); + await clearPrivateSpace(); } export async function deleteAccount(password: string) { @@ -121,6 +151,7 @@ export async function deleteAccount(password: string) { throw new Error(responseErrorMessage(body, "Could not delete account")); } await clearSessionToken(); + await clearPrivateSpace(); } export async function rpc( @@ -158,7 +189,7 @@ export type MobileBot = Pick< | "updatedAt" | "computerMode" > & - Partial>; + Partial>; export type MobileBotSection = BotSection; @@ -199,7 +230,13 @@ export type MobileGroup = Pick< | "unread" | "updatedAt" | "members" ->; +> & + Partial>; + +export type MobilePrivateSpace = PrivateSpace; +export type MobilePrivateSpaceBot = PrivateSpaceBot; +export type MobilePrivateSpaceGroup = PrivateSpaceGroup; +export type MobilePrivateSpaceNavigation = PrivateSpaceNavigation; export type MobileSnapshot = { botId?: string; diff --git a/apps/mobile/lib/inbox.ts b/apps/mobile/lib/inbox.ts index c904b478fc..dd7433ee6c 100644 --- a/apps/mobile/lib/inbox.ts +++ b/apps/mobile/lib/inbox.ts @@ -1,8 +1,9 @@ -import type { MobileBot } from "./api"; - const DAY_MS = 86_400_000; -export function filterBots(bots: MobileBot[], query: string) { +export function filterBots( + bots: T[], + query: string, +): T[] { const needle = query.trim().toLowerCase(); if (!needle) return bots; return bots.filter((bot) => diff --git a/apps/web/e2e/private-spaces.spec.ts b/apps/web/e2e/private-spaces.spec.ts new file mode 100644 index 0000000000..eb555ab0c9 --- /dev/null +++ b/apps/web/e2e/private-spaces.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from "@playwright/test"; +import { captureScreenshot, completeOnboarding, signup } from "./helpers"; + +test("private spaces keep all bots in the sidebar and switch the request boundary", async ({ + page, +}, testInfo) => { + const stamp = Date.now(); + await signup(page, `private-spaces-${stamp}@rakazo.test`, "password12", "Space Owner"); + await completeOnboarding(page); + + await page.getByTitle("Create").click(); + await page.getByRole("button", { name: "New private space" }).click(); + const dialog = page.getByRole("dialog", { name: "New private space" }); + await expect(dialog).toContainText( + "Computers, memory, integrations, files, and chats stay inside this space.", + ); + await dialog.getByLabel("Name").fill("Customer support"); + await captureScreenshot(page, testInfo, "new-private-space-dialog"); + await dialog.getByRole("button", { name: "Create space" }).click(); + + await page.waitForURL(/\/onboarding/); + const supportSpaceId = await page.evaluate(() => + window.localStorage.getItem("rakazo:private-space-id"), + ); + expect(supportSpaceId).toBeTruthy(); + await completeOnboarding(page); + + const sidebar = page.locator("aside").first(); + await expect(sidebar.getByText("Personal", { exact: true })).toBeVisible(); + await expect(sidebar.getByText("Customer support", { exact: true })).toBeVisible(); + await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(2); + await captureScreenshot(page, testInfo, "private-spaces-sidebar"); + + const personalSpace = sidebar + .locator('[data-sidebar-group^="space:"]') + .filter({ hasText: "Personal" }); + await personalSpace.getByRole("button", { name: /^Chief/ }).click(); + await page.waitForURL(/\/app\/[^/]+$/); + await expect + .poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:private-space-id"))) + .not.toBe(supportSpaceId); + await expect(sidebar.getByText("Customer support", { exact: true })).toBeVisible(); +}); diff --git a/apps/web/src/lib/rpc.ts b/apps/web/src/lib/rpc.ts index 37cf64f37f..6706d8a8c0 100644 --- a/apps/web/src/lib/rpc.ts +++ b/apps/web/src/lib/rpc.ts @@ -3,10 +3,35 @@ import { RPCLink } from "@orpc/client/fetch"; import type { ContractRouterClient } from "@orpc/contract"; import type { AppContract } from "@rakazo/contracts"; +const WORKSPACE_STORAGE_KEY = "rakazo:private-space-id"; + +export function selectedPrivateSpaceId(): string | null { + if (typeof window === "undefined") return null; + try { + return window.localStorage.getItem(WORKSPACE_STORAGE_KEY); + } catch { + return null; + } +} + +export function selectPrivateSpace(id: string): void { + window.localStorage.setItem(WORKSPACE_STORAGE_KEY, id); +} + +export function clearPrivateSpaceSelection(): void { + window.localStorage.removeItem(WORKSPACE_STORAGE_KEY); +} + const link = new RPCLink({ url: () => typeof window === "undefined" ? "http://127.0.0.1:5173/rpc" : `${window.location.origin}/rpc`, - fetch: (input, init) => fetch(input, { ...init, credentials: "include" }), + fetch: (input, init) => { + const request = new Request(input, init); + const headers = new Headers(request.headers); + const workspaceId = selectedPrivateSpaceId(); + if (workspaceId) headers.set("x-rakazo-workspace-id", workspaceId); + return fetch(request, { headers, credentials: "include" }); + }, }); export const rpc: ContractRouterClient = createORPCClient(link); diff --git a/apps/web/src/pages/Auth.tsx b/apps/web/src/pages/Auth.tsx index 8c8eff023e..162136ef79 100644 --- a/apps/web/src/pages/Auth.tsx +++ b/apps/web/src/pages/Auth.tsx @@ -2,6 +2,7 @@ import { Trans, useLingui } from "@lingui/react/macro"; import { useState } from "react"; import { Link, useNavigate } from "react-router-dom"; import { authClient } from "../lib/auth"; +import { clearPrivateSpaceSelection } from "../lib/rpc"; export function AuthPage({ mode }: { mode: "in" | "up" }) { const { t } = useLingui(); @@ -33,6 +34,7 @@ export function AuthPage({ mode }: { mode: "in" | "up" }) { setError(result.error.message ?? t`Could not continue`); return; } + clearPrivateSpaceSelection(); navigate(mode === "up" ? "/onboarding" : "/app"); } diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index fefdd08a61..36df05474d 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -15,6 +15,7 @@ import type { MessageBlock, ModelCatalogEntry, ModelCredential, + PrivateSpace, ProductEvent, Routine, SearchHit, @@ -72,6 +73,7 @@ import { Copy, Cpu, Gauge, + Lock, LogOut, Menu, Mic, @@ -132,7 +134,7 @@ import { localTimezone } from "../lib/local-timezone"; import { connectMcpOauth } from "../lib/mcp-connect"; import { isFileDrag, revokePendingAttachmentPreviews } from "../lib/pending-attachments"; import { markAfterPaint, markOnce } from "../lib/performance"; -import { rpc } from "../lib/rpc"; +import { clearPrivateSpaceSelection, rpc, selectPrivateSpace } from "../lib/rpc"; import { activeThreadRuns, clearActiveThreadRuns, @@ -268,6 +270,7 @@ export function ShellPage() { const botsRef = useRef(bots); botsRef.current = bots; const [botSections, setBotSections] = useState([]); + const [privateSpaces, setPrivateSpaces] = useState([]); const [archivedBots, setArchivedBots] = useState([]); const [archivedGroups, setArchivedGroups] = useState([]); const [archivedOpen, setArchivedOpen] = useState(false); @@ -374,6 +377,7 @@ export function ShellPage() { const [menuOpen, setMenuOpen] = useState(false); const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false); const [createMenuOpen, setCreateMenuOpen] = useState(false); + const [newPrivateSpaceOpen, setNewPrivateSpaceOpen] = useState(false); const [activityMode, setActivityMode] = useState(readActivityMode); const toggleActivityMode = useCallback(() => { setActivityMode((on) => { @@ -573,13 +577,12 @@ export function ShellPage() { const archivedRequest = includeArchived ? ++archivedBotsRefreshEpoch.current : null; botsRefreshInFlight.current += 1; try { - const [list, sections, archived, groupList, archivedGroupList] = await Promise.all([ - rpc.bots.list(), - rpc.botSections.list(), + const [navigation, archived, archivedGroupList] = await Promise.all([ + rpc.privateSpaces.list(), includeArchived ? rpc.bots.listArchived() : Promise.resolve(null), - rpc.groups.list(), includeArchived ? rpc.groups.listArchived() : Promise.resolve(null), ]); + const { bots: list, botSections: sections, groups: groupList } = navigation.current; markOnce("rk:renderer:bots-response"); const botsFresh = request === botsRefreshEpoch.current; const archivedFresh = @@ -594,6 +597,7 @@ export function ShellPage() { setBots(list); setBotSections(sections); setGroups(groupList); + setPrivateSpaces(navigation.privateSpaces); setInitialBotsLoaded(true); botsRefreshApplied.current = request; if ( @@ -795,9 +799,10 @@ export function ShellPage() { } }); const appliedAtStart = botsRefreshApplied.current; - void Promise.all([takeInitialBootstrap(botId), rpc.groups.list()]) - .then(([bootstrap, groupList]) => { + void takeInitialBootstrap(botId) + .then((bootstrap) => { if (cancelled) return; + const groupList = bootstrap.groups; setBootstrapMe(bootstrap.me); // Skip list/route writes only if a later refreshBots() successfully // committed (failed refreshes bump epoch but not botsRefreshApplied). @@ -808,6 +813,7 @@ export function ShellPage() { setArchivedBots(bootstrap.archivedBots); setArchivedGroups(bootstrap.archivedGroups); setGroups(groupList); + setPrivateSpaces(bootstrap.privateSpaces); setInitialBotsLoaded(true); } if (!groupId && bootstrap.thread) { @@ -1249,28 +1255,62 @@ export function ShellPage() { }; }, [activeGroup?.id, groupId, notifyBrowserForEvent]); - const filtered = useMemo( - () => - bots.filter((b) => - `${b.name} ${b.title ?? ""} ${b.preview ?? ""}`.toLowerCase().includes(query.toLowerCase()), - ), - [bots, query], - ); - const filteredGroups = useMemo( - () => - groups.filter((g) => `${g.name} ${g.preview}`.toLowerCase().includes(query.toLowerCase())), - [groups, query], - ); - const sidebarGroups = useMemo( - () => - groupBotsForSidebar( + const sidebarGroups = useMemo(() => { + const needle = query.toLowerCase(); + const spaces = + privateSpaces.length > 0 + ? privateSpaces.map((space) => + space.id === bootstrapMe?.workspaceId ? { ...space, bots, groups, botSections } : space, + ) + : bootstrapMe + ? [ + { + id: bootstrapMe.workspaceId, + name: "Personal", + bots, + groups, + botSections, + }, + ] + : []; + const showSpaceNames = spaces.length > 1; + return spaces.flatMap((space) => { + const visibleBots = space.bots.filter((bot) => + `${bot.name} ${bot.title ?? ""} ${bot.preview ?? ""}`.toLowerCase().includes(needle), + ); + const visibleGroups = space.groups.filter((group) => + `${group.name} ${group.preview}`.toLowerCase().includes(needle), + ); + return groupBotsForSidebar( [ - ...filtered.map((chat) => ({ kind: "bot" as const, chat })), - ...filteredGroups.map((chat) => ({ kind: "group" as const, chat })), + ...visibleBots.map((chat) => ({ kind: "bot" as const, chat })), + ...visibleGroups.map((chat) => ({ kind: "group" as const, chat })), ].map((item) => ({ ...item, pinned: item.chat.pinned, sectionId: item.chat.sectionId })), - botSections, - ), - [botSections, filtered, filteredGroups], + space.botSections, + ).map((group) => ({ + ...group, + key: showSpaceNames ? `space:${space.id}:${group.key}` : group.key, + title: showSpaceNames + ? group.title + ? `${space.name} · ${group.title}` + : space.name + : group.title, + showLock: showSpaceNames, + })); + }); + }, [bootstrapMe, botSections, bots, groups, privateSpaces, query]); + + const openPrivateSpaceChat = useCallback( + (workspaceId: string, path: string) => { + setMobileSidebarOpen(false); + if (workspaceId === bootstrapMe?.workspaceId) { + navigate(path); + return; + } + selectPrivateSpace(workspaceId); + window.location.assign(path); + }, + [bootstrapMe?.workspaceId, navigate], ); const toggleSidebarSection = useCallback( (key: string) => { @@ -2167,6 +2207,18 @@ export function ShellPage() { > New group +
+
) : null} @@ -2213,7 +2265,12 @@ export function ShellPage() { collapsed ? t`Expand ${group.title}` : t`Collapse ${group.title}` } > - {group.title} + + {group.showLock ? ( + { - setMobileSidebarOpen(false); - navigate( + openPrivateSpaceChat( + item.chat.workspaceId, item.kind === "bot" ? `/app/${item.chat.id}` : `/app/g/${item.chat.id}`, ); }} onContextMenu={(event) => { + if (item.chat.workspaceId !== bootstrapMe?.workspaceId) return; event.preventDefault(); setBotMenu({ kind: item.kind, @@ -2511,7 +2569,12 @@ export function ShellPage() { ) : null} ) : null} @@ -5475,6 +5503,14 @@ function NewPrivateSpaceDialog({ const [saving, setSaving] = useState(false); const [error, setError] = useState(null); + useEffect(() => { + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape" && !saving) onCancel(); + } + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [onCancel, saving]); + const create = () => { const trimmed = name.trim(); if (!trimmed || saving) return; From ee442a76294026035c4b958d38c7490e8f42b763 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 00:23:04 +0000 Subject: [PATCH 05/54] fix(api): serialize private-space create against the membership cap Reuse the existing withSerializableRetry pattern so concurrent creates cannot both pass the in-transaction 32-space count under default isolation. Co-authored-by: Elie Steinbock --- apps/api/src/router.ts | 43 +++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 5b1160c81f..490c481831 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -373,25 +373,30 @@ export function createRouter(deps: RouterDeps) { create: authed.privateSpaces.create.handler(async ({ context, input }) => { const workspaceId = randomUUID(); const createdAt = new Date(); - await deps.prisma.$transaction(async (tx) => { - const count = await tx.member.count({ where: { userId: context.actor.userId } }); - if (count >= 32) { - throw new ORPCError("BAD_REQUEST", { message: "Private space limit reached" }); - } - await createOwnedWorkspace(tx, { - workspaceId, - membershipId: randomUUID(), - userId: context.actor.userId, - name: input.name, - slug: `space-${randomUUID()}`, - createdAt, - }); - await createWorkspaceDefaults(tx, { - workspaceId, - userId: context.actor.userId, - memoryContent: "# Space memory\n\n", - }); - }); + await withSerializableRetry(() => + deps.prisma.$transaction( + async (tx) => { + const count = await tx.member.count({ where: { userId: context.actor.userId } }); + if (count >= 32) { + throw new ORPCError("BAD_REQUEST", { message: "Private space limit reached" }); + } + await createOwnedWorkspace(tx, { + workspaceId, + membershipId: randomUUID(), + userId: context.actor.userId, + name: input.name, + slug: `space-${randomUUID()}`, + createdAt, + }); + await createWorkspaceDefaults(tx, { + workspaceId, + userId: context.actor.userId, + memoryContent: "# Space memory\n\n", + }); + }, + { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, + ), + ); return { id: workspaceId, name: input.name, From 363cf4d658451af0192cecc82fc3e97d73a64401 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 00:27:19 +0000 Subject: [PATCH 06/54] fix(mobile): clear credentials before switching API endpoints Clear session and private-space selection before activating a new origin, and make clearSessionToken best-effort so a SecureStore delete failure cannot leave the old bearer attached to the next host. Co-authored-by: Elie Steinbock --- apps/mobile/lib/api.ts | 16 +++++++++------- apps/mobile/lib/session.test.ts | 6 ++++++ apps/mobile/lib/session.ts | 10 +++++++++- 3 files changed, 24 insertions(+), 8 deletions(-) diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index b2076a7c0a..35970e8ef4 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -91,28 +91,30 @@ export async function saveApiBase(input: string): Promise { if (!parsed.ok) return parsed; if (parsed.url === defaultApiBase()) return resetApiBase(); const previous = currentApiBase(); - await SecureStore.setItemAsync(ENDPOINT_KEY, parsed.url); - cachedApiBase = parsed.url; if (parsed.url !== previous) { + // Clear credentials before activating the new origin so a SecureStore + // failure cannot leave the old bearer/workspace attached to the new host. await clearSessionToken(); await clearPrivateSpace(); } + await SecureStore.setItemAsync(ENDPOINT_KEY, parsed.url); + cachedApiBase = parsed.url; return parsed; } export async function resetApiBase(): Promise { const previous = currentApiBase(); + const url = defaultApiBase(); + if (url !== previous) { + await clearSessionToken(); + await clearPrivateSpace(); + } try { await SecureStore.deleteItemAsync(ENDPOINT_KEY); } catch { // ignore missing keys } - const url = defaultApiBase(); cachedApiBase = url; - if (url !== previous) { - await clearSessionToken(); - await clearPrivateSpace(); - } return { ok: true, url }; } diff --git a/apps/mobile/lib/session.test.ts b/apps/mobile/lib/session.test.ts index e280548062..6fca3ebf29 100644 --- a/apps/mobile/lib/session.test.ts +++ b/apps/mobile/lib/session.test.ts @@ -28,6 +28,12 @@ describe("mobile session storage", () => { expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.session_token"); }); + it("overwrites the token when SecureStore delete fails", async () => { + vi.mocked(SecureStore.deleteItemAsync).mockRejectedValueOnce(new Error("device locked")); + await clearSessionToken(); + expect(SecureStore.setItemAsync).toHaveBeenCalledWith("rakazo.session_token", ""); + }); + it("returns an empty token when secure storage is empty or unavailable", async () => { vi.mocked(SecureStore.getItemAsync).mockResolvedValueOnce(null); await expect(loadSessionToken()).resolves.toBe(""); diff --git a/apps/mobile/lib/session.ts b/apps/mobile/lib/session.ts index fa3b1210dc..482051328c 100644 --- a/apps/mobile/lib/session.ts +++ b/apps/mobile/lib/session.ts @@ -15,7 +15,15 @@ export async function saveSessionToken(token: string) { } export async function clearSessionToken() { - await SecureStore.deleteItemAsync(SESSION_KEY); + try { + await SecureStore.deleteItemAsync(SESSION_KEY); + } catch { + try { + await SecureStore.setItemAsync(SESSION_KEY, ""); + } catch { + // Best-effort clear so endpoint switches do not keep sending the old bearer. + } + } } export function tokenFromAuthResponse(res: Response, body: unknown) { From e20800e3aaa871c0c1930d3dcbd6f166654ee7df Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 00:28:56 +0000 Subject: [PATCH 07/54] fix(web): type voice header test fetch mocks for tsc Annotate mocked fetch init args so mock.calls header reads typecheck cleanly under the web package check. Co-authored-by: Elie Steinbock --- apps/web/src/lib/dictation.test.ts | 4 ++-- apps/web/src/lib/tts.test.ts | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/web/src/lib/dictation.test.ts b/apps/web/src/lib/dictation.test.ts index 678400208d..92e5822a96 100644 --- a/apps/web/src/lib/dictation.test.ts +++ b/apps/web/src/lib/dictation.test.ts @@ -126,7 +126,7 @@ describe("Dictation recorder fallback", () => { }, language: "en-US", }); - const fetchMock = vi.fn(async () => ({ + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ ok: true, json: async () => ({ text: "hello" }), })); @@ -157,7 +157,7 @@ describe("Dictation recorder fallback", () => { "/api/voice/transcribe", expect.objectContaining({ credentials: "include" }), ); - const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers as HeadersInit); + const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); expect(headers.get("x-rakazo-workspace-id")).toBe("space-support"); expect(headers.get("content-type")).toBe("application/json"); }); diff --git a/apps/web/src/lib/tts.test.ts b/apps/web/src/lib/tts.test.ts index 0a5aed5aca..559da19672 100644 --- a/apps/web/src/lib/tts.test.ts +++ b/apps/web/src/lib/tts.test.ts @@ -46,7 +46,7 @@ describe("Speaker", () => { it("forwards the selected private space on speak requests", async () => { stubSelectedSpace("space-support"); - const fetchMock = vi.fn(async () => ({ + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ ok: true, blob: async () => new Blob(["audio"]), json: async () => ({}), @@ -79,8 +79,8 @@ describe("Speaker", () => { "/api/voice/speak", expect.objectContaining({ credentials: "include" }), ); - const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers as HeadersInit); + const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); expect(headers.get("x-rakazo-workspace-id")).toBe("space-support"); expect(headers.get("content-type")).toBe("application/json"); }); -}); \ No newline at end of file +}); From ad4dfb8ea30b070a440a628dfe5fc81e1ab6d865 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:20:08 +0300 Subject: [PATCH 08/54] fix: preserve private-space request boundaries --- apps/mobile/lib/api.test.ts | 25 +++++++++++++++++ packages/testkit/src/authorization.test.ts | 32 ++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index c304baf071..e298cc73e1 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -2,12 +2,16 @@ import * as SecureStore from "expo-secure-store"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { applyMobileThreadEvent, + authHeaders, blockText, type MobileMessage, type MobileSnapshot, mergeMobileSnapshot, prependMobileMessagePage, + resetApiBase, rpc, + saveApiBase, + selectPrivateSpace, shouldApplyMobileThreadRefresh, signIn, signOut, @@ -93,6 +97,27 @@ describe("mobile API authentication", () => { ); await expect(rpc("bots/get", { botId: "missing" })).rejects.toThrow("Bot does not exist"); }); + + it("shares the selected private space with direct API requests", async () => { + vi.mocked(SecureStore.getItemAsync).mockResolvedValue("session-token"); + await selectPrivateSpace("space-support"); + + await expect(authHeaders()).resolves.toEqual({ + authorization: "Bearer session-token", + "x-rakazo-workspace-id": "space-support", + }); + }); + + it("clears server-specific session and space state when the API endpoint changes", async () => { + await selectPrivateSpace("space-support"); + vi.mocked(SecureStore.deleteItemAsync).mockClear(); + + await expect(saveApiBase("https://second-server.example")).resolves.toMatchObject({ ok: true }); + + expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.session_token"); + expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.private_space_id"); + await resetApiBase(); + }); }); describe("mobile thread subscription", () => { diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 0035880cd1..0766b00d36 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -569,6 +569,38 @@ describeWithDatabase("API authorization and resource isolation", () => { await expectDenied(app, intruder, "bots/list", {}, support.id); }); + it("enforces the private-space limit across concurrent creation requests", async () => { + const cookie = await signup(app, `private-space-limit-${stamp}@rakazo.test`, "Space Limit"); + const actor = await rpc(app, cookie, "me"); + const extraSpaces = Array.from({ length: 30 }, (_, index) => ({ + id: `limit-space-${stamp}-${index}`, + name: `Limit space ${index}`, + slug: `limit-space-${stamp}-${index}`, + createdAt: new Date(), + })); + await handles.prisma.organization.createMany({ data: extraSpaces }); + await handles.prisma.member.createMany({ + data: extraSpaces.map((space, index) => ({ + id: `limit-member-${stamp}-${index}`, + organizationId: space.id, + userId: actor.userId, + role: "owner", + createdAt: space.createdAt, + })), + }); + + const results = await Promise.allSettled([ + rpc(app, cookie, "privateSpaces/create", { name: "Concurrent A" }), + rpc(app, cookie, "privateSpaces/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.member.count({ where: { userId: actor.userId } })).resolves.toBe( + 32, + ); + }); + it("isolates model defaults by workspace and switches them atomically", async () => { const cookie = await signup(app, `model-defaults-${stamp}@rakazo.test`, "Model Defaults"); const actor = await rpc(app, cookie, "me"); From 5e45c92ad7807d4d5282bda257d00f0ab31092d9 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 00:35:28 +0000 Subject: [PATCH 09/54] fix(mobile): refuse endpoint switches when credentials cannot clear Return a clear failure from save/resetApiBase if SecureStore cannot delete or overwrite the prior session or private space, and invalidate the in-memory session only when that wipe fails. Co-authored-by: Elie Steinbock --- apps/mobile/lib/api.test.ts | 15 +++++++++++++++ apps/mobile/lib/api.ts | 26 ++++++++++++++++++-------- apps/mobile/lib/session.test.ts | 11 ++++++++++- apps/mobile/lib/session.ts | 15 +++++++++++++-- 4 files changed, 56 insertions(+), 11 deletions(-) diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index e298cc73e1..33b864e773 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -118,6 +118,21 @@ describe("mobile API authentication", () => { expect(SecureStore.deleteItemAsync).toHaveBeenCalledWith("rakazo.private_space_id"); await resetApiBase(); }); + + it("refuses to switch endpoints when SecureStore cannot clear credentials", async () => { + await selectPrivateSpace("space-support"); + vi.mocked(SecureStore.deleteItemAsync).mockRejectedValue(new Error("device locked")); + vi.mocked(SecureStore.setItemAsync).mockRejectedValue(new Error("device locked")); + + await expect(saveApiBase("https://second-server.example")).resolves.toEqual({ + ok: false, + error: "Could not clear the previous server session", + }); + expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith( + "rakazo.api_base", + "https://second-server.example", + ); + }); }); describe("mobile thread subscription", () => { diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 35970e8ef4..0edeb83f38 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -77,12 +77,18 @@ export async function selectPrivateSpace(id: string) { } } -async function clearPrivateSpace() { +async function clearPrivateSpace(): Promise { cachedPrivateSpaceId = ""; try { await SecureStore.deleteItemAsync(PRIVATE_SPACE_KEY); + return true; } catch { - // Keep sign-out and account deletion reliable when secure storage is unavailable. + try { + await SecureStore.setItemAsync(PRIVATE_SPACE_KEY, ""); + return true; + } catch { + return false; + } } } @@ -92,10 +98,11 @@ export async function saveApiBase(input: string): Promise { if (parsed.url === defaultApiBase()) return resetApiBase(); const previous = currentApiBase(); if (parsed.url !== previous) { - // Clear credentials before activating the new origin so a SecureStore - // failure cannot leave the old bearer/workspace attached to the new host. - await clearSessionToken(); - await clearPrivateSpace(); + const sessionCleared = await clearSessionToken(); + const spaceCleared = await clearPrivateSpace(); + if (!sessionCleared || !spaceCleared) { + return { ok: false, error: "Could not clear the previous server session" }; + } } await SecureStore.setItemAsync(ENDPOINT_KEY, parsed.url); cachedApiBase = parsed.url; @@ -106,8 +113,11 @@ export async function resetApiBase(): Promise { const previous = currentApiBase(); const url = defaultApiBase(); if (url !== previous) { - await clearSessionToken(); - await clearPrivateSpace(); + const sessionCleared = await clearSessionToken(); + const spaceCleared = await clearPrivateSpace(); + if (!sessionCleared || !spaceCleared) { + return { ok: false, error: "Could not clear the previous server session" }; + } } try { await SecureStore.deleteItemAsync(ENDPOINT_KEY); diff --git a/apps/mobile/lib/session.test.ts b/apps/mobile/lib/session.test.ts index 6fca3ebf29..85f31f0853 100644 --- a/apps/mobile/lib/session.test.ts +++ b/apps/mobile/lib/session.test.ts @@ -30,10 +30,19 @@ describe("mobile session storage", () => { it("overwrites the token when SecureStore delete fails", async () => { vi.mocked(SecureStore.deleteItemAsync).mockRejectedValueOnce(new Error("device locked")); - await clearSessionToken(); + await expect(clearSessionToken()).resolves.toBe(true); expect(SecureStore.setItemAsync).toHaveBeenCalledWith("rakazo.session_token", ""); }); + it("invalidates the in-memory session when SecureStore cannot clear the token", async () => { + vi.mocked(SecureStore.getItemAsync).mockResolvedValue("secret-token"); + vi.mocked(SecureStore.deleteItemAsync).mockRejectedValue(new Error("device locked")); + vi.mocked(SecureStore.setItemAsync).mockRejectedValue(new Error("device locked")); + + await expect(clearSessionToken()).resolves.toBe(false); + await expect(loadSessionToken()).resolves.toBe(""); + }); + it("returns an empty token when secure storage is empty or unavailable", async () => { vi.mocked(SecureStore.getItemAsync).mockResolvedValueOnce(null); await expect(loadSessionToken()).resolves.toBe(""); diff --git a/apps/mobile/lib/session.ts b/apps/mobile/lib/session.ts index 482051328c..85b306c378 100644 --- a/apps/mobile/lib/session.ts +++ b/apps/mobile/lib/session.ts @@ -2,7 +2,11 @@ import * as SecureStore from "expo-secure-store"; const SESSION_KEY = "rakazo.session_token"; +/** In-memory gate so a failed SecureStore wipe cannot keep sending the old bearer. */ +let sessionInvalidated = false; + export async function loadSessionToken() { + if (sessionInvalidated) return ""; try { return (await SecureStore.getItemAsync(SESSION_KEY)) ?? ""; } catch { @@ -11,17 +15,24 @@ export async function loadSessionToken() { } export async function saveSessionToken(token: string) { + sessionInvalidated = false; await SecureStore.setItemAsync(SESSION_KEY, token); } -export async function clearSessionToken() { +/** Clears the session. Returns false only when SecureStore could neither delete nor overwrite. */ +export async function clearSessionToken(): Promise { try { await SecureStore.deleteItemAsync(SESSION_KEY); + sessionInvalidated = false; + return true; } catch { try { await SecureStore.setItemAsync(SESSION_KEY, ""); + sessionInvalidated = false; + return true; } catch { - // Best-effort clear so endpoint switches do not keep sending the old bearer. + sessionInvalidated = true; + return false; } } } From 92a44cd4ff85402d56017a3e1f1934e340691a4d Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 00:39:39 +0000 Subject: [PATCH 10/54] fix(mobile): restore credentials when endpoint clear is partial Snapshot the session token and selected space before wiping them for an endpoint change, and put both back if either SecureStore clear fails so the active server session is not left half-destroyed. Co-authored-by: Elie Steinbock --- apps/mobile/lib/api.test.ts | 28 ++++++++++++++++++++++++++++ apps/mobile/lib/api.ts | 37 +++++++++++++++++++++++++++---------- apps/mobile/lib/session.ts | 14 ++++++++++++++ 3 files changed, 69 insertions(+), 10 deletions(-) diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index 33b864e773..147e1b68d9 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -133,6 +133,34 @@ describe("mobile API authentication", () => { "https://second-server.example", ); }); + + it("restores the active session when only one credential clear succeeds", async () => { + vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { + if (key === "rakazo.session_token") return "session-token"; + return null; + }); + await selectPrivateSpace("space-support"); + vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { + if (key === "rakazo.private_space_id") throw new Error("device locked"); + }); + vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key) => { + if (key === "rakazo.private_space_id") throw new Error("device locked"); + }); + + await expect(saveApiBase("https://second-server.example")).resolves.toEqual({ + ok: false, + error: "Could not clear the previous server session", + }); + await expect(authHeaders()).resolves.toEqual({ + authorization: "Bearer session-token", + "x-rakazo-workspace-id": "space-support", + }); + expect(SecureStore.setItemAsync).toHaveBeenCalledWith("rakazo.session_token", "session-token"); + expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith( + "rakazo.api_base", + "https://second-server.example", + ); + }); }); describe("mobile thread subscription", () => { diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 0edeb83f38..14fe1de302 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -24,8 +24,10 @@ import { import * as SecureStore from "expo-secure-store"; import { defaultApiBase, type EndpointResult, normalizeApiBase } from "./endpoint"; import { + acknowledgeStoredSession, clearSessionToken, loadSessionToken, + peekStoredSessionToken, saveSessionToken, tokenFromAuthResponse, } from "./session"; @@ -92,17 +94,35 @@ async function clearPrivateSpace(): Promise { } } +/** Clears session + space for an endpoint change. Restores both if either wipe fails. */ +async function clearCredentialsForEndpointChange(): Promise { + const previousToken = await peekStoredSessionToken(); + const previousSpace = cachedPrivateSpaceId; + const sessionCleared = await clearSessionToken(); + const spaceCleared = await clearPrivateSpace(); + if (sessionCleared && spaceCleared) return null; + + if (previousToken) { + try { + await saveSessionToken(previousToken); + } catch { + if (!sessionCleared) acknowledgeStoredSession(); + } + } else if (!sessionCleared) { + acknowledgeStoredSession(); + } + if (previousSpace) await selectPrivateSpace(previousSpace); + return { ok: false, error: "Could not clear the previous server session" }; +} + export async function saveApiBase(input: string): Promise { const parsed = normalizeApiBase(input); if (!parsed.ok) return parsed; if (parsed.url === defaultApiBase()) return resetApiBase(); const previous = currentApiBase(); if (parsed.url !== previous) { - const sessionCleared = await clearSessionToken(); - const spaceCleared = await clearPrivateSpace(); - if (!sessionCleared || !spaceCleared) { - return { ok: false, error: "Could not clear the previous server session" }; - } + const failed = await clearCredentialsForEndpointChange(); + if (failed) return failed; } await SecureStore.setItemAsync(ENDPOINT_KEY, parsed.url); cachedApiBase = parsed.url; @@ -113,11 +133,8 @@ export async function resetApiBase(): Promise { const previous = currentApiBase(); const url = defaultApiBase(); if (url !== previous) { - const sessionCleared = await clearSessionToken(); - const spaceCleared = await clearPrivateSpace(); - if (!sessionCleared || !spaceCleared) { - return { ok: false, error: "Could not clear the previous server session" }; - } + const failed = await clearCredentialsForEndpointChange(); + if (failed) return failed; } try { await SecureStore.deleteItemAsync(ENDPOINT_KEY); diff --git a/apps/mobile/lib/session.ts b/apps/mobile/lib/session.ts index 85b306c378..116464bd26 100644 --- a/apps/mobile/lib/session.ts +++ b/apps/mobile/lib/session.ts @@ -37,6 +37,20 @@ export async function clearSessionToken(): Promise { } } +/** Drop the in-memory gate when a failed wipe left the bearer in SecureStore. */ +export function acknowledgeStoredSession() { + sessionInvalidated = false; +} + +/** Read the stored token even if the in-memory gate is set (for restore snapshots). */ +export async function peekStoredSessionToken() { + try { + return (await SecureStore.getItemAsync(SESSION_KEY)) ?? ""; + } catch { + return ""; + } +} + export function tokenFromAuthResponse(res: Response, body: unknown) { const fromJson = jsonToken(body); if (fromJson) return fromJson; From 5d441025f4d654a4200553b4c15166ed0cae6b6e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:43:31 +0300 Subject: [PATCH 11/54] fix(mobile): preserve active session on cleanup failure --- apps/mobile/lib/api.test.ts | 6 ++++-- apps/mobile/lib/api.ts | 12 ++---------- apps/mobile/lib/session.test.ts | 12 ++++++++++++ apps/mobile/lib/session.ts | 19 +++++++++++++++---- 4 files changed, 33 insertions(+), 16 deletions(-) diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index 147e1b68d9..05fc4add42 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -143,8 +143,10 @@ describe("mobile API authentication", () => { vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { if (key === "rakazo.private_space_id") throw new Error("device locked"); }); - vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key) => { - if (key === "rakazo.private_space_id") throw new Error("device locked"); + vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { + if (key === "rakazo.session_token" || (key === "rakazo.private_space_id" && value === "")) { + throw new Error("device locked"); + } }); await expect(saveApiBase("https://second-server.example")).resolves.toEqual({ diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 14fe1de302..3004a83219 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -24,10 +24,10 @@ import { import * as SecureStore from "expo-secure-store"; import { defaultApiBase, type EndpointResult, normalizeApiBase } from "./endpoint"; import { - acknowledgeStoredSession, clearSessionToken, loadSessionToken, peekStoredSessionToken, + restoreSessionToken, saveSessionToken, tokenFromAuthResponse, } from "./session"; @@ -102,15 +102,7 @@ async function clearCredentialsForEndpointChange(): Promise { vi.mocked(SecureStore.getItemAsync).mockRejectedValueOnce(new Error("device locked")); await expect(loadSessionToken()).resolves.toBe(""); }); + + it("restores the active session in memory when persistence is unavailable", async () => { + vi.mocked(SecureStore.setItemAsync).mockRejectedValue(new Error("device locked")); + + await restoreSessionToken("secret-token"); + await expect(loadSessionToken()).resolves.toBe("secret-token"); + + vi.mocked(SecureStore.deleteItemAsync).mockRejectedValue(new Error("device locked")); + await expect(clearSessionToken()).resolves.toBe(false); + await expect(loadSessionToken()).resolves.toBe(""); + }); }); describe("auth response token parsing", () => { diff --git a/apps/mobile/lib/session.ts b/apps/mobile/lib/session.ts index 116464bd26..09bf8ddc67 100644 --- a/apps/mobile/lib/session.ts +++ b/apps/mobile/lib/session.ts @@ -4,8 +4,10 @@ const SESSION_KEY = "rakazo.session_token"; /** In-memory gate so a failed SecureStore wipe cannot keep sending the old bearer. */ let sessionInvalidated = false; +let sessionFallback: string | undefined; export async function loadSessionToken() { + if (sessionFallback !== undefined) return sessionFallback; if (sessionInvalidated) return ""; try { return (await SecureStore.getItemAsync(SESSION_KEY)) ?? ""; @@ -15,8 +17,9 @@ export async function loadSessionToken() { } export async function saveSessionToken(token: string) { - sessionInvalidated = false; await SecureStore.setItemAsync(SESSION_KEY, token); + sessionInvalidated = false; + sessionFallback = undefined; } /** Clears the session. Returns false only when SecureStore could neither delete nor overwrite. */ @@ -24,22 +27,30 @@ export async function clearSessionToken(): Promise { try { await SecureStore.deleteItemAsync(SESSION_KEY); sessionInvalidated = false; + sessionFallback = undefined; return true; } catch { try { await SecureStore.setItemAsync(SESSION_KEY, ""); sessionInvalidated = false; + sessionFallback = undefined; return true; } catch { sessionInvalidated = true; + sessionFallback = undefined; return false; } } } -/** Drop the in-memory gate when a failed wipe left the bearer in SecureStore. */ -export function acknowledgeStoredSession() { - sessionInvalidated = false; +/** Restores the current-server session in memory even when persistence is unavailable. */ +export async function restoreSessionToken(token: string) { + try { + await saveSessionToken(token); + } catch { + sessionInvalidated = false; + sessionFallback = token; + } } /** Read the stored token even if the in-memory gate is set (for restore snapshots). */ From f87722d97acc2db41abeea3ee152707430ca8f4f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 00:47:45 +0000 Subject: [PATCH 12/54] fix: harden endpoint switch rollback and voice workspace binding Restore credentials if the new API base cannot be persisted after a successful clear, and snapshot the selected private space for the life of each speak/transcribe operation. Co-authored-by: Elie Steinbock --- apps/mobile/lib/api.test.ts | 21 +++++++++++- apps/mobile/lib/api.ts | 39 +++++++++++++++++----- apps/web/src/lib/dictation.test.ts | 53 ++++++++++++++++++++++++++++++ apps/web/src/lib/dictation.ts | 7 ++-- apps/web/src/lib/rpc.ts | 6 ++-- apps/web/src/lib/tts.test.ts | 38 +++++++++++++++++++++ apps/web/src/lib/tts.ts | 15 ++++++--- 7 files changed, 161 insertions(+), 18 deletions(-) diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index 05fc4add42..01d2b85a60 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -157,12 +157,31 @@ describe("mobile API authentication", () => { authorization: "Bearer session-token", "x-rakazo-workspace-id": "space-support", }); - expect(SecureStore.setItemAsync).toHaveBeenCalledWith("rakazo.session_token", "session-token"); expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith( "rakazo.api_base", "https://second-server.example", ); }); + + it("restores credentials when the new endpoint cannot be persisted", async () => { + vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { + if (key === "rakazo.session_token") return "session-token"; + return null; + }); + await selectPrivateSpace("space-support"); + vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key) => { + if (key === "rakazo.api_base") throw new Error("device locked"); + }); + + await expect(saveApiBase("https://second-server.example")).resolves.toEqual({ + ok: false, + error: "Could not save the server URL", + }); + await expect(authHeaders()).resolves.toEqual({ + authorization: "Bearer session-token", + "x-rakazo-workspace-id": "space-support", + }); + }); }); describe("mobile thread subscription", () => { diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 3004a83219..8e03914e69 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -95,16 +95,25 @@ async function clearPrivateSpace(): Promise { } /** Clears session + space for an endpoint change. Restores both if either wipe fails. */ -async function clearCredentialsForEndpointChange(): Promise { +async function clearCredentialsForEndpointChange(): Promise< + { ok: true; previousToken: string; previousSpace: string } | { ok: false; result: EndpointResult } +> { const previousToken = await peekStoredSessionToken(); const previousSpace = cachedPrivateSpaceId; const sessionCleared = await clearSessionToken(); const spaceCleared = await clearPrivateSpace(); - if (sessionCleared && spaceCleared) return null; + if (sessionCleared && spaceCleared) { + return { ok: true, previousToken, previousSpace }; + } + + await restoreSessionToken(previousToken); + if (previousSpace) await selectPrivateSpace(previousSpace); + return { ok: false, result: { ok: false, error: "Could not clear the previous server session" } }; +} +async function restoreCredentials(previousToken: string, previousSpace: string) { await restoreSessionToken(previousToken); if (previousSpace) await selectPrivateSpace(previousSpace); - return { ok: false, error: "Could not clear the previous server session" }; } export async function saveApiBase(input: string): Promise { @@ -112,11 +121,18 @@ export async function saveApiBase(input: string): Promise { if (!parsed.ok) return parsed; if (parsed.url === defaultApiBase()) return resetApiBase(); const previous = currentApiBase(); + let cleared: { previousToken: string; previousSpace: string } | undefined; if (parsed.url !== previous) { - const failed = await clearCredentialsForEndpointChange(); - if (failed) return failed; + const result = await clearCredentialsForEndpointChange(); + if (!result.ok) return result.result; + cleared = result; + } + try { + await SecureStore.setItemAsync(ENDPOINT_KEY, parsed.url); + } catch { + if (cleared) await restoreCredentials(cleared.previousToken, cleared.previousSpace); + return { ok: false, error: "Could not save the server URL" }; } - await SecureStore.setItemAsync(ENDPOINT_KEY, parsed.url); cachedApiBase = parsed.url; return parsed; } @@ -124,14 +140,19 @@ export async function saveApiBase(input: string): Promise { export async function resetApiBase(): Promise { const previous = currentApiBase(); const url = defaultApiBase(); + let cleared: { previousToken: string; previousSpace: string } | undefined; if (url !== previous) { - const failed = await clearCredentialsForEndpointChange(); - if (failed) return failed; + const result = await clearCredentialsForEndpointChange(); + if (!result.ok) return result.result; + cleared = result; } try { await SecureStore.deleteItemAsync(ENDPOINT_KEY); } catch { - // ignore missing keys + if (cleared) { + await restoreCredentials(cleared.previousToken, cleared.previousSpace); + return { ok: false, error: "Could not clear the custom server URL" }; + } } cachedApiBase = url; return { ok: true, url }; diff --git a/apps/web/src/lib/dictation.test.ts b/apps/web/src/lib/dictation.test.ts index 92e5822a96..6fc504763b 100644 --- a/apps/web/src/lib/dictation.test.ts +++ b/apps/web/src/lib/dictation.test.ts @@ -162,6 +162,59 @@ describe("Dictation recorder fallback", () => { expect(headers.get("content-type")).toBe("application/json"); }); + it("keeps the transcribe workspace when selection changes mid-flight", async () => { + const store = new Map([["rakazo:private-space-id", "space-support"]]); + const localStorage = { + getItem: (key: string) => store.get(key) ?? null, + setItem: (key: string, value: string) => { + store.set(key, value); + }, + removeItem: (key: string) => { + store.delete(key); + }, + }; + vi.stubGlobal("window", { localStorage }); + vi.stubGlobal("localStorage", localStorage); + + const track = { stop: vi.fn() }; + vi.stubGlobal("navigator", { + mediaDevices: { + getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })), + }, + language: "en-US", + }); + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ + ok: true, + json: async () => ({ text: "hello" }), + })); + vi.stubGlobal("fetch", fetchMock); + + class FakeRecorder { + state = "inactive"; + ondataavailable: ((event: { data: Blob }) => void) | null = null; + onstop: (() => void) | null = null; + start() { + this.state = "recording"; + } + stop() { + this.state = "inactive"; + this.ondataavailable?.({ data: new Blob(["audio"], { type: "audio/webm" }) }); + this.onstop?.(); + } + } + vi.stubGlobal("MediaRecorder", FakeRecorder); + + const onFinal = vi.fn(); + const dictation = new Dictation(); + await dictation.listen({ mode: "hold", transcribe: true, onFinal }); + store.set("rakazo:private-space-id", "space-other"); + dictation.submitHold(); + await vi.waitFor(() => expect(onFinal).toHaveBeenCalledWith("hello")); + + const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); + expect(headers.get("x-rakazo-workspace-id")).toBe("space-support"); + }); + it("ignores leftover audio from a replaced recorder", async () => { const track = { stop: vi.fn() }; vi.stubGlobal("navigator", { diff --git a/apps/web/src/lib/dictation.ts b/apps/web/src/lib/dictation.ts index d797aaf027..9c6ce4cd02 100644 --- a/apps/web/src/lib/dictation.ts +++ b/apps/web/src/lib/dictation.ts @@ -1,3 +1,5 @@ +import { selectedPrivateSpaceId, withPrivateSpaceHeaders } from "./rpc.js"; + export type DictationMode = "hold" | "endpoint"; export type DictationSnapshot = { @@ -58,6 +60,7 @@ export class Dictation { private audioContext: AudioContext | null = null; private vadTimer: ReturnType | undefined; private onFinal: ((text: string) => void) | null = null; + private workspaceId: string | null = null; subscribe(fn: (s: DictationSnapshot) => void): () => void { this.watchers.add(fn); @@ -119,6 +122,7 @@ export class Dictation { this.stop("replace"); const mine = this.token; this.onFinal = opts.onFinal; + this.workspaceId = selectedPrivateSpaceId(); this.set({ status: "listening", transcript: "" }); if (webSpeechAvailable()) { this.listenWebSpeech(opts.mode, opts.endpointMs ?? 850, mine); @@ -292,10 +296,9 @@ export class Dictation { try { const audioBase64 = await blobToBase64(blob); if (this.token !== mine) return; - const { withPrivateSpaceHeaders } = await import("./rpc.js"); const res = await fetch("/api/voice/transcribe", { method: "POST", - headers: withPrivateSpaceHeaders({ "content-type": "application/json" }), + headers: withPrivateSpaceHeaders({ "content-type": "application/json" }, this.workspaceId), credentials: "include", body: JSON.stringify({ audioBase64, mimeType: blob.type }), signal: abort.signal, diff --git a/apps/web/src/lib/rpc.ts b/apps/web/src/lib/rpc.ts index d38758d1d6..3a4eb55f05 100644 --- a/apps/web/src/lib/rpc.ts +++ b/apps/web/src/lib/rpc.ts @@ -23,9 +23,11 @@ export function clearPrivateSpaceSelection(): void { } /** Adds `x-rakazo-workspace-id` when a private space is selected. */ -export function withPrivateSpaceHeaders(init?: HeadersInit): Headers { +export function withPrivateSpaceHeaders( + init?: HeadersInit, + workspaceId: string | null = selectedPrivateSpaceId(), +): Headers { const headers = new Headers(init); - const workspaceId = selectedPrivateSpaceId(); if (workspaceId) headers.set("x-rakazo-workspace-id", workspaceId); return headers; } diff --git a/apps/web/src/lib/tts.test.ts b/apps/web/src/lib/tts.test.ts index 559da19672..76b3f1341c 100644 --- a/apps/web/src/lib/tts.test.ts +++ b/apps/web/src/lib/tts.test.ts @@ -83,4 +83,42 @@ describe("Speaker", () => { expect(headers.get("x-rakazo-workspace-id")).toBe("space-support"); expect(headers.get("content-type")).toBe("application/json"); }); + + it("keeps the speak workspace when selection changes mid-flight", async () => { + stubSelectedSpace("space-support"); + const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ + ok: true, + blob: async () => new Blob(["audio"]), + json: async () => ({}), + })); + vi.stubGlobal("fetch", fetchMock); + vi.stubGlobal( + "Audio", + class { + src = ""; + onended: (() => void) | null = null; + onerror: (() => void) | null = null; + play() { + queueMicrotask(() => this.onended?.()); + return Promise.resolve(); + } + pause() {} + }, + ); + vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:voice"); + vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); + + const speaker = new Speaker(); + vi.spyOn( + speaker as unknown as { prepare: () => Promise }, + "prepare", + ).mockImplementation(async () => { + window.localStorage.setItem("rakazo:private-space-id", "space-other"); + return ["Hello."]; + }); + await speaker.speak("Hello.", { messageId: "m1" }); + + const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); + expect(headers.get("x-rakazo-workspace-id")).toBe("space-support"); + }); }); diff --git a/apps/web/src/lib/tts.ts b/apps/web/src/lib/tts.ts index 6f2c94c66e..1984a6081d 100644 --- a/apps/web/src/lib/tts.ts +++ b/apps/web/src/lib/tts.ts @@ -1,3 +1,5 @@ +import { selectedPrivateSpaceId, withPrivateSpaceHeaders } from "./rpc.js"; + export type SpeechStatus = "idle" | "preparing" | "speaking"; export interface SpeechSnapshot { @@ -76,6 +78,7 @@ export class Speaker { const controller = new AbortController(); this.request = controller; const live = () => this.token === mine && !controller.signal.aborted; + const workspaceId = selectedPrivateSpaceId(); this.set({ status: "preparing", botId: opts.botId, messageId: opts.messageId }); let utterances: string[]; @@ -99,7 +102,7 @@ export class Speaker { type Rendered = { blob: Blob; error?: never } | { blob?: never; error: unknown }; const render = (utterance: string): Promise => - this.render(utterance, opts, controller.signal).then( + this.render(utterance, opts, controller.signal, workspaceId).then( (blob) => ({ blob }), (error: unknown) => ({ error }), ); @@ -150,11 +153,15 @@ export class Speaker { return body.utterances ?? []; } - private async render(text: string, opts: SpeakOptions, signal: AbortSignal): Promise { - const { withPrivateSpaceHeaders } = await import("./rpc.js"); + private async render( + text: string, + opts: SpeakOptions, + signal: AbortSignal, + workspaceId: string | null, + ): Promise { const res = await fetch("/api/voice/speak", { method: "POST", - headers: withPrivateSpaceHeaders({ "content-type": "application/json" }), + headers: withPrivateSpaceHeaders({ "content-type": "application/json" }, workspaceId), credentials: "include", body: JSON.stringify({ text, voiceId: opts.voiceId, botId: opts.botId }), signal, From eeb086f2808dacb48a54354d2d53635f73369121 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 00:48:23 +0000 Subject: [PATCH 13/54] fix: clear default-space selection and peek session fallbacks Clear the persisted private-space ID when opening a default-workspace chat so voice stays scoped correctly, and include in-memory session fallbacks when snapshotting credentials for endpoint-switch rollback. Co-authored-by: Elie Steinbock --- apps/mobile/lib/session.test.ts | 2 ++ apps/mobile/lib/session.ts | 3 ++- apps/web/src/pages/Shell.tsx | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/mobile/lib/session.test.ts b/apps/mobile/lib/session.test.ts index ecfb037492..8640e907a4 100644 --- a/apps/mobile/lib/session.test.ts +++ b/apps/mobile/lib/session.test.ts @@ -3,6 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { clearSessionToken, loadSessionToken, + peekStoredSessionToken, restoreSessionToken, saveSessionToken, tokenFromAuthResponse, @@ -57,6 +58,7 @@ describe("mobile session storage", () => { await restoreSessionToken("secret-token"); await expect(loadSessionToken()).resolves.toBe("secret-token"); + await expect(peekStoredSessionToken()).resolves.toBe("secret-token"); vi.mocked(SecureStore.deleteItemAsync).mockRejectedValue(new Error("device locked")); await expect(clearSessionToken()).resolves.toBe(false); diff --git a/apps/mobile/lib/session.ts b/apps/mobile/lib/session.ts index 09bf8ddc67..409fbace3e 100644 --- a/apps/mobile/lib/session.ts +++ b/apps/mobile/lib/session.ts @@ -53,8 +53,9 @@ export async function restoreSessionToken(token: string) { } } -/** Read the stored token even if the in-memory gate is set (for restore snapshots). */ +/** Read the active token for restore snapshots, including in-memory fallbacks. */ export async function peekStoredSessionToken() { + if (sessionFallback !== undefined) return sessionFallback; try { return (await SecureStore.getItemAsync(SESSION_KEY)) ?? ""; } catch { diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index bc75500b5f..29a88daf95 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -1318,6 +1318,7 @@ export function ShellPage() { (workspaceId: string, path: string) => { setMobileSidebarOpen(false); if (workspaceId === bootstrapMe?.workspaceId) { + clearPrivateSpaceSelection(); navigate(path); return; } From 02f1c7755672d7ecbb0fe7b54960d21b01ee7ce8 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 00:49:18 +0000 Subject: [PATCH 14/54] fix(mobile): include session fallback when snapshotting credentials Do not restore an empty token into the in-memory fallback, and let peekStoredSessionToken read a non-empty fallback so later endpoint switch rollbacks keep the active bearer. Co-authored-by: Elie Steinbock --- apps/mobile/lib/session.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/apps/mobile/lib/session.ts b/apps/mobile/lib/session.ts index 409fbace3e..dfbc9e4bed 100644 --- a/apps/mobile/lib/session.ts +++ b/apps/mobile/lib/session.ts @@ -45,6 +45,11 @@ export async function clearSessionToken(): Promise { /** Restores the current-server session in memory even when persistence is unavailable. */ export async function restoreSessionToken(token: string) { + if (!token) { + sessionInvalidated = false; + sessionFallback = undefined; + return; + } try { await saveSessionToken(token); } catch { @@ -55,7 +60,7 @@ export async function restoreSessionToken(token: string) { /** Read the active token for restore snapshots, including in-memory fallbacks. */ export async function peekStoredSessionToken() { - if (sessionFallback !== undefined) return sessionFallback; + if (sessionFallback) return sessionFallback; try { return (await SecureStore.getItemAsync(SESSION_KEY)) ?? ""; } catch { From 9a7c9270ab863223556abbc1490f2df3071a0f11 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 00:52:23 +0000 Subject: [PATCH 15/54] test(mobile): cover consecutive failed endpoint switches Ensure an in-memory session fallback survives two refused API base changes without signing the user out or activating a new origin. Co-authored-by: Elie Steinbock --- apps/mobile/lib/api.test.ts | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index 01d2b85a60..ec7ad98ac2 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -182,6 +182,35 @@ describe("mobile API authentication", () => { "x-rakazo-workspace-id": "space-support", }); }); + + it("keeps the in-memory session across consecutive failed endpoint switches", async () => { + vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { + if (key === "rakazo.session_token") return "session-token"; + return null; + }); + await selectPrivateSpace("space-support"); + vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { + if (key === "rakazo.private_space_id") throw new Error("device locked"); + }); + vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { + if (key === "rakazo.session_token" || (key === "rakazo.private_space_id" && value === "")) { + throw new Error("device locked"); + } + }); + + await expect(saveApiBase("https://second-server.example")).resolves.toMatchObject({ ok: false }); + vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null); + await expect(saveApiBase("https://third-server.example")).resolves.toMatchObject({ ok: false }); + + await expect(authHeaders()).resolves.toEqual({ + authorization: "Bearer session-token", + "x-rakazo-workspace-id": "space-support", + }); + expect(SecureStore.setItemAsync).not.toHaveBeenCalledWith( + "rakazo.api_base", + expect.stringMatching(/second-server|third-server/), + ); + }); }); describe("mobile thread subscription", () => { From 9900ddd8d46843795a19afd6ef9110633f8179ef Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 00:53:42 +0000 Subject: [PATCH 16/54] style(mobile): format consecutive endpoint-switch test Co-authored-by: Elie Steinbock --- apps/mobile/lib/api.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index ec7ad98ac2..edc60cddea 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -198,7 +198,9 @@ describe("mobile API authentication", () => { } }); - await expect(saveApiBase("https://second-server.example")).resolves.toMatchObject({ ok: false }); + await expect(saveApiBase("https://second-server.example")).resolves.toMatchObject({ + ok: false, + }); vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null); await expect(saveApiBase("https://third-server.example")).resolves.toMatchObject({ ok: false }); From bcf619d45bb766848d8ad821d65984c4a5f3d24f Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 03:54:08 +0300 Subject: [PATCH 17/54] fix: bind voice runs and verify endpoint rollback --- apps/mobile/lib/api.test.ts | 31 +++++++++++++++ apps/web/src/lib/dictation.test.ts | 56 +------------------------- apps/web/src/lib/dictation.ts | 18 +++++---- apps/web/src/lib/rpc.ts | 15 +++++-- apps/web/src/lib/tts.test.ts | 64 ++++++++++-------------------- apps/web/src/lib/tts.ts | 14 ++++--- 6 files changed, 84 insertions(+), 114 deletions(-) diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index edc60cddea..5e76a6ec38 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -4,6 +4,7 @@ import { applyMobileThreadEvent, authHeaders, blockText, + currentApiBase, type MobileMessage, type MobileSnapshot, mergeMobileSnapshot, @@ -164,6 +165,7 @@ describe("mobile API authentication", () => { }); it("restores credentials when the new endpoint cannot be persisted", async () => { + const previous = currentApiBase(); vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { if (key === "rakazo.session_token") return "session-token"; return null; @@ -177,6 +179,7 @@ describe("mobile API authentication", () => { ok: false, error: "Could not save the server URL", }); + expect(currentApiBase()).toBe(previous); await expect(authHeaders()).resolves.toEqual({ authorization: "Bearer session-token", "x-rakazo-workspace-id": "space-support", @@ -213,6 +216,34 @@ describe("mobile API authentication", () => { expect.stringMatching(/second-server|third-server/), ); }); + + it("restores credentials when resetting the endpoint cannot be persisted", async () => { + await saveApiBase("https://second-server.example"); + const previous = currentApiBase(); + vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { + if (key === "rakazo.session_token") return "session-token"; + return null; + }); + await selectPrivateSpace("space-support"); + vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { + if (key === "rakazo.api_base") throw new Error("device locked"); + }); + + await expect(resetApiBase()).resolves.toEqual({ + ok: false, + error: "Could not clear the custom server URL", + }); + expect(currentApiBase()).toBe(previous); + await expect(authHeaders()).resolves.toEqual({ + authorization: "Bearer session-token", + "x-rakazo-workspace-id": "space-support", + }); + + vi.mocked(SecureStore.getItemAsync).mockReset(); + vi.mocked(SecureStore.setItemAsync).mockReset(); + vi.mocked(SecureStore.deleteItemAsync).mockReset(); + await resetApiBase(); + }); }); describe("mobile thread subscription", () => { diff --git a/apps/web/src/lib/dictation.test.ts b/apps/web/src/lib/dictation.test.ts index 6fc504763b..03a77f2df8 100644 --- a/apps/web/src/lib/dictation.test.ts +++ b/apps/web/src/lib/dictation.test.ts @@ -105,7 +105,7 @@ describe("Dictation recorder fallback", () => { expect(dictation.state.status).toBe("listening"); }); - it("forwards the selected private space on transcribe requests", async () => { + it("keeps transcription in the private space where recording started", async () => { const store = new Map([["rakazo:private-space-id", "space-support"]]); const localStorage = { getItem: (key: string) => store.get(key) ?? null, @@ -150,6 +150,7 @@ describe("Dictation recorder fallback", () => { const onFinal = vi.fn(); const dictation = new Dictation(); await dictation.listen({ mode: "hold", transcribe: true, onFinal }); + store.set("rakazo:private-space-id", "space-other"); dictation.submitHold(); await vi.waitFor(() => expect(onFinal).toHaveBeenCalledWith("hello")); @@ -162,59 +163,6 @@ describe("Dictation recorder fallback", () => { expect(headers.get("content-type")).toBe("application/json"); }); - it("keeps the transcribe workspace when selection changes mid-flight", async () => { - const store = new Map([["rakazo:private-space-id", "space-support"]]); - const localStorage = { - getItem: (key: string) => store.get(key) ?? null, - setItem: (key: string, value: string) => { - store.set(key, value); - }, - removeItem: (key: string) => { - store.delete(key); - }, - }; - vi.stubGlobal("window", { localStorage }); - vi.stubGlobal("localStorage", localStorage); - - const track = { stop: vi.fn() }; - vi.stubGlobal("navigator", { - mediaDevices: { - getUserMedia: vi.fn(async () => ({ getTracks: () => [track] })), - }, - language: "en-US", - }); - const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ - ok: true, - json: async () => ({ text: "hello" }), - })); - vi.stubGlobal("fetch", fetchMock); - - class FakeRecorder { - state = "inactive"; - ondataavailable: ((event: { data: Blob }) => void) | null = null; - onstop: (() => void) | null = null; - start() { - this.state = "recording"; - } - stop() { - this.state = "inactive"; - this.ondataavailable?.({ data: new Blob(["audio"], { type: "audio/webm" }) }); - this.onstop?.(); - } - } - vi.stubGlobal("MediaRecorder", FakeRecorder); - - const onFinal = vi.fn(); - const dictation = new Dictation(); - await dictation.listen({ mode: "hold", transcribe: true, onFinal }); - store.set("rakazo:private-space-id", "space-other"); - dictation.submitHold(); - await vi.waitFor(() => expect(onFinal).toHaveBeenCalledWith("hello")); - - const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); - expect(headers.get("x-rakazo-workspace-id")).toBe("space-support"); - }); - it("ignores leftover audio from a replaced recorder", async () => { const track = { stop: vi.fn() }; vi.stubGlobal("navigator", { diff --git a/apps/web/src/lib/dictation.ts b/apps/web/src/lib/dictation.ts index 9c6ce4cd02..a9b06daa42 100644 --- a/apps/web/src/lib/dictation.ts +++ b/apps/web/src/lib/dictation.ts @@ -60,7 +60,6 @@ export class Dictation { private audioContext: AudioContext | null = null; private vadTimer: ReturnType | undefined; private onFinal: ((text: string) => void) | null = null; - private workspaceId: string | null = null; subscribe(fn: (s: DictationSnapshot) => void): () => void { this.watchers.add(fn); @@ -121,15 +120,15 @@ export class Dictation { }): Promise { this.stop("replace"); const mine = this.token; + const workspaceId = selectedPrivateSpaceId(); this.onFinal = opts.onFinal; - this.workspaceId = selectedPrivateSpaceId(); this.set({ status: "listening", transcript: "" }); if (webSpeechAvailable()) { this.listenWebSpeech(opts.mode, opts.endpointMs ?? 850, mine); return; } if (opts.transcribe) { - await this.listenRecorder(mine, opts.mode, opts.endpointMs ?? 850); + await this.listenRecorder(mine, opts.mode, opts.endpointMs ?? 850, workspaceId); return; } this.set({ @@ -191,7 +190,12 @@ export class Dictation { rec.start(); } - private async listenRecorder(mine: number, mode: DictationMode, endpointMs: number) { + private async listenRecorder( + mine: number, + mode: DictationMode, + endpointMs: number, + workspaceId: string | null, + ) { let stream: MediaStream; try { stream = await navigator.mediaDevices.getUserMedia({ audio: true }); @@ -223,7 +227,7 @@ export class Dictation { for (const track of stream.getTracks()) track.stop(); if (this.token !== mine) return; this.stopVad(); - void this.transcribeChunks(mine); + void this.transcribeChunks(mine, workspaceId); }; media.start(mode === "endpoint" ? 250 : undefined); } @@ -282,7 +286,7 @@ export class Dictation { if (ctx) void ctx.close().catch(() => undefined); } - private async transcribeChunks(mine: number) { + private async transcribeChunks(mine: number, workspaceId: string | null) { if (this.token !== mine) return; const blob = new Blob(this.chunks, { type: this.chunks[0]?.type || "audio/webm" }); this.chunks = []; @@ -298,7 +302,7 @@ export class Dictation { if (this.token !== mine) return; const res = await fetch("/api/voice/transcribe", { method: "POST", - headers: withPrivateSpaceHeaders({ "content-type": "application/json" }, this.workspaceId), + headers: withPrivateSpaceHeaders({ "content-type": "application/json" }, workspaceId), credentials: "include", body: JSON.stringify({ audioBase64, mimeType: blob.type }), signal: abort.signal, diff --git a/apps/web/src/lib/rpc.ts b/apps/web/src/lib/rpc.ts index 3a4eb55f05..0897ce3823 100644 --- a/apps/web/src/lib/rpc.ts +++ b/apps/web/src/lib/rpc.ts @@ -5,6 +5,8 @@ import type { AppContract } from "@rakazo/contracts"; const WORKSPACE_STORAGE_KEY = "rakazo:private-space-id"; +type RpcClientContext = { privateSpaceId?: string | null }; + export function selectedPrivateSpaceId(): string | null { if (typeof window === "undefined") return null; try { @@ -29,19 +31,24 @@ export function withPrivateSpaceHeaders( ): Headers { const headers = new Headers(init); if (workspaceId) headers.set("x-rakazo-workspace-id", workspaceId); + else headers.delete("x-rakazo-workspace-id"); return headers; } -const link = new RPCLink({ +const link = new RPCLink({ url: () => typeof window === "undefined" ? "http://127.0.0.1:5173/rpc" : `${window.location.origin}/rpc`, - fetch: (input, init) => { + fetch: (input, init, options) => { const request = new Request(input, init); + const workspaceId = + options.context.privateSpaceId === undefined + ? selectedPrivateSpaceId() + : options.context.privateSpaceId; return fetch(request, { - headers: withPrivateSpaceHeaders(request.headers), + headers: withPrivateSpaceHeaders(request.headers, workspaceId), credentials: "include", }); }, }); -export const rpc: ContractRouterClient = createORPCClient(link); +export const rpc: ContractRouterClient = createORPCClient(link); diff --git a/apps/web/src/lib/tts.test.ts b/apps/web/src/lib/tts.test.ts index 76b3f1341c..44073d46f8 100644 --- a/apps/web/src/lib/tts.test.ts +++ b/apps/web/src/lib/tts.test.ts @@ -19,6 +19,7 @@ function stubSelectedSpace(id: string) { }; vi.stubGlobal("window", { localStorage }); vi.stubGlobal("localStorage", localStorage); + return (next: string) => store.set("rakazo:private-space-id", next); } describe("Speaker", () => { @@ -44,8 +45,8 @@ describe("Speaker", () => { expect(speaker.state.error).toBe("ElevenLabs rejected that key."); }); - it("forwards the selected private space on speak requests", async () => { - stubSelectedSpace("space-support"); + it("keeps speak requests in the private space where playback started", async () => { + const changeSelectedSpace = stubSelectedSpace("space-support"); const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ ok: true, blob: async () => new Blob(["audio"]), @@ -69,10 +70,22 @@ describe("Speaker", () => { vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); const speaker = new Speaker(); - vi.spyOn( - speaker as unknown as { prepare: () => Promise }, - "prepare", - ).mockResolvedValue(["Hello."]); + const prepare = vi + .spyOn( + speaker as unknown as { + prepare: ( + text: string, + opts: unknown, + signal: AbortSignal, + workspaceId: string | null, + ) => Promise; + }, + "prepare", + ) + .mockImplementation(async () => { + changeSelectedSpace("space-other"); + return ["Hello."]; + }); await speaker.speak("Hello.", { messageId: "m1" }); expect(fetchMock).toHaveBeenCalledWith( @@ -82,43 +95,6 @@ describe("Speaker", () => { const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); expect(headers.get("x-rakazo-workspace-id")).toBe("space-support"); expect(headers.get("content-type")).toBe("application/json"); - }); - - it("keeps the speak workspace when selection changes mid-flight", async () => { - stubSelectedSpace("space-support"); - const fetchMock = vi.fn(async (_url: string, _init?: RequestInit) => ({ - ok: true, - blob: async () => new Blob(["audio"]), - json: async () => ({}), - })); - vi.stubGlobal("fetch", fetchMock); - vi.stubGlobal( - "Audio", - class { - src = ""; - onended: (() => void) | null = null; - onerror: (() => void) | null = null; - play() { - queueMicrotask(() => this.onended?.()); - return Promise.resolve(); - } - pause() {} - }, - ); - vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:voice"); - vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => undefined); - - const speaker = new Speaker(); - vi.spyOn( - speaker as unknown as { prepare: () => Promise }, - "prepare", - ).mockImplementation(async () => { - window.localStorage.setItem("rakazo:private-space-id", "space-other"); - return ["Hello."]; - }); - await speaker.speak("Hello.", { messageId: "m1" }); - - const headers = new Headers(fetchMock.mock.calls[0]?.[1]?.headers); - expect(headers.get("x-rakazo-workspace-id")).toBe("space-support"); + expect(prepare.mock.calls[0]?.[3]).toBe("space-support"); }); }); diff --git a/apps/web/src/lib/tts.ts b/apps/web/src/lib/tts.ts index 1984a6081d..d37d2c3685 100644 --- a/apps/web/src/lib/tts.ts +++ b/apps/web/src/lib/tts.ts @@ -1,4 +1,4 @@ -import { selectedPrivateSpaceId, withPrivateSpaceHeaders } from "./rpc.js"; +import { rpc, selectedPrivateSpaceId, withPrivateSpaceHeaders } from "./rpc.js"; export type SpeechStatus = "idle" | "preparing" | "speaking"; @@ -84,7 +84,7 @@ export class Speaker { let utterances: string[]; try { utterances = await withAbort(controller.signal, () => - this.prepare(text, opts, controller.signal), + this.prepare(text, opts, controller.signal, workspaceId), ); } catch (error) { if (live()) { @@ -141,11 +141,15 @@ export class Speaker { if (this.request === controller) this.request = null; } - private async prepare(text: string, opts: SpeakOptions, signal: AbortSignal): Promise { - const { rpc } = await import("./rpc.js"); + private async prepare( + text: string, + opts: SpeakOptions, + signal: AbortSignal, + workspaceId: string | null, + ): Promise { const body = await rpc.voice.prepare( { text, voiceId: opts.voiceId, botId: opts.botId }, - { signal }, + { signal, context: { privateSpaceId: workspaceId } }, ); if (!body.ready) { throw new Error("Add a voice provider key and pick a voice in Voice settings."); From c32e315c4758af185d637a44501e186c20bbab84 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 01:03:51 +0000 Subject: [PATCH 18/54] fix(web): full-reload when returning to primary workspace Soft-navigating after clearPrivateSpaceSelection left private-space bots/groups in React state while RPC headers targeted the default workspace. Match private-space switches with a full reload. Co-authored-by: Elie Steinbock --- apps/web/src/pages/Shell.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 29a88daf95..876c7dcb98 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -1319,13 +1319,13 @@ export function ShellPage() { setMobileSidebarOpen(false); if (workspaceId === bootstrapMe?.workspaceId) { clearPrivateSpaceSelection(); - navigate(path); - return; + } else { + selectPrivateSpace(workspaceId); } - selectPrivateSpace(workspaceId); + // Full reload so bots/groups bootstrap matches the selected workspace boundary. window.location.assign(path); }, - [bootstrapMe?.workspaceId, navigate], + [bootstrapMe?.workspaceId], ); const toggleSidebarSection = useCallback( (key: string) => { From 9f997a9d02c51d39d5c486d0f70d80082d5aa226 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 01:10:21 +0000 Subject: [PATCH 19/54] fix(web): reload only when private-space boundary changes Full-reloading on every sidebar chat click broke same-workspace bot switches (pending routine saves, in-progress runs, group edits). Keep soft navigation within a space; reload only when entering or leaving a private space so bootstrap matches the request header. Co-authored-by: Elie Steinbock --- apps/web/src/pages/Shell.tsx | 25 ++++++++++++++++++++----- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 876c7dcb98..04426a7f74 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -134,7 +134,12 @@ import { localTimezone } from "../lib/local-timezone"; import { connectMcpOauth } from "../lib/mcp-connect"; import { isFileDrag, revokePendingAttachmentPreviews } from "../lib/pending-attachments"; import { markAfterPaint, markOnce } from "../lib/performance"; -import { clearPrivateSpaceSelection, rpc, selectPrivateSpace } from "../lib/rpc"; +import { + clearPrivateSpaceSelection, + rpc, + selectPrivateSpace, + selectedPrivateSpaceId, +} from "../lib/rpc"; import { activeThreadRuns, clearActiveThreadRuns, @@ -1317,15 +1322,25 @@ export function ShellPage() { const openPrivateSpaceChat = useCallback( (workspaceId: string, path: string) => { setMobileSidebarOpen(false); - if (workspaceId === bootstrapMe?.workspaceId) { + const previousSpaceId = selectedPrivateSpaceId(); + const targetIsPrimary = workspaceId === bootstrapMe?.workspaceId; + if (targetIsPrimary) { clearPrivateSpaceSelection(); } else { selectPrivateSpace(workspaceId); } - // Full reload so bots/groups bootstrap matches the selected workspace boundary. - window.location.assign(path); + const boundaryChanged = targetIsPrimary + ? previousSpaceId !== null + : previousSpaceId !== workspaceId; + // Soft-navigate within the same workspace; reload only when the auth boundary changes + // so bootstrapped bots/groups match the request header. + if (boundaryChanged) { + window.location.assign(path); + return; + } + navigate(path); }, - [bootstrapMe?.workspaceId], + [bootstrapMe?.workspaceId, navigate], ); const toggleSidebarSection = useCallback( (key: string) => { From 7a6e056c64561205eb31604bb4c68dc2bf6a90b1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 01:13:47 +0000 Subject: [PATCH 20/54] style(web): organize private-space imports in Shell Co-authored-by: Elie Steinbock --- apps/web/src/pages/Shell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 04426a7f74..a4bfe0b039 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -137,8 +137,8 @@ import { markAfterPaint, markOnce } from "../lib/performance"; import { clearPrivateSpaceSelection, rpc, - selectPrivateSpace, selectedPrivateSpaceId, + selectPrivateSpace, } from "../lib/rpc"; import { activeThreadRuns, From d2bc1260652e4c07a9c9a6f7874464f38340e0bf Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 04:13:45 +0300 Subject: [PATCH 21/54] fix(web): preserve active private-space navigation --- apps/web/e2e/private-spaces.spec.ts | 13 ++++++++++++- apps/web/src/pages/Shell.tsx | 27 ++++++--------------------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/apps/web/e2e/private-spaces.spec.ts b/apps/web/e2e/private-spaces.spec.ts index 1785987271..933ef5a11d 100644 --- a/apps/web/e2e/private-spaces.spec.ts +++ b/apps/web/e2e/private-spaces.spec.ts @@ -29,13 +29,24 @@ test("private spaces keep all bots in the sidebar and switch the request boundar await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(2); await captureScreenshot(page, testInfo, "private-spaces-sidebar"); + const supportSpace = sidebar + .locator(`[data-sidebar-group^="space:${supportSpaceId}:"]`) + .filter({ hasText: "Customer support" }); + await supportSpace.getByRole("button", { name: /^Chief/ }).click(); + await expect + .poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:private-space-id"))) + .toBe(supportSpaceId); + const personalSpace = sidebar .locator('[data-sidebar-group^="space:"]') .filter({ hasText: "Personal" }); + const personalSpaceGroup = await personalSpace.getAttribute("data-sidebar-group"); + const personalSpaceId = personalSpaceGroup?.split(":")[1]; + expect(personalSpaceId).toBeTruthy(); await personalSpace.getByRole("button", { name: /^Chief/ }).click(); await page.waitForURL(/\/app\/[^/]+$/); await expect .poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:private-space-id"))) - .not.toBe(supportSpaceId); + .toBe(personalSpaceId); await expect(sidebar.getByText("Customer support", { exact: true })).toBeVisible(); }); diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index a4bfe0b039..497a0878c4 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -134,12 +134,7 @@ import { localTimezone } from "../lib/local-timezone"; import { connectMcpOauth } from "../lib/mcp-connect"; import { isFileDrag, revokePendingAttachmentPreviews } from "../lib/pending-attachments"; import { markAfterPaint, markOnce } from "../lib/performance"; -import { - clearPrivateSpaceSelection, - rpc, - selectedPrivateSpaceId, - selectPrivateSpace, -} from "../lib/rpc"; +import { clearPrivateSpaceSelection, rpc, selectPrivateSpace } from "../lib/rpc"; import { activeThreadRuns, clearActiveThreadRuns, @@ -1322,23 +1317,13 @@ export function ShellPage() { const openPrivateSpaceChat = useCallback( (workspaceId: string, path: string) => { setMobileSidebarOpen(false); - const previousSpaceId = selectedPrivateSpaceId(); - const targetIsPrimary = workspaceId === bootstrapMe?.workspaceId; - if (targetIsPrimary) { - clearPrivateSpaceSelection(); - } else { - selectPrivateSpace(workspaceId); - } - const boundaryChanged = targetIsPrimary - ? previousSpaceId !== null - : previousSpaceId !== workspaceId; - // Soft-navigate within the same workspace; reload only when the auth boundary changes - // so bootstrapped bots/groups match the request header. - if (boundaryChanged) { - window.location.assign(path); + if (workspaceId === bootstrapMe?.workspaceId) { + navigate(path); return; } - navigate(path); + selectPrivateSpace(workspaceId); + // Full reload so bots/groups bootstrap matches the selected workspace boundary. + window.location.assign(path); }, [bootstrapMe?.workspaceId, navigate], ); From d4cf0bdad4b9fada06e1cd3950134827689459f0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 01:17:22 +0000 Subject: [PATCH 22/54] fix(web): persist workspace id and reload only on boundary change Align Shell with the private-spaces e2e: selecting Personal stores the primary workspace id so request headers leave the previous private space. Reload only when the effective workspace changes; soft-navigate for same-space bot switches. Co-authored-by: Elie Steinbock --- apps/web/src/pages/Shell.tsx | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 497a0878c4..3b747f9d8c 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -134,7 +134,12 @@ import { localTimezone } from "../lib/local-timezone"; import { connectMcpOauth } from "../lib/mcp-connect"; import { isFileDrag, revokePendingAttachmentPreviews } from "../lib/pending-attachments"; import { markAfterPaint, markOnce } from "../lib/performance"; -import { clearPrivateSpaceSelection, rpc, selectPrivateSpace } from "../lib/rpc"; +import { + clearPrivateSpaceSelection, + rpc, + selectedPrivateSpaceId, + selectPrivateSpace, +} from "../lib/rpc"; import { activeThreadRuns, clearActiveThreadRuns, @@ -1317,13 +1322,18 @@ export function ShellPage() { const openPrivateSpaceChat = useCallback( (workspaceId: string, path: string) => { setMobileSidebarOpen(false); - if (workspaceId === bootstrapMe?.workspaceId) { - navigate(path); + const previousSpaceId = selectedPrivateSpaceId(); + // Persist the active workspace id (including primary) so voice/RPC headers match the chat. + selectPrivateSpace(workspaceId); + const previousEffective = previousSpaceId ?? bootstrapMe?.workspaceId; + const boundaryChanged = previousEffective !== workspaceId; + // Soft-navigate within the same workspace; reload only when the auth boundary changes + // so bootstrapped bots/groups match the request header. + if (boundaryChanged) { + window.location.assign(path); return; } - selectPrivateSpace(workspaceId); - // Full reload so bots/groups bootstrap matches the selected workspace boundary. - window.location.assign(path); + navigate(path); }, [bootstrapMe?.workspaceId, navigate], ); From 6f1dc9c8c52ee7deb4680d309f63980b53d88217 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 01:22:03 +0000 Subject: [PATCH 23/54] fix(mobile): peek SecureStore space when snapshotting endpoint rollback If the in-memory private-space cache was never primed (SecureStore load failed) but the key still exists, clearing it during a refused endpoint switch must restore from SecureStore rather than an empty cache snapshot. Co-authored-by: Elie Steinbock --- apps/mobile/lib/api.test.ts | 28 ++++++++++++++++++++++++++++ apps/mobile/lib/api.ts | 14 ++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index 5e76a6ec38..44a845f37e 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -5,6 +5,7 @@ import { authHeaders, blockText, currentApiBase, + loadApiBase, type MobileMessage, type MobileSnapshot, mergeMobileSnapshot, @@ -186,6 +187,33 @@ describe("mobile API authentication", () => { }); }); + it("restores a SecureStore space when the in-memory cache was never primed", async () => { + vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null); + await loadApiBase(); // clear any leftover in-memory selection from earlier tests + vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { + if (key === "rakazo.session_token") return "session-token"; + if (key === "rakazo.private_space_id") return "space-support"; + return null; + }); + // Leave cachedPrivateSpaceId empty: load failed / never ran, but SecureStore still has the space. + vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key) => { + if (key === "rakazo.api_base") throw new Error("device locked"); + }); + + await expect(saveApiBase("https://second-server.example")).resolves.toEqual({ + ok: false, + error: "Could not save the server URL", + }); + await expect(authHeaders()).resolves.toEqual({ + authorization: "Bearer session-token", + "x-rakazo-workspace-id": "space-support", + }); + expect(SecureStore.setItemAsync).toHaveBeenCalledWith( + "rakazo.private_space_id", + "space-support", + ); + }); + it("keeps the in-memory session across consecutive failed endpoint switches", async () => { vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { if (key === "rakazo.session_token") return "session-token"; diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 8e03914e69..6cba9b27da 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -52,7 +52,7 @@ export async function loadApiBase() { try { cachedPrivateSpaceId = (await SecureStore.getItemAsync(PRIVATE_SPACE_KEY)) ?? ""; } catch { - cachedPrivateSpaceId = ""; + // Keep any in-memory selection when SecureStore is temporarily unavailable. } try { const stored = await SecureStore.getItemAsync(ENDPOINT_KEY); @@ -79,6 +79,16 @@ export async function selectPrivateSpace(id: string) { } } +/** Active space for restore snapshots, including SecureStore when the cache was never primed. */ +async function peekStoredPrivateSpaceId() { + if (cachedPrivateSpaceId) return cachedPrivateSpaceId; + try { + return (await SecureStore.getItemAsync(PRIVATE_SPACE_KEY)) ?? ""; + } catch { + return ""; + } +} + async function clearPrivateSpace(): Promise { cachedPrivateSpaceId = ""; try { @@ -99,7 +109,7 @@ async function clearCredentialsForEndpointChange(): Promise< { ok: true; previousToken: string; previousSpace: string } | { ok: false; result: EndpointResult } > { const previousToken = await peekStoredSessionToken(); - const previousSpace = cachedPrivateSpaceId; + const previousSpace = await peekStoredPrivateSpaceId(); const sessionCleared = await clearSessionToken(); const spaceCleared = await clearPrivateSpace(); if (sessionCleared && spaceCleared) { From 9cb684a27ab7f0c10e762851db68133cc7b16a7b Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 04:22:58 +0300 Subject: [PATCH 24/54] fix(mobile): preserve persisted workspace on rollback --- apps/mobile/lib/api.test.ts | 34 +++++++++++++++++++++++++++------- apps/mobile/lib/api.ts | 31 ++++++++++++++++++------------- 2 files changed, 45 insertions(+), 20 deletions(-) diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index 44a845f37e..e7d6aea8c0 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -187,15 +187,18 @@ describe("mobile API authentication", () => { }); }); - it("restores a SecureStore space when the in-memory cache was never primed", async () => { + it("restores a persisted workspace when its initial load failed", async () => { vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null); - await loadApiBase(); // clear any leftover in-memory selection from earlier tests + await loadApiBase(); + const previous = currentApiBase(); + let privateSpaceReads = 0; vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { - if (key === "rakazo.session_token") return "session-token"; - if (key === "rakazo.private_space_id") return "space-support"; - return null; + if (key !== "rakazo.private_space_id") return null; + privateSpaceReads += 1; + if (privateSpaceReads === 1) throw new Error("device locked"); + return "space-support"; }); - // Leave cachedPrivateSpaceId empty: load failed / never ran, but SecureStore still has the space. + await loadApiBase(); vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key) => { if (key === "rakazo.api_base") throw new Error("device locked"); }); @@ -204,8 +207,8 @@ describe("mobile API authentication", () => { ok: false, error: "Could not save the server URL", }); + expect(currentApiBase()).toBe(previous); await expect(authHeaders()).resolves.toEqual({ - authorization: "Bearer session-token", "x-rakazo-workspace-id": "space-support", }); expect(SecureStore.setItemAsync).toHaveBeenCalledWith( @@ -214,6 +217,23 @@ describe("mobile API authentication", () => { ); }); + it("refuses an endpoint switch when the active workspace cannot be snapshotted", async () => { + vi.mocked(SecureStore.getItemAsync).mockResolvedValue(null); + await loadApiBase(); + vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { + if (key === "rakazo.private_space_id") throw new Error("device locked"); + return null; + }); + await loadApiBase(); + vi.mocked(SecureStore.deleteItemAsync).mockClear(); + + await expect(saveApiBase("https://second-server.example")).resolves.toEqual({ + ok: false, + error: "Could not clear the previous server session", + }); + expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled(); + }); + it("keeps the in-memory session across consecutive failed endpoint switches", async () => { vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { if (key === "rakazo.session_token") return "session-token"; diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 6cba9b27da..b608ac91bd 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -79,16 +79,6 @@ export async function selectPrivateSpace(id: string) { } } -/** Active space for restore snapshots, including SecureStore when the cache was never primed. */ -async function peekStoredPrivateSpaceId() { - if (cachedPrivateSpaceId) return cachedPrivateSpaceId; - try { - return (await SecureStore.getItemAsync(PRIVATE_SPACE_KEY)) ?? ""; - } catch { - return ""; - } -} - async function clearPrivateSpace(): Promise { cachedPrivateSpaceId = ""; try { @@ -104,20 +94,35 @@ async function clearPrivateSpace(): Promise { } } +async function snapshotPrivateSpace(): Promise<{ ok: true; value: string } | { ok: false }> { + if (cachedPrivateSpaceId) return { ok: true, value: cachedPrivateSpaceId }; + try { + return { ok: true, value: (await SecureStore.getItemAsync(PRIVATE_SPACE_KEY)) ?? "" }; + } catch { + return { ok: false }; + } +} + /** Clears session + space for an endpoint change. Restores both if either wipe fails. */ async function clearCredentialsForEndpointChange(): Promise< { ok: true; previousToken: string; previousSpace: string } | { ok: false; result: EndpointResult } > { const previousToken = await peekStoredSessionToken(); - const previousSpace = await peekStoredPrivateSpaceId(); + const previousSpace = await snapshotPrivateSpace(); + if (!previousSpace.ok) { + return { + ok: false, + result: { ok: false, error: "Could not clear the previous server session" }, + }; + } const sessionCleared = await clearSessionToken(); const spaceCleared = await clearPrivateSpace(); if (sessionCleared && spaceCleared) { - return { ok: true, previousToken, previousSpace }; + return { ok: true, previousToken, previousSpace: previousSpace.value }; } await restoreSessionToken(previousToken); - if (previousSpace) await selectPrivateSpace(previousSpace); + if (previousSpace.value) await selectPrivateSpace(previousSpace.value); return { ok: false, result: { ok: false, error: "Could not clear the previous server session" } }; } From 9fec93dad32fc7b68125537a8dbdef1699d4b9bd Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 01:29:24 +0000 Subject: [PATCH 25/54] fix(web): ignore private-space localStorage write failures selectPrivateSpace must not throw after privateSpaces.create succeeds, or the create dialog reports a false failure and a retry can duplicate the space. Match mobile: keep navigation going when storage is unavailable. Co-authored-by: Elie Steinbock --- apps/web/src/lib/rpc.test.ts | 26 ++++++++++++++++++++++++++ apps/web/src/lib/rpc.ts | 12 ++++++++++-- 2 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 apps/web/src/lib/rpc.test.ts diff --git a/apps/web/src/lib/rpc.test.ts b/apps/web/src/lib/rpc.test.ts new file mode 100644 index 0000000000..763eb616aa --- /dev/null +++ b/apps/web/src/lib/rpc.test.ts @@ -0,0 +1,26 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { clearPrivateSpaceSelection, selectedPrivateSpaceId, selectPrivateSpace } from "./rpc.js"; + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("private space selection storage", () => { + it("swallows localStorage write failures so callers can keep navigating", () => { + const localStorage = { + getItem: () => null, + setItem: () => { + throw new Error("quota exceeded"); + }, + removeItem: () => { + throw new Error("quota exceeded"); + }, + }; + vi.stubGlobal("window", { localStorage }); + vi.stubGlobal("localStorage", localStorage); + + expect(() => selectPrivateSpace("space-support")).not.toThrow(); + expect(() => clearPrivateSpaceSelection()).not.toThrow(); + expect(selectedPrivateSpaceId()).toBeNull(); + }); +}); diff --git a/apps/web/src/lib/rpc.ts b/apps/web/src/lib/rpc.ts index 0897ce3823..ac03a896b9 100644 --- a/apps/web/src/lib/rpc.ts +++ b/apps/web/src/lib/rpc.ts @@ -17,11 +17,19 @@ export function selectedPrivateSpaceId(): string | null { } export function selectPrivateSpace(id: string): void { - window.localStorage.setItem(WORKSPACE_STORAGE_KEY, id); + try { + window.localStorage.setItem(WORKSPACE_STORAGE_KEY, id); + } catch { + // Keep navigation usable when storage is unavailable (create must not abort after success). + } } export function clearPrivateSpaceSelection(): void { - window.localStorage.removeItem(WORKSPACE_STORAGE_KEY); + try { + window.localStorage.removeItem(WORKSPACE_STORAGE_KEY); + } catch { + // Ignore storage failures on sign-out / reset paths. + } } /** Adds `x-rakazo-workspace-id` when a private space is selected. */ From 877732453ea2f19477f5824b84984e44098443d1 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 04:32:42 +0300 Subject: [PATCH 26/54] fix(web): fail closed on workspace storage errors --- apps/web/src/lib/rpc.test.ts | 14 ++++++++++++-- apps/web/src/lib/rpc.ts | 5 +++-- apps/web/src/pages/Shell.tsx | 9 +++++++-- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/rpc.test.ts b/apps/web/src/lib/rpc.test.ts index 763eb616aa..ce0a04e189 100644 --- a/apps/web/src/lib/rpc.test.ts +++ b/apps/web/src/lib/rpc.test.ts @@ -6,7 +6,7 @@ afterEach(() => { }); describe("private space selection storage", () => { - it("swallows localStorage write failures so callers can keep navigating", () => { + it("reports localStorage write failures without throwing", () => { const localStorage = { getItem: () => null, setItem: () => { @@ -19,8 +19,18 @@ describe("private space selection storage", () => { vi.stubGlobal("window", { localStorage }); vi.stubGlobal("localStorage", localStorage); - expect(() => selectPrivateSpace("space-support")).not.toThrow(); + expect(selectPrivateSpace("space-support")).toBe(false); expect(() => clearPrivateSpaceSelection()).not.toThrow(); expect(selectedPrivateSpaceId()).toBeNull(); }); + + it("reports when a private-space selection was persisted", () => { + const setItem = vi.fn(); + const localStorage = { getItem: () => null, setItem, removeItem: vi.fn() }; + vi.stubGlobal("window", { localStorage }); + vi.stubGlobal("localStorage", localStorage); + + expect(selectPrivateSpace("space-support")).toBe(true); + expect(setItem).toHaveBeenCalledWith("rakazo:private-space-id", "space-support"); + }); }); diff --git a/apps/web/src/lib/rpc.ts b/apps/web/src/lib/rpc.ts index ac03a896b9..84b9f4905c 100644 --- a/apps/web/src/lib/rpc.ts +++ b/apps/web/src/lib/rpc.ts @@ -16,11 +16,12 @@ export function selectedPrivateSpaceId(): string | null { } } -export function selectPrivateSpace(id: string): void { +export function selectPrivateSpace(id: string): boolean { try { window.localStorage.setItem(WORKSPACE_STORAGE_KEY, id); + return true; } catch { - // Keep navigation usable when storage is unavailable (create must not abort after success). + return false; } } diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 3b747f9d8c..9872c65036 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -1324,12 +1324,13 @@ export function ShellPage() { setMobileSidebarOpen(false); const previousSpaceId = selectedPrivateSpaceId(); // Persist the active workspace id (including primary) so voice/RPC headers match the chat. - selectPrivateSpace(workspaceId); + const selectionStored = selectPrivateSpace(workspaceId); const previousEffective = previousSpaceId ?? bootstrapMe?.workspaceId; const boundaryChanged = previousEffective !== workspaceId; // Soft-navigate within the same workspace; reload only when the auth boundary changes // so bootstrapped bots/groups match the request header. if (boundaryChanged) { + if (!selectionStored) return; window.location.assign(path); return; } @@ -3355,7 +3356,11 @@ export function ShellPage() { onCancel={() => setNewPrivateSpaceOpen(false)} onConfirm={async (name) => { const space = await rpc.privateSpaces.create({ name }); - selectPrivateSpace(space.id); + if (!selectPrivateSpace(space.id)) { + setNewPrivateSpaceOpen(false); + await refreshBots(); + return; + } window.location.assign("/onboarding"); }} /> From 21e5dd5998a909b405c10303dcf7c4abd409e968 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 04:40:25 +0300 Subject: [PATCH 27/54] fix(web): require workspace persistence before navigation --- apps/web/src/pages/Shell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 9872c65036..c98bf5e842 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -1325,12 +1325,12 @@ export function ShellPage() { const previousSpaceId = selectedPrivateSpaceId(); // Persist the active workspace id (including primary) so voice/RPC headers match the chat. const selectionStored = selectPrivateSpace(workspaceId); + if (!selectionStored) return; const previousEffective = previousSpaceId ?? bootstrapMe?.workspaceId; const boundaryChanged = previousEffective !== workspaceId; // Soft-navigate within the same workspace; reload only when the auth boundary changes // so bootstrapped bots/groups match the request header. if (boundaryChanged) { - if (!selectionStored) return; window.location.assign(path); return; } From c34e70904ad2d1eb0c998754ae83eb73464d32e1 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 30 Aug 2026 01:40:29 +0000 Subject: [PATCH 28/54] fix(web): block all space navigation when selection cannot be stored Require a durable workspace selection before soft-navigate or reload. If setItem fails but the desired id is already in storage, treat that as success so same-space chat switches still work. Co-authored-by: Elie Steinbock --- apps/web/src/lib/rpc.test.ts | 15 +++++++++++++++ apps/web/src/lib/rpc.ts | 7 ++++++- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/rpc.test.ts b/apps/web/src/lib/rpc.test.ts index ce0a04e189..8e311c443b 100644 --- a/apps/web/src/lib/rpc.test.ts +++ b/apps/web/src/lib/rpc.test.ts @@ -24,6 +24,21 @@ describe("private space selection storage", () => { expect(selectedPrivateSpaceId()).toBeNull(); }); + it("treats an already-persisted selection as success when writes fail", () => { + const localStorage = { + getItem: (key: string) => (key === "rakazo:private-space-id" ? "space-support" : null), + setItem: () => { + throw new Error("quota exceeded"); + }, + removeItem: vi.fn(), + }; + vi.stubGlobal("window", { localStorage }); + vi.stubGlobal("localStorage", localStorage); + + expect(selectPrivateSpace("space-support")).toBe(true); + expect(selectPrivateSpace("space-other")).toBe(false); + }); + it("reports when a private-space selection was persisted", () => { const setItem = vi.fn(); const localStorage = { getItem: () => null, setItem, removeItem: vi.fn() }; diff --git a/apps/web/src/lib/rpc.ts b/apps/web/src/lib/rpc.ts index 84b9f4905c..69f5c4541c 100644 --- a/apps/web/src/lib/rpc.ts +++ b/apps/web/src/lib/rpc.ts @@ -21,7 +21,12 @@ export function selectPrivateSpace(id: string): boolean { window.localStorage.setItem(WORKSPACE_STORAGE_KEY, id); return true; } catch { - return false; + try { + // Write failed but the desired selection is already durable — treat as success. + return window.localStorage.getItem(WORKSPACE_STORAGE_KEY) === id; + } catch { + return false; + } } } From 1868cca0d3fbd75cb0bfc27efcfc1f98bda0d891 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:29:21 +0300 Subject: [PATCH 29/54] test: assert private-space notification setting --- packages/testkit/src/authorization.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index 4b52df1b58..f1edf3973b 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -532,12 +532,12 @@ describeWithDatabase("API authorization and resource isolation", () => { expect.arrayContaining([ expect.objectContaining({ id: original.workspaceId, - bots: [expect.objectContaining({ id: originalBot.id, notifyOnFinish: true })], + bots: [expect.objectContaining({ id: originalBot.id, notifyOnFinish: false })], }), expect.objectContaining({ id: support.id, name: "Customer support", - bots: [expect.objectContaining({ id: supportBot.id, notifyOnFinish: true })], + bots: [expect.objectContaining({ id: supportBot.id, notifyOnFinish: false })], }), ]), ); From 4b77e2464ca90c7cdb5c4456c4d1253780a9027e Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:32:09 +0300 Subject: [PATCH 30/54] fix(mobile): preserve session on failed endpoint switch --- apps/mobile/lib/api.test.ts | 28 ++++++++++++++++++++++++++++ apps/mobile/lib/api.ts | 10 +++++----- apps/mobile/lib/session.test.ts | 11 +++++++++-- apps/mobile/lib/session.ts | 20 ++++++++------------ 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index cdb7035e1c..6fe1be5920 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -20,6 +20,7 @@ import { signOut, subscribeThread, } from "./api.js"; +import { saveSessionToken } from "./session.js"; vi.mock("expo-secure-store", () => ({ getItemAsync: vi.fn(), @@ -299,6 +300,33 @@ describe("mobile API authentication", () => { expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled(); }); + it("preserves credentials when the active session cannot be snapshotted", async () => { + await saveSessionToken("session-token"); + await selectPrivateSpace("space-support"); + vi.mocked(SecureStore.getItemAsync).mockRejectedValue(new Error("device locked")); + vi.mocked(SecureStore.deleteItemAsync).mockClear(); + const previous = currentApiBase(); + const next = + previous === "https://second-server.example" + ? "https://third-server.example" + : "https://second-server.example"; + + await expect(saveApiBase(next)).resolves.toEqual({ + ok: false, + error: "Could not clear the previous server session", + }); + expect(currentApiBase()).toBe(previous); + expect(SecureStore.deleteItemAsync).not.toHaveBeenCalled(); + + vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => + key === "rakazo.session_token" ? "session-token" : null, + ); + await expect(authHeaders()).resolves.toEqual({ + authorization: "Bearer session-token", + "x-rakazo-workspace-id": "space-support", + }); + }); + it("keeps the in-memory session across consecutive failed endpoint switches", async () => { vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { if (key === "rakazo.session_token") return "session-token"; diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 1dd215a217..f14c32acf1 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -28,9 +28,9 @@ import { resumeLiveNotifications } from "./live-notifications"; import { clearSessionToken, loadSessionToken, - peekStoredSessionToken, restoreSessionToken, saveSessionToken, + snapshotSessionToken, tokenFromAuthResponse, } from "./session"; @@ -110,9 +110,9 @@ async function snapshotPrivateSpace(): Promise<{ ok: true; value: string } | { o async function clearCredentialsForEndpointChange(): Promise< { ok: true; previousToken: string; previousSpace: string } | { ok: false; result: EndpointResult } > { - const previousToken = await peekStoredSessionToken(); + const previousToken = await snapshotSessionToken(); const previousSpace = await snapshotPrivateSpace(); - if (!previousSpace.ok) { + if (!previousToken.ok || !previousSpace.ok) { return { ok: false, result: { ok: false, error: "Could not clear the previous server session" }, @@ -121,10 +121,10 @@ async function clearCredentialsForEndpointChange(): Promise< const sessionCleared = await clearSessionToken(); const spaceCleared = await clearPrivateSpace(); if (sessionCleared && spaceCleared) { - return { ok: true, previousToken, previousSpace: previousSpace.value }; + return { ok: true, previousToken: previousToken.value, previousSpace: previousSpace.value }; } - await restoreSessionToken(previousToken); + await restoreSessionToken(previousToken.value); if (previousSpace.value) await selectPrivateSpace(previousSpace.value); return { ok: false, result: { ok: false, error: "Could not clear the previous server session" } }; } diff --git a/apps/mobile/lib/session.test.ts b/apps/mobile/lib/session.test.ts index 53e4c05e46..dca3075086 100644 --- a/apps/mobile/lib/session.test.ts +++ b/apps/mobile/lib/session.test.ts @@ -3,9 +3,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { clearSessionToken, loadSessionToken, - peekStoredSessionToken, restoreSessionToken, saveSessionToken, + snapshotSessionToken, tokenFromAuthResponse, } from "./session.js"; @@ -61,12 +61,19 @@ describe("mobile session storage", () => { await restoreSessionToken("secret-token"); await expect(loadSessionToken()).resolves.toBe("secret-token"); - await expect(peekStoredSessionToken()).resolves.toBe("secret-token"); + await expect(snapshotSessionToken()).resolves.toEqual({ ok: true, value: "secret-token" }); vi.mocked(SecureStore.deleteItemAsync).mockRejectedValue(new Error("device locked")); await expect(clearSessionToken()).resolves.toBe(false); await expect(loadSessionToken()).resolves.toBe(""); }); + + it("distinguishes an unreadable token store from an empty session", async () => { + await saveSessionToken("secret-token"); + vi.mocked(SecureStore.getItemAsync).mockRejectedValueOnce(new Error("device locked")); + + await expect(snapshotSessionToken()).resolves.toEqual({ ok: false }); + }); }); describe("auth response token parsing", () => { diff --git a/apps/mobile/lib/session.ts b/apps/mobile/lib/session.ts index 4d7c07e810..2f7f28c835 100644 --- a/apps/mobile/lib/session.ts +++ b/apps/mobile/lib/session.ts @@ -8,13 +8,8 @@ let sessionInvalidated = false; let sessionFallback: string | undefined; export async function loadSessionToken() { - if (sessionFallback !== undefined) return sessionFallback; - if (sessionInvalidated) return ""; - try { - return (await SecureStore.getItemAsync(SESSION_KEY)) ?? ""; - } catch { - return ""; - } + const snapshot = await snapshotSessionToken(); + return snapshot.ok ? snapshot.value : ""; } export async function saveSessionToken(token: string) { @@ -60,13 +55,14 @@ export async function restoreSessionToken(token: string) { } } -/** Read the active token for restore snapshots, including in-memory fallbacks. */ -export async function peekStoredSessionToken() { - if (sessionFallback) return sessionFallback; +/** Snapshots the active token without treating an unreadable store as an empty session. */ +export async function snapshotSessionToken(): Promise<{ ok: true; value: string } | { ok: false }> { + if (sessionFallback !== undefined) return { ok: true, value: sessionFallback }; + if (sessionInvalidated) return { ok: true, value: "" }; try { - return (await SecureStore.getItemAsync(SESSION_KEY)) ?? ""; + return { ok: true, value: (await SecureStore.getItemAsync(SESSION_KEY)) ?? "" }; } catch { - return ""; + return { ok: false }; } } From 3a66f00c7bb85cfa2403fd2a753a0f6ea3bf4340 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:45:21 +0300 Subject: [PATCH 31/54] test(web): intercept consolidated roster refresh --- apps/web/e2e/bot-organization.spec.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/e2e/bot-organization.spec.ts b/apps/web/e2e/bot-organization.spec.ts index cb59bc4522..49a5b7a632 100644 --- a/apps/web/e2e/bot-organization.spec.ts +++ b/apps/web/e2e/bot-organization.spec.ts @@ -94,7 +94,7 @@ test("bots can be reordered by drag or keyboard and keep that order", async ({ p releaseStaleList = resolve; }); let interceptedList = false; - await page.route("**/rpc/bots/list", async (route) => { + await page.route("**/rpc/privateSpaces/list", async (route) => { if (interceptedList) { await route.continue(); return; From 79fa6db4c6223e5a57b825f11939acdbb305fc05 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:51:09 +0300 Subject: [PATCH 32/54] fix(mobile): keep invalidated sessions fail closed --- apps/mobile/lib/api.test.ts | 29 ++++++++++++++++++++++++++++- apps/mobile/lib/api.ts | 5 ++--- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index 9dfa1fd8d6..8dc86c00c3 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -20,7 +20,7 @@ import { signOut, subscribeThread, } from "./api.js"; -import { saveSessionToken } from "./session.js"; +import { clearSessionToken, saveSessionToken, snapshotSessionToken } from "./session.js"; vi.mock("expo-secure-store", () => ({ getItemAsync: vi.fn(), @@ -327,6 +327,33 @@ describe("mobile API authentication", () => { }); }); + it("keeps an invalidated empty session fail closed during credential rollback", async () => { + await saveSessionToken("session-token"); + await selectPrivateSpace("space-support"); + vi.mocked(SecureStore.deleteItemAsync).mockImplementation(async (key) => { + if (key === "rakazo.session_token") throw new Error("device locked"); + }); + vi.mocked(SecureStore.setItemAsync).mockImplementation(async (key, value) => { + if (key === "rakazo.session_token" && value === "") throw new Error("device locked"); + }); + await expect(clearSessionToken()).resolves.toBe(false); + vi.mocked(SecureStore.getItemAsync).mockResolvedValue("stale-session-token"); + const previous = currentApiBase(); + const next = + previous === "https://second-server.example" + ? "https://third-server.example" + : "https://second-server.example"; + + await expect(saveApiBase(next)).resolves.toEqual({ + ok: false, + error: "Could not clear the previous server session", + }); + expect(currentApiBase()).toBe(previous); + await expect(snapshotSessionToken()).resolves.toEqual({ ok: true, value: "" }); + + await saveSessionToken("session-token"); + }); + it("keeps the in-memory session across consecutive failed endpoint switches", async () => { vi.mocked(SecureStore.getItemAsync).mockImplementation(async (key) => { if (key === "rakazo.session_token") return "session-token"; diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index b60018a5c8..292ef15a89 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -124,13 +124,12 @@ async function clearCredentialsForEndpointChange(): Promise< return { ok: true, previousToken: previousToken.value, previousSpace: previousSpace.value }; } - await restoreSessionToken(previousToken.value); - if (previousSpace.value) await selectPrivateSpace(previousSpace.value); + await restoreCredentials(previousToken.value, previousSpace.value); return { ok: false, result: { ok: false, error: "Could not clear the previous server session" } }; } async function restoreCredentials(previousToken: string, previousSpace: string) { - await restoreSessionToken(previousToken); + if (previousToken) await restoreSessionToken(previousToken); if (previousSpace) await selectPrivateSpace(previousSpace); } From 9607099b137a4e87b3766634ad324ca882bc2e22 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 11:59:09 +0300 Subject: [PATCH 33/54] test(mobile): reset session state between cases --- apps/mobile/lib/api.test.ts | 10 ++++++++-- apps/mobile/lib/session.test.ts | 8 ++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/mobile/lib/api.test.ts b/apps/mobile/lib/api.test.ts index 8dc86c00c3..b0cee51a9c 100644 --- a/apps/mobile/lib/api.test.ts +++ b/apps/mobile/lib/api.test.ts @@ -20,7 +20,12 @@ import { signOut, subscribeThread, } from "./api.js"; -import { clearSessionToken, saveSessionToken, snapshotSessionToken } from "./session.js"; +import { + clearSessionToken, + restoreSessionToken, + saveSessionToken, + snapshotSessionToken, +} from "./session.js"; vi.mock("expo-secure-store", () => ({ getItemAsync: vi.fn(), @@ -38,11 +43,12 @@ afterEach(() => { }); describe("mobile API authentication", () => { - beforeEach(() => { + beforeEach(async () => { vi.restoreAllMocks(); vi.mocked(SecureStore.getItemAsync).mockReset(); vi.mocked(SecureStore.setItemAsync).mockReset(); vi.mocked(SecureStore.deleteItemAsync).mockReset(); + await restoreSessionToken(""); }); it("persists a successful sign-in token and sends the native origin", async () => { diff --git a/apps/mobile/lib/session.test.ts b/apps/mobile/lib/session.test.ts index dca3075086..bed4ced5a0 100644 --- a/apps/mobile/lib/session.test.ts +++ b/apps/mobile/lib/session.test.ts @@ -19,10 +19,11 @@ vi.mock("./live-notifications.js", () => ({ })); describe("mobile session storage", () => { - beforeEach(() => { + beforeEach(async () => { vi.mocked(SecureStore.getItemAsync).mockReset(); vi.mocked(SecureStore.setItemAsync).mockReset(); vi.mocked(SecureStore.deleteItemAsync).mockReset(); + await restoreSessionToken(""); }); it("stores and clears only the session token key", async () => { @@ -54,6 +55,10 @@ describe("mobile session storage", () => { vi.mocked(SecureStore.getItemAsync).mockRejectedValueOnce(new Error("device locked")); await expect(loadSessionToken()).resolves.toBe(""); + + expect(SecureStore.getItemAsync).toHaveBeenCalledTimes(2); + expect(SecureStore.getItemAsync).toHaveBeenNthCalledWith(1, "rakazo.session_token"); + expect(SecureStore.getItemAsync).toHaveBeenNthCalledWith(2, "rakazo.session_token"); }); it("restores the active session in memory when persistence is unavailable", async () => { @@ -69,7 +74,6 @@ describe("mobile session storage", () => { }); it("distinguishes an unreadable token store from an empty session", async () => { - await saveSessionToken("secret-token"); vi.mocked(SecureStore.getItemAsync).mockRejectedValueOnce(new Error("device locked")); await expect(snapshotSessionToken()).resolves.toEqual({ ok: false }); From e8b03d97925bfe7658abd805e024271dfad3b917 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:06:28 +0300 Subject: [PATCH 34/54] feat(db): model private spaces within organizations --- apps/api/src/router.ts | 88 ++++++----- apps/mobile/lib/api.ts | 4 - .../migration.sql | 69 +++++++++ .../migration.sql | 28 ++++ .../migration.sql | 28 ++++ .../migration.sql | 57 +++++++ packages/db/prisma/schema.prisma | 146 +++++++++++------- packages/db/src/bootstrap-user.test.ts | 23 ++- packages/db/src/bootstrap-user.ts | 34 +++- packages/db/src/groups.test.ts | 60 +++++++ packages/db/src/groups.ts | 81 +++++++++- packages/db/src/phone.postgres.test.ts | 10 ++ packages/db/src/phone.test.ts | 2 +- packages/db/src/phone.ts | 4 +- packages/db/src/repos.test.ts | 48 ++++++ packages/db/src/repos.ts | 93 +++++++++-- packages/db/src/scope.test.ts | 13 +- packages/db/src/scope.ts | 16 +- packages/db/src/workspaces.ts | 24 +-- packages/testkit/src/authorization.test.ts | 88 ++++++++++- 20 files changed, 758 insertions(+), 158 deletions(-) create mode 100644 packages/db/prisma/migrations/20260830150000_organization_workspaces/migration.sql create mode 100644 packages/db/prisma/migrations/20260830150001_workspace_resource_fks_not_valid/migration.sql create mode 100644 packages/db/prisma/migrations/20260830150002_validate_workspace_resource_fks/migration.sql create mode 100644 packages/db/prisma/migrations/20260830150003_swap_workspace_resource_fks/migration.sql diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index 930ed49575..cdd75aae8d 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -90,9 +90,9 @@ import { import { appendEventInTransaction, createGroupRepos, - createOwnedWorkspace, createRepos, createThreadMessageInTransaction, + createWorkspace, createWorkspaceDefaults, findDefaultModelCredential, findDefaultVoiceCredential, @@ -372,7 +372,7 @@ export function createRouter(deps: RouterDeps) { }, privateSpaces: { list: authed.privateSpaces.list.handler(async ({ context }) => - privateSpaceNavigationDto(deps, context.actor), + privateSpaceNavigationDto(deps, context.actor, repos, groupRepos), ), create: authed.privateSpaces.create.handler(async ({ context, input }) => { const workspaceId = randomUUID(); @@ -380,16 +380,26 @@ export function createRouter(deps: RouterDeps) { await withSerializableRetry(() => deps.prisma.$transaction( async (tx) => { - const count = await tx.member.count({ where: { userId: context.actor.userId } }); + const currentWorkspace = await tx.workspace.findUnique({ + where: { id: context.actor.workspaceId }, + select: { organizationId: true }, + }); + if (!currentWorkspace) throw new IsolationError(); + const count = await tx.workspaceMember.count({ + where: { + userId: context.actor.userId, + organizationId: currentWorkspace.organizationId, + }, + }); if (count >= 32) { throw new ORPCError("BAD_REQUEST", { message: "Private space limit reached" }); } - await createOwnedWorkspace(tx, { + await createWorkspace(tx, { workspaceId, - membershipId: randomUUID(), + workspaceMembershipId: randomUUID(), + organizationId: currentWorkspace.organizationId, userId: context.actor.userId, name: input.name, - slug: `space-${randomUUID()}`, createdAt, }); await createWorkspaceDefaults(tx, { @@ -414,7 +424,7 @@ export function createRouter(deps: RouterDeps) { const actor = context.actor; const [me, navigation, archivedBots, archivedGroups] = await Promise.all([ meDto(deps, actor), - privateSpaceNavigationDto(deps, actor), + privateSpaceNavigationDto(deps, actor, repos, groupRepos), repos.listBots(actor, { archived: true }), groupRepos.listGroups(actor, { archived: true }), ]); @@ -3388,56 +3398,60 @@ function mapUpdaterError(error: unknown): never { async function privateSpaceNavigationDto( deps: RouterDeps, actor: Actor, + repos: ReturnType, + groupRepos: ReturnType, ): Promise { - const memberships = await deps.prisma.member.findMany({ - where: { userId: actor.userId }, + const currentWorkspace = await deps.prisma.workspace.findUnique({ + where: { id: actor.workspaceId }, + select: { organizationId: true }, + }); + if (!currentWorkspace) throw new IsolationError(); + const memberships = await deps.prisma.workspaceMember.findMany({ + where: { userId: actor.userId, organizationId: currentWorkspace.organizationId }, select: { - organizationId: true, - organization: { select: { name: true } }, + workspaceId: true, + workspace: { select: { name: true } }, }, orderBy: { createdAt: "asc" }, }); - const repos = createRepos(deps.prisma); - const groupRepos = createGroupRepos(deps.prisma); - const workspaceIds = memberships.map((membership) => membership.organizationId); - const [bots, groups, botSections] = await Promise.all([ - repos.listBotsForWorkspaces(actor, workspaceIds), - groupRepos.listGroupsForWorkspaces(actor, workspaceIds), - repos.listBotSectionsForWorkspaces(actor, workspaceIds), - ]); + const workspaceIds = memberships.map((membership) => membership.workspaceId); + const inactiveWorkspaceIds = workspaceIds.filter( + (workspaceId) => workspaceId !== actor.workspaceId, + ); + const [currentBots, currentGroups, inactiveBots, inactiveGroups, botSections] = await Promise.all( + [ + repos.listBots(actor), + groupRepos.listGroups(actor), + repos.listPrivateSpaceBotsForWorkspaces(actor, inactiveWorkspaceIds), + groupRepos.listPrivateSpaceGroupsForWorkspaces(actor, inactiveWorkspaceIds), + repos.listBotSectionsForWorkspaces(actor, workspaceIds), + ], + ); const currentMembership = memberships.find( - (membership) => membership.organizationId === actor.workspaceId, + (membership) => membership.workspaceId === actor.workspaceId, ); if (!currentMembership) throw new IsolationError(); - const botsByWorkspace = partitionByWorkspace(bots); - const groupsByWorkspace = partitionByWorkspace(groups); + const botsByWorkspace = partitionByWorkspace([...currentBots, ...inactiveBots]); + const groupsByWorkspace = partitionByWorkspace([...currentGroups, ...inactiveGroups]); const sectionsByWorkspace = partitionByWorkspace(botSections); const botsFor = (workspaceId: string) => botsByWorkspace.get(workspaceId) ?? []; const groupsFor = (workspaceId: string) => groupsByWorkspace.get(workspaceId) ?? []; const sectionsFor = (workspaceId: string) => sectionsByWorkspace.get(workspaceId) ?? []; - const currentBots = botsFor(actor.workspaceId); - const currentGroups = groupsFor(actor.workspaceId); return { current: { id: actor.workspaceId, - name: currentMembership.organization.name, + name: currentMembership.workspace.name, bots: currentBots, groups: currentGroups, botSections: sectionsFor(actor.workspaceId), }, privateSpaces: memberships.map((membership) => { - const workspaceBots = - membership.organizationId === actor.workspaceId - ? currentBots - : botsFor(membership.organizationId); - const workspaceGroups = - membership.organizationId === actor.workspaceId - ? currentGroups - : groupsFor(membership.organizationId); + const workspaceBots = botsFor(membership.workspaceId); + const workspaceGroups = groupsFor(membership.workspaceId); return { - id: membership.organizationId, - name: membership.organization.name, + id: membership.workspaceId, + name: membership.workspace.name, bots: workspaceBots.map((bot) => ({ id: bot.id, workspaceId: bot.workspaceId, @@ -3463,7 +3477,7 @@ async function privateSpaceNavigationDto( unread: group.unread, updatedAt: group.updatedAt, })), - botSections: sectionsFor(membership.organizationId), + botSections: sectionsFor(membership.workspaceId), }; }), }; @@ -3740,8 +3754,8 @@ async function persistModelCredential( async function requireWorkspaceOwner(prisma: PrismaClient, actor: Actor): Promise { const member = await prisma.member.findFirst({ where: { - organizationId: actor.workspaceId, userId: actor.userId, + workspaceMemberships: { some: { workspaceId: actor.workspaceId } }, }, select: { role: true }, }); diff --git a/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts index 292ef15a89..f322dd7265 100644 --- a/apps/mobile/lib/api.ts +++ b/apps/mobile/lib/api.ts @@ -8,8 +8,6 @@ import type { ModelCatalogEntry, ModelCredential, PrivateSpace, - PrivateSpaceBot, - PrivateSpaceGroup, PrivateSpaceNavigation, } from "@rakazo/contracts"; import { @@ -319,8 +317,6 @@ export type MobileGroup = Pick< Partial>; export type MobilePrivateSpace = PrivateSpace; -export type MobilePrivateSpaceBot = PrivateSpaceBot; -export type MobilePrivateSpaceGroup = PrivateSpaceGroup; export type MobilePrivateSpaceNavigation = PrivateSpaceNavigation; export type MobileSnapshot = { diff --git a/packages/db/prisma/migrations/20260830150000_organization_workspaces/migration.sql b/packages/db/prisma/migrations/20260830150000_organization_workspaces/migration.sql new file mode 100644 index 0000000000..d78260a541 --- /dev/null +++ b/packages/db/prisma/migrations/20260830150000_organization_workspaces/migration.sql @@ -0,0 +1,69 @@ +BEGIN; + +-- Organizations remain the company/account boundary. Workspaces are the +-- private execution and data boundary inside an organization. +CREATE TABLE "workspaces" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "workspaces_pkey" PRIMARY KEY ("id") +); + +CREATE TABLE "workspace_members" ( + "id" TEXT NOT NULL, + "workspaceId" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "workspace_members_pkey" PRIMARY KEY ("id") +); + +-- Preserve every existing resource ID and scope by giving each organization +-- a default workspace with the same ID. No bot, thread, memory, or secret row +-- needs to be rewritten. +INSERT INTO "workspaces" ("id", "organizationId", "name", "createdAt") +SELECT "id", "id", "name", "createdAt" +FROM "organization"; + +INSERT INTO "workspace_members" ( + "id", + "workspaceId", + "organizationId", + "userId", + "createdAt" +) +SELECT "id", "organizationId", "organizationId", "userId", "createdAt" +FROM "member"; + +CREATE UNIQUE INDEX "workspaces_id_organizationId_key" +ON "workspaces"("id", "organizationId"); +CREATE INDEX "workspaces_organizationId_createdAt_idx" +ON "workspaces"("organizationId", "createdAt"); +CREATE UNIQUE INDEX "workspace_members_workspaceId_userId_key" +ON "workspace_members"("workspaceId", "userId"); +CREATE INDEX "workspace_members_organizationId_userId_idx" +ON "workspace_members"("organizationId", "userId"); +CREATE INDEX "workspace_members_userId_createdAt_idx" +ON "workspace_members"("userId", "createdAt"); + +ALTER TABLE "workspaces" +ADD CONSTRAINT "workspaces_organizationId_fkey" +FOREIGN KEY ("organizationId") REFERENCES "organization"("id") +ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "workspace_members" +ADD CONSTRAINT "workspace_members_workspaceId_organizationId_fkey" +FOREIGN KEY ("workspaceId", "organizationId") +REFERENCES "workspaces"("id", "organizationId") +ON DELETE CASCADE ON UPDATE CASCADE; + +ALTER TABLE "workspace_members" +ADD CONSTRAINT "workspace_members_organizationId_userId_fkey" +FOREIGN KEY ("organizationId", "userId") +REFERENCES "member"("organizationId", "userId") +ON DELETE CASCADE ON UPDATE CASCADE; + +COMMIT; diff --git a/packages/db/prisma/migrations/20260830150001_workspace_resource_fks_not_valid/migration.sql b/packages/db/prisma/migrations/20260830150001_workspace_resource_fks_not_valid/migration.sql new file mode 100644 index 0000000000..46791bd1f2 --- /dev/null +++ b/packages/db/prisma/migrations/20260830150001_workspace_resource_fks_not_valid/migration.sql @@ -0,0 +1,28 @@ +-- Add the replacement constraints without scanning existing rows. Each +-- statement commits independently so locks are released before the next table. +ALTER TABLE "action_approval_rules" ADD CONSTRAINT "action_approval_rules_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "action_auto_review_preferences" ADD CONSTRAINT "action_auto_review_preferences_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "bots" ADD CONSTRAINT "bots_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "bot_sections" ADD CONSTRAINT "bot_sections_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "bot_deletions" ADD CONSTRAINT "bot_deletions_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "chat_groups" ADD CONSTRAINT "chat_groups_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "threads" ADD CONSTRAINT "threads_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "events" ADD CONSTRAINT "events_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "tasks" ADD CONSTRAINT "tasks_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "runs" ADD CONSTRAINT "runs_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "external_effects" ADD CONSTRAINT "external_effects_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "routines" ADD CONSTRAINT "routines_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "scratchpad_items" ADD CONSTRAINT "scratchpad_items_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "taught_skills" ADD CONSTRAINT "taught_skills_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "agent_skills" ADD CONSTRAINT "agent_skills_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "connections" ADD CONSTRAINT "connections_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "capability_installs" ADD CONSTRAINT "capability_installs_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "memory_documents" ADD CONSTRAINT "memory_documents_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "agent_homes" ADD CONSTRAINT "agent_homes_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "browser_profiles" ADD CONSTRAINT "browser_profiles_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "computers" ADD CONSTRAINT "computers_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "artifacts" ADD CONSTRAINT "artifacts_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "usage_records" ADD CONSTRAINT "usage_records_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "notification_preferences" ADD CONSTRAINT "notification_preferences_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "workspace_memory_configs" ADD CONSTRAINT "workspace_memory_configs_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; +ALTER TABLE "mcp_servers" ADD CONSTRAINT "mcp_servers_workspace_fkey" FOREIGN KEY ("workspaceId") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE NOT VALID; diff --git a/packages/db/prisma/migrations/20260830150002_validate_workspace_resource_fks/migration.sql b/packages/db/prisma/migrations/20260830150002_validate_workspace_resource_fks/migration.sql new file mode 100644 index 0000000000..7653a59b05 --- /dev/null +++ b/packages/db/prisma/migrations/20260830150002_validate_workspace_resource_fks/migration.sql @@ -0,0 +1,28 @@ +-- PostgreSQL validates with a lighter lock than adding an immediately valid +-- foreign key, and each table releases that lock before the next validation. +ALTER TABLE "action_approval_rules" VALIDATE CONSTRAINT "action_approval_rules_workspace_fkey"; +ALTER TABLE "action_auto_review_preferences" VALIDATE CONSTRAINT "action_auto_review_preferences_workspace_fkey"; +ALTER TABLE "bots" VALIDATE CONSTRAINT "bots_workspace_fkey"; +ALTER TABLE "bot_sections" VALIDATE CONSTRAINT "bot_sections_workspace_fkey"; +ALTER TABLE "bot_deletions" VALIDATE CONSTRAINT "bot_deletions_workspace_fkey"; +ALTER TABLE "chat_groups" VALIDATE CONSTRAINT "chat_groups_workspace_fkey"; +ALTER TABLE "threads" VALIDATE CONSTRAINT "threads_workspace_fkey"; +ALTER TABLE "events" VALIDATE CONSTRAINT "events_workspace_fkey"; +ALTER TABLE "tasks" VALIDATE CONSTRAINT "tasks_workspace_fkey"; +ALTER TABLE "runs" VALIDATE CONSTRAINT "runs_workspace_fkey"; +ALTER TABLE "external_effects" VALIDATE CONSTRAINT "external_effects_workspace_fkey"; +ALTER TABLE "routines" VALIDATE CONSTRAINT "routines_workspace_fkey"; +ALTER TABLE "scratchpad_items" VALIDATE CONSTRAINT "scratchpad_items_workspace_fkey"; +ALTER TABLE "taught_skills" VALIDATE CONSTRAINT "taught_skills_workspace_fkey"; +ALTER TABLE "agent_skills" VALIDATE CONSTRAINT "agent_skills_workspace_fkey"; +ALTER TABLE "connections" VALIDATE CONSTRAINT "connections_workspace_fkey"; +ALTER TABLE "capability_installs" VALIDATE CONSTRAINT "capability_installs_workspace_fkey"; +ALTER TABLE "memory_documents" VALIDATE CONSTRAINT "memory_documents_workspace_fkey"; +ALTER TABLE "agent_homes" VALIDATE CONSTRAINT "agent_homes_workspace_fkey"; +ALTER TABLE "browser_profiles" VALIDATE CONSTRAINT "browser_profiles_workspace_fkey"; +ALTER TABLE "computers" VALIDATE CONSTRAINT "computers_workspace_fkey"; +ALTER TABLE "artifacts" VALIDATE CONSTRAINT "artifacts_workspace_fkey"; +ALTER TABLE "usage_records" VALIDATE CONSTRAINT "usage_records_workspace_fkey"; +ALTER TABLE "notification_preferences" VALIDATE CONSTRAINT "notification_preferences_workspace_fkey"; +ALTER TABLE "workspace_memory_configs" VALIDATE CONSTRAINT "workspace_memory_configs_workspace_fkey"; +ALTER TABLE "mcp_servers" VALIDATE CONSTRAINT "mcp_servers_workspace_fkey"; diff --git a/packages/db/prisma/migrations/20260830150003_swap_workspace_resource_fks/migration.sql b/packages/db/prisma/migrations/20260830150003_swap_workspace_resource_fks/migration.sql new file mode 100644 index 0000000000..c5a234114e --- /dev/null +++ b/packages/db/prisma/migrations/20260830150003_swap_workspace_resource_fks/migration.sql @@ -0,0 +1,57 @@ +BEGIN; + +-- Validation is complete, so the final swap only holds brief metadata locks. +ALTER TABLE "action_approval_rules" DROP CONSTRAINT "action_approval_rules_workspaceId_fkey"; +ALTER TABLE "action_approval_rules" RENAME CONSTRAINT "action_approval_rules_workspace_fkey" TO "action_approval_rules_workspaceId_fkey"; +ALTER TABLE "action_auto_review_preferences" DROP CONSTRAINT "action_auto_review_preferences_workspaceId_fkey"; +ALTER TABLE "action_auto_review_preferences" RENAME CONSTRAINT "action_auto_review_preferences_workspace_fkey" TO "action_auto_review_preferences_workspaceId_fkey"; +ALTER TABLE "bots" DROP CONSTRAINT "bots_workspaceId_fkey"; +ALTER TABLE "bots" RENAME CONSTRAINT "bots_workspace_fkey" TO "bots_workspaceId_fkey"; +ALTER TABLE "bot_sections" DROP CONSTRAINT "bot_sections_workspaceId_fkey"; +ALTER TABLE "bot_sections" RENAME CONSTRAINT "bot_sections_workspace_fkey" TO "bot_sections_workspaceId_fkey"; +ALTER TABLE "bot_deletions" DROP CONSTRAINT "bot_deletions_workspaceId_fkey"; +ALTER TABLE "bot_deletions" RENAME CONSTRAINT "bot_deletions_workspace_fkey" TO "bot_deletions_workspaceId_fkey"; +ALTER TABLE "chat_groups" DROP CONSTRAINT "chat_groups_workspaceId_fkey"; +ALTER TABLE "chat_groups" RENAME CONSTRAINT "chat_groups_workspace_fkey" TO "chat_groups_workspaceId_fkey"; +ALTER TABLE "threads" DROP CONSTRAINT "threads_workspaceId_fkey"; +ALTER TABLE "threads" RENAME CONSTRAINT "threads_workspace_fkey" TO "threads_workspaceId_fkey"; +ALTER TABLE "events" DROP CONSTRAINT "events_workspaceId_fkey"; +ALTER TABLE "events" RENAME CONSTRAINT "events_workspace_fkey" TO "events_workspaceId_fkey"; +ALTER TABLE "tasks" DROP CONSTRAINT "tasks_workspaceId_fkey"; +ALTER TABLE "tasks" RENAME CONSTRAINT "tasks_workspace_fkey" TO "tasks_workspaceId_fkey"; +ALTER TABLE "runs" DROP CONSTRAINT "runs_workspaceId_fkey"; +ALTER TABLE "runs" RENAME CONSTRAINT "runs_workspace_fkey" TO "runs_workspaceId_fkey"; +ALTER TABLE "external_effects" DROP CONSTRAINT "external_effects_workspaceId_fkey"; +ALTER TABLE "external_effects" RENAME CONSTRAINT "external_effects_workspace_fkey" TO "external_effects_workspaceId_fkey"; +ALTER TABLE "routines" DROP CONSTRAINT "routines_workspaceId_fkey"; +ALTER TABLE "routines" RENAME CONSTRAINT "routines_workspace_fkey" TO "routines_workspaceId_fkey"; +ALTER TABLE "scratchpad_items" DROP CONSTRAINT "scratchpad_items_workspaceId_fkey"; +ALTER TABLE "scratchpad_items" RENAME CONSTRAINT "scratchpad_items_workspace_fkey" TO "scratchpad_items_workspaceId_fkey"; +ALTER TABLE "taught_skills" DROP CONSTRAINT "taught_skills_workspaceId_fkey"; +ALTER TABLE "taught_skills" RENAME CONSTRAINT "taught_skills_workspace_fkey" TO "taught_skills_workspaceId_fkey"; +ALTER TABLE "agent_skills" DROP CONSTRAINT "agent_skills_workspaceId_fkey"; +ALTER TABLE "agent_skills" RENAME CONSTRAINT "agent_skills_workspace_fkey" TO "agent_skills_workspaceId_fkey"; +ALTER TABLE "connections" DROP CONSTRAINT "connections_workspaceId_fkey"; +ALTER TABLE "connections" RENAME CONSTRAINT "connections_workspace_fkey" TO "connections_workspaceId_fkey"; +ALTER TABLE "capability_installs" DROP CONSTRAINT "capability_installs_workspaceId_fkey"; +ALTER TABLE "capability_installs" RENAME CONSTRAINT "capability_installs_workspace_fkey" TO "capability_installs_workspaceId_fkey"; +ALTER TABLE "memory_documents" DROP CONSTRAINT "memory_documents_workspaceId_fkey"; +ALTER TABLE "memory_documents" RENAME CONSTRAINT "memory_documents_workspace_fkey" TO "memory_documents_workspaceId_fkey"; +ALTER TABLE "agent_homes" DROP CONSTRAINT "agent_homes_workspaceId_fkey"; +ALTER TABLE "agent_homes" RENAME CONSTRAINT "agent_homes_workspace_fkey" TO "agent_homes_workspaceId_fkey"; +ALTER TABLE "browser_profiles" DROP CONSTRAINT "browser_profiles_workspaceId_fkey"; +ALTER TABLE "browser_profiles" RENAME CONSTRAINT "browser_profiles_workspace_fkey" TO "browser_profiles_workspaceId_fkey"; +ALTER TABLE "computers" DROP CONSTRAINT "computers_workspaceId_fkey"; +ALTER TABLE "computers" RENAME CONSTRAINT "computers_workspace_fkey" TO "computers_workspaceId_fkey"; +ALTER TABLE "artifacts" DROP CONSTRAINT "artifacts_workspaceId_fkey"; +ALTER TABLE "artifacts" RENAME CONSTRAINT "artifacts_workspace_fkey" TO "artifacts_workspaceId_fkey"; +ALTER TABLE "usage_records" DROP CONSTRAINT "usage_records_workspaceId_fkey"; +ALTER TABLE "usage_records" RENAME CONSTRAINT "usage_records_workspace_fkey" TO "usage_records_workspaceId_fkey"; +ALTER TABLE "notification_preferences" DROP CONSTRAINT "notification_preferences_workspaceId_fkey"; +ALTER TABLE "notification_preferences" RENAME CONSTRAINT "notification_preferences_workspace_fkey" TO "notification_preferences_workspaceId_fkey"; +ALTER TABLE "workspace_memory_configs" DROP CONSTRAINT "workspace_memory_configs_workspaceId_fkey"; +ALTER TABLE "workspace_memory_configs" RENAME CONSTRAINT "workspace_memory_configs_workspace_fkey" TO "workspace_memory_configs_workspaceId_fkey"; +ALTER TABLE "mcp_servers" DROP CONSTRAINT "mcp_servers_workspaceId_fkey"; +ALTER TABLE "mcp_servers" RENAME CONSTRAINT "mcp_servers_workspace_fkey" TO "mcp_servers_workspaceId_fkey"; + +COMMIT; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 8c7e57a8ae..e32c3d474a 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -90,32 +90,7 @@ model Organization { metadata String? members Member[] invitations Invitation[] - bots Bot[] - botSections BotSection[] - botDeletions BotDeletion[] - threads Thread[] - events Event[] - tasks Task[] - runs Run[] - routines Routine[] - scratchpadItems ScratchpadItem[] - taughtSkills TaughtSkill[] - agentSkills AgentSkill[] - connections Connection[] - capabilities CapabilityInstall[] - memoryDocuments MemoryDocument[] - agentHomes AgentHome[] - browserProfiles BrowserProfile[] - artifacts Artifact[] - usageRecords UsageRecord[] - notificationPreferences NotificationPreference[] - computers Computer[] - externalEffects ExternalEffect[] - mcpServers McpServer[] - actionApprovalRules ActionApprovalRule[] - actionAutoReviewPreferences ActionAutoReviewPreference[] - workspaceMemoryConfig WorkspaceMemoryConfig? - chatGroups ChatGroup[] + workspaces Workspace[] @@unique([slug]) @@map("organization") @@ -124,7 +99,7 @@ model Organization { model ActionApprovalRule { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) createdByUserId String createdByUser User @relation(fields: [createdByUserId], references: [id], onDelete: Cascade) effect String @@ -140,7 +115,7 @@ model ActionApprovalRule { model ActionAutoReviewPreference { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) enabled Boolean @default(false) @@ -152,18 +127,73 @@ model ActionAutoReviewPreference { } model Member { + id String @id + organizationId String + organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + role String + createdAt DateTime + workspaceMemberships WorkspaceMember[] + + @@unique([organizationId, userId]) + @@index([organizationId]) + @@index([userId]) + @@map("member") +} + +model Workspace { id String @id organizationId String organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade) + name String + createdAt DateTime @default(now()) + memberships WorkspaceMember[] + bots Bot[] + botSections BotSection[] + botDeletions BotDeletion[] + threads Thread[] + events Event[] + tasks Task[] + runs Run[] + routines Routine[] + scratchpadItems ScratchpadItem[] + taughtSkills TaughtSkill[] + agentSkills AgentSkill[] + connections Connection[] + capabilities CapabilityInstall[] + memoryDocuments MemoryDocument[] + agentHomes AgentHome[] + browserProfiles BrowserProfile[] + artifacts Artifact[] + usageRecords UsageRecord[] + notificationPreferences NotificationPreference[] + computers Computer[] + externalEffects ExternalEffect[] + mcpServers McpServer[] + actionApprovalRules ActionApprovalRule[] + actionAutoReviewPreferences ActionAutoReviewPreference[] + memoryConfig WorkspaceMemoryConfig? + chatGroups ChatGroup[] + + @@unique([id, organizationId]) + @@index([organizationId, createdAt]) + @@map("workspaces") +} + +model WorkspaceMember { + id String @id + workspaceId String + organizationId String + workspace Workspace @relation(fields: [workspaceId, organizationId], references: [id, organizationId], onDelete: Cascade) userId String - user User @relation(fields: [userId], references: [id], onDelete: Cascade) - role String + member Member @relation(fields: [organizationId, userId], references: [organizationId, userId], onDelete: Cascade) createdAt DateTime - @@unique([organizationId, userId]) - @@index([organizationId]) - @@index([userId]) - @@map("member") + @@unique([workspaceId, userId]) + @@index([organizationId, userId]) + @@index([userId, createdAt]) + @@map("workspace_members") } model Invitation { @@ -234,7 +264,7 @@ model UserVoiceCredential { model Bot { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) userId String name String title String @default("") @@ -288,7 +318,7 @@ model Bot { model BotSection { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) name String @@ -306,7 +336,7 @@ model BotSection { model BotDeletion { id String @id workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) name String deletedByUserId String memoriesPreserved Boolean @@ -319,7 +349,7 @@ model BotDeletion { model ChatGroup { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) userId String name String pinned Boolean @default(false) @@ -353,7 +383,7 @@ model ChatGroupMember { model Thread { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) botId String? @unique bot Bot? @relation(fields: [botId], references: [id], onDelete: Cascade) groupId String? @unique @@ -401,7 +431,7 @@ model Message { model Event { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) threadId String thread Thread @relation(fields: [threadId], references: [id], onDelete: Cascade) botId String @@ -421,7 +451,7 @@ model Event { model Task { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) botId String bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade) threadId String @@ -440,7 +470,7 @@ model Task { model Run { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) botId String bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade) threadId String @@ -499,7 +529,7 @@ model Attempt { model ExternalEffect { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) runId String run Run @relation(fields: [runId], references: [id], onDelete: Cascade) kind String @@ -521,7 +551,7 @@ model ExternalEffect { model Routine { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) botId String bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade) userId String @@ -546,7 +576,7 @@ model Routine { model ScratchpadItem { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) botId String bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade) userId String @@ -564,7 +594,7 @@ model ScratchpadItem { model TaughtSkill { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) botId String bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade) userId String @@ -592,7 +622,7 @@ model TaughtSkill { model AgentSkill { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) userId String name String description String @@ -610,7 +640,7 @@ model AgentSkill { model Connection { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) userId String connectorId String @default("composio") provider String @@ -629,7 +659,7 @@ model Connection { model CapabilityInstall { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) userId String kind String name String @@ -647,7 +677,7 @@ model CapabilityInstall { model MemoryDocument { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) userId String botId String? bot Bot? @relation(fields: [botId], references: [id], onDelete: Cascade) @@ -681,7 +711,7 @@ model MemoryRevision { model AgentHome { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) botId String @unique bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade) userId String @@ -695,7 +725,7 @@ model AgentHome { model BrowserProfile { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) botId String @unique bot Bot @relation(fields: [botId], references: [id], onDelete: Cascade) userId String @@ -709,7 +739,7 @@ model BrowserProfile { model Computer { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) userId String scope String @default("team") scopeKey String @unique @@ -758,7 +788,7 @@ model ComputerExecutionLease { model Artifact { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) botId String? bot Bot? @relation(fields: [botId], references: [id], onDelete: SetNull) groupId String? @@ -780,7 +810,7 @@ model Artifact { model UsageRecord { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) botId String? bot Bot? @relation(fields: [botId], references: [id], onDelete: Cascade) userId String @@ -799,7 +829,7 @@ model UsageRecord { model NotificationPreference { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) userId String finish Boolean @default(true) help Boolean @default(true) @@ -812,7 +842,7 @@ model NotificationPreference { model WorkspaceMemoryConfig { id String @id @default(cuid()) workspaceId String @unique - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) userId String provider String settings Json @@ -843,7 +873,7 @@ model Secret { model McpServer { id String @id @default(cuid()) workspaceId String - workspace Organization @relation(fields: [workspaceId], references: [id], onDelete: Cascade) + workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade) userId String user User @relation(fields: [userId], references: [id], onDelete: Cascade) slug String diff --git a/packages/db/src/bootstrap-user.test.ts b/packages/db/src/bootstrap-user.test.ts index 6675b23853..5f35f4b5d7 100644 --- a/packages/db/src/bootstrap-user.test.ts +++ b/packages/db/src/bootstrap-user.test.ts @@ -12,6 +12,8 @@ function makePrisma(settings: { id: string; ownerUserId: string | null } | null) }), }, member: { create: create() }, + workspace: { create: create() }, + workspaceMember: { create: create() }, deploymentSettings: { findUnique: vi.fn(async () => settings), create: create(), @@ -31,7 +33,7 @@ function makePrisma(settings: { id: string; ownerUserId: string | null } | null) const env = { signupsEnabled: "false", signupAllowlist: "a@example.com, b@example.com" }; describe("bootstrapUserWorkspace", () => { - it("creates a personal org, owner membership, and returns the workspace id", async () => { + it("creates a personal organization with a default workspace", async () => { const prisma = makePrisma({ id: "default", ownerUserId: "user-1" }); const result = await bootstrapUserWorkspace( prisma as unknown as PrismaClient, @@ -49,6 +51,23 @@ describe("bootstrapUserWorkspace", () => { expect(memberData.organizationId).toBe(orgData.id); expect(memberData.userId).toBe("user-1"); expect(memberData.role).toBe("owner"); + + const workspaceData = prisma.workspace.create.mock.calls[0]![0].data; + expect(workspaceData).toEqual( + expect.objectContaining({ + id: orgData.id, + organizationId: orgData.id, + name: "Personal", + }), + ); + const workspaceMemberData = prisma.workspaceMember.create.mock.calls[0]![0].data; + expect(workspaceMemberData).toEqual( + expect.objectContaining({ + workspaceId: orgData.id, + organizationId: orgData.id, + userId: "user-1", + }), + ); }); it("seeds deployment settings from the env policy when none exist", async () => { @@ -140,6 +159,8 @@ describe("bootstrapUserWorkspace concurrency", () => { findUniqueOrThrow: vi.fn(async () => ({ id: "org-winner" })), }, member: { create: vi.fn(uniqueViolation) }, + workspace: { create: vi.fn(uniqueViolation) }, + workspaceMember: { create: vi.fn(uniqueViolation) }, deploymentSettings: { findUnique: vi.fn(async () => null), create: vi.fn(uniqueViolation), diff --git a/packages/db/src/bootstrap-user.ts b/packages/db/src/bootstrap-user.ts index 49188a8a15..5033e91167 100644 --- a/packages/db/src/bootstrap-user.ts +++ b/packages/db/src/bootstrap-user.ts @@ -17,10 +17,11 @@ function isUniqueViolation(error: unknown): boolean { } /** - * Everything a brand-new user needs around their account row: personal - * workspace + owner membership, deployment-owner claim, user memory, and - * notification preferences. Shared by the Better Auth `user.create.after` - * hook and phone-identity provisioning so both paths stay in lockstep. + * Everything a brand-new user needs around their account row: a personal + * organization, its default workspace, owner memberships for both boundaries, + * deployment-owner claim, user memory, and notification preferences. Shared by + * the Better Auth `user.create.after` hook and phone-identity provisioning so + * both paths stay in lockstep. * * `claimDeploymentOwner: false` is for identities that did not sign up * through the app (phone provisioning): a first texter must never become @@ -59,6 +60,31 @@ export async function bootstrapUserWorkspace( .catch((error: unknown) => { if (!isUniqueViolation(error)) throw error; }); + await prisma.workspace + .create({ + data: { + id: orgId, + organizationId: orgId, + name: "Personal", + createdAt: new Date(), + }, + }) + .catch((error: unknown) => { + if (!isUniqueViolation(error)) throw error; + }); + await prisma.workspaceMember + .create({ + data: { + id: newId(), + workspaceId: orgId, + organizationId: orgId, + userId: user.id, + createdAt: new Date(), + }, + }) + .catch((error: unknown) => { + if (!isUniqueViolation(error)) throw error; + }); const policy = signupPolicyFromEnv(env); await prisma.deploymentSettings.upsert({ where: { id: "default" }, diff --git a/packages/db/src/groups.test.ts b/packages/db/src/groups.test.ts index fcedf4493c..0a1fa2b546 100644 --- a/packages/db/src/groups.test.ts +++ b/packages/db/src/groups.test.ts @@ -3,6 +3,66 @@ import type { PrismaClient } from "./client.js"; import { createGroupRepos } from "./groups.js"; import { IsolationError } from "./scope.js"; +describe("listPrivateSpaceGroupsForWorkspaces", () => { + it("loads and maps compact cross-space group fields", async () => { + const findMany = vi.fn(async (_query: { where: unknown; select: Record }) => [ + { + id: "group-1", + workspaceId: "workspace-2", + name: "Support crew", + pinned: true, + sectionId: null, + updatedAt: new Date("2026-08-20T00:00:00.000Z"), + thread: { + unread: true, + messages: [{ blocks: [{ kind: "text", text: "Escalation pending" }] }], + }, + members: [ + { bot: { id: "bot-1", name: "Triage", color: "#111", runs: [] } }, + { + bot: { + id: "bot-2", + name: "Responder", + color: "#222", + runs: [{ status: "running" }], + }, + }, + ], + }, + ]); + const repos = createGroupRepos({ chatGroup: { findMany } } as unknown as PrismaClient); + const actor = { + workspaceId: "workspace-1", + userId: "user-1", + email: "user@example.test", + isDeploymentOwner: false, + }; + + await expect( + repos.listPrivateSpaceGroupsForWorkspaces(actor, ["workspace-2"]), + ).resolves.toEqual([ + { + id: "group-1", + workspaceId: "workspace-2", + name: "Support crew", + pinned: true, + sectionId: null, + members: [ + { botId: "bot-1", name: "Triage", color: "#111", status: "idle" }, + { botId: "bot-2", name: "Responder", color: "#222", status: "running" }, + ], + preview: "Escalation pending", + unread: true, + updatedAt: "2026-08-20T00:00:00.000Z", + }, + ]); + const query = findMany.mock.calls[0]![0]; + expect(query.select).not.toHaveProperty("userId"); + expect(query.select).not.toHaveProperty("archivedAt"); + expect(query.select).not.toHaveProperty("createdAt"); + }); +}); + describe("archiveGroup", () => { const actor = { workspaceId: "workspace-1", diff --git a/packages/db/src/groups.ts b/packages/db/src/groups.ts index 52f9e97737..9f2a651979 100644 --- a/packages/db/src/groups.ts +++ b/packages/db/src/groups.ts @@ -4,6 +4,7 @@ import { GROUP_MEMBER_MIN, type Group, type GroupMember, + type PrivateSpaceGroup, } from "@rakazo/contracts"; import { ACTIVE_RUN_STATUSES } from "@rakazo/core"; import type { Prisma, PrismaClient } from "./client.js"; @@ -42,6 +43,16 @@ type GroupRecord = { }>; }; +type PrivateSpaceGroupRecord = Pick< + GroupRecord, + "id" | "workspaceId" | "name" | "pinned" | "sectionId" | "updatedAt" | "members" +> & { + thread: { + unread: boolean; + messages: Array<{ blocks: unknown }>; + } | null; +}; + function previewFromBlocks(blocks: unknown): string { const rows = Array.isArray(blocks) ? blocks : []; for (const block of rows) { @@ -57,6 +68,15 @@ function previewFromBlocks(blocks: unknown): string { return ""; } +function mapGroupMembers(members: GroupRecord["members"]): GroupMember[] { + return members.map((member) => ({ + botId: member.bot.id, + name: member.bot.name, + color: member.bot.color, + status: member.bot.runs[0]?.status ?? "idle", + })); +} + function mapGroup(group: GroupRecord): Group { if (!group.thread) throw new IsolationError("Group is missing its thread"); const preview = previewFromBlocks(group.thread.messages[0]?.blocks); @@ -67,12 +87,7 @@ function mapGroup(group: GroupRecord): Group { pinned: group.pinned, sectionId: group.sectionId, archivedAt: group.archivedAt?.toISOString() ?? null, - members: group.members.map((member) => ({ - botId: member.bot.id, - name: member.bot.name, - color: member.bot.color, - status: member.bot.runs[0]?.status ?? "idle", - })), + members: mapGroupMembers(group.members), threadId: group.thread.id, preview, unread: group.thread.unread, @@ -81,6 +96,21 @@ function mapGroup(group: GroupRecord): Group { }; } +function mapPrivateSpaceGroup(group: PrivateSpaceGroupRecord): PrivateSpaceGroup { + if (!group.thread) throw new IsolationError("Group is missing its thread"); + return { + id: group.id, + workspaceId: group.workspaceId, + name: group.name, + pinned: group.pinned, + sectionId: group.sectionId, + members: mapGroupMembers(group.members), + preview: previewFromBlocks(group.thread.messages[0]?.blocks), + unread: group.thread.unread, + updatedAt: group.updatedAt.toISOString(), + }; +} + function hasMinimumActiveMembers(members: readonly unknown[]) { return members.length >= GROUP_MEMBER_MIN; } @@ -175,6 +205,43 @@ export function createGroupRepos(prisma: PrismaClient) { .map((group) => mapGroup(group as GroupRecord)); } + async function listPrivateSpaceGroupsForWorkspaces( + actor: Actor, + workspaceIds: string[], + ): Promise { + if (workspaceIds.length === 0) return []; + const groups = await prisma.chatGroup.findMany({ + where: { + workspaceId: { in: workspaceIds }, + userId: actor.userId, + archivedAt: null, + }, + select: { + id: true, + workspaceId: true, + name: true, + pinned: true, + sectionId: true, + updatedAt: true, + thread: { + select: { + unread: true, + messages: { + orderBy: { seq: "desc" }, + take: 1, + select: { blocks: true }, + }, + }, + }, + members: groupInclude.members, + }, + orderBy: [{ pinned: "desc" }, { updatedAt: "desc" }], + }); + return groups + .filter((group) => hasMinimumActiveMembers(group.members)) + .map((group) => mapPrivateSpaceGroup(group)); + } + return { async listGroups(actor: Actor, options: { archived?: boolean } = {}): Promise { return listGroupsForWorkspaces(actor, [actor.workspaceId], options); @@ -182,6 +249,8 @@ export function createGroupRepos(prisma: PrismaClient) { listGroupsForWorkspaces, + listPrivateSpaceGroupsForWorkspaces, + async getGroup(actor: Actor, groupId: string, options: { includeArchived?: boolean } = {}) { const group = await prisma.chatGroup.findFirst({ where: { diff --git a/packages/db/src/phone.postgres.test.ts b/packages/db/src/phone.postgres.test.ts index 794848f967..8bca22d742 100644 --- a/packages/db/src/phone.postgres.test.ts +++ b/packages/db/src/phone.postgres.test.ts @@ -53,6 +53,16 @@ describePostgres("provisionPhoneIdentity (PostgreSQL)", () => { expect(org!.name).toBe("Personal"); expect(org!.slug).toBe(`user-${result.userId.slice(0, 12)}`); + const workspace = await prisma.workspace.findUnique({ + where: { id: result.workspaceId }, + include: { memberships: true }, + }); + expect(workspace).toMatchObject({ + organizationId: org!.id, + name: "Personal", + }); + expect(workspace!.memberships).toEqual([expect.objectContaining({ userId: result.userId })]); + const bot = await prisma.bot.findUnique({ where: { id: result.botId }, include: { thread: true }, diff --git a/packages/db/src/phone.test.ts b/packages/db/src/phone.test.ts index e7a7cb1ea6..fd7a08f5c9 100644 --- a/packages/db/src/phone.test.ts +++ b/packages/db/src/phone.test.ts @@ -96,7 +96,7 @@ describe("provisionPhoneIdentity create race", () => { }), }, user: { findUnique: vi.fn(async () => ({ id: "user-1", email: "phone-x@phone.invalid" })) }, - member: { findFirst: vi.fn(async () => ({ organizationId: "ws-1" })) }, + workspaceMember: { findFirst: vi.fn(async () => ({ workspaceId: "ws-1" })) }, bot: { findFirst: vi.fn(async () => ({ id: "bot-loser" })) }, thread: { findFirst: vi.fn(async ({ where }: { where: { botId: string } }) => diff --git a/packages/db/src/phone.ts b/packages/db/src/phone.ts index 888c81c067..e88740f347 100644 --- a/packages/db/src/phone.ts +++ b/packages/db/src/phone.ts @@ -66,9 +66,9 @@ export async function provisionPhoneIdentity( .catch(() => prisma.user.findUniqueOrThrow({ where: { email } })); } - const member = await prisma.member.findFirst({ where: { userId: user.id } }); + const membership = await prisma.workspaceMember.findFirst({ where: { userId: user.id } }); const workspaceId = - member?.organizationId ?? + membership?.workspaceId ?? ( await bootstrapUserWorkspace(prisma, user, env, { claimDeploymentOwner: false, diff --git a/packages/db/src/repos.test.ts b/packages/db/src/repos.test.ts index 20d1e4ab1d..71657ed866 100644 --- a/packages/db/src/repos.test.ts +++ b/packages/db/src/repos.test.ts @@ -57,6 +57,54 @@ describe("createRepos.listBots", () => { }); }); +describe("createRepos.listPrivateSpaceBotsForWorkspaces", () => { + it("loads and maps only the compact cross-space sidebar fields", async () => { + const findMany = vi.fn(async (_query: { where: unknown; select: Record }) => [ + { + id: "bot-2", + workspaceId: "ws-2", + name: "Support", + title: "Customer support", + color: "#123456", + notifyOnFinish: false, + pinned: true, + sectionId: null, + updatedAt: new Date("2026-08-20T00:00:00.000Z"), + thread: { + unread: true, + messages: [{ blocks: [{ kind: "text", text: "Waiting for a reply" }] }], + }, + runs: [{ status: "running" }], + }, + ]); + const repos = createRepos({ bot: { findMany } } as unknown as PrismaClient); + + await expect(repos.listPrivateSpaceBotsForWorkspaces(actor, ["ws-2"])).resolves.toEqual([ + { + id: "bot-2", + workspaceId: "ws-2", + name: "Support", + title: "Customer support", + color: "#123456", + notifyOnFinish: false, + pinned: true, + sectionId: null, + unread: true, + preview: "Waiting for a reply", + status: "running", + updatedAt: "2026-08-20T00:00:00.000Z", + }, + ]); + const query = findMany.mock.calls[0]![0]; + expect(query.where).toEqual( + expect.objectContaining({ workspaceId: { in: ["ws-2"] }, userId: actor.userId }), + ); + expect(query.select).not.toHaveProperty("description"); + expect(query.select).not.toHaveProperty("instructions"); + expect(query.select).not.toHaveProperty("computer"); + }); +}); + describe("createRepos.reorderBots", () => { function reorderRepos(ids: string[]) { const update = vi.fn().mockResolvedValue({}); diff --git a/packages/db/src/repos.ts b/packages/db/src/repos.ts index 4b67d5017d..90c8c70b53 100644 --- a/packages/db/src/repos.ts +++ b/packages/db/src/repos.ts @@ -4,12 +4,37 @@ import { type Bot, type BotSection, type MessageBlock, + type PrivateSpaceBot, } from "@rakazo/contracts"; +import { ACTIVE_RUN_STATUSES } from "@rakazo/core"; import type { PrismaClient } from "./client.js"; import { type ComputerMode, ensureComputerRecord, parseComputerMode } from "./computers.js"; import { createThreadMessageInTransaction } from "./messages.js"; import { IsolationError } from "./scope.js"; +const activeRunStatuses = [...ACTIVE_RUN_STATUSES]; +const activeRunSelection = { + where: { status: { in: activeRunStatuses } }, + orderBy: { createdAt: "desc" as const }, + take: 1, + select: { status: true }, +} as const; + +function previewFromBlocks(blocks: unknown): string { + const rows = Array.isArray(blocks) ? blocks : []; + for (const block of rows) { + if ( + block && + typeof block === "object" && + "text" in block && + typeof (block as { text?: unknown }).text === "string" + ) { + return (block as { text: string }).text; + } + } + return ""; +} + function mapBot( bot: { id: string; @@ -111,26 +136,72 @@ export function createRepos(prisma: PrismaClient) { }, }, runs: { - where: { - status: { in: ["running", "queued", "leased", "waiting_input", "waiting_takeover"] }, - }, - orderBy: { createdAt: "desc" }, - take: 1, + ...activeRunSelection, }, computer: { select: { scope: true } }, }, orderBy: [{ pinned: "desc" }, { position: "asc" }, { createdAt: "asc" }], }); return bots.map((bot) => { - const blocks = (bot.thread?.messages[0]?.blocks ?? []) as Array<{ - kind?: string; - text?: string; - }>; - const preview = blocks.find((block) => block.text)?.text ?? ""; + const preview = previewFromBlocks(bot.thread?.messages[0]?.blocks); return mapBot(bot, preview, bot.runs[0]?.status ?? "idle"); }); } + async function listPrivateSpaceBotsForWorkspaces( + actor: Actor, + workspaceIds: string[], + ): Promise { + if (workspaceIds.length === 0) return []; + const bots = await prisma.bot.findMany({ + where: { + workspaceId: { in: workspaceIds }, + userId: actor.userId, + archivedAt: null, + }, + select: { + id: true, + workspaceId: true, + name: true, + title: true, + color: true, + notifyOnFinish: true, + pinned: true, + sectionId: true, + updatedAt: true, + thread: { + select: { + unread: true, + messages: { + orderBy: { seq: "desc" }, + take: 1, + select: { blocks: true }, + }, + }, + }, + runs: activeRunSelection, + }, + orderBy: [{ pinned: "desc" }, { position: "asc" }, { createdAt: "asc" }], + }); + return bots.map((bot) => { + if (!bot.thread) throw new IsolationError("Bot is missing its thread"); + return { + id: bot.id, + workspaceId: bot.workspaceId, + name: bot.name, + title: bot.title, + color: bot.color, + notifyOnFinish: bot.notifyOnFinish, + pinned: bot.pinned, + sectionId: bot.sectionId, + unread: bot.thread.unread, + preview: previewFromBlocks(bot.thread.messages[0]?.blocks), + status: bot.runs[0]?.status ?? "idle", + updatedAt: bot.updatedAt.toISOString(), + }; + }); + } + return { async listBotSections(actor: Actor): Promise { return listBotSectionsForWorkspaces(actor, [actor.workspaceId]); @@ -211,6 +282,8 @@ export function createRepos(prisma: PrismaClient) { listBotsForWorkspaces, + listPrivateSpaceBotsForWorkspaces, + async getBot(actor: Actor, botId: string, options: { includeArchived?: boolean } = {}) { const bot = await prisma.bot.findFirst({ where: { diff --git a/packages/db/src/scope.test.ts b/packages/db/src/scope.test.ts index 07f8b7699d..62f72a1c02 100644 --- a/packages/db/src/scope.test.ts +++ b/packages/db/src/scope.test.ts @@ -4,14 +4,13 @@ import { IsolationError, requireMembership } from "./scope.js"; function prismaForMembership(found: boolean) { return { - member: { - findFirst: vi.fn(async ({ where }: { where: { userId: string; organizationId?: string } }) => + workspaceMember: { + findFirst: vi.fn(async ({ where }: { where: { userId: string; workspaceId?: string } }) => found ? { userId: where.userId, - organizationId: where.organizationId ?? "space-default", - user: { email: "owner@example.test" }, - organization: { id: where.organizationId ?? "space-default" }, + workspaceId: where.workspaceId ?? "space-default", + member: { user: { email: "owner@example.test" } }, } : null, ), @@ -32,9 +31,9 @@ describe("requireMembership", () => { email: "owner@example.test", isDeploymentOwner: true, }); - expect(prisma.member.findFirst).toHaveBeenCalledWith( + expect(prisma.workspaceMember.findFirst).toHaveBeenCalledWith( expect.objectContaining({ - where: { userId: "user-1", organizationId: "space-support" }, + where: { userId: "user-1", workspaceId: "space-support" }, }), ); }); diff --git a/packages/db/src/scope.ts b/packages/db/src/scope.ts index 870814555e..ff7551151a 100644 --- a/packages/db/src/scope.ts +++ b/packages/db/src/scope.ts @@ -13,25 +13,25 @@ export async function requireMembership( userId: string, requestedWorkspaceId?: string | null, ): Promise { - const member = await prisma.member.findFirst({ + const membership = await prisma.workspaceMember.findFirst({ where: { userId, - ...(requestedWorkspaceId ? { organizationId: requestedWorkspaceId } : {}), + ...(requestedWorkspaceId ? { workspaceId: requestedWorkspaceId } : {}), }, orderBy: [{ createdAt: "asc" }, { id: "asc" }], - include: { user: true, organization: true }, + include: { member: { include: { user: true } } }, }); - if (!member) { + if (!membership) { throw new IsolationError("No personal workspace"); } const settings = await prisma.deploymentSettings.findUnique({ where: { id: "default" }, }); return { - userId: member.userId, - workspaceId: member.organizationId, - email: member.user.email, - isDeploymentOwner: settings?.ownerUserId === member.userId, + userId: membership.userId, + workspaceId: membership.workspaceId, + email: membership.member.user.email, + isDeploymentOwner: settings?.ownerUserId === membership.userId, }; } diff --git a/packages/db/src/workspaces.ts b/packages/db/src/workspaces.ts index a932c533d1..b19974556b 100644 --- a/packages/db/src/workspaces.ts +++ b/packages/db/src/workspaces.ts @@ -2,36 +2,36 @@ import type { PrismaClient } from "./client.js"; type WorkspaceClient = Pick< PrismaClient, - "organization" | "member" | "memoryDocument" | "notificationPreference" + "workspace" | "workspaceMember" | "memoryDocument" | "notificationPreference" >; -export interface OwnedWorkspaceInput { +export interface CreateWorkspaceInput { workspaceId: string; - membershipId: string; + workspaceMembershipId: string; + organizationId: string; userId: string; name: string; - slug: string; createdAt: Date; } -export async function createOwnedWorkspace( +export async function createWorkspace( prisma: WorkspaceClient, - input: OwnedWorkspaceInput, + input: CreateWorkspaceInput, ): Promise { - await prisma.organization.create({ + await prisma.workspace.create({ data: { id: input.workspaceId, + organizationId: input.organizationId, name: input.name, - slug: input.slug, createdAt: input.createdAt, }, }); - await prisma.member.create({ + await prisma.workspaceMember.create({ data: { - id: input.membershipId, - organizationId: input.workspaceId, + id: input.workspaceMembershipId, + workspaceId: input.workspaceId, + organizationId: input.organizationId, userId: input.userId, - role: "owner", createdAt: input.createdAt, }, }); diff --git a/packages/testkit/src/authorization.test.ts b/packages/testkit/src/authorization.test.ts index f1edf3973b..78896ee635 100644 --- a/packages/testkit/src/authorization.test.ts +++ b/packages/testkit/src/authorization.test.ts @@ -474,6 +474,15 @@ describeWithDatabase("API authorization and resource isolation", () => { createdAt: new Date(), }, }); + await handles.prisma.workspaceMember.create({ + data: { + id: `approval-workspace-member-${stamp}`, + workspaceId: ownerActor.workspaceId, + organizationId: ownerActor.workspaceId, + userId: memberActor.userId, + createdAt: new Date(), + }, + }); const ownerRule = await rpc<{ id: string }>(app, owner, "approvalRules/set", { effect: "always_allow", @@ -507,6 +516,50 @@ describeWithDatabase("API authorization and resource isolation", () => { const support = await rpc(app, cookie, "privateSpaces/create", { name: "Customer support", }); + const storedSpaces = await handles.prisma.workspace.findMany({ + where: { id: { in: [original.workspaceId, support.id] } }, + select: { id: true, organizationId: true }, + }); + expect(new Set(storedSpaces.map((space) => space.organizationId))).toEqual( + new Set([original.workspaceId]), + ); + const otherOrganizationId = `private-spaces-other-org-${stamp}`; + const otherWorkspaceId = `private-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: `private-spaces-other-member-${stamp}`, + organizationId: otherOrganizationId, + userId: original.userId, + role: "owner", + createdAt: new Date(), + }, + }), + handles.prisma.workspace.create({ + data: { + id: otherWorkspaceId, + organizationId: otherOrganizationId, + name: "Other company workspace", + }, + }), + handles.prisma.workspaceMember.create({ + data: { + id: `private-spaces-other-workspace-member-${stamp}`, + workspaceId: otherWorkspaceId, + organizationId: otherOrganizationId, + userId: original.userId, + createdAt: new Date(), + }, + }), + ]); const supportMe = await rpc(app, cookie, "me", {}, support.id); expect(supportMe.workspaceId).toBe(support.id); @@ -541,6 +594,9 @@ describeWithDatabase("API authorization and resource isolation", () => { }), ]), ); + expect(navigation.privateSpaces).not.toEqual( + expect.arrayContaining([expect.objectContaining({ id: otherWorkspaceId })]), + ); const supportNavigation = await rpc( app, cookie, @@ -554,6 +610,16 @@ describeWithDatabase("API authorization and resource isolation", () => { bots: [expect.objectContaining({ id: supportBot.id })], }), ); + const otherOrganizationNavigation = await rpc( + app, + cookie, + "privateSpaces/list", + {}, + otherWorkspaceId, + ); + expect(otherOrganizationNavigation.privateSpaces.map((space) => space.id)).toEqual([ + otherWorkspaceId, + ]); const storedBots = await handles.prisma.bot.findMany({ where: { id: { in: [originalBot.id, supportBot.id] } }, @@ -572,19 +638,23 @@ describeWithDatabase("API authorization and resource isolation", () => { it("enforces the private-space limit across concurrent creation requests", async () => { const cookie = await signup(app, `private-space-limit-${stamp}@rakazo.test`, "Space Limit"); const actor = await rpc(app, cookie, "me"); + const currentWorkspace = await handles.prisma.workspace.findUniqueOrThrow({ + where: { id: actor.workspaceId }, + select: { organizationId: true }, + }); const extraSpaces = Array.from({ length: 30 }, (_, index) => ({ id: `limit-space-${stamp}-${index}`, + organizationId: currentWorkspace.organizationId, name: `Limit space ${index}`, - slug: `limit-space-${stamp}-${index}`, createdAt: new Date(), })); - await handles.prisma.organization.createMany({ data: extraSpaces }); - await handles.prisma.member.createMany({ + await handles.prisma.workspace.createMany({ data: extraSpaces }); + await handles.prisma.workspaceMember.createMany({ data: extraSpaces.map((space, index) => ({ id: `limit-member-${stamp}-${index}`, - organizationId: space.id, + workspaceId: space.id, + organizationId: space.organizationId, userId: actor.userId, - role: "owner", createdAt: space.createdAt, })), }); @@ -596,9 +666,11 @@ describeWithDatabase("API authorization and resource isolation", () => { expect(results.filter((result) => result.status === "fulfilled")).toHaveLength(1); expect(results.filter((result) => result.status === "rejected")).toHaveLength(1); - await expect(handles.prisma.member.count({ where: { userId: actor.userId } })).resolves.toBe( - 32, - ); + await expect( + handles.prisma.workspaceMember.count({ + where: { userId: actor.userId, organizationId: currentWorkspace.organizationId }, + }), + ).resolves.toBe(32); }); it("isolates model defaults by workspace and switches them atomically", async () => { From ce1fa02f5cfaf0ed74c67a894d76f0d9670f64c2 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:20:16 +0300 Subject: [PATCH 35/54] test(web): capture the single-space sidebar --- apps/web/e2e/private-spaces.spec.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/web/e2e/private-spaces.spec.ts b/apps/web/e2e/private-spaces.spec.ts index 933ef5a11d..735feb0efe 100644 --- a/apps/web/e2e/private-spaces.spec.ts +++ b/apps/web/e2e/private-spaces.spec.ts @@ -8,6 +8,11 @@ test("private spaces keep all bots in the sidebar and switch the request boundar await signup(page, `private-spaces-${stamp}@rakazo.test`, "password12", "Space Owner"); await completeOnboarding(page); + const sidebar = page.locator("aside").first(); + await expect(sidebar.getByText("Personal", { exact: true })).toHaveCount(0); + await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(1); + await captureScreenshot(page, testInfo, "single-space-sidebar"); + await page.getByTitle("Create").click(); await page.getByRole("button", { name: "New private space" }).click(); const dialog = page.getByRole("dialog", { name: "New private space" }); @@ -23,7 +28,6 @@ test("private spaces keep all bots in the sidebar and switch the request boundar expect(supportSpaceId).toBeTruthy(); await completeOnboarding(page); - const sidebar = page.locator("aside").first(); await expect(sidebar.getByText("Personal", { exact: true })).toBeVisible(); await expect(sidebar.getByText("Customer support", { exact: true })).toBeVisible(); await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(2); From e248f971de3791d086ee1dc8b9a6159ad6002071 Mon Sep 17 00:00:00 2001 From: Eliezer Steinbock <3090527+elie222@users.noreply.github.com> Date: Sun, 30 Aug 2026 13:55:57 +0300 Subject: [PATCH 36/54] feat(spaces): create spaces from chat --- apps/api/src/router.ts | 58 +++++----------- apps/mobile/app/_layout.tsx | 2 +- apps/mobile/app/index.tsx | 2 +- apps/mobile/app/new-space.tsx | 4 +- apps/mobile/app/thread.tsx | 8 ++- apps/web/e2e/private-spaces.spec.ts | 48 +++++++++----- apps/web/src/components/AskCard.tsx | 20 +++++- apps/web/src/pages/Shell.tsx | 6 +- packages/adapters/src/approval-ask.test.ts | 20 ++++++ packages/adapters/src/approval-ask.ts | 36 ++++++++-- packages/adapters/src/builtin-tools.ts | 17 +++++ packages/adapters/src/executor.ts | 53 +++++++++++---- packages/adapters/src/index.test.ts | 12 ++++ packages/adapters/src/pi-runtime.test.ts | 3 + packages/adapters/src/pi-runtime.ts | 7 ++ packages/adapters/src/scripted-runtime.ts | 22 +++++++ packages/contracts/src/events.ts | 10 ++- packages/core/src/action-approval.test.ts | 4 ++ packages/core/src/action-approval.ts | 7 ++ packages/core/src/speech-text.ts | 1 + packages/db/src/workspaces.ts | 77 +++++++++++++++++++++- packages/testkit/src/journeys.test.ts | 69 +++++++++++++++++++ 22 files changed, 396 insertions(+), 90 deletions(-) diff --git a/apps/api/src/router.ts b/apps/api/src/router.ts index cdd75aae8d..564a3da9dd 100644 --- a/apps/api/src/router.ts +++ b/apps/api/src/router.ts @@ -91,12 +91,12 @@ import { appendEventInTransaction, createGroupRepos, createRepos, + createSpaceForMember, createThreadMessageInTransaction, - createWorkspace, - createWorkspaceDefaults, findDefaultModelCredential, findDefaultVoiceCredential, findWorkspaceMemoryConfig, + InvalidSpaceNameError, IsolationError, lockOwnedGroup, newestModelCredentialOrder, @@ -104,6 +104,7 @@ import { Prisma, type PrismaClient, parseComputerMode, + SpaceLimitError, type ThreadEvents, touchGroupUpdatedAt, } from "@rakazo/db"; @@ -375,45 +376,22 @@ export function createRouter(deps: RouterDeps) { privateSpaceNavigationDto(deps, context.actor, repos, groupRepos), ), create: authed.privateSpaces.create.handler(async ({ context, input }) => { - const workspaceId = randomUUID(); - const createdAt = new Date(); - await withSerializableRetry(() => - deps.prisma.$transaction( - async (tx) => { - const currentWorkspace = await tx.workspace.findUnique({ - where: { id: context.actor.workspaceId }, - select: { organizationId: true }, - }); - if (!currentWorkspace) throw new IsolationError(); - const count = await tx.workspaceMember.count({ - where: { - userId: context.actor.userId, - organizationId: currentWorkspace.organizationId, - }, - }); - if (count >= 32) { - throw new ORPCError("BAD_REQUEST", { message: "Private space limit reached" }); - } - await createWorkspace(tx, { - workspaceId, - workspaceMembershipId: randomUUID(), - organizationId: currentWorkspace.organizationId, - userId: context.actor.userId, - name: input.name, - createdAt, - }); - await createWorkspaceDefaults(tx, { - workspaceId, - userId: context.actor.userId, - memoryContent: "# Space memory\n\n", - }); - }, - { isolationLevel: Prisma.TransactionIsolationLevel.Serializable }, - ), - ); + let space: { id: string; name: string }; + try { + space = await createSpaceForMember(deps.prisma, { + currentWorkspaceId: context.actor.workspaceId, + userId: context.actor.userId, + name: input.name, + }); + } catch (error) { + if (error instanceof SpaceLimitError || error instanceof InvalidSpaceNameError) { + throw new ORPCError("BAD_REQUEST", { message: error.message }); + } + throw error; + } return { - id: workspaceId, - name: input.name, + id: space.id, + name: space.name, bots: [], groups: [], botSections: [], diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx index da25bda711..0727b2b89e 100644 --- a/apps/mobile/app/_layout.tsx +++ b/apps/mobile/app/_layout.tsx @@ -67,7 +67,7 @@ export default function Layout() { router.push("/new") }, { text: "New group", onPress: () => router.push("/new-group") }, - { text: "New private space", onPress: () => router.push("/new-space") }, + { text: "New space", onPress: () => router.push("/new-space") }, { text: "Cancel", style: "cancel" }, ]) } diff --git a/apps/mobile/app/new-space.tsx b/apps/mobile/app/new-space.tsx index ef85b8ac16..503a839c41 100644 --- a/apps/mobile/app/new-space.tsx +++ b/apps/mobile/app/new-space.tsx @@ -21,7 +21,7 @@ export default function NewPrivateSpace() { router.dismissAll(); router.replace("/"); } catch (reason) { - setError(reason instanceof Error ? reason.message : "Could not create private space"); + setError(reason instanceof Error ? reason.message : "Could not create space"); setPending(false); } } @@ -56,7 +56,7 @@ export default function NewPrivateSpace() { padding: 18, }} > - Private space + Space Name ["actions"]>[number]; function newClientNonce(): string { const webCrypto = globalThis.crypto; @@ -103,8 +104,11 @@ function newClientNonce(): string { return `m-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`; } -function formatApprovalAnswer(answer: string | undefined): string { +function formatApprovalAnswer(answer: string | undefined, actions?: AskAction[]): string { if (!answer) return "Answered"; + const outcome = actions?.find((action) => action.id === answer)?.outcome; + if (outcome === "created") return "Created"; + if (outcome === "cancelled") return "Cancelled"; if (answer === "allow") return "Allowed once"; if (answer === "always") return "Always allowed"; if (answer === "deny") return "Denied"; @@ -2017,7 +2021,7 @@ const MessageBubble = memo(function MessageBubble({ fontWeight: "600", }} > - {formatApprovalAnswer(askBlock.answer)} + {formatApprovalAnswer(askBlock.answer, askBlock.actions)} ) : canAnswer && onAnswer ? ( { const stamp = Date.now(); @@ -14,32 +14,48 @@ test("private spaces keep all bots in the sidebar and switch the request boundar await captureScreenshot(page, testInfo, "single-space-sidebar"); await page.getByTitle("Create").click(); - await page.getByRole("button", { name: "New private space" }).click(); - const dialog = page.getByRole("dialog", { name: "New private space" }); + await page.getByRole("button", { name: "New space" }).click(); + const dialog = page.getByRole("dialog", { name: "New space" }); await expect(dialog.getByLabel("Name")).toBeVisible(); await dialog.getByLabel("Name").fill("Customer support"); - await captureScreenshot(page, testInfo, "new-private-space-dialog"); - await dialog.getByRole("button", { name: "Create space" }).click(); + await captureScreenshot(page, testInfo, "new-space-dialog"); + await dialog.getByRole("button", { name: "Cancel" }).click(); - await page.waitForURL(/\/onboarding/); - const supportSpaceId = await page.evaluate(() => - window.localStorage.getItem("rakazo:private-space-id"), - ); - expect(supportSpaceId).toBeTruthy(); - await completeOnboarding(page); + const composer = page.getByRole("textbox", { name: "Message Chief" }); + await composer.fill("Create a space named Customer support"); + await composer.press("Enter"); + await expect(page.getByRole("button", { name: "Create space", exact: true })).toBeVisible({ + timeout: 15_000, + }); + await expect(page.getByRole("button", { name: "Cancel", exact: true })).toBeVisible(); + await expect(page.getByRole("button", { name: "Always allow this tool" })).toHaveCount(0); + await expect(sidebar.getByText("Customer support", { exact: true })).toHaveCount(0); + await captureScreenshot(page, testInfo, "create-space-chat-approval"); + await page.getByRole("button", { name: "Create space", exact: true }).click(); + await expect(page.getByText("Created", { exact: true })).toBeVisible(); await expect(sidebar.getByText("Personal", { exact: true })).toBeVisible(); - await expect(sidebar.getByText("Customer support", { exact: true })).toBeVisible(); - await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(2); - await captureScreenshot(page, testInfo, "private-spaces-sidebar"); + await expect(sidebar.getByText("Customer support", { exact: true })).toBeVisible({ + timeout: 15_000, + }); const supportSpace = sidebar - .locator(`[data-sidebar-group^="space:${supportSpaceId}:"]`) + .locator('[data-sidebar-group^="space:"]') .filter({ hasText: "Customer support" }); - await supportSpace.getByRole("button", { name: /^Chief/ }).click(); + const supportSpaceGroup = await supportSpace.getAttribute("data-sidebar-group"); + const supportSpaceId = supportSpaceGroup?.split(":")[1]; + expect(supportSpaceId).toBeTruthy(); + await supportSpace.getByRole("button", { name: "Open Customer support" }).click(); + await page.waitForURL(/\/onboarding/); await expect .poll(() => page.evaluate(() => window.localStorage.getItem("rakazo:private-space-id"))) .toBe(supportSpaceId); + await completeOnboarding(page); + + await expect(sidebar.getByText("Personal", { exact: true })).toBeVisible(); + await expect(sidebar.getByText("Customer support", { exact: true })).toBeVisible(); + await expect(sidebar.getByRole("button", { name: /^Chief/ })).toHaveCount(2); + await captureScreenshot(page, testInfo, "private-spaces-sidebar"); const personalSpace = sidebar .locator('[data-sidebar-group^="space:"]') diff --git a/apps/web/src/components/AskCard.tsx b/apps/web/src/components/AskCard.tsx index 93f23a29fb..9662da94b5 100644 --- a/apps/web/src/components/AskCard.tsx +++ b/apps/web/src/components/AskCard.tsx @@ -11,17 +11,26 @@ function formatAnsweredState( answer: string | undefined, approval: boolean, secret: boolean, + outcome?: "created" | "cancelled", ): string { if (secret) return t`Submitted`; if (!answer) return t`Answered`; if (!approval) return t`Answered: ${answer}`; + if (outcome === "created") return t`Created`; + if (outcome === "cancelled") return t`Cancelled`; if (answer === "allow") return t`Allowed once`; if (answer === "always") return t`Always allowed`; if (answer === "deny") return t`Denied`; return t`Answered: ${answer}`; } -function approvalActionLabel(id: string, fallback: string): string { +function approvalActionLabel( + id: string, + fallback: string, + outcome?: "created" | "cancelled", +): string { + if (outcome === "created") return t`Create space`; + if (outcome === "cancelled") return t`Cancel`; if (id === "allow") return t`Allow once`; if (id === "always") return t`Always allow this tool`; if (id === "deny") return t`Deny`; @@ -73,7 +82,12 @@ export function AskCard({ ) : null} {block.status === "answered" ? (
- {formatAnsweredState(block.answer, Boolean(approvalActions), secretInput)} + {formatAnsweredState( + block.answer, + Boolean(approvalActions), + secretInput, + approvalActions?.find((action) => action.id === block.answer)?.outcome, + )}
) : !canAnswer ? (
@@ -96,7 +110,7 @@ export function AskCard({ {pendingAction === action.id ? ( Sending… ) : ( - approvalActionLabel(action.id, action.label) + approvalActionLabel(action.id, action.label, action.outcome) )} ))} diff --git a/apps/web/src/pages/Shell.tsx b/apps/web/src/pages/Shell.tsx index 93b45a3618..172b495276 100644 --- a/apps/web/src/pages/Shell.tsx +++ b/apps/web/src/pages/Shell.tsx @@ -2326,7 +2326,7 @@ export function ShellPage() { }} >
) : null} @@ -5675,7 +5675,7 @@ function NewPrivateSpaceDialog({ setSaving(true); setError(null); void onConfirm(trimmed).catch((reason: unknown) => { - setError(reason instanceof Error ? reason.message : t`Could not create private space`); + setError(reason instanceof Error ? reason.message : t`Could not create space`); setSaving(false); }); }; @@ -5698,7 +5698,7 @@ function NewPrivateSpaceDialog({