fix(api,web): separate workspace context from auth session (#551)
All checks were successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/api Pipeline was successful

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
This commit was merged in pull request #551.
This commit is contained in:
2026-02-28 15:14:29 +00:00
committed by jason.woltje
parent d2c51eda91
commit 128431ba58
21 changed files with 604 additions and 79 deletions

View File

@@ -42,6 +42,7 @@ import { SpeechModule } from "./speech/speech.module";
import { DashboardModule } from "./dashboard/dashboard.module";
import { TerminalModule } from "./terminal/terminal.module";
import { PersonalitiesModule } from "./personalities/personalities.module";
import { WorkspacesModule } from "./workspaces/workspaces.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
@Module({
@@ -107,6 +108,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
DashboardModule,
TerminalModule,
PersonalitiesModule,
WorkspacesModule,
],
controllers: [AppController, CsrfController],
providers: [

View File

@@ -361,16 +361,13 @@ describe("AuthController", () => {
});
describe("getProfile", () => {
it("should return complete user profile with workspace fields", () => {
it("should return complete user profile with identity fields", () => {
const mockUser: AuthUser = {
id: "user-123",
email: "test@example.com",
name: "Test User",
image: "https://example.com/avatar.jpg",
emailVerified: true,
workspaceId: "workspace-123",
currentWorkspaceId: "workspace-456",
workspaceRole: "admin",
};
const result = controller.getProfile(mockUser);
@@ -381,13 +378,10 @@ describe("AuthController", () => {
name: mockUser.name,
image: mockUser.image,
emailVerified: mockUser.emailVerified,
workspaceId: mockUser.workspaceId,
currentWorkspaceId: mockUser.currentWorkspaceId,
workspaceRole: mockUser.workspaceRole,
});
});
it("should return user profile with optional fields undefined", () => {
it("should return user profile with only required fields", () => {
const mockUser: AuthUser = {
id: "user-123",
email: "test@example.com",
@@ -400,12 +394,11 @@ describe("AuthController", () => {
id: mockUser.id,
email: mockUser.email,
name: mockUser.name,
image: undefined,
emailVerified: undefined,
workspaceId: undefined,
currentWorkspaceId: undefined,
workspaceRole: undefined,
});
// Workspace fields are not included — served by GET /api/workspaces
expect(result).not.toHaveProperty("workspaceId");
expect(result).not.toHaveProperty("currentWorkspaceId");
expect(result).not.toHaveProperty("workspaceRole");
});
});

View File

@@ -72,15 +72,10 @@ export class AuthController {
if (user.emailVerified !== undefined) {
profile.emailVerified = user.emailVerified;
}
if (user.workspaceId !== undefined) {
profile.workspaceId = user.workspaceId;
}
if (user.currentWorkspaceId !== undefined) {
profile.currentWorkspaceId = user.currentWorkspaceId;
}
if (user.workspaceRole !== undefined) {
profile.workspaceRole = user.workspaceRole;
}
// Workspace context is served by GET /api/workspaces, not the auth profile.
// The deprecated workspaceId/currentWorkspaceId/workspaceRole fields on
// AuthUser are never populated by BetterAuth and are omitted here.
return profile;
}

View File

@@ -0,0 +1 @@
export { WorkspaceResponseDto } from "./workspace-response.dto";

View File

@@ -0,0 +1,12 @@
import type { WorkspaceMemberRole } from "@prisma/client";
/**
* Response DTO for a workspace the authenticated user belongs to.
*/
export class WorkspaceResponseDto {
id!: string;
name!: string;
ownerId!: string;
role!: WorkspaceMemberRole;
createdAt!: Date;
}

View File

@@ -0,0 +1,3 @@
export { WorkspacesModule } from "./workspaces.module";
export { WorkspacesService } from "./workspaces.service";
export { WorkspacesController } from "./workspaces.controller";

View File

@@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { WorkspacesController } from "./workspaces.controller";
import { WorkspacesService } from "./workspaces.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceMemberRole } from "@prisma/client";
import type { AuthUser } from "@mosaic/shared";
describe("WorkspacesController", () => {
let controller: WorkspacesController;
let service: WorkspacesService;
const mockWorkspacesService = {
getUserWorkspaces: vi.fn(),
};
const mockUser: AuthUser = {
id: "user-1",
email: "test@example.com",
name: "Test User",
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [WorkspacesController],
providers: [
{
provide: WorkspacesService,
useValue: mockWorkspacesService,
},
],
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<WorkspacesController>(WorkspacesController);
service = module.get<WorkspacesService>(WorkspacesService);
vi.clearAllMocks();
});
describe("GET /api/workspaces", () => {
it("should call service with authenticated user id", async () => {
mockWorkspacesService.getUserWorkspaces.mockResolvedValueOnce([]);
await controller.getUserWorkspaces(mockUser);
expect(service.getUserWorkspaces).toHaveBeenCalledWith("user-1");
});
it("should return workspace list from service", async () => {
const mockWorkspaces = [
{
id: "ws-1",
name: "My Workspace",
ownerId: "user-1",
role: WorkspaceMemberRole.OWNER,
createdAt: new Date("2026-01-01"),
},
];
mockWorkspacesService.getUserWorkspaces.mockResolvedValueOnce(mockWorkspaces);
const result = await controller.getUserWorkspaces(mockUser);
expect(result).toEqual(mockWorkspaces);
});
it("should propagate service errors", async () => {
mockWorkspacesService.getUserWorkspaces.mockRejectedValueOnce(new Error("Database error"));
await expect(controller.getUserWorkspaces(mockUser)).rejects.toThrow("Database error");
});
});
});

View File

@@ -0,0 +1,28 @@
import { Controller, Get, UseGuards } from "@nestjs/common";
import { WorkspacesService } from "./workspaces.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import type { AuthUser } from "@mosaic/shared";
import type { WorkspaceResponseDto } from "./dto";
/**
* User-scoped workspace operations.
*
* Intentionally does NOT use WorkspaceGuard — these routes operate across all
* workspaces the user belongs to, not within a single workspace context.
*/
@Controller("workspaces")
@UseGuards(AuthGuard)
export class WorkspacesController {
constructor(private readonly workspacesService: WorkspacesService) {}
/**
* GET /api/workspaces
* Returns workspaces the authenticated user is a member of.
* Auto-provisions a default workspace if the user has none.
*/
@Get()
async getUserWorkspaces(@CurrentUser() user: AuthUser): Promise<WorkspaceResponseDto[]> {
return this.workspacesService.getUserWorkspaces(user.id);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from "@nestjs/common";
import { WorkspacesController } from "./workspaces.controller";
import { WorkspacesService } from "./workspaces.service";
import { PrismaModule } from "../prisma/prisma.module";
import { AuthModule } from "../auth/auth.module";
@Module({
imports: [PrismaModule, AuthModule],
controllers: [WorkspacesController],
providers: [WorkspacesService],
exports: [WorkspacesService],
})
export class WorkspacesModule {}

View File

@@ -0,0 +1,229 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { WorkspacesService } from "./workspaces.service";
import { PrismaService } from "../prisma/prisma.service";
import { WorkspaceMemberRole } from "@prisma/client";
describe("WorkspacesService", () => {
let service: WorkspacesService;
const mockUserId = "550e8400-e29b-41d4-a716-446655440001";
const mockWorkspaceId = "550e8400-e29b-41d4-a716-446655440002";
const mockWorkspace = {
id: mockWorkspaceId,
name: "Test Workspace",
ownerId: mockUserId,
settings: {},
matrixRoomId: null,
createdAt: new Date("2026-01-01"),
updatedAt: new Date("2026-01-01"),
};
const mockMembership = {
workspaceId: mockWorkspaceId,
userId: mockUserId,
role: WorkspaceMemberRole.OWNER,
joinedAt: new Date("2026-01-01"),
workspace: {
id: mockWorkspaceId,
name: "Test Workspace",
ownerId: mockUserId,
createdAt: new Date("2026-01-01"),
},
};
const mockPrismaService = {
workspaceMember: {
findMany: vi.fn(),
create: vi.fn(),
},
workspace: {
create: vi.fn(),
},
$transaction: vi.fn(),
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
WorkspacesService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<WorkspacesService>(WorkspacesService);
vi.clearAllMocks();
});
describe("getUserWorkspaces", () => {
it("should return all workspaces user is a member of", async () => {
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([mockMembership]);
const result = await service.getUserWorkspaces(mockUserId);
expect(result).toEqual([
{
id: mockWorkspaceId,
name: "Test Workspace",
ownerId: mockUserId,
role: WorkspaceMemberRole.OWNER,
createdAt: mockMembership.workspace.createdAt,
},
]);
expect(mockPrismaService.workspaceMember.findMany).toHaveBeenCalledWith({
where: { userId: mockUserId },
include: {
workspace: {
select: { id: true, name: true, ownerId: true, createdAt: true },
},
},
orderBy: { joinedAt: "asc" },
});
});
it("should return multiple workspaces ordered by joinedAt", async () => {
const secondWorkspace = {
...mockMembership,
workspaceId: "ws-2",
role: WorkspaceMemberRole.MEMBER,
joinedAt: new Date("2026-02-01"),
workspace: {
id: "ws-2",
name: "Second Workspace",
ownerId: "other-user",
createdAt: new Date("2026-02-01"),
},
};
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([
mockMembership,
secondWorkspace,
]);
const result = await service.getUserWorkspaces(mockUserId);
expect(result).toHaveLength(2);
expect(result[0].id).toBe(mockWorkspaceId);
expect(result[1].id).toBe("ws-2");
expect(result[1].role).toBe(WorkspaceMemberRole.MEMBER);
});
it("should auto-provision a default workspace when user has no memberships", async () => {
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]);
mockPrismaService.$transaction.mockImplementationOnce(
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) => {
const txMock = {
workspaceMember: {
findFirst: vi.fn().mockResolvedValueOnce(null),
create: vi.fn().mockResolvedValueOnce({}),
},
workspace: {
create: vi.fn().mockResolvedValueOnce(mockWorkspace),
},
};
return fn(txMock as unknown as typeof mockPrismaService);
}
);
const result = await service.getUserWorkspaces(mockUserId);
expect(result).toHaveLength(1);
expect(result[0].name).toBe("Test Workspace");
expect(result[0].role).toBe(WorkspaceMemberRole.OWNER);
expect(mockPrismaService.$transaction).toHaveBeenCalledTimes(1);
});
it("should return existing workspace if one was created between initial check and transaction", async () => {
// Simulates a race condition: initial findMany returns [], but inside the
// transaction another request already created a workspace.
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]);
mockPrismaService.$transaction.mockImplementationOnce(
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) => {
const txMock = {
workspaceMember: {
findFirst: vi.fn().mockResolvedValueOnce(mockMembership),
},
workspace: {
create: vi.fn(),
},
};
return fn(txMock as unknown as typeof mockPrismaService);
}
);
const result = await service.getUserWorkspaces(mockUserId);
expect(result).toHaveLength(1);
expect(result[0].id).toBe(mockWorkspaceId);
expect(result[0].name).toBe("Test Workspace");
});
it("should create workspace with correct data during auto-provisioning", async () => {
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([]);
let capturedWorkspaceData: unknown;
let capturedMemberData: unknown;
mockPrismaService.$transaction.mockImplementationOnce(
async (fn: (tx: typeof mockPrismaService) => Promise<unknown>) => {
const txMock = {
workspaceMember: {
findFirst: vi.fn().mockResolvedValueOnce(null),
create: vi.fn().mockImplementation((args: unknown) => {
capturedMemberData = args;
return {};
}),
},
workspace: {
create: vi.fn().mockImplementation((args: unknown) => {
capturedWorkspaceData = args;
return mockWorkspace;
}),
},
};
return fn(txMock as unknown as typeof mockPrismaService);
}
);
await service.getUserWorkspaces(mockUserId);
expect(capturedWorkspaceData).toEqual({
data: {
name: "My Workspace",
ownerId: mockUserId,
settings: {},
},
});
expect(capturedMemberData).toEqual({
data: {
workspaceId: mockWorkspaceId,
userId: mockUserId,
role: WorkspaceMemberRole.OWNER,
},
});
});
it("should not auto-provision when user already has workspaces", async () => {
mockPrismaService.workspaceMember.findMany.mockResolvedValueOnce([mockMembership]);
await service.getUserWorkspaces(mockUserId);
expect(mockPrismaService.$transaction).not.toHaveBeenCalled();
});
it("should propagate database errors", async () => {
mockPrismaService.workspaceMember.findMany.mockRejectedValueOnce(
new Error("Database connection failed")
);
await expect(service.getUserWorkspaces(mockUserId)).rejects.toThrow(
"Database connection failed"
);
});
});
});

View File

@@ -0,0 +1,97 @@
import { Injectable, Logger } from "@nestjs/common";
import { WorkspaceMemberRole } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import type { WorkspaceResponseDto } from "./dto";
@Injectable()
export class WorkspacesService {
private readonly logger = new Logger(WorkspacesService.name);
constructor(private readonly prisma: PrismaService) {}
/**
* Get all workspaces the user is a member of.
*
* Auto-provisioning: if the user has no workspace memberships (e.g. fresh
* signup via BetterAuth), a default workspace is created atomically and
* returned. This is the only call site for workspace bootstrapping.
*/
async getUserWorkspaces(userId: string): Promise<WorkspaceResponseDto[]> {
const memberships = await this.prisma.workspaceMember.findMany({
where: { userId },
include: {
workspace: {
select: { id: true, name: true, ownerId: true, createdAt: true },
},
},
orderBy: { joinedAt: "asc" },
});
if (memberships.length > 0) {
return memberships.map((m) => ({
id: m.workspace.id,
name: m.workspace.name,
ownerId: m.workspace.ownerId,
role: m.role,
createdAt: m.workspace.createdAt,
}));
}
// Auto-provision a default workspace for new users.
// Re-query inside the transaction to guard against concurrent requests
// both seeing zero memberships and creating duplicate workspaces.
this.logger.log(`Auto-provisioning default workspace for user ${userId}`);
const workspace = await this.prisma.$transaction(async (tx) => {
const existing = await tx.workspaceMember.findFirst({
where: { userId },
include: {
workspace: {
select: { id: true, name: true, ownerId: true, createdAt: true },
},
},
});
if (existing) {
return { ...existing.workspace, alreadyExisted: true as const };
}
const created = await tx.workspace.create({
data: {
name: "My Workspace",
ownerId: userId,
settings: {},
},
});
await tx.workspaceMember.create({
data: {
workspaceId: created.id,
userId,
role: WorkspaceMemberRole.OWNER,
},
});
return { ...created, alreadyExisted: false as const };
});
if (workspace.alreadyExisted) {
return [
{
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
role: WorkspaceMemberRole.OWNER,
createdAt: workspace.createdAt,
},
];
}
return [
{
id: workspace.id,
name: workspace.name,
ownerId: workspace.ownerId,
role: WorkspaceMemberRole.OWNER,
createdAt: workspace.createdAt,
},
];
}
}

View File

@@ -265,23 +265,8 @@ export default function ProfilePage(): ReactElement {
</p>
)}
{user?.workspaceRole && (
<span
style={{
display: "inline-block",
marginTop: 8,
padding: "3px 10px",
borderRadius: "var(--r)",
background: "rgba(47, 128, 255, 0.1)",
color: "var(--ms-blue-400)",
fontSize: "0.75rem",
fontWeight: 600,
textTransform: "capitalize",
}}
>
{user.workspaceRole}
</span>
)}
{/* Workspace role badge — placeholder until workspace context API
provides role data via GET /api/workspaces */}
</div>
</div>
</div>

View File

@@ -5,6 +5,7 @@ import { useAuth } from "@/lib/auth/auth-context";
import { useChat } from "@/hooks/useChat";
import { useOrchestratorCommands } from "@/hooks/useOrchestratorCommands";
import { useWebSocket } from "@/hooks/useWebSocket";
import { useWorkspaceId } from "@/lib/hooks";
import { MessageList } from "./MessageList";
import { ChatInput, type ModelId, DEFAULT_TEMPERATURE, DEFAULT_MAX_TOKENS } from "./ChatInput";
import { ChatEmptyState } from "./ChatEmptyState";
@@ -89,10 +90,10 @@ export const Chat = forwardRef<ChatRef, ChatProps>(function Chat(
...(initialProjectId !== undefined && { projectId: initialProjectId }),
});
// Use the actual workspace ID for the WebSocket room subscription.
// Read workspace ID from localStorage (set by auth-context after session check).
// Cookie-based auth (withCredentials) handles authentication, so no explicit
// token is needed here — pass an empty string as the token placeholder.
const workspaceId = user?.currentWorkspaceId ?? user?.workspaceId ?? "";
const workspaceId = useWorkspaceId() ?? "";
const { isConnected: isWsConnected } = useWebSocket(workspaceId, "", {});
const { isCommand, executeCommand } = useOrchestratorCommands();

View File

@@ -464,7 +464,7 @@ function UserCard({ collapsed }: UserCardProps): React.JSX.Element {
const displayName = user?.name ?? "User";
const initials = getInitials(displayName);
const role = user?.workspaceRole ?? "Member";
const role = "Member";
return (
<footer

View File

@@ -15,3 +15,4 @@ export * from "./personalities";
export * from "./telemetry";
export * from "./dashboard";
export * from "./projects";
export * from "./workspaces";

View File

@@ -0,0 +1,26 @@
/**
* Workspaces API Client
* User-scoped workspace discovery — does NOT require X-Workspace-Id header.
*/
import { apiGet } from "./client";
/**
* A workspace entry from the user's membership list.
* Matches WorkspaceResponseDto from the API.
*/
export interface UserWorkspace {
id: string;
name: string;
ownerId: string;
role: string;
createdAt: string;
}
/**
* Fetch all workspaces the authenticated user is a member of.
* The API auto-provisions a default workspace if the user has none.
*/
export async function fetchUserWorkspaces(): Promise<UserWorkspace[]> {
return apiGet<UserWorkspace[]>("/api/workspaces");
}

View File

@@ -10,7 +10,13 @@ vi.mock("../api/client", () => ({
apiPost: vi.fn(),
}));
// Mock the workspaces API client
vi.mock("../api/workspaces", () => ({
fetchUserWorkspaces: vi.fn(),
}));
const { apiGet, apiPost } = await import("../api/client");
const { fetchUserWorkspaces } = await import("../api/workspaces");
/** Helper: returns a date far in the future (1 hour from now) for session mocks */
function futureExpiry(): string {
@@ -739,19 +745,26 @@ describe("AuthContext", (): void => {
vi.restoreAllMocks();
});
it("should persist currentWorkspaceId to localStorage after session check", async (): Promise<void> => {
it("should call fetchUserWorkspaces after successful session check", async (): Promise<void> => {
const mockUser: AuthUser = {
id: "user-1",
email: "test@example.com",
name: "Test User",
currentWorkspaceId: "ws-current-123",
workspaceId: "ws-default-456",
};
vi.mocked(apiGet).mockResolvedValueOnce({
user: mockUser,
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
});
vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([
{
id: "ws-1",
name: "My Workspace",
ownerId: "user-1",
role: "OWNER",
createdAt: "2026-01-01",
},
]);
render(
<AuthProvider>
@@ -763,25 +776,36 @@ describe("AuthContext", (): void => {
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
});
// currentWorkspaceId takes priority over workspaceId
expect(localStorageMock.setItem).toHaveBeenCalledWith(
"mosaic-workspace-id",
"ws-current-123"
);
expect(fetchUserWorkspaces).toHaveBeenCalledTimes(1);
});
it("should fall back to workspaceId when currentWorkspaceId is absent", async (): Promise<void> => {
it("should persist the first workspace ID to localStorage", async (): Promise<void> => {
const mockUser: AuthUser = {
id: "user-1",
email: "test@example.com",
name: "Test User",
workspaceId: "ws-default-456",
};
vi.mocked(apiGet).mockResolvedValueOnce({
user: mockUser,
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
});
vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([
{
id: "ws-abc-123",
name: "My Workspace",
ownerId: "user-1",
role: "OWNER",
createdAt: "2026-01-01",
},
{
id: "ws-def-456",
name: "Second Workspace",
ownerId: "other",
role: "MEMBER",
createdAt: "2026-02-01",
},
]);
render(
<AuthProvider>
@@ -793,24 +817,21 @@ describe("AuthContext", (): void => {
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
});
expect(localStorageMock.setItem).toHaveBeenCalledWith(
"mosaic-workspace-id",
"ws-default-456"
);
expect(localStorageMock.setItem).toHaveBeenCalledWith("mosaic-workspace-id", "ws-abc-123");
});
it("should not write to localStorage when no workspace ID is present on user", async (): Promise<void> => {
it("should not write localStorage when fetchUserWorkspaces returns empty array", async (): Promise<void> => {
const mockUser: AuthUser = {
id: "user-1",
email: "test@example.com",
name: "Test User",
// no workspaceId or currentWorkspaceId
};
vi.mocked(apiGet).mockResolvedValueOnce({
user: mockUser,
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
});
vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([]);
render(
<AuthProvider>
@@ -828,18 +849,53 @@ describe("AuthContext", (): void => {
);
});
it("should remove workspace ID from localStorage on sign-out", async (): Promise<void> => {
it("should handle fetchUserWorkspaces failure gracefully — auth still succeeds", async (): Promise<void> => {
const mockUser: AuthUser = {
id: "user-1",
email: "test@example.com",
name: "Test User",
currentWorkspaceId: "ws-current-123",
};
vi.mocked(apiGet).mockResolvedValueOnce({
user: mockUser,
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
});
vi.mocked(fetchUserWorkspaces).mockRejectedValueOnce(new Error("Network error"));
render(
<AuthProvider>
<TestComponent />
</AuthProvider>
);
await waitFor(() => {
expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
});
// Auth succeeded despite workspace fetch failure
expect(screen.getByTestId("auth-error")).toHaveTextContent("none");
});
it("should remove workspace ID from localStorage on sign-out", async (): Promise<void> => {
const mockUser: AuthUser = {
id: "user-1",
email: "test@example.com",
name: "Test User",
};
vi.mocked(apiGet).mockResolvedValueOnce({
user: mockUser,
session: { id: "session-1", token: "token123", expiresAt: futureExpiry() },
});
vi.mocked(fetchUserWorkspaces).mockResolvedValueOnce([
{
id: "ws-1",
name: "My Workspace",
ownerId: "user-1",
role: "OWNER",
createdAt: "2026-01-01",
},
]);
vi.mocked(apiPost).mockResolvedValueOnce({ success: true });
render(

View File

@@ -11,6 +11,7 @@ import {
} from "react";
import type { AuthUser, AuthSession } from "@mosaic/shared";
import { apiGet, apiPost } from "../api/client";
import { fetchUserWorkspaces } from "../api/workspaces";
import { IS_MOCK_AUTH_MODE } from "../config";
import { parseAuthError } from "./auth-errors";
@@ -134,10 +135,18 @@ function RealAuthProvider({ children }: { children: ReactNode }): React.JSX.Elem
setUser(session.user);
setAuthError(null);
// Persist workspace ID to localStorage so useWorkspaceId and apiRequest
// can pick it up without re-fetching the session.
// Prefer currentWorkspaceId (the user's active workspace) over workspaceId.
persistWorkspaceId(session.user.currentWorkspaceId ?? session.user.workspaceId);
// Fetch the user's workspace memberships and persist the default.
// Workspace context is an application concern, not an auth concern —
// BetterAuth does not return workspace fields on the session user.
try {
const workspaces = await fetchUserWorkspaces();
const defaultWorkspace = workspaces[0];
if (defaultWorkspace) {
persistWorkspaceId(defaultWorkspace.id);
}
} catch (wsError) {
logAuthError("Failed to fetch workspaces after session check", wsError);
}
// Track session expiry timestamp
expiresAtRef.current = new Date(session.session.expiresAt);

View File

@@ -72,7 +72,8 @@
"qs": ">=6.15.0",
"tough-cookie": ">=4.1.3",
"undici": ">=6.23.0",
"rollup": ">=4.59.0"
"rollup": ">=4.59.0",
"serialize-javascript": ">=7.0.3"
}
}
}

View File

@@ -13,9 +13,14 @@ export interface AuthUser {
name: string;
image?: string;
emailVerified?: boolean;
// Workspace context (added for workspace-scoped operations)
/**
* @deprecated Never populated by BetterAuth session. Workspace context is
* fetched separately via GET /api/workspaces. Will be removed in a future pass.
*/
workspaceId?: string;
/** @deprecated See workspaceId. */
currentWorkspaceId?: string;
/** @deprecated See workspaceId. */
workspaceRole?: string;
}

19
pnpm-lock.yaml generated
View File

@@ -16,6 +16,7 @@ overrides:
tough-cookie: '>=4.1.3'
undici: '>=6.23.0'
rollup: '>=4.59.0'
serialize-javascript: '>=7.0.3'
importers:
@@ -6389,9 +6390,6 @@ packages:
raf-schd@4.0.3:
resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==}
randombytes@2.1.0:
resolution: {integrity: sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==}
range-parser@1.2.1:
resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==}
engines: {node: '>= 0.6'}
@@ -6679,8 +6677,9 @@ packages:
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
engines: {node: '>= 18'}
serialize-javascript@6.0.2:
resolution: {integrity: sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==}
serialize-javascript@7.0.3:
resolution: {integrity: sha512-h+cZ/XXarqDgCjo+YSyQU/ulDEESGGf8AMK9pPNmhNSl/FzPl6L8pMp1leca5z6NuG6tvV/auC8/43tmovowww==}
engines: {node: '>=20.0.0'}
serve-static@1.16.3:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
@@ -13990,10 +13989,6 @@ snapshots:
raf-schd@4.0.3: {}
randombytes@2.1.0:
dependencies:
safe-buffer: 5.2.1
range-parser@1.2.1: {}
raw-body@2.5.3:
@@ -14362,9 +14357,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
serialize-javascript@6.0.2:
dependencies:
randombytes: 2.1.0
serialize-javascript@7.0.3: {}
serve-static@1.16.3:
dependencies:
@@ -14769,7 +14762,7 @@ snapshots:
'@jridgewell/trace-mapping': 0.3.31
jest-worker: 27.5.1
schema-utils: 4.3.3
serialize-javascript: 6.0.2
serialize-javascript: 7.0.3
terser: 5.46.0
webpack: 5.104.1(@swc/core@1.15.11)
optionalDependencies: