diff --git a/.env.example b/.env.example
index 1700b65..36dbd73 100644
--- a/.env.example
+++ b/.env.example
@@ -79,7 +79,7 @@ OIDC_CLIENT_ID=your-client-id-here
OIDC_CLIENT_SECRET=your-client-secret-here
# Redirect URI must match what's configured in Authentik
# Development: http://localhost:3001/auth/oauth2/callback/authentik
-# Production: https://api.mosaicstack.dev/auth/oauth2/callback/authentik
+# Production: https://mosaic-api.woltje.com/auth/oauth2/callback/authentik
OIDC_REDIRECT_URI=http://localhost:3001/auth/oauth2/callback/authentik
# Authentik PostgreSQL Database
@@ -314,17 +314,19 @@ COORDINATOR_ENABLED=true
# TTL is in seconds, limits are per TTL window
# Global rate limit (applies to all endpoints unless overridden)
-RATE_LIMIT_TTL=60 # Time window in seconds
-RATE_LIMIT_GLOBAL_LIMIT=100 # Requests per window
+# Time window in seconds
+RATE_LIMIT_TTL=60
+# Requests per window
+RATE_LIMIT_GLOBAL_LIMIT=100
-# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch)
-RATE_LIMIT_WEBHOOK_LIMIT=60 # Requests per minute
+# Webhook endpoints (/stitcher/webhook, /stitcher/dispatch) — requests per minute
+RATE_LIMIT_WEBHOOK_LIMIT=60
-# Coordinator endpoints (/coordinator/*)
-RATE_LIMIT_COORDINATOR_LIMIT=100 # Requests per minute
+# Coordinator endpoints (/coordinator/*) — requests per minute
+RATE_LIMIT_COORDINATOR_LIMIT=100
-# Health check endpoints (/coordinator/health)
-RATE_LIMIT_HEALTH_LIMIT=300 # Requests per minute (higher for monitoring)
+# Health check endpoints (/coordinator/health) — requests per minute (higher for monitoring)
+RATE_LIMIT_HEALTH_LIMIT=300
# Storage backend for rate limiting (redis or memory)
# redis: Uses Valkey for distributed rate limiting (recommended for production)
@@ -359,17 +361,17 @@ RATE_LIMIT_STORAGE=redis
# a single workspace.
MATRIX_HOMESERVER_URL=http://synapse:8008
MATRIX_ACCESS_TOKEN=
-MATRIX_BOT_USER_ID=@mosaic-bot:matrix.example.com
-MATRIX_SERVER_NAME=matrix.example.com
-# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.example.com
+MATRIX_BOT_USER_ID=@mosaic-bot:matrix.woltje.com
+MATRIX_SERVER_NAME=matrix.woltje.com
+# MATRIX_CONTROL_ROOM_ID=!roomid:matrix.woltje.com
# MATRIX_WORKSPACE_ID=your-workspace-uuid
# ======================
# Matrix / Synapse Deployment
# ======================
# Domains for Traefik routing to Matrix services
-MATRIX_DOMAIN=matrix.example.com
-ELEMENT_DOMAIN=chat.example.com
+MATRIX_DOMAIN=matrix.woltje.com
+ELEMENT_DOMAIN=chat.woltje.com
# Synapse database (created automatically by synapse-db-init in the swarm compose)
SYNAPSE_POSTGRES_DB=synapse
diff --git a/.mosaic/orchestrator/mission.json b/.mosaic/orchestrator/mission.json
index 9175d8a..8b95e42 100644
--- a/.mosaic/orchestrator/mission.json
+++ b/.mosaic/orchestrator/mission.json
@@ -1,14 +1,69 @@
{
"schema_version": 1,
- "mission_id": "prd-implementation-20260222",
- "name": "PRD implementation",
- "description": "",
+ "mission_id": "ms21-multi-tenant-rbac-data-migration-20260228",
+ "name": "MS21 Multi-Tenant RBAC Data Migration",
+ "description": "Build multi-tenant user/workspace/team management, break-glass auth, RBAC UI enforcement, and migrate jarvis-brain data into Mosaic Stack",
"project_path": "/home/jwoltje/src/mosaic-stack",
- "created_at": "2026-02-23T03:20:55Z",
+ "created_at": "2026-02-28T17:10:22Z",
"status": "active",
- "task_prefix": "",
- "quality_gates": "",
- "milestone_version": "0.0.20",
- "milestones": [],
+ "task_prefix": "MS21",
+ "quality_gates": "pnpm lint && pnpm build && pnpm test",
+ "milestone_version": "0.0.21",
+ "milestones": [
+ {
+ "id": "phase-1",
+ "name": "Schema and Admin API",
+ "status": "pending",
+ "branch": "schema-and-admin-api",
+ "issue_ref": "",
+ "started_at": "",
+ "completed_at": ""
+ },
+ {
+ "id": "phase-2",
+ "name": "Break-Glass Authentication",
+ "status": "pending",
+ "branch": "break-glass-authentication",
+ "issue_ref": "",
+ "started_at": "",
+ "completed_at": ""
+ },
+ {
+ "id": "phase-3",
+ "name": "Data Migration",
+ "status": "pending",
+ "branch": "data-migration",
+ "issue_ref": "",
+ "started_at": "",
+ "completed_at": ""
+ },
+ {
+ "id": "phase-4",
+ "name": "Admin UI",
+ "status": "pending",
+ "branch": "admin-ui",
+ "issue_ref": "",
+ "started_at": "",
+ "completed_at": ""
+ },
+ {
+ "id": "phase-5",
+ "name": "RBAC UI Enforcement",
+ "status": "pending",
+ "branch": "rbac-ui-enforcement",
+ "issue_ref": "",
+ "started_at": "",
+ "completed_at": ""
+ },
+ {
+ "id": "phase-6",
+ "name": "Verification",
+ "status": "pending",
+ "branch": "verification",
+ "issue_ref": "",
+ "started_at": "",
+ "completed_at": ""
+ }
+ ],
"sessions": []
}
diff --git a/apps/api/prisma/migrations/20260227000000_add_personality_tone_formality/migration.sql b/apps/api/prisma/migrations/20260227000000_add_personality_tone_formality/migration.sql
new file mode 100644
index 0000000..fd88954
--- /dev/null
+++ b/apps/api/prisma/migrations/20260227000000_add_personality_tone_formality/migration.sql
@@ -0,0 +1,3 @@
+-- AlterTable: add tone and formality_level columns to personalities
+ALTER TABLE "personalities" ADD COLUMN "tone" TEXT NOT NULL DEFAULT 'neutral';
+ALTER TABLE "personalities" ADD COLUMN "formality_level" "FormalityLevel" NOT NULL DEFAULT 'NEUTRAL';
diff --git a/apps/api/prisma/schema.prisma b/apps/api/prisma/schema.prisma
index 833e0c6..35d474b 100644
--- a/apps/api/prisma/schema.prisma
+++ b/apps/api/prisma/schema.prisma
@@ -3,6 +3,7 @@
generator client {
provider = "prisma-client-js"
+ binaryTargets = ["native", "debian-openssl-3.0.x"]
previewFeatures = ["postgresqlExtensions"]
}
@@ -1067,6 +1068,10 @@ model Personality {
displayName String @map("display_name")
description String? @db.Text
+ // Tone and formality
+ tone String @default("neutral")
+ formalityLevel FormalityLevel @default(NEUTRAL) @map("formality_level")
+
// System prompt
systemPrompt String @map("system_prompt") @db.Text
diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts
index 945f6e4..994d191 100644
--- a/apps/api/src/app.module.ts
+++ b/apps/api/src/app.module.ts
@@ -41,6 +41,8 @@ import { MosaicTelemetryModule } from "./mosaic-telemetry";
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({
@@ -105,6 +107,8 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
SpeechModule,
DashboardModule,
TerminalModule,
+ PersonalitiesModule,
+ WorkspacesModule,
],
controllers: [AppController, CsrfController],
providers: [
diff --git a/apps/api/src/auth/auth.controller.spec.ts b/apps/api/src/auth/auth.controller.spec.ts
index 8f54a32..3740338 100644
--- a/apps/api/src/auth/auth.controller.spec.ts
+++ b/apps/api/src/auth/auth.controller.spec.ts
@@ -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");
});
});
diff --git a/apps/api/src/auth/auth.controller.ts b/apps/api/src/auth/auth.controller.ts
index b8802f7..f0bd96b 100644
--- a/apps/api/src/auth/auth.controller.ts
+++ b/apps/api/src/auth/auth.controller.ts
@@ -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;
}
diff --git a/apps/api/src/common/guards/workspace.guard.ts b/apps/api/src/common/guards/workspace.guard.ts
index 75d065f..058441a 100644
--- a/apps/api/src/common/guards/workspace.guard.ts
+++ b/apps/api/src/common/guards/workspace.guard.ts
@@ -110,10 +110,10 @@ export class WorkspaceGuard implements CanActivate {
return paramWorkspaceId;
}
- // 3. Check request body
- const bodyWorkspaceId = request.body.workspaceId;
- if (typeof bodyWorkspaceId === "string") {
- return bodyWorkspaceId;
+ // 3. Check request body (body may be undefined for GET requests despite Express typings)
+ const body = request.body as Record | undefined;
+ if (body && typeof body.workspaceId === "string") {
+ return body.workspaceId;
}
// 4. Check query string (backward compatibility for existing clients)
diff --git a/apps/api/src/knowledge/dto/entry-query.dto.ts b/apps/api/src/knowledge/dto/entry-query.dto.ts
index 5a5f97b..c455838 100644
--- a/apps/api/src/knowledge/dto/entry-query.dto.ts
+++ b/apps/api/src/knowledge/dto/entry-query.dto.ts
@@ -1,6 +1,6 @@
-import { IsOptional, IsEnum, IsString, IsInt, Min, Max } from "class-validator";
+import { IsOptional, IsEnum, IsString, IsInt, IsIn, Min, Max } from "class-validator";
import { Type } from "class-transformer";
-import { EntryStatus } from "@prisma/client";
+import { EntryStatus, Visibility } from "@prisma/client";
/**
* DTO for querying knowledge entries (list endpoint)
@@ -10,10 +10,28 @@ export class EntryQueryDto {
@IsEnum(EntryStatus, { message: "status must be a valid EntryStatus" })
status?: EntryStatus;
+ @IsOptional()
+ @IsEnum(Visibility, { message: "visibility must be a valid Visibility" })
+ visibility?: Visibility;
+
@IsOptional()
@IsString({ message: "tag must be a string" })
tag?: string;
+ @IsOptional()
+ @IsString({ message: "search must be a string" })
+ search?: string;
+
+ @IsOptional()
+ @IsIn(["updatedAt", "createdAt", "title"], {
+ message: "sortBy must be updatedAt, createdAt, or title",
+ })
+ sortBy?: "updatedAt" | "createdAt" | "title";
+
+ @IsOptional()
+ @IsIn(["asc", "desc"], { message: "sortOrder must be asc or desc" })
+ sortOrder?: "asc" | "desc";
+
@IsOptional()
@Type(() => Number)
@IsInt({ message: "page must be an integer" })
diff --git a/apps/api/src/knowledge/knowledge.service.ts b/apps/api/src/knowledge/knowledge.service.ts
index f004d91..e1ef04c 100644
--- a/apps/api/src/knowledge/knowledge.service.ts
+++ b/apps/api/src/knowledge/knowledge.service.ts
@@ -48,6 +48,10 @@ export class KnowledgeService {
where.status = query.status;
}
+ if (query.visibility) {
+ where.visibility = query.visibility;
+ }
+
if (query.tag) {
where.tags = {
some: {
@@ -58,6 +62,20 @@ export class KnowledgeService {
};
}
+ if (query.search) {
+ where.OR = [
+ { title: { contains: query.search, mode: "insensitive" } },
+ { content: { contains: query.search, mode: "insensitive" } },
+ ];
+ }
+
+ // Build orderBy
+ const sortField = query.sortBy ?? "updatedAt";
+ const sortDirection = query.sortOrder ?? "desc";
+ const orderBy: Prisma.KnowledgeEntryOrderByWithRelationInput = {
+ [sortField]: sortDirection,
+ };
+
// Get total count
const total = await this.prisma.knowledgeEntry.count({ where });
@@ -71,9 +89,7 @@ export class KnowledgeService {
},
},
},
- orderBy: {
- updatedAt: "desc",
- },
+ orderBy,
skip,
take: limit,
});
diff --git a/apps/api/src/personalities/dto/create-personality.dto.ts b/apps/api/src/personalities/dto/create-personality.dto.ts
index 81cb86e..cc006d7 100644
--- a/apps/api/src/personalities/dto/create-personality.dto.ts
+++ b/apps/api/src/personalities/dto/create-personality.dto.ts
@@ -1,59 +1,38 @@
-import {
- IsString,
- IsOptional,
- IsBoolean,
- IsNumber,
- IsInt,
- IsUUID,
- MinLength,
- MaxLength,
- Min,
- Max,
-} from "class-validator";
+import { FormalityLevel } from "@prisma/client";
+import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
/**
- * DTO for creating a new personality/assistant configuration
+ * DTO for creating a new personality
+ * Field names match the frontend API contract from @mosaic/shared Personality type.
*/
export class CreatePersonalityDto {
- @IsString()
- @MinLength(1)
- @MaxLength(100)
- name!: string; // unique identifier slug
-
- @IsString()
- @MinLength(1)
- @MaxLength(200)
- displayName!: string; // human-readable name
+ @IsString({ message: "name must be a string" })
+ @MinLength(1, { message: "name must not be empty" })
+ @MaxLength(255, { message: "name must not exceed 255 characters" })
+ name!: string;
@IsOptional()
- @IsString()
- @MaxLength(1000)
+ @IsString({ message: "description must be a string" })
+ @MaxLength(2000, { message: "description must not exceed 2000 characters" })
description?: string;
- @IsString()
- @MinLength(10)
- systemPrompt!: string;
+ @IsString({ message: "tone must be a string" })
+ @MinLength(1, { message: "tone must not be empty" })
+ @MaxLength(100, { message: "tone must not exceed 100 characters" })
+ tone!: string;
+
+ @IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
+ formalityLevel!: FormalityLevel;
+
+ @IsString({ message: "systemPromptTemplate must be a string" })
+ @MinLength(1, { message: "systemPromptTemplate must not be empty" })
+ systemPromptTemplate!: string;
@IsOptional()
- @IsNumber()
- @Min(0)
- @Max(2)
- temperature?: number; // null = use provider default
-
- @IsOptional()
- @IsInt()
- @Min(1)
- maxTokens?: number; // null = use provider default
-
- @IsOptional()
- @IsUUID("4")
- llmProviderInstanceId?: string; // FK to LlmProviderInstance
-
- @IsOptional()
- @IsBoolean()
+ @IsBoolean({ message: "isDefault must be a boolean" })
isDefault?: boolean;
@IsOptional()
- @IsBoolean()
- isEnabled?: boolean;
+ @IsBoolean({ message: "isActive must be a boolean" })
+ isActive?: boolean;
}
diff --git a/apps/api/src/personalities/dto/index.ts b/apps/api/src/personalities/dto/index.ts
index b33be96..aca0d0b 100644
--- a/apps/api/src/personalities/dto/index.ts
+++ b/apps/api/src/personalities/dto/index.ts
@@ -1,2 +1,3 @@
export * from "./create-personality.dto";
export * from "./update-personality.dto";
+export * from "./personality-query.dto";
diff --git a/apps/api/src/personalities/dto/personality-query.dto.ts b/apps/api/src/personalities/dto/personality-query.dto.ts
new file mode 100644
index 0000000..786ac64
--- /dev/null
+++ b/apps/api/src/personalities/dto/personality-query.dto.ts
@@ -0,0 +1,12 @@
+import { IsBoolean, IsOptional } from "class-validator";
+import { Transform } from "class-transformer";
+
+/**
+ * DTO for querying/filtering personalities
+ */
+export class PersonalityQueryDto {
+ @IsOptional()
+ @IsBoolean({ message: "isActive must be a boolean" })
+ @Transform(({ value }) => value === "true" || value === true)
+ isActive?: boolean;
+}
diff --git a/apps/api/src/personalities/dto/update-personality.dto.ts b/apps/api/src/personalities/dto/update-personality.dto.ts
index 4098592..937ab4a 100644
--- a/apps/api/src/personalities/dto/update-personality.dto.ts
+++ b/apps/api/src/personalities/dto/update-personality.dto.ts
@@ -1,62 +1,42 @@
-import {
- IsString,
- IsOptional,
- IsBoolean,
- IsNumber,
- IsInt,
- IsUUID,
- MinLength,
- MaxLength,
- Min,
- Max,
-} from "class-validator";
+import { FormalityLevel } from "@prisma/client";
+import { IsString, IsEnum, IsOptional, IsBoolean, MinLength, MaxLength } from "class-validator";
/**
- * DTO for updating an existing personality/assistant configuration
+ * DTO for updating an existing personality
+ * All fields are optional; only provided fields are updated.
*/
export class UpdatePersonalityDto {
@IsOptional()
- @IsString()
- @MinLength(1)
- @MaxLength(100)
- name?: string; // unique identifier slug
+ @IsString({ message: "name must be a string" })
+ @MinLength(1, { message: "name must not be empty" })
+ @MaxLength(255, { message: "name must not exceed 255 characters" })
+ name?: string;
@IsOptional()
- @IsString()
- @MinLength(1)
- @MaxLength(200)
- displayName?: string; // human-readable name
-
- @IsOptional()
- @IsString()
- @MaxLength(1000)
+ @IsString({ message: "description must be a string" })
+ @MaxLength(2000, { message: "description must not exceed 2000 characters" })
description?: string;
@IsOptional()
- @IsString()
- @MinLength(10)
- systemPrompt?: string;
+ @IsString({ message: "tone must be a string" })
+ @MinLength(1, { message: "tone must not be empty" })
+ @MaxLength(100, { message: "tone must not exceed 100 characters" })
+ tone?: string;
@IsOptional()
- @IsNumber()
- @Min(0)
- @Max(2)
- temperature?: number; // null = use provider default
+ @IsEnum(FormalityLevel, { message: "formalityLevel must be a valid FormalityLevel" })
+ formalityLevel?: FormalityLevel;
@IsOptional()
- @IsInt()
- @Min(1)
- maxTokens?: number; // null = use provider default
+ @IsString({ message: "systemPromptTemplate must be a string" })
+ @MinLength(1, { message: "systemPromptTemplate must not be empty" })
+ systemPromptTemplate?: string;
@IsOptional()
- @IsUUID("4")
- llmProviderInstanceId?: string; // FK to LlmProviderInstance
-
- @IsOptional()
- @IsBoolean()
+ @IsBoolean({ message: "isDefault must be a boolean" })
isDefault?: boolean;
@IsOptional()
- @IsBoolean()
- isEnabled?: boolean;
+ @IsBoolean({ message: "isActive must be a boolean" })
+ isActive?: boolean;
}
diff --git a/apps/api/src/personalities/entities/personality.entity.ts b/apps/api/src/personalities/entities/personality.entity.ts
index e685121..5ef146d 100644
--- a/apps/api/src/personalities/entities/personality.entity.ts
+++ b/apps/api/src/personalities/entities/personality.entity.ts
@@ -1,20 +1,24 @@
-import type { Personality as PrismaPersonality } from "@prisma/client";
+import type { FormalityLevel } from "@prisma/client";
/**
- * Personality entity representing an assistant configuration
+ * Personality response entity
+ * Maps Prisma Personality fields to the frontend API contract.
+ *
+ * Field mapping (Prisma -> API):
+ * systemPrompt -> systemPromptTemplate
+ * isEnabled -> isActive
+ * (tone, formalityLevel are identical in both)
*/
-export class Personality implements PrismaPersonality {
- id!: string;
- workspaceId!: string;
- name!: string; // unique identifier slug
- displayName!: string; // human-readable name
- description!: string | null;
- systemPrompt!: string;
- temperature!: number | null; // null = use provider default
- maxTokens!: number | null; // null = use provider default
- llmProviderInstanceId!: string | null; // FK to LlmProviderInstance
- isDefault!: boolean;
- isEnabled!: boolean;
- createdAt!: Date;
- updatedAt!: Date;
+export interface PersonalityResponse {
+ id: string;
+ workspaceId: string;
+ name: string;
+ description: string | null;
+ tone: string;
+ formalityLevel: FormalityLevel;
+ systemPromptTemplate: string;
+ isDefault: boolean;
+ isActive: boolean;
+ createdAt: Date;
+ updatedAt: Date;
}
diff --git a/apps/api/src/personalities/personalities.controller.spec.ts b/apps/api/src/personalities/personalities.controller.spec.ts
index 8e1dc23..ef767c1 100644
--- a/apps/api/src/personalities/personalities.controller.spec.ts
+++ b/apps/api/src/personalities/personalities.controller.spec.ts
@@ -2,36 +2,32 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { PersonalitiesController } from "./personalities.controller";
import { PersonalitiesService } from "./personalities.service";
-import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
+import type { CreatePersonalityDto } from "./dto/create-personality.dto";
+import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
import { AuthGuard } from "../auth/guards/auth.guard";
+import { WorkspaceGuard, PermissionGuard } from "../common/guards";
+import { FormalityLevel } from "@prisma/client";
describe("PersonalitiesController", () => {
let controller: PersonalitiesController;
let service: PersonalitiesService;
const mockWorkspaceId = "workspace-123";
- const mockUserId = "user-123";
const mockPersonalityId = "personality-123";
+ /** API response shape (frontend field names) */
const mockPersonality = {
id: mockPersonalityId,
workspaceId: mockWorkspaceId,
name: "professional-assistant",
- displayName: "Professional Assistant",
description: "A professional communication assistant",
- systemPrompt: "You are a professional assistant who helps with tasks.",
- temperature: 0.7,
- maxTokens: 2000,
- llmProviderInstanceId: "provider-123",
+ tone: "professional",
+ formalityLevel: FormalityLevel.FORMAL,
+ systemPromptTemplate: "You are a professional assistant who helps with tasks.",
isDefault: true,
- isEnabled: true,
- createdAt: new Date(),
- updatedAt: new Date(),
- };
-
- const mockRequest = {
- user: { id: mockUserId },
- workspaceId: mockWorkspaceId,
+ isActive: true,
+ createdAt: new Date("2026-01-01"),
+ updatedAt: new Date("2026-01-01"),
};
const mockPersonalitiesService = {
@@ -57,46 +53,43 @@ describe("PersonalitiesController", () => {
})
.overrideGuard(AuthGuard)
.useValue({ canActivate: () => true })
+ .overrideGuard(WorkspaceGuard)
+ .useValue({
+ canActivate: (ctx: {
+ switchToHttp: () => { getRequest: () => { workspaceId: string } };
+ }) => {
+ const req = ctx.switchToHttp().getRequest();
+ req.workspaceId = mockWorkspaceId;
+ return true;
+ },
+ })
+ .overrideGuard(PermissionGuard)
+ .useValue({ canActivate: () => true })
.compile();
controller = module.get(PersonalitiesController);
service = module.get(PersonalitiesService);
- // Reset mocks
vi.clearAllMocks();
});
describe("findAll", () => {
- it("should return all personalities", async () => {
- const mockPersonalities = [mockPersonality];
- mockPersonalitiesService.findAll.mockResolvedValue(mockPersonalities);
+ it("should return success response with personalities list", async () => {
+ const mockList = [mockPersonality];
+ mockPersonalitiesService.findAll.mockResolvedValue(mockList);
- const result = await controller.findAll(mockRequest);
+ const result = await controller.findAll(mockWorkspaceId, {});
- expect(result).toEqual(mockPersonalities);
- expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId);
+ expect(result).toEqual({ success: true, data: mockList });
+ expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, {});
});
- });
- describe("findOne", () => {
- it("should return a personality by id", async () => {
- mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
+ it("should pass isActive query filter to service", async () => {
+ mockPersonalitiesService.findAll.mockResolvedValue([mockPersonality]);
- const result = await controller.findOne(mockRequest, mockPersonalityId);
+ await controller.findAll(mockWorkspaceId, { isActive: true });
- expect(result).toEqual(mockPersonality);
- expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
- });
- });
-
- describe("findByName", () => {
- it("should return a personality by name", async () => {
- mockPersonalitiesService.findByName.mockResolvedValue(mockPersonality);
-
- const result = await controller.findByName(mockRequest, "professional-assistant");
-
- expect(result).toEqual(mockPersonality);
- expect(service.findByName).toHaveBeenCalledWith(mockWorkspaceId, "professional-assistant");
+ expect(service.findAll).toHaveBeenCalledWith(mockWorkspaceId, { isActive: true });
});
});
@@ -104,32 +97,40 @@ describe("PersonalitiesController", () => {
it("should return the default personality", async () => {
mockPersonalitiesService.findDefault.mockResolvedValue(mockPersonality);
- const result = await controller.findDefault(mockRequest);
+ const result = await controller.findDefault(mockWorkspaceId);
expect(result).toEqual(mockPersonality);
expect(service.findDefault).toHaveBeenCalledWith(mockWorkspaceId);
});
});
+ describe("findOne", () => {
+ it("should return a personality by id", async () => {
+ mockPersonalitiesService.findOne.mockResolvedValue(mockPersonality);
+
+ const result = await controller.findOne(mockWorkspaceId, mockPersonalityId);
+
+ expect(result).toEqual(mockPersonality);
+ expect(service.findOne).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
+ });
+ });
+
describe("create", () => {
it("should create a new personality", async () => {
const createDto: CreatePersonalityDto = {
name: "casual-helper",
- displayName: "Casual Helper",
description: "A casual helper",
- systemPrompt: "You are a casual assistant.",
- temperature: 0.8,
- maxTokens: 1500,
+ tone: "casual",
+ formalityLevel: FormalityLevel.CASUAL,
+ systemPromptTemplate: "You are a casual assistant.",
};
- mockPersonalitiesService.create.mockResolvedValue({
- ...mockPersonality,
- ...createDto,
- });
+ const created = { ...mockPersonality, ...createDto, isActive: true, isDefault: false };
+ mockPersonalitiesService.create.mockResolvedValue(created);
- const result = await controller.create(mockRequest, createDto);
+ const result = await controller.create(mockWorkspaceId, createDto);
- expect(result).toMatchObject(createDto);
+ expect(result).toMatchObject({ name: createDto.name, tone: createDto.tone });
expect(service.create).toHaveBeenCalledWith(mockWorkspaceId, createDto);
});
});
@@ -138,15 +139,13 @@ describe("PersonalitiesController", () => {
it("should update a personality", async () => {
const updateDto: UpdatePersonalityDto = {
description: "Updated description",
- temperature: 0.9,
+ tone: "enthusiastic",
};
- mockPersonalitiesService.update.mockResolvedValue({
- ...mockPersonality,
- ...updateDto,
- });
+ const updated = { ...mockPersonality, ...updateDto };
+ mockPersonalitiesService.update.mockResolvedValue(updated);
- const result = await controller.update(mockRequest, mockPersonalityId, updateDto);
+ const result = await controller.update(mockWorkspaceId, mockPersonalityId, updateDto);
expect(result).toMatchObject(updateDto);
expect(service.update).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId, updateDto);
@@ -157,7 +156,7 @@ describe("PersonalitiesController", () => {
it("should delete a personality", async () => {
mockPersonalitiesService.delete.mockResolvedValue(undefined);
- await controller.delete(mockRequest, mockPersonalityId);
+ await controller.delete(mockWorkspaceId, mockPersonalityId);
expect(service.delete).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
});
@@ -165,12 +164,10 @@ describe("PersonalitiesController", () => {
describe("setDefault", () => {
it("should set a personality as default", async () => {
- mockPersonalitiesService.setDefault.mockResolvedValue({
- ...mockPersonality,
- isDefault: true,
- });
+ const updated = { ...mockPersonality, isDefault: true };
+ mockPersonalitiesService.setDefault.mockResolvedValue(updated);
- const result = await controller.setDefault(mockRequest, mockPersonalityId);
+ const result = await controller.setDefault(mockWorkspaceId, mockPersonalityId);
expect(result).toMatchObject({ isDefault: true });
expect(service.setDefault).toHaveBeenCalledWith(mockWorkspaceId, mockPersonalityId);
diff --git a/apps/api/src/personalities/personalities.controller.ts b/apps/api/src/personalities/personalities.controller.ts
index 79714de..ba1f2ef 100644
--- a/apps/api/src/personalities/personalities.controller.ts
+++ b/apps/api/src/personalities/personalities.controller.ts
@@ -6,105 +6,122 @@ import {
Delete,
Body,
Param,
+ Query,
UseGuards,
- Req,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import { AuthGuard } from "../auth/guards/auth.guard";
+import { WorkspaceGuard, PermissionGuard } from "../common/guards";
+import { Workspace, Permission, RequirePermission } from "../common/decorators";
import { PersonalitiesService } from "./personalities.service";
-import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
-import { Personality } from "./entities/personality.entity";
-
-interface AuthenticatedRequest {
- user: { id: string };
- workspaceId: string;
-}
+import { CreatePersonalityDto } from "./dto/create-personality.dto";
+import { UpdatePersonalityDto } from "./dto/update-personality.dto";
+import { PersonalityQueryDto } from "./dto/personality-query.dto";
+import type { PersonalityResponse } from "./entities/personality.entity";
/**
- * Controller for managing personality/assistant configurations
+ * Controller for personality CRUD endpoints.
+ * Route: /api/personalities
+ *
+ * Guards applied in order:
+ * 1. AuthGuard - verifies the user is authenticated
+ * 2. WorkspaceGuard - validates workspace access
+ * 3. PermissionGuard - checks role-based permissions
*/
-@Controller("personality")
-@UseGuards(AuthGuard)
+@Controller("personalities")
+@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class PersonalitiesController {
constructor(private readonly personalitiesService: PersonalitiesService) {}
/**
- * List all personalities for the workspace
+ * GET /api/personalities
+ * List all personalities for the workspace.
+ * Supports ?isActive=true|false filter.
*/
@Get()
- async findAll(@Req() req: AuthenticatedRequest): Promise {
- return this.personalitiesService.findAll(req.workspaceId);
+ @RequirePermission(Permission.WORKSPACE_ANY)
+ async findAll(
+ @Workspace() workspaceId: string,
+ @Query() query: PersonalityQueryDto
+ ): Promise<{ success: true; data: PersonalityResponse[] }> {
+ const data = await this.personalitiesService.findAll(workspaceId, query);
+ return { success: true, data };
}
/**
- * Get the default personality for the workspace
+ * GET /api/personalities/default
+ * Get the default personality for the workspace.
+ * Must be declared before :id to avoid route conflicts.
*/
@Get("default")
- async findDefault(@Req() req: AuthenticatedRequest): Promise {
- return this.personalitiesService.findDefault(req.workspaceId);
+ @RequirePermission(Permission.WORKSPACE_ANY)
+ async findDefault(@Workspace() workspaceId: string): Promise {
+ return this.personalitiesService.findDefault(workspaceId);
}
/**
- * Get a personality by its unique name
- */
- @Get("by-name/:name")
- async findByName(
- @Req() req: AuthenticatedRequest,
- @Param("name") name: string
- ): Promise {
- return this.personalitiesService.findByName(req.workspaceId, name);
- }
-
- /**
- * Get a personality by ID
+ * GET /api/personalities/:id
+ * Get a single personality by ID.
*/
@Get(":id")
- async findOne(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise {
- return this.personalitiesService.findOne(req.workspaceId, id);
+ @RequirePermission(Permission.WORKSPACE_ANY)
+ async findOne(
+ @Workspace() workspaceId: string,
+ @Param("id") id: string
+ ): Promise {
+ return this.personalitiesService.findOne(workspaceId, id);
}
/**
- * Create a new personality
+ * POST /api/personalities
+ * Create a new personality.
*/
@Post()
@HttpCode(HttpStatus.CREATED)
+ @RequirePermission(Permission.WORKSPACE_MEMBER)
async create(
- @Req() req: AuthenticatedRequest,
+ @Workspace() workspaceId: string,
@Body() dto: CreatePersonalityDto
- ): Promise {
- return this.personalitiesService.create(req.workspaceId, dto);
+ ): Promise {
+ return this.personalitiesService.create(workspaceId, dto);
}
/**
- * Update a personality
+ * PATCH /api/personalities/:id
+ * Update an existing personality.
*/
@Patch(":id")
+ @RequirePermission(Permission.WORKSPACE_MEMBER)
async update(
- @Req() req: AuthenticatedRequest,
+ @Workspace() workspaceId: string,
@Param("id") id: string,
@Body() dto: UpdatePersonalityDto
- ): Promise {
- return this.personalitiesService.update(req.workspaceId, id, dto);
+ ): Promise {
+ return this.personalitiesService.update(workspaceId, id, dto);
}
/**
- * Delete a personality
+ * DELETE /api/personalities/:id
+ * Delete a personality.
*/
@Delete(":id")
@HttpCode(HttpStatus.NO_CONTENT)
- async delete(@Req() req: AuthenticatedRequest, @Param("id") id: string): Promise {
- return this.personalitiesService.delete(req.workspaceId, id);
+ @RequirePermission(Permission.WORKSPACE_MEMBER)
+ async delete(@Workspace() workspaceId: string, @Param("id") id: string): Promise {
+ return this.personalitiesService.delete(workspaceId, id);
}
/**
- * Set a personality as the default
+ * POST /api/personalities/:id/set-default
+ * Convenience endpoint to set a personality as the default.
*/
@Post(":id/set-default")
+ @RequirePermission(Permission.WORKSPACE_MEMBER)
async setDefault(
- @Req() req: AuthenticatedRequest,
+ @Workspace() workspaceId: string,
@Param("id") id: string
- ): Promise {
- return this.personalitiesService.setDefault(req.workspaceId, id);
+ ): Promise {
+ return this.personalitiesService.setDefault(workspaceId, id);
}
}
diff --git a/apps/api/src/personalities/personalities.service.spec.ts b/apps/api/src/personalities/personalities.service.spec.ts
index b0e1b20..2cc1c02 100644
--- a/apps/api/src/personalities/personalities.service.spec.ts
+++ b/apps/api/src/personalities/personalities.service.spec.ts
@@ -2,8 +2,10 @@ import { describe, it, expect, beforeEach, vi } from "vitest";
import { Test, TestingModule } from "@nestjs/testing";
import { PersonalitiesService } from "./personalities.service";
import { PrismaService } from "../prisma/prisma.service";
-import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
+import type { CreatePersonalityDto } from "./dto/create-personality.dto";
+import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
import { NotFoundException, ConflictException } from "@nestjs/common";
+import { FormalityLevel } from "@prisma/client";
describe("PersonalitiesService", () => {
let service: PersonalitiesService;
@@ -11,22 +13,39 @@ describe("PersonalitiesService", () => {
const mockWorkspaceId = "workspace-123";
const mockPersonalityId = "personality-123";
- const mockProviderId = "provider-123";
- const mockPersonality = {
+ /** Raw Prisma record shape (uses Prisma field names) */
+ const mockPrismaRecord = {
id: mockPersonalityId,
workspaceId: mockWorkspaceId,
name: "professional-assistant",
displayName: "Professional Assistant",
description: "A professional communication assistant",
+ tone: "professional",
+ formalityLevel: FormalityLevel.FORMAL,
systemPrompt: "You are a professional assistant who helps with tasks.",
temperature: 0.7,
maxTokens: 2000,
- llmProviderInstanceId: mockProviderId,
+ llmProviderInstanceId: "provider-123",
isDefault: true,
isEnabled: true,
- createdAt: new Date(),
- updatedAt: new Date(),
+ createdAt: new Date("2026-01-01"),
+ updatedAt: new Date("2026-01-01"),
+ };
+
+ /** Expected API response shape (uses frontend field names) */
+ const mockResponse = {
+ id: mockPersonalityId,
+ workspaceId: mockWorkspaceId,
+ name: "professional-assistant",
+ description: "A professional communication assistant",
+ tone: "professional",
+ formalityLevel: FormalityLevel.FORMAL,
+ systemPromptTemplate: "You are a professional assistant who helps with tasks.",
+ isDefault: true,
+ isActive: true,
+ createdAt: new Date("2026-01-01"),
+ updatedAt: new Date("2026-01-01"),
};
const mockPrismaService = {
@@ -37,9 +56,7 @@ describe("PersonalitiesService", () => {
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
- count: vi.fn(),
},
- $transaction: vi.fn((callback) => callback(mockPrismaService)),
};
beforeEach(async () => {
@@ -56,44 +73,54 @@ describe("PersonalitiesService", () => {
service = module.get(PersonalitiesService);
prisma = module.get(PrismaService);
- // Reset mocks
vi.clearAllMocks();
});
describe("create", () => {
const createDto: CreatePersonalityDto = {
name: "casual-helper",
- displayName: "Casual Helper",
description: "A casual communication helper",
- systemPrompt: "You are a casual assistant.",
- temperature: 0.8,
- maxTokens: 1500,
- llmProviderInstanceId: mockProviderId,
+ tone: "casual",
+ formalityLevel: FormalityLevel.CASUAL,
+ systemPromptTemplate: "You are a casual assistant.",
+ isDefault: false,
+ isActive: true,
};
- it("should create a new personality", async () => {
+ const createdRecord = {
+ ...mockPrismaRecord,
+ name: createDto.name,
+ description: createDto.description,
+ tone: createDto.tone,
+ formalityLevel: createDto.formalityLevel,
+ systemPrompt: createDto.systemPromptTemplate,
+ isDefault: false,
+ isEnabled: true,
+ id: "new-personality-id",
+ };
+
+ it("should create a new personality and return API response shape", async () => {
mockPrismaService.personality.findFirst.mockResolvedValue(null);
- mockPrismaService.personality.create.mockResolvedValue({
- ...mockPersonality,
- ...createDto,
- id: "new-personality-id",
- isDefault: false,
- isEnabled: true,
- });
+ mockPrismaService.personality.create.mockResolvedValue(createdRecord);
const result = await service.create(mockWorkspaceId, createDto);
- expect(result).toMatchObject(createDto);
+ expect(result.name).toBe(createDto.name);
+ expect(result.tone).toBe(createDto.tone);
+ expect(result.formalityLevel).toBe(createDto.formalityLevel);
+ expect(result.systemPromptTemplate).toBe(createDto.systemPromptTemplate);
+ expect(result.isActive).toBe(true);
+ expect(result.isDefault).toBe(false);
+
expect(prisma.personality.create).toHaveBeenCalledWith({
data: {
workspaceId: mockWorkspaceId,
name: createDto.name,
- displayName: createDto.displayName,
+ displayName: createDto.name,
description: createDto.description ?? null,
- systemPrompt: createDto.systemPrompt,
- temperature: createDto.temperature ?? null,
- maxTokens: createDto.maxTokens ?? null,
- llmProviderInstanceId: createDto.llmProviderInstanceId ?? null,
+ tone: createDto.tone,
+ formalityLevel: createDto.formalityLevel,
+ systemPrompt: createDto.systemPromptTemplate,
isDefault: false,
isEnabled: true,
},
@@ -101,68 +128,73 @@ describe("PersonalitiesService", () => {
});
it("should throw ConflictException when name already exists", async () => {
- mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
+ mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
await expect(service.create(mockWorkspaceId, createDto)).rejects.toThrow(ConflictException);
});
it("should unset other defaults when creating a new default personality", async () => {
- const createDefaultDto = { ...createDto, isDefault: true };
- // First call to findFirst checks for name conflict (should be null)
- // Second call to findFirst finds the existing default personality
+ const createDefaultDto: CreatePersonalityDto = { ...createDto, isDefault: true };
+ const otherDefault = { ...mockPrismaRecord, id: "other-id" };
+
mockPrismaService.personality.findFirst
- .mockResolvedValueOnce(null) // No name conflict
- .mockResolvedValueOnce(mockPersonality); // Existing default
- mockPrismaService.personality.update.mockResolvedValue({
- ...mockPersonality,
- isDefault: false,
- });
+ .mockResolvedValueOnce(null) // name conflict check
+ .mockResolvedValueOnce(otherDefault); // existing default lookup
+ mockPrismaService.personality.update.mockResolvedValue({ ...otherDefault, isDefault: false });
mockPrismaService.personality.create.mockResolvedValue({
- ...mockPersonality,
- ...createDefaultDto,
+ ...createdRecord,
+ isDefault: true,
});
await service.create(mockWorkspaceId, createDefaultDto);
expect(prisma.personality.update).toHaveBeenCalledWith({
- where: { id: mockPersonalityId },
+ where: { id: "other-id" },
data: { isDefault: false },
});
});
});
describe("findAll", () => {
- it("should return all personalities for a workspace", async () => {
- const mockPersonalities = [mockPersonality];
- mockPrismaService.personality.findMany.mockResolvedValue(mockPersonalities);
+ it("should return mapped response list for a workspace", async () => {
+ mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
const result = await service.findAll(mockWorkspaceId);
- expect(result).toEqual(mockPersonalities);
+ expect(result).toHaveLength(1);
+ expect(result[0]).toEqual(mockResponse);
expect(prisma.personality.findMany).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId },
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
});
});
+
+ it("should filter by isActive when provided", async () => {
+ mockPrismaService.personality.findMany.mockResolvedValue([mockPrismaRecord]);
+
+ await service.findAll(mockWorkspaceId, { isActive: true });
+
+ expect(prisma.personality.findMany).toHaveBeenCalledWith({
+ where: { workspaceId: mockWorkspaceId, isEnabled: true },
+ orderBy: [{ isDefault: "desc" }, { name: "asc" }],
+ });
+ });
});
describe("findOne", () => {
- it("should return a personality by id", async () => {
- mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
+ it("should return a mapped personality response by id", async () => {
+ mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
const result = await service.findOne(mockWorkspaceId, mockPersonalityId);
- expect(result).toEqual(mockPersonality);
- expect(prisma.personality.findUnique).toHaveBeenCalledWith({
- where: {
- id: mockPersonalityId,
- workspaceId: mockWorkspaceId,
- },
+ expect(result).toEqual(mockResponse);
+ expect(prisma.personality.findFirst).toHaveBeenCalledWith({
+ where: { id: mockPersonalityId, workspaceId: mockWorkspaceId },
});
});
it("should throw NotFoundException when personality not found", async () => {
- mockPrismaService.personality.findUnique.mockResolvedValue(null);
+ mockPrismaService.personality.findFirst.mockResolvedValue(null);
await expect(service.findOne(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException
@@ -171,17 +203,14 @@ describe("PersonalitiesService", () => {
});
describe("findByName", () => {
- it("should return a personality by name", async () => {
- mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
+ it("should return a mapped personality response by name", async () => {
+ mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
const result = await service.findByName(mockWorkspaceId, "professional-assistant");
- expect(result).toEqual(mockPersonality);
+ expect(result).toEqual(mockResponse);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
- where: {
- workspaceId: mockWorkspaceId,
- name: "professional-assistant",
- },
+ where: { workspaceId: mockWorkspaceId, name: "professional-assistant" },
});
});
@@ -196,11 +225,11 @@ describe("PersonalitiesService", () => {
describe("findDefault", () => {
it("should return the default personality", async () => {
- mockPrismaService.personality.findFirst.mockResolvedValue(mockPersonality);
+ mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
const result = await service.findDefault(mockWorkspaceId);
- expect(result).toEqual(mockPersonality);
+ expect(result).toEqual(mockResponse);
expect(prisma.personality.findFirst).toHaveBeenCalledWith({
where: { workspaceId: mockWorkspaceId, isDefault: true, isEnabled: true },
});
@@ -216,41 +245,45 @@ describe("PersonalitiesService", () => {
describe("update", () => {
const updateDto: UpdatePersonalityDto = {
description: "Updated description",
- temperature: 0.9,
+ tone: "formal",
+ isActive: false,
};
- it("should update a personality", async () => {
- mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
- mockPrismaService.personality.findFirst.mockResolvedValue(null);
- mockPrismaService.personality.update.mockResolvedValue({
- ...mockPersonality,
- ...updateDto,
- });
+ it("should update a personality and return mapped response", async () => {
+ const updatedRecord = {
+ ...mockPrismaRecord,
+ description: updateDto.description,
+ tone: updateDto.tone,
+ isEnabled: false,
+ };
+
+ mockPrismaService.personality.findFirst
+ .mockResolvedValueOnce(mockPrismaRecord) // findOne check
+ .mockResolvedValueOnce(null); // name conflict check (no dto.name here)
+ mockPrismaService.personality.update.mockResolvedValue(updatedRecord);
const result = await service.update(mockWorkspaceId, mockPersonalityId, updateDto);
- expect(result).toMatchObject(updateDto);
- expect(prisma.personality.update).toHaveBeenCalledWith({
- where: { id: mockPersonalityId },
- data: updateDto,
- });
+ expect(result.description).toBe(updateDto.description);
+ expect(result.tone).toBe(updateDto.tone);
+ expect(result.isActive).toBe(false);
});
it("should throw NotFoundException when personality not found", async () => {
- mockPrismaService.personality.findUnique.mockResolvedValue(null);
+ mockPrismaService.personality.findFirst.mockResolvedValue(null);
await expect(service.update(mockWorkspaceId, mockPersonalityId, updateDto)).rejects.toThrow(
NotFoundException
);
});
- it("should throw ConflictException when updating to existing name", async () => {
- const updateNameDto = { name: "existing-name" };
- mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
- mockPrismaService.personality.findFirst.mockResolvedValue({
- ...mockPersonality,
- id: "different-id",
- });
+ it("should throw ConflictException when updating to an existing name", async () => {
+ const updateNameDto: UpdatePersonalityDto = { name: "existing-name" };
+ const conflictRecord = { ...mockPrismaRecord, id: "different-id" };
+
+ mockPrismaService.personality.findFirst
+ .mockResolvedValueOnce(mockPrismaRecord) // findOne check
+ .mockResolvedValueOnce(conflictRecord); // name conflict
await expect(
service.update(mockWorkspaceId, mockPersonalityId, updateNameDto)
@@ -258,14 +291,16 @@ describe("PersonalitiesService", () => {
});
it("should unset other defaults when setting as default", async () => {
- const updateDefaultDto = { isDefault: true };
- const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
+ const updateDefaultDto: UpdatePersonalityDto = { isDefault: true };
+ const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
+ const updatedRecord = { ...mockPrismaRecord, isDefault: true };
- mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
- mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality); // Existing default from unsetOtherDefaults
+ mockPrismaService.personality.findFirst
+ .mockResolvedValueOnce(mockPrismaRecord) // findOne check
+ .mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
mockPrismaService.personality.update
- .mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
- .mockResolvedValueOnce({ ...mockPersonality, isDefault: true }); // Set new default
+ .mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
+ .mockResolvedValueOnce(updatedRecord);
await service.update(mockWorkspaceId, mockPersonalityId, updateDefaultDto);
@@ -273,16 +308,12 @@ describe("PersonalitiesService", () => {
where: { id: "other-id" },
data: { isDefault: false },
});
- expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
- where: { id: mockPersonalityId },
- data: updateDefaultDto,
- });
});
});
describe("delete", () => {
it("should delete a personality", async () => {
- mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
+ mockPrismaService.personality.findFirst.mockResolvedValue(mockPrismaRecord);
mockPrismaService.personality.delete.mockResolvedValue(undefined);
await service.delete(mockWorkspaceId, mockPersonalityId);
@@ -293,7 +324,7 @@ describe("PersonalitiesService", () => {
});
it("should throw NotFoundException when personality not found", async () => {
- mockPrismaService.personality.findUnique.mockResolvedValue(null);
+ mockPrismaService.personality.findFirst.mockResolvedValue(null);
await expect(service.delete(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException
@@ -303,30 +334,27 @@ describe("PersonalitiesService", () => {
describe("setDefault", () => {
it("should set a personality as default", async () => {
- const otherPersonality = { ...mockPersonality, id: "other-id", isDefault: true };
- const updatedPersonality = { ...mockPersonality, isDefault: true };
+ const otherPersonality = { ...mockPrismaRecord, id: "other-id", isDefault: true };
+ const updatedRecord = { ...mockPrismaRecord, isDefault: true };
- mockPrismaService.personality.findUnique.mockResolvedValue(mockPersonality);
- mockPrismaService.personality.findFirst.mockResolvedValue(otherPersonality);
+ mockPrismaService.personality.findFirst
+ .mockResolvedValueOnce(mockPrismaRecord) // findOne check
+ .mockResolvedValueOnce(otherPersonality); // unsetOtherDefaults lookup
mockPrismaService.personality.update
- .mockResolvedValueOnce({ ...otherPersonality, isDefault: false }) // Unset old default
- .mockResolvedValueOnce(updatedPersonality); // Set new default
+ .mockResolvedValueOnce({ ...otherPersonality, isDefault: false })
+ .mockResolvedValueOnce(updatedRecord);
const result = await service.setDefault(mockWorkspaceId, mockPersonalityId);
- expect(result).toMatchObject({ isDefault: true });
- expect(prisma.personality.update).toHaveBeenNthCalledWith(1, {
- where: { id: "other-id" },
- data: { isDefault: false },
- });
- expect(prisma.personality.update).toHaveBeenNthCalledWith(2, {
+ expect(result.isDefault).toBe(true);
+ expect(prisma.personality.update).toHaveBeenCalledWith({
where: { id: mockPersonalityId },
data: { isDefault: true },
});
});
it("should throw NotFoundException when personality not found", async () => {
- mockPrismaService.personality.findUnique.mockResolvedValue(null);
+ mockPrismaService.personality.findFirst.mockResolvedValue(null);
await expect(service.setDefault(mockWorkspaceId, mockPersonalityId)).rejects.toThrow(
NotFoundException
diff --git a/apps/api/src/personalities/personalities.service.ts b/apps/api/src/personalities/personalities.service.ts
index e766c8a..cf5a7f0 100644
--- a/apps/api/src/personalities/personalities.service.ts
+++ b/apps/api/src/personalities/personalities.service.ts
@@ -1,10 +1,17 @@
import { Injectable, NotFoundException, ConflictException, Logger } from "@nestjs/common";
+import type { FormalityLevel, Personality } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
-import { CreatePersonalityDto, UpdatePersonalityDto } from "./dto";
-import { Personality } from "./entities/personality.entity";
+import type { CreatePersonalityDto } from "./dto/create-personality.dto";
+import type { UpdatePersonalityDto } from "./dto/update-personality.dto";
+import type { PersonalityQueryDto } from "./dto/personality-query.dto";
+import type { PersonalityResponse } from "./entities/personality.entity";
/**
- * Service for managing personality/assistant configurations
+ * Service for managing personality/assistant configurations.
+ *
+ * Field mapping:
+ * Prisma `systemPrompt` <-> API/frontend `systemPromptTemplate`
+ * Prisma `isEnabled` <-> API/frontend `isActive`
*/
@Injectable()
export class PersonalitiesService {
@@ -12,11 +19,30 @@ export class PersonalitiesService {
constructor(private readonly prisma: PrismaService) {}
+ /**
+ * Map a Prisma Personality record to the API response shape.
+ */
+ private toResponse(personality: Personality): PersonalityResponse {
+ return {
+ id: personality.id,
+ workspaceId: personality.workspaceId,
+ name: personality.name,
+ description: personality.description,
+ tone: personality.tone,
+ formalityLevel: personality.formalityLevel,
+ systemPromptTemplate: personality.systemPrompt,
+ isDefault: personality.isDefault,
+ isActive: personality.isEnabled,
+ createdAt: personality.createdAt,
+ updatedAt: personality.updatedAt,
+ };
+ }
+
/**
* Create a new personality
*/
- async create(workspaceId: string, dto: CreatePersonalityDto): Promise {
- // Check for duplicate name
+ async create(workspaceId: string, dto: CreatePersonalityDto): Promise {
+ // Check for duplicate name within workspace
const existing = await this.prisma.personality.findFirst({
where: { workspaceId, name: dto.name },
});
@@ -25,7 +51,7 @@ export class PersonalitiesService {
throw new ConflictException(`Personality with name "${dto.name}" already exists`);
}
- // If creating a default personality, unset other defaults
+ // If creating as default, unset other defaults first
if (dto.isDefault) {
await this.unsetOtherDefaults(workspaceId);
}
@@ -34,36 +60,43 @@ export class PersonalitiesService {
data: {
workspaceId,
name: dto.name,
- displayName: dto.displayName,
+ displayName: dto.name, // use name as displayName since frontend doesn't send displayName separately
description: dto.description ?? null,
- systemPrompt: dto.systemPrompt,
- temperature: dto.temperature ?? null,
- maxTokens: dto.maxTokens ?? null,
- llmProviderInstanceId: dto.llmProviderInstanceId ?? null,
+ tone: dto.tone,
+ formalityLevel: dto.formalityLevel,
+ systemPrompt: dto.systemPromptTemplate,
isDefault: dto.isDefault ?? false,
- isEnabled: dto.isEnabled ?? true,
+ isEnabled: dto.isActive ?? true,
},
});
this.logger.log(`Created personality ${personality.id} for workspace ${workspaceId}`);
- return personality;
+ return this.toResponse(personality);
}
/**
- * Find all personalities for a workspace
+ * Find all personalities for a workspace with optional active filter
*/
- async findAll(workspaceId: string): Promise {
- return this.prisma.personality.findMany({
- where: { workspaceId },
+ async findAll(workspaceId: string, query?: PersonalityQueryDto): Promise {
+ const where: { workspaceId: string; isEnabled?: boolean } = { workspaceId };
+
+ if (query?.isActive !== undefined) {
+ where.isEnabled = query.isActive;
+ }
+
+ const personalities = await this.prisma.personality.findMany({
+ where,
orderBy: [{ isDefault: "desc" }, { name: "asc" }],
});
+
+ return personalities.map((p) => this.toResponse(p));
}
/**
* Find a specific personality by ID
*/
- async findOne(workspaceId: string, id: string): Promise {
- const personality = await this.prisma.personality.findUnique({
+ async findOne(workspaceId: string, id: string): Promise {
+ const personality = await this.prisma.personality.findFirst({
where: { id, workspaceId },
});
@@ -71,13 +104,13 @@ export class PersonalitiesService {
throw new NotFoundException(`Personality with ID ${id} not found`);
}
- return personality;
+ return this.toResponse(personality);
}
/**
- * Find a personality by name
+ * Find a personality by name slug
*/
- async findByName(workspaceId: string, name: string): Promise {
+ async findByName(workspaceId: string, name: string): Promise {
const personality = await this.prisma.personality.findFirst({
where: { workspaceId, name },
});
@@ -86,13 +119,13 @@ export class PersonalitiesService {
throw new NotFoundException(`Personality with name "${name}" not found`);
}
- return personality;
+ return this.toResponse(personality);
}
/**
- * Find the default personality for a workspace
+ * Find the default (and enabled) personality for a workspace
*/
- async findDefault(workspaceId: string): Promise {
+ async findDefault(workspaceId: string): Promise {
const personality = await this.prisma.personality.findFirst({
where: { workspaceId, isDefault: true, isEnabled: true },
});
@@ -101,14 +134,18 @@ export class PersonalitiesService {
throw new NotFoundException(`No default personality found for workspace ${workspaceId}`);
}
- return personality;
+ return this.toResponse(personality);
}
/**
* Update an existing personality
*/
- async update(workspaceId: string, id: string, dto: UpdatePersonalityDto): Promise {
- // Check existence
+ async update(
+ workspaceId: string,
+ id: string,
+ dto: UpdatePersonalityDto
+ ): Promise {
+ // Verify existence
await this.findOne(workspaceId, id);
// Check for duplicate name if updating name
@@ -127,20 +164,43 @@ export class PersonalitiesService {
await this.unsetOtherDefaults(workspaceId, id);
}
+ // Build update data with field mapping
+ const updateData: {
+ name?: string;
+ displayName?: string;
+ description?: string;
+ tone?: string;
+ formalityLevel?: FormalityLevel;
+ systemPrompt?: string;
+ isDefault?: boolean;
+ isEnabled?: boolean;
+ } = {};
+
+ if (dto.name !== undefined) {
+ updateData.name = dto.name;
+ updateData.displayName = dto.name;
+ }
+ if (dto.description !== undefined) updateData.description = dto.description;
+ if (dto.tone !== undefined) updateData.tone = dto.tone;
+ if (dto.formalityLevel !== undefined) updateData.formalityLevel = dto.formalityLevel;
+ if (dto.systemPromptTemplate !== undefined) updateData.systemPrompt = dto.systemPromptTemplate;
+ if (dto.isDefault !== undefined) updateData.isDefault = dto.isDefault;
+ if (dto.isActive !== undefined) updateData.isEnabled = dto.isActive;
+
const personality = await this.prisma.personality.update({
where: { id },
- data: dto,
+ data: updateData,
});
this.logger.log(`Updated personality ${id} for workspace ${workspaceId}`);
- return personality;
+ return this.toResponse(personality);
}
/**
* Delete a personality
*/
async delete(workspaceId: string, id: string): Promise {
- // Check existence
+ // Verify existence
await this.findOne(workspaceId, id);
await this.prisma.personality.delete({
@@ -151,23 +211,22 @@ export class PersonalitiesService {
}
/**
- * Set a personality as the default
+ * Set a personality as the default (convenience endpoint)
*/
- async setDefault(workspaceId: string, id: string): Promise {
- // Check existence
+ async setDefault(workspaceId: string, id: string): Promise {
+ // Verify existence
await this.findOne(workspaceId, id);
// Unset other defaults
await this.unsetOtherDefaults(workspaceId, id);
- // Set this one as default
const personality = await this.prisma.personality.update({
where: { id },
data: { isDefault: true },
});
this.logger.log(`Set personality ${id} as default for workspace ${workspaceId}`);
- return personality;
+ return this.toResponse(personality);
}
/**
@@ -178,7 +237,7 @@ export class PersonalitiesService {
where: {
workspaceId,
isDefault: true,
- ...(excludeId && { id: { not: excludeId } }),
+ ...(excludeId !== undefined && { id: { not: excludeId } }),
},
});
diff --git a/apps/api/src/prisma/prisma.service.ts b/apps/api/src/prisma/prisma.service.ts
index 66cfbfd..6721734 100644
--- a/apps/api/src/prisma/prisma.service.ts
+++ b/apps/api/src/prisma/prisma.service.ts
@@ -140,8 +140,11 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
workspaceId: string,
client: PrismaClient = this
): Promise {
- await client.$executeRaw`SET LOCAL app.current_user_id = ${userId}`;
- await client.$executeRaw`SET LOCAL app.current_workspace_id = ${workspaceId}`;
+ // Use set_config() instead of SET LOCAL so values are safely parameterized.
+ // SET LOCAL with Prisma's tagged template produces invalid SQL (bind parameter $1
+ // is not supported in SET statements by PostgreSQL).
+ await client.$executeRaw`SELECT set_config('app.current_user_id', ${userId}, true)`;
+ await client.$executeRaw`SELECT set_config('app.current_workspace_id', ${workspaceId}, true)`;
}
/**
@@ -151,8 +154,8 @@ export class PrismaService extends PrismaClient implements OnModuleInit, OnModul
* @param client - Optional Prisma client (uses 'this' if not provided)
*/
async clearWorkspaceContext(client: PrismaClient = this): Promise {
- await client.$executeRaw`SET LOCAL app.current_user_id = NULL`;
- await client.$executeRaw`SET LOCAL app.current_workspace_id = NULL`;
+ await client.$executeRaw`SELECT set_config('app.current_user_id', '', true)`;
+ await client.$executeRaw`SELECT set_config('app.current_workspace_id', '', true)`;
}
/**
diff --git a/apps/api/src/users/preferences.controller.spec.ts b/apps/api/src/users/preferences.controller.spec.ts
new file mode 100644
index 0000000..1ca27fa
--- /dev/null
+++ b/apps/api/src/users/preferences.controller.spec.ts
@@ -0,0 +1,112 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { UnauthorizedException } from "@nestjs/common";
+import { PreferencesController } from "./preferences.controller";
+import { PreferencesService } from "./preferences.service";
+import type { UpdatePreferencesDto, PreferencesResponseDto } from "./dto";
+import type { AuthenticatedRequest } from "../common/types/user.types";
+
+describe("PreferencesController", () => {
+ let controller: PreferencesController;
+ let service: PreferencesService;
+
+ const mockPreferencesService = {
+ getPreferences: vi.fn(),
+ updatePreferences: vi.fn(),
+ };
+
+ const mockUserId = "user-uuid-123";
+
+ const mockPreferencesResponse: PreferencesResponseDto = {
+ id: "pref-uuid-456",
+ userId: mockUserId,
+ theme: "system",
+ locale: "en",
+ timezone: null,
+ settings: {},
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
+ };
+
+ function makeRequest(userId?: string): AuthenticatedRequest {
+ return {
+ user: userId ? { id: userId } : undefined,
+ } as unknown as AuthenticatedRequest;
+ }
+
+ beforeEach(() => {
+ service = mockPreferencesService as unknown as PreferencesService;
+ controller = new PreferencesController(service);
+ vi.clearAllMocks();
+ });
+
+ describe("GET /api/users/me/preferences", () => {
+ it("should return preferences for authenticated user", async () => {
+ mockPreferencesService.getPreferences.mockResolvedValue(mockPreferencesResponse);
+
+ const result = await controller.getPreferences(makeRequest(mockUserId));
+
+ expect(result).toEqual(mockPreferencesResponse);
+ expect(mockPreferencesService.getPreferences).toHaveBeenCalledWith(mockUserId);
+ });
+
+ it("should throw UnauthorizedException when user is not authenticated", async () => {
+ await expect(controller.getPreferences(makeRequest())).rejects.toThrow(UnauthorizedException);
+ expect(mockPreferencesService.getPreferences).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("PUT /api/users/me/preferences", () => {
+ const updateDto: UpdatePreferencesDto = {
+ theme: "dark",
+ locale: "fr",
+ timezone: "Europe/Paris",
+ };
+
+ it("should update and return preferences for authenticated user", async () => {
+ const updatedResponse: PreferencesResponseDto = {
+ ...mockPreferencesResponse,
+ theme: "dark",
+ locale: "fr",
+ timezone: "Europe/Paris",
+ };
+ mockPreferencesService.updatePreferences.mockResolvedValue(updatedResponse);
+
+ const result = await controller.updatePreferences(updateDto, makeRequest(mockUserId));
+
+ expect(result).toEqual(updatedResponse);
+ expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, updateDto);
+ });
+
+ it("should throw UnauthorizedException when user is not authenticated", async () => {
+ await expect(controller.updatePreferences(updateDto, makeRequest())).rejects.toThrow(
+ UnauthorizedException
+ );
+ expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("PATCH /api/users/me/preferences", () => {
+ const patchDto: UpdatePreferencesDto = {
+ theme: "light",
+ };
+
+ it("should partially update and return preferences for authenticated user", async () => {
+ const patchedResponse: PreferencesResponseDto = {
+ ...mockPreferencesResponse,
+ theme: "light",
+ };
+ mockPreferencesService.updatePreferences.mockResolvedValue(patchedResponse);
+
+ const result = await controller.patchPreferences(patchDto, makeRequest(mockUserId));
+
+ expect(result).toEqual(patchedResponse);
+ expect(mockPreferencesService.updatePreferences).toHaveBeenCalledWith(mockUserId, patchDto);
+ });
+
+ it("should throw UnauthorizedException when user is not authenticated", async () => {
+ await expect(controller.patchPreferences(patchDto, makeRequest())).rejects.toThrow(
+ UnauthorizedException
+ );
+ expect(mockPreferencesService.updatePreferences).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/apps/api/src/users/preferences.controller.ts b/apps/api/src/users/preferences.controller.ts
index 166d50c..25ab701 100644
--- a/apps/api/src/users/preferences.controller.ts
+++ b/apps/api/src/users/preferences.controller.ts
@@ -2,6 +2,7 @@ import {
Controller,
Get,
Put,
+ Patch,
Body,
UseGuards,
Request,
@@ -38,7 +39,7 @@ export class PreferencesController {
/**
* PUT /api/users/me/preferences
- * Update current user's preferences
+ * Full replace of current user's preferences
*/
@Put()
async updatePreferences(
@@ -53,4 +54,22 @@ export class PreferencesController {
return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
}
+
+ /**
+ * PATCH /api/users/me/preferences
+ * Partial update of current user's preferences
+ */
+ @Patch()
+ async patchPreferences(
+ @Body() updatePreferencesDto: UpdatePreferencesDto,
+ @Request() req: AuthenticatedRequest
+ ) {
+ const userId = req.user?.id;
+
+ if (!userId) {
+ throw new UnauthorizedException("Authentication required");
+ }
+
+ return this.preferencesService.updatePreferences(userId, updatePreferencesDto);
+ }
}
diff --git a/apps/api/src/users/preferences.service.spec.ts b/apps/api/src/users/preferences.service.spec.ts
new file mode 100644
index 0000000..48a96ba
--- /dev/null
+++ b/apps/api/src/users/preferences.service.spec.ts
@@ -0,0 +1,141 @@
+import { describe, it, expect, beforeEach, vi } from "vitest";
+import { PreferencesService } from "./preferences.service";
+import type { PrismaService } from "../prisma/prisma.service";
+import type { UpdatePreferencesDto } from "./dto";
+
+describe("PreferencesService", () => {
+ let service: PreferencesService;
+
+ const mockPrisma = {
+ userPreference: {
+ findUnique: vi.fn(),
+ create: vi.fn(),
+ update: vi.fn(),
+ },
+ };
+
+ const mockUserId = "user-uuid-123";
+
+ const mockDbPreference = {
+ id: "pref-uuid-456",
+ userId: mockUserId,
+ theme: "system",
+ locale: "en",
+ timezone: null,
+ settings: {},
+ updatedAt: new Date("2026-01-01T00:00:00Z"),
+ };
+
+ beforeEach(() => {
+ service = new PreferencesService(mockPrisma as unknown as PrismaService);
+ vi.clearAllMocks();
+ });
+
+ describe("getPreferences", () => {
+ it("should return existing preferences", async () => {
+ mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
+
+ const result = await service.getPreferences(mockUserId);
+
+ expect(result).toMatchObject({
+ id: mockDbPreference.id,
+ userId: mockUserId,
+ theme: "system",
+ locale: "en",
+ timezone: null,
+ settings: {},
+ });
+ expect(mockPrisma.userPreference.findUnique).toHaveBeenCalledWith({
+ where: { userId: mockUserId },
+ });
+ expect(mockPrisma.userPreference.create).not.toHaveBeenCalled();
+ });
+
+ it("should create default preferences when none exist", async () => {
+ mockPrisma.userPreference.findUnique.mockResolvedValue(null);
+ mockPrisma.userPreference.create.mockResolvedValue(mockDbPreference);
+
+ const result = await service.getPreferences(mockUserId);
+
+ expect(result).toMatchObject({
+ id: mockDbPreference.id,
+ userId: mockUserId,
+ theme: "system",
+ locale: "en",
+ });
+ expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ userId: mockUserId,
+ theme: "system",
+ locale: "en",
+ }),
+ });
+ });
+ });
+
+ describe("updatePreferences", () => {
+ it("should update existing preferences", async () => {
+ const updateDto: UpdatePreferencesDto = { theme: "dark", locale: "fr" };
+ const updatedPreference = { ...mockDbPreference, theme: "dark", locale: "fr" };
+
+ mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
+ mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
+
+ const result = await service.updatePreferences(mockUserId, updateDto);
+
+ expect(result).toMatchObject({ theme: "dark", locale: "fr" });
+ expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({
+ where: { userId: mockUserId },
+ data: expect.objectContaining({ theme: "dark", locale: "fr" }),
+ });
+ expect(mockPrisma.userPreference.create).not.toHaveBeenCalled();
+ });
+
+ it("should create preferences when updating non-existent record", async () => {
+ const updateDto: UpdatePreferencesDto = { theme: "light" };
+ const createdPreference = { ...mockDbPreference, theme: "light" };
+
+ mockPrisma.userPreference.findUnique.mockResolvedValue(null);
+ mockPrisma.userPreference.create.mockResolvedValue(createdPreference);
+
+ const result = await service.updatePreferences(mockUserId, updateDto);
+
+ expect(result).toMatchObject({ theme: "light" });
+ expect(mockPrisma.userPreference.create).toHaveBeenCalledWith({
+ data: expect.objectContaining({
+ userId: mockUserId,
+ theme: "light",
+ }),
+ });
+ expect(mockPrisma.userPreference.update).not.toHaveBeenCalled();
+ });
+
+ it("should handle timezone update", async () => {
+ const updateDto: UpdatePreferencesDto = { timezone: "America/New_York" };
+ const updatedPreference = { ...mockDbPreference, timezone: "America/New_York" };
+
+ mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
+ mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
+
+ const result = await service.updatePreferences(mockUserId, updateDto);
+
+ expect(result.timezone).toBe("America/New_York");
+ expect(mockPrisma.userPreference.update).toHaveBeenCalledWith({
+ where: { userId: mockUserId },
+ data: expect.objectContaining({ timezone: "America/New_York" }),
+ });
+ });
+
+ it("should handle settings update", async () => {
+ const updateDto: UpdatePreferencesDto = { settings: { notifications: true } };
+ const updatedPreference = { ...mockDbPreference, settings: { notifications: true } };
+
+ mockPrisma.userPreference.findUnique.mockResolvedValue(mockDbPreference);
+ mockPrisma.userPreference.update.mockResolvedValue(updatedPreference);
+
+ const result = await service.updatePreferences(mockUserId, updateDto);
+
+ expect(result.settings).toEqual({ notifications: true });
+ });
+ });
+});
diff --git a/apps/api/src/websocket/websocket.gateway.ts b/apps/api/src/websocket/websocket.gateway.ts
index 79caa61..f5b385c 100644
--- a/apps/api/src/websocket/websocket.gateway.ts
+++ b/apps/api/src/websocket/websocket.gateway.ts
@@ -7,6 +7,7 @@ import {
import { Logger } from "@nestjs/common";
import { Server, Socket } from "socket.io";
import { AuthService } from "../auth/auth.service";
+import { getTrustedOrigins } from "../auth/auth.config";
import { PrismaService } from "../prisma/prisma.service";
interface AuthenticatedSocket extends Socket {
@@ -77,7 +78,7 @@ interface StepOutputData {
*/
@WSGateway({
cors: {
- origin: process.env.WEB_URL ?? "http://localhost:3000",
+ origin: getTrustedOrigins(),
credentials: true,
},
})
@@ -167,17 +168,36 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
}
/**
- * @description Extract authentication token from Socket.IO handshake
+ * @description Extract authentication token from Socket.IO handshake.
+ *
+ * Checks sources in order:
+ * 1. handshake.auth.token — explicit token (e.g. from API clients)
+ * 2. handshake.headers.cookie — session cookie sent by browser via withCredentials
+ * 3. query.token — URL query parameter fallback
+ * 4. Authorization header — Bearer token fallback
+ *
* @param client - The socket client
* @returns The token string or undefined if not found
*/
private extractTokenFromHandshake(client: Socket): string | undefined {
- // Check handshake.auth.token (preferred method)
+ // Check handshake.auth.token (preferred method for non-browser clients)
const authToken = client.handshake.auth.token as unknown;
if (typeof authToken === "string" && authToken.length > 0) {
return authToken;
}
+ // Fallback: parse session cookie from request headers.
+ // Browsers send httpOnly cookies automatically when withCredentials: true is set
+ // on the socket.io client. BetterAuth uses one of these cookie names depending
+ // on whether the connection is HTTPS (Secure prefix) or HTTP (dev).
+ const cookieHeader = client.handshake.headers.cookie;
+ if (typeof cookieHeader === "string" && cookieHeader.length > 0) {
+ const cookieToken = this.extractTokenFromCookieHeader(cookieHeader);
+ if (cookieToken) {
+ return cookieToken;
+ }
+ }
+
// Fallback: check query parameters
const queryToken = client.handshake.query.token as unknown;
if (typeof queryToken === "string" && queryToken.length > 0) {
@@ -197,6 +217,45 @@ export class WebSocketGateway implements OnGatewayConnection, OnGatewayDisconnec
return undefined;
}
+ /**
+ * @description Parse the BetterAuth session token from a raw Cookie header string.
+ *
+ * BetterAuth names the session cookie differently based on the security context:
+ * - `__Secure-better-auth.session_token` — HTTPS with Secure flag
+ * - `better-auth.session_token` — HTTP (development)
+ * - `__Host-better-auth.session_token` — HTTPS with Host prefix
+ *
+ * @param cookieHeader - The raw Cookie header value
+ * @returns The session token value or undefined if no matching cookie found
+ */
+ private extractTokenFromCookieHeader(cookieHeader: string): string | undefined {
+ const SESSION_COOKIE_NAMES = [
+ "__Secure-better-auth.session_token",
+ "better-auth.session_token",
+ "__Host-better-auth.session_token",
+ ] as const;
+
+ // Parse the Cookie header into a key-value map
+ const cookies = Object.fromEntries(
+ cookieHeader.split(";").map((pair) => {
+ const eqIndex = pair.indexOf("=");
+ if (eqIndex === -1) {
+ return [pair.trim(), ""];
+ }
+ return [pair.slice(0, eqIndex).trim(), pair.slice(eqIndex + 1).trim()];
+ })
+ );
+
+ for (const name of SESSION_COOKIE_NAMES) {
+ const value = cookies[name];
+ if (typeof value === "string" && value.length > 0) {
+ return value;
+ }
+ }
+
+ return undefined;
+ }
+
/**
* @description Handle client disconnect by leaving the workspace room.
* @param client - The socket client containing workspaceId in data.
diff --git a/apps/api/src/widgets/widgets.controller.ts b/apps/api/src/widgets/widgets.controller.ts
index c90c33d..fb4bd5a 100644
--- a/apps/api/src/widgets/widgets.controller.ts
+++ b/apps/api/src/widgets/widgets.controller.ts
@@ -1,22 +1,14 @@
-import {
- Controller,
- Get,
- Post,
- Body,
- Param,
- UseGuards,
- Request,
- UnauthorizedException,
-} from "@nestjs/common";
+import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common";
import { WidgetsService } from "./widgets.service";
import { WidgetDataService } from "./widget-data.service";
import { AuthGuard } from "../auth/guards/auth.guard";
+import { WorkspaceGuard } from "../common/guards/workspace.guard";
import type { StatCardQueryDto, ChartQueryDto, ListQueryDto, CalendarPreviewQueryDto } from "./dto";
-import type { AuthenticatedRequest } from "../common/types/user.types";
+import type { RequestWithWorkspace } from "../common/types/user.types";
/**
* Controller for widget definition and data endpoints
- * All endpoints require authentication
+ * All endpoints require authentication; data endpoints also require workspace context
*/
@Controller("widgets")
@UseGuards(AuthGuard)
@@ -51,12 +43,9 @@ export class WidgetsController {
* Get stat card widget data
*/
@Post("data/stat-card")
- async getStatCardData(@Request() req: AuthenticatedRequest, @Body() query: StatCardQueryDto) {
- const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
- if (!workspaceId) {
- throw new UnauthorizedException("Workspace ID required");
- }
- return this.widgetDataService.getStatCardData(workspaceId, query);
+ @UseGuards(WorkspaceGuard)
+ async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) {
+ return this.widgetDataService.getStatCardData(req.workspace.id, query);
}
/**
@@ -64,12 +53,9 @@ export class WidgetsController {
* Get chart widget data
*/
@Post("data/chart")
- async getChartData(@Request() req: AuthenticatedRequest, @Body() query: ChartQueryDto) {
- const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
- if (!workspaceId) {
- throw new UnauthorizedException("Workspace ID required");
- }
- return this.widgetDataService.getChartData(workspaceId, query);
+ @UseGuards(WorkspaceGuard)
+ async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) {
+ return this.widgetDataService.getChartData(req.workspace.id, query);
}
/**
@@ -77,12 +63,9 @@ export class WidgetsController {
* Get list widget data
*/
@Post("data/list")
- async getListData(@Request() req: AuthenticatedRequest, @Body() query: ListQueryDto) {
- const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
- if (!workspaceId) {
- throw new UnauthorizedException("Workspace ID required");
- }
- return this.widgetDataService.getListData(workspaceId, query);
+ @UseGuards(WorkspaceGuard)
+ async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) {
+ return this.widgetDataService.getListData(req.workspace.id, query);
}
/**
@@ -90,15 +73,12 @@ export class WidgetsController {
* Get calendar preview widget data
*/
@Post("data/calendar-preview")
+ @UseGuards(WorkspaceGuard)
async getCalendarPreviewData(
- @Request() req: AuthenticatedRequest,
+ @Request() req: RequestWithWorkspace,
@Body() query: CalendarPreviewQueryDto
) {
- const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
- if (!workspaceId) {
- throw new UnauthorizedException("Workspace ID required");
- }
- return this.widgetDataService.getCalendarPreviewData(workspaceId, query);
+ return this.widgetDataService.getCalendarPreviewData(req.workspace.id, query);
}
/**
@@ -106,12 +86,9 @@ export class WidgetsController {
* Get active projects widget data
*/
@Post("data/active-projects")
- async getActiveProjectsData(@Request() req: AuthenticatedRequest) {
- const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
- if (!workspaceId) {
- throw new UnauthorizedException("Workspace ID required");
- }
- return this.widgetDataService.getActiveProjectsData(workspaceId);
+ @UseGuards(WorkspaceGuard)
+ async getActiveProjectsData(@Request() req: RequestWithWorkspace) {
+ return this.widgetDataService.getActiveProjectsData(req.workspace.id);
}
/**
@@ -119,11 +96,8 @@ export class WidgetsController {
* Get agent chains widget data (active agent sessions)
*/
@Post("data/agent-chains")
- async getAgentChainsData(@Request() req: AuthenticatedRequest) {
- const workspaceId = req.user?.currentWorkspaceId ?? req.user?.workspaceId;
- if (!workspaceId) {
- throw new UnauthorizedException("Workspace ID required");
- }
- return this.widgetDataService.getAgentChainsData(workspaceId);
+ @UseGuards(WorkspaceGuard)
+ async getAgentChainsData(@Request() req: RequestWithWorkspace) {
+ return this.widgetDataService.getAgentChainsData(req.workspace.id);
}
}
diff --git a/apps/api/src/workspaces/dto/index.ts b/apps/api/src/workspaces/dto/index.ts
new file mode 100644
index 0000000..32bb49a
--- /dev/null
+++ b/apps/api/src/workspaces/dto/index.ts
@@ -0,0 +1 @@
+export { WorkspaceResponseDto } from "./workspace-response.dto";
diff --git a/apps/api/src/workspaces/dto/workspace-response.dto.ts b/apps/api/src/workspaces/dto/workspace-response.dto.ts
new file mode 100644
index 0000000..b89d294
--- /dev/null
+++ b/apps/api/src/workspaces/dto/workspace-response.dto.ts
@@ -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;
+}
diff --git a/apps/api/src/workspaces/index.ts b/apps/api/src/workspaces/index.ts
new file mode 100644
index 0000000..402915b
--- /dev/null
+++ b/apps/api/src/workspaces/index.ts
@@ -0,0 +1,3 @@
+export { WorkspacesModule } from "./workspaces.module";
+export { WorkspacesService } from "./workspaces.service";
+export { WorkspacesController } from "./workspaces.controller";
diff --git a/apps/api/src/workspaces/workspaces.controller.spec.ts b/apps/api/src/workspaces/workspaces.controller.spec.ts
new file mode 100644
index 0000000..31a04ee
--- /dev/null
+++ b/apps/api/src/workspaces/workspaces.controller.spec.ts
@@ -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);
+ service = module.get(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");
+ });
+ });
+});
diff --git a/apps/api/src/workspaces/workspaces.controller.ts b/apps/api/src/workspaces/workspaces.controller.ts
new file mode 100644
index 0000000..d23f825
--- /dev/null
+++ b/apps/api/src/workspaces/workspaces.controller.ts
@@ -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 {
+ return this.workspacesService.getUserWorkspaces(user.id);
+ }
+}
diff --git a/apps/api/src/workspaces/workspaces.module.ts b/apps/api/src/workspaces/workspaces.module.ts
new file mode 100644
index 0000000..e9157fa
--- /dev/null
+++ b/apps/api/src/workspaces/workspaces.module.ts
@@ -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 {}
diff --git a/apps/api/src/workspaces/workspaces.service.spec.ts b/apps/api/src/workspaces/workspaces.service.spec.ts
new file mode 100644
index 0000000..2c87b0c
--- /dev/null
+++ b/apps/api/src/workspaces/workspaces.service.spec.ts
@@ -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);
+
+ 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) => {
+ 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) => {
+ 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) => {
+ 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"
+ );
+ });
+ });
+});
diff --git a/apps/api/src/workspaces/workspaces.service.ts b/apps/api/src/workspaces/workspaces.service.ts
new file mode 100644
index 0000000..247932e
--- /dev/null
+++ b/apps/api/src/workspaces/workspaces.service.ts
@@ -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 {
+ 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,
+ },
+ ];
+ }
+}
diff --git a/apps/web/src/app/(auth)/login/page.tsx b/apps/web/src/app/(auth)/login/page.tsx
index d5c09e5..467b647 100644
--- a/apps/web/src/app/(auth)/login/page.tsx
+++ b/apps/web/src/app/(auth)/login/page.tsx
@@ -326,7 +326,7 @@ function LoginPageContent(): ReactElement {
diff --git a/apps/web/src/app/(authenticated)/profile/page.tsx b/apps/web/src/app/(authenticated)/profile/page.tsx
index 52bef2c..9ae73fc 100644
--- a/apps/web/src/app/(authenticated)/profile/page.tsx
+++ b/apps/web/src/app/(authenticated)/profile/page.tsx
@@ -103,7 +103,7 @@ export default function ProfilePage(): ReactElement {
setPrefsError(null);
try {
- const data = await apiGet("/users/me/preferences");
+ const data = await apiGet("/api/users/me/preferences");
setPreferences(data);
} catch (err: unknown) {
const message = err instanceof Error ? err.message : "Could not load preferences";
@@ -265,23 +265,8 @@ export default function ProfilePage(): ReactElement {
)}
- {user?.workspaceRole && (
-
- {user.workspaceRole}
-
- )}
+ {/* Workspace role badge — placeholder until workspace context API
+ provides role data via GET /api/workspaces */}
diff --git a/apps/web/src/app/(authenticated)/settings/appearance/page.tsx b/apps/web/src/app/(authenticated)/settings/appearance/page.tsx
index 3149d9a..6728ea6 100644
--- a/apps/web/src/app/(authenticated)/settings/appearance/page.tsx
+++ b/apps/web/src/app/(authenticated)/settings/appearance/page.tsx
@@ -240,7 +240,7 @@ export default function AppearanceSettingsPage(): ReactElement {
setLocalTheme(themeId);
setSaving(true);
try {
- await apiPatch("/users/me/preferences", { theme: themeId });
+ await apiPatch("/api/users/me/preferences", { theme: themeId });
} catch {
// Theme is still applied locally even if API save fails
} finally {
diff --git a/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx b/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx
index 4eb77d4..271df01 100644
--- a/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx
+++ b/apps/web/src/app/(authenticated)/settings/credentials/audit/page.tsx
@@ -14,6 +14,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { fetchCredentialAuditLog, type AuditLogEntry } from "@/lib/api/credentials";
+import { useWorkspaceId } from "@/lib/hooks";
const ACTIVITY_ACTIONS = [
{ value: "CREDENTIAL_CREATED", label: "Created" },
@@ -39,17 +40,17 @@ export default function CredentialAuditPage(): React.ReactElement {
const [filters, setFilters] = useState({});
const [hasFilters, setHasFilters] = useState(false);
- // TODO: Get workspace ID from context/auth
- const workspaceId = "default-workspace-id"; // Placeholder
+ const workspaceId = useWorkspaceId();
useEffect(() => {
- void loadLogs();
- }, [page, filters]);
+ if (!workspaceId) return;
+ void loadLogs(workspaceId);
+ }, [workspaceId, page, filters]);
- async function loadLogs(): Promise {
+ async function loadLogs(wsId: string): Promise {
try {
setIsLoading(true);
- const response = await fetchCredentialAuditLog(workspaceId, {
+ const response = await fetchCredentialAuditLog(wsId, {
...filters,
page,
limit,
diff --git a/apps/web/src/app/(authenticated)/settings/credentials/page.tsx b/apps/web/src/app/(authenticated)/settings/credentials/page.tsx
index 5df0c66..e0f3af1 100644
--- a/apps/web/src/app/(authenticated)/settings/credentials/page.tsx
+++ b/apps/web/src/app/(authenticated)/settings/credentials/page.tsx
@@ -1,27 +1,827 @@
"use client";
-import { useState, useEffect } from "react";
-import { Plus, History } from "lucide-react";
+import { useState, useEffect, type SyntheticEvent } from "react";
+import { Plus, History, Key, RotateCw, Trash2, Eye, EyeOff } from "lucide-react";
import Link from "next/link";
import { Button } from "@/components/ui/button";
-import { Card, CardContent } from "@/components/ui/card";
-import { fetchCredentials, type Credential } from "@/lib/api/credentials";
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ fetchCredentials,
+ createCredential,
+ deleteCredential,
+ rotateCredential,
+ CredentialType,
+ CredentialScope,
+ type Credential,
+ type CreateCredentialDto,
+} from "@/lib/api/credentials";
+import { useWorkspaceId } from "@/lib/hooks";
+
+/* ---------------------------------------------------------------------------
+ Constants
+ --------------------------------------------------------------------------- */
+
+const CREDENTIAL_TYPE_LABELS: Record = {
+ [CredentialType.API_KEY]: "API Key",
+ [CredentialType.OAUTH_TOKEN]: "OAuth Token",
+ [CredentialType.ACCESS_TOKEN]: "Access Token",
+ [CredentialType.SECRET]: "Secret",
+ [CredentialType.PASSWORD]: "Password",
+ [CredentialType.CUSTOM]: "Custom",
+};
+
+const CREDENTIAL_SCOPE_LABELS: Record = {
+ [CredentialScope.USER]: "User",
+ [CredentialScope.WORKSPACE]: "Workspace",
+ [CredentialScope.SYSTEM]: "System",
+};
+
+/* ---------------------------------------------------------------------------
+ Add Credential Dialog
+ --------------------------------------------------------------------------- */
+
+interface AddCredentialDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSubmit: (data: CreateCredentialDto) => Promise;
+ isSubmitting: boolean;
+}
+
+function AddCredentialDialog({
+ open,
+ onOpenChange,
+ onSubmit,
+ isSubmitting,
+}: AddCredentialDialogProps): React.ReactElement | null {
+ const [name, setName] = useState("");
+ const [provider, setProvider] = useState("");
+ const [type, setType] = useState(CredentialType.API_KEY);
+ const [scope, setScope] = useState(CredentialScope.WORKSPACE);
+ const [value, setValue] = useState("");
+ const [description, setDescription] = useState("");
+ const [showValue, setShowValue] = useState(false);
+ const [formError, setFormError] = useState(null);
+
+ function resetForm(): void {
+ setName("");
+ setProvider("");
+ setType(CredentialType.API_KEY);
+ setScope(CredentialScope.WORKSPACE);
+ setValue("");
+ setDescription("");
+ setShowValue(false);
+ setFormError(null);
+ }
+
+ async function handleSubmit(e: SyntheticEvent): Promise {
+ e.preventDefault();
+ setFormError(null);
+
+ const trimmedName = name.trim();
+ if (!trimmedName) {
+ setFormError("Name is required.");
+ return;
+ }
+
+ const trimmedProvider = provider.trim();
+ if (!trimmedProvider) {
+ setFormError("Provider is required.");
+ return;
+ }
+
+ const trimmedValue = value.trim();
+ if (!trimmedValue) {
+ setFormError("Credential value is required.");
+ return;
+ }
+
+ try {
+ const payload: CreateCredentialDto = {
+ name: trimmedName,
+ provider: trimmedProvider,
+ type,
+ scope,
+ value: trimmedValue,
+ };
+
+ const trimmedDesc = description.trim();
+ if (trimmedDesc) {
+ payload.description = trimmedDesc;
+ }
+
+ await onSubmit(payload);
+ resetForm();
+ } catch (err: unknown) {
+ setFormError(err instanceof Error ? err.message : "Failed to create credential.");
+ }
+ }
+
+ if (!open) return null;
+
+ return (
+
+ {/* Backdrop */}
+
{
+ if (!isSubmitting) {
+ resetForm();
+ onOpenChange(false);
+ }
+ }}
+ />
+
+ {/* Dialog */}
+
+
+ Add Credential
+
+
+ Securely store an API key, token, or secret.
+
+
+
+
+
+ );
+}
+
+/* ---------------------------------------------------------------------------
+ Rotate Credential Dialog
+ --------------------------------------------------------------------------- */
+
+interface RotateCredentialDialogProps {
+ credential: Credential;
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSubmit: (credentialId: string, newValue: string) => Promise
;
+ isSubmitting: boolean;
+}
+
+function RotateCredentialDialog({
+ credential,
+ open,
+ onOpenChange,
+ onSubmit,
+ isSubmitting,
+}: RotateCredentialDialogProps): React.ReactElement | null {
+ const [newValue, setNewValue] = useState("");
+ const [showValue, setShowValue] = useState(false);
+ const [formError, setFormError] = useState(null);
+
+ function resetForm(): void {
+ setNewValue("");
+ setShowValue(false);
+ setFormError(null);
+ }
+
+ async function handleSubmit(e: SyntheticEvent): Promise {
+ e.preventDefault();
+ setFormError(null);
+
+ const trimmedValue = newValue.trim();
+ if (!trimmedValue) {
+ setFormError("New value is required.");
+ return;
+ }
+
+ try {
+ await onSubmit(credential.id, trimmedValue);
+ resetForm();
+ } catch (err: unknown) {
+ setFormError(err instanceof Error ? err.message : "Failed to rotate credential.");
+ }
+ }
+
+ if (!open) return null;
+
+ return (
+
+ {/* Backdrop */}
+
{
+ if (!isSubmitting) {
+ resetForm();
+ onOpenChange(false);
+ }
+ }}
+ />
+
+ {/* Dialog */}
+
+
+ Rotate Credential
+
+
+ Replace the value for{" "}
+ {credential.name} .
+
+
+ The old value will be permanently replaced.
+
+
+
{
+ void handleSubmit(e);
+ }}
+ >
+
+
+ New Value *
+
+
+ ) => {
+ setNewValue(e.target.value);
+ }}
+ placeholder="Paste new secret value"
+ autoFocus
+ style={{ paddingRight: "40px" }}
+ />
+ {
+ setShowValue((prev) => !prev);
+ }}
+ style={{
+ position: "absolute",
+ right: "10px",
+ top: "50%",
+ transform: "translateY(-50%)",
+ background: "none",
+ border: "none",
+ cursor: "pointer",
+ color: "var(--muted, #6b7280)",
+ padding: 0,
+ display: "flex",
+ alignItems: "center",
+ }}
+ aria-label={showValue ? "Hide value" : "Show value"}
+ >
+ {showValue ? : }
+
+
+
+
+ {formError !== null && (
+
+ {formError}
+
+ )}
+
+
+ {
+ resetForm();
+ onOpenChange(false);
+ }}
+ disabled={isSubmitting}
+ style={{
+ padding: "8px 16px",
+ background: "transparent",
+ border: "1px solid var(--border, #d1d5db)",
+ borderRadius: "6px",
+ color: "var(--text-2, #374151)",
+ fontSize: "0.85rem",
+ cursor: "pointer",
+ }}
+ >
+ Cancel
+
+
+ {isSubmitting ? "Rotating..." : "Rotate Credential"}
+
+
+
+
+
+ );
+}
+
+/* ---------------------------------------------------------------------------
+ Credential Row
+ --------------------------------------------------------------------------- */
+
+interface CredentialRowProps {
+ credential: Credential;
+ onDelete: (credential: Credential) => void;
+ onRotate: (credential: Credential) => void;
+}
+
+function formatCredentialDate(date: Date | string | null): string {
+ if (!date) return "-";
+ return new Intl.DateTimeFormat("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ }).format(new Date(date));
+}
+
+function CredentialRow({ credential, onDelete, onRotate }: CredentialRowProps): React.ReactElement {
+ return (
+
+
+
+
+
+
{credential.name}
+ {credential.description !== null && credential.description.length > 0 && (
+
{credential.description}
+ )}
+
+
+
+
+ {credential.provider}
+
+
+
+ {CREDENTIAL_TYPE_LABELS[credential.type]}
+
+
+
+
+ {CREDENTIAL_SCOPE_LABELS[credential.scope]}
+
+
+
+ {credential.maskedValue ?? "••••••••"}
+
+
+ {formatCredentialDate(credential.createdAt)}
+
+
+
+ {
+ onRotate(credential);
+ }}
+ title="Rotate credential value"
+ style={{
+ padding: "4px 8px",
+ background: "transparent",
+ border: "1px solid var(--border, #d1d5db)",
+ borderRadius: "4px",
+ cursor: "pointer",
+ color: "var(--text-2, #374151)",
+ display: "flex",
+ alignItems: "center",
+ gap: 4,
+ fontSize: "0.75rem",
+ }}
+ >
+
+ Rotate
+
+ {
+ onDelete(credential);
+ }}
+ title="Delete credential"
+ style={{
+ padding: "4px 8px",
+ background: "transparent",
+ border: "1px solid var(--danger, #fca5a5)",
+ borderRadius: "4px",
+ cursor: "pointer",
+ color: "var(--danger, #ef4444)",
+ display: "flex",
+ alignItems: "center",
+ gap: 4,
+ fontSize: "0.75rem",
+ }}
+ >
+
+ Delete
+
+
+
+
+ );
+}
+
+/* ---------------------------------------------------------------------------
+ Credentials Page
+ --------------------------------------------------------------------------- */
export default function CredentialsPage(): React.ReactElement {
const [credentials, setCredentials] = useState
([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
- const workspaceId = "default-workspace-id";
+ // Add dialog state
+ const [addOpen, setAddOpen] = useState(false);
+ const [isAdding, setIsAdding] = useState(false);
+
+ // Rotate dialog state
+ const [rotateTarget, setRotateTarget] = useState(null);
+ const [isRotating, setIsRotating] = useState(false);
+
+ const workspaceId = useWorkspaceId();
useEffect(() => {
- void loadCredentials();
- }, []);
+ if (!workspaceId) return;
+ void loadCredentials(workspaceId);
+ }, [workspaceId]);
- async function loadCredentials(): Promise {
+ async function loadCredentials(wsId: string): Promise {
try {
setIsLoading(true);
- const response = await fetchCredentials(workspaceId);
+ const response = await fetchCredentials(wsId);
setCredentials(response.data);
setError(null);
} catch (err) {
@@ -31,6 +831,42 @@ export default function CredentialsPage(): React.ReactElement {
}
}
+ async function handleAdd(data: CreateCredentialDto): Promise {
+ if (!workspaceId) return;
+ setIsAdding(true);
+ try {
+ await createCredential(workspaceId, data);
+ setAddOpen(false);
+ await loadCredentials(workspaceId);
+ } finally {
+ setIsAdding(false);
+ }
+ }
+
+ async function handleDelete(credential: Credential): Promise {
+ if (!workspaceId) return;
+ if (!confirm(`Delete credential "${credential.name}"? This action cannot be undone.`)) return;
+
+ try {
+ await deleteCredential(credential.id, workspaceId);
+ await loadCredentials(workspaceId);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to delete credential");
+ }
+ }
+
+ async function handleRotate(credentialId: string, newValue: string): Promise {
+ if (!workspaceId) return;
+ setIsRotating(true);
+ try {
+ await rotateCredential(credentialId, workspaceId, newValue);
+ setRotateTarget(null);
+ await loadCredentials(workspaceId);
+ } finally {
+ setIsRotating(false);
+ }
+ }
+
return (
{/* Header */}
@@ -43,7 +879,11 @@ export default function CredentialsPage(): React.ReactElement {
-
+ {
+ setAddOpen(true);
+ }}
+ >
Add Credential
@@ -58,7 +898,11 @@ export default function CredentialsPage(): React.ReactElement {
{/* Error Display */}
- {error && {error}
}
+ {error !== null && (
+
+ {error}
+
+ )}
{/* Loading State */}
{isLoading ? (
@@ -68,30 +912,156 @@ export default function CredentialsPage(): React.ReactElement {
) : credentials.length === 0 ? (
- No credentials found
-
+
+ No credentials yet
+
+ Add your first API key, token, or secret to get started.
+
+ {
+ setAddOpen(true);
+ }}
+ >
Add First Credential
) : (
-
-
-
-
-
Credentials feature coming soon.
-
-
-
- View Audit Log →
-
-
-
+
+
+
+
+ Stored Credentials
+
+ {credentials.length} credential{credentials.length !== 1 ? "s" : ""} stored
+
-
-
-
+
+
+
+ {/* Desktop table */}
+
+
+
+
+ Name
+ Provider
+ Type
+ Scope
+ Value
+ Created
+ Actions
+
+
+
+ {credentials.map((cred) => (
+ {
+ void handleDelete(c);
+ }}
+ onRotate={(c) => {
+ setRotateTarget(c);
+ }}
+ />
+ ))}
+
+
+
+
+ {/* Mobile cards */}
+
+ {credentials.map((cred) => (
+
+
+
+
+
+
{cred.name}
+ {cred.description !== null && cred.description.length > 0 && (
+
{cred.description}
+ )}
+
+
+
+ {CREDENTIAL_TYPE_LABELS[cred.type]}
+
+
+
+ {cred.maskedValue ?? "••••••••"}
+
+
+ {
+ setRotateTarget(cred);
+ }}
+ style={{
+ padding: "4px 10px",
+ background: "transparent",
+ border: "1px solid var(--border, #d1d5db)",
+ borderRadius: "4px",
+ cursor: "pointer",
+ color: "var(--text-2, #374151)",
+ display: "flex",
+ alignItems: "center",
+ gap: 4,
+ fontSize: "0.75rem",
+ }}
+ >
+
+ Rotate
+
+ {
+ void handleDelete(cred);
+ }}
+ style={{
+ padding: "4px 10px",
+ background: "transparent",
+ border: "1px solid var(--danger, #fca5a5)",
+ borderRadius: "4px",
+ cursor: "pointer",
+ color: "var(--danger, #ef4444)",
+ display: "flex",
+ alignItems: "center",
+ gap: 4,
+ fontSize: "0.75rem",
+ }}
+ >
+
+ Delete
+
+
+
+ ))}
+
+
+
+ )}
+
+ {/* Add Credential Dialog */}
+
+
+ {/* Rotate Credential Dialog */}
+ {rotateTarget !== null && (
+
{
+ if (!open) setRotateTarget(null);
+ }}
+ onSubmit={handleRotate}
+ isSubmitting={isRotating}
+ />
)}
);
diff --git a/apps/web/src/app/(authenticated)/settings/domains/page.tsx b/apps/web/src/app/(authenticated)/settings/domains/page.tsx
index a945f87..20ca4a7 100644
--- a/apps/web/src/app/(authenticated)/settings/domains/page.tsx
+++ b/apps/web/src/app/(authenticated)/settings/domains/page.tsx
@@ -1,23 +1,383 @@
"use client";
-import { useState, useEffect } from "react";
+import { useState, useEffect, type SyntheticEvent } from "react";
+import type { ReactElement } from "react";
import type { Domain } from "@mosaic/shared";
import { DomainList } from "@/components/domains/DomainList";
-import { fetchDomains, deleteDomain } from "@/lib/api/domains";
+import { fetchDomains, createDomain, deleteDomain } from "@/lib/api/domains";
+import type { CreateDomainDto } from "@/lib/api/domains";
+import { useWorkspaceId } from "@/lib/hooks";
-export default function DomainsPage(): React.ReactElement {
+/* ---------------------------------------------------------------------------
+ Slug generation helper
+ --------------------------------------------------------------------------- */
+
+function generateSlug(name: string): string {
+ return name
+ .toLowerCase()
+ .trim()
+ .replace(/[^a-z0-9\s-]/g, "")
+ .replace(/\s+/g, "-")
+ .replace(/-+/g, "-")
+ .slice(0, 100);
+}
+
+/* ---------------------------------------------------------------------------
+ Create Domain Dialog
+ --------------------------------------------------------------------------- */
+
+interface CreateDomainDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onSubmit: (data: CreateDomainDto) => Promise;
+ isSubmitting: boolean;
+}
+
+function CreateDomainDialog({
+ open,
+ onOpenChange,
+ onSubmit,
+ isSubmitting,
+}: CreateDomainDialogProps): ReactElement | null {
+ const [name, setName] = useState("");
+ const [slug, setSlug] = useState("");
+ const [slugTouched, setSlugTouched] = useState(false);
+ const [description, setDescription] = useState("");
+ const [formError, setFormError] = useState(null);
+
+ function resetForm(): void {
+ setName("");
+ setSlug("");
+ setSlugTouched(false);
+ setDescription("");
+ setFormError(null);
+ }
+
+ function handleNameChange(value: string): void {
+ setName(value);
+ if (!slugTouched) {
+ setSlug(generateSlug(value));
+ }
+ }
+
+ function handleSlugChange(value: string): void {
+ setSlugTouched(true);
+ setSlug(value.toLowerCase().replace(/[^a-z0-9-]/g, ""));
+ }
+
+ async function handleSubmit(e: SyntheticEvent): Promise {
+ e.preventDefault();
+ setFormError(null);
+
+ const trimmedName = name.trim();
+ if (!trimmedName) {
+ setFormError("Domain name is required.");
+ return;
+ }
+
+ const trimmedSlug = slug.trim();
+ if (!trimmedSlug) {
+ setFormError("Slug is required.");
+ return;
+ }
+
+ if (!/^[a-z0-9-]+$/.test(trimmedSlug)) {
+ setFormError("Slug must contain only lowercase letters, numbers, and hyphens.");
+ return;
+ }
+
+ try {
+ const payload: CreateDomainDto = { name: trimmedName, slug: trimmedSlug };
+ const trimmedDesc = description.trim();
+ if (trimmedDesc) {
+ payload.description = trimmedDesc;
+ }
+ await onSubmit(payload);
+ resetForm();
+ } catch (err: unknown) {
+ setFormError(err instanceof Error ? err.message : "Failed to create domain.");
+ }
+ }
+
+ if (!open) return null;
+
+ return (
+
+ {/* Backdrop */}
+
{
+ if (!isSubmitting) {
+ resetForm();
+ onOpenChange(false);
+ }
+ }}
+ />
+
+ {/* Dialog */}
+
+
+ New Domain
+
+
+ Domains help you organize tasks, projects, and events by life area.
+
+
+
{
+ void handleSubmit(e);
+ }}
+ >
+ {/* Name */}
+
+
+ Name *
+
+ {
+ handleNameChange(e.target.value);
+ }}
+ placeholder="e.g. Personal Finance"
+ maxLength={255}
+ autoFocus
+ style={{
+ width: "100%",
+ padding: "8px 12px",
+ background: "var(--bg, #f9fafb)",
+ border: "1px solid var(--border, #d1d5db)",
+ borderRadius: "6px",
+ color: "var(--text, #111)",
+ fontSize: "0.9rem",
+ outline: "none",
+ boxSizing: "border-box",
+ }}
+ />
+
+
+ {/* Slug */}
+
+
+ Slug *
+
+
{
+ handleSlugChange(e.target.value);
+ }}
+ placeholder="e.g. personal-finance"
+ maxLength={100}
+ style={{
+ width: "100%",
+ padding: "8px 12px",
+ background: "var(--bg, #f9fafb)",
+ border: "1px solid var(--border, #d1d5db)",
+ borderRadius: "6px",
+ color: "var(--text, #111)",
+ fontSize: "0.9rem",
+ outline: "none",
+ boxSizing: "border-box",
+ fontFamily: "var(--mono, monospace)",
+ }}
+ />
+
+ Lowercase letters, numbers, and hyphens only.
+
+
+
+ {/* Description */}
+
+
+ Description
+
+ {
+ setDescription(e.target.value);
+ }}
+ placeholder="A brief summary of this domain..."
+ rows={3}
+ maxLength={10000}
+ style={{
+ width: "100%",
+ padding: "8px 12px",
+ background: "var(--bg, #f9fafb)",
+ border: "1px solid var(--border, #d1d5db)",
+ borderRadius: "6px",
+ color: "var(--text, #111)",
+ fontSize: "0.9rem",
+ outline: "none",
+ resize: "vertical",
+ fontFamily: "inherit",
+ boxSizing: "border-box",
+ }}
+ />
+
+
+ {/* Form error */}
+ {formError !== null && (
+
+ {formError}
+
+ )}
+
+ {/* Buttons */}
+
+ {
+ resetForm();
+ onOpenChange(false);
+ }}
+ disabled={isSubmitting}
+ style={{
+ padding: "8px 16px",
+ background: "transparent",
+ border: "1px solid var(--border, #d1d5db)",
+ borderRadius: "6px",
+ color: "var(--text-2, #374151)",
+ fontSize: "0.85rem",
+ cursor: "pointer",
+ }}
+ >
+ Cancel
+
+
+ {isSubmitting ? "Creating..." : "Create Domain"}
+
+
+
+
+
+ );
+}
+
+/* ---------------------------------------------------------------------------
+ Domains Page
+ --------------------------------------------------------------------------- */
+
+export default function DomainsPage(): ReactElement {
+ const workspaceId = useWorkspaceId();
const [domains, setDomains] = useState
([]);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
+ // Create dialog state
+ const [createOpen, setCreateOpen] = useState(false);
+ const [isCreating, setIsCreating] = useState(false);
+
useEffect(() => {
+ if (!workspaceId) {
+ setIsLoading(false);
+ return;
+ }
void loadDomains();
- }, []);
+ }, [workspaceId]);
async function loadDomains(): Promise {
try {
setIsLoading(true);
- const response = await fetchDomains();
+ const response = await fetchDomains(undefined, workspaceId ?? undefined);
setDomains(response.data);
setError(null);
} catch (err) {
@@ -27,9 +387,8 @@ export default function DomainsPage(): React.ReactElement {
}
}
- function handleEdit(domain: Domain): void {
+ function handleEdit(_domain: Domain): void {
// TODO: Open edit modal/form
- console.log("Edit domain:", domain);
}
async function handleDelete(domain: Domain): Promise {
@@ -38,13 +397,26 @@ export default function DomainsPage(): React.ReactElement {
}
try {
- await deleteDomain(domain.id);
+ await deleteDomain(domain.id, workspaceId ?? undefined);
await loadDomains();
} catch (err) {
setError(err instanceof Error ? err.message : "Failed to delete domain");
}
}
+ async function handleCreate(data: CreateDomainDto): Promise {
+ setIsCreating(true);
+ try {
+ await createDomain(data, workspaceId ?? undefined);
+ setCreateOpen(false);
+ await loadDomains();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to create domain.");
+ } finally {
+ setIsCreating(false);
+ }
+ }
+
return (
@@ -60,7 +432,7 @@ export default function DomainsPage(): React.ReactElement {
{
- console.log("TODO: Open create modal");
+ setCreateOpen(true);
}}
>
Create Domain
@@ -73,6 +445,13 @@ export default function DomainsPage(): React.ReactElement {
onEdit={handleEdit}
onDelete={handleDelete}
/>
+
+
);
}
diff --git a/apps/web/src/app/(authenticated)/terminal/page.tsx b/apps/web/src/app/(authenticated)/terminal/page.tsx
new file mode 100644
index 0000000..915b9a3
--- /dev/null
+++ b/apps/web/src/app/(authenticated)/terminal/page.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+/**
+ * Terminal page — dedicated full-screen terminal route at /terminal.
+ *
+ * Renders the TerminalPanel component filling the available content area.
+ * The panel is always open on this page; there is no close action since
+ * the user navigates away using the sidebar instead.
+ */
+
+import { useState, useEffect } from "react";
+import type { ReactElement } from "react";
+import { TerminalPanel } from "@/components/terminal";
+import { getAccessToken } from "@/lib/auth-client";
+
+export default function TerminalPage(): ReactElement {
+ const [token, setToken] = useState
("");
+
+ // Resolve the access token once on mount. The WebSocket connection inside
+ // TerminalPanel uses this token for authentication.
+ useEffect((): void => {
+ getAccessToken()
+ .then((t) => {
+ setToken(t ?? "");
+ })
+ .catch((err: unknown) => {
+ console.error("[TerminalPage] Failed to retrieve access token:", err);
+ });
+ }, []);
+
+ return (
+ <>
+ {/* Override TerminalPanel inline height so it fills the page */}
+
+
+
+ {
+ /* No-op: on the dedicated terminal page the panel is always open.
+ Users navigate away using the sidebar rather than closing the panel. */
+ }}
+ token={token}
+ className="terminal-page-panel"
+ />
+
+ >
+ );
+}
diff --git a/apps/web/src/app/favicon.ico b/apps/web/src/app/favicon.ico
new file mode 100644
index 0000000..b621c87
Binary files /dev/null and b/apps/web/src/app/favicon.ico differ
diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx
index 90c96bc..494d9d0 100644
--- a/apps/web/src/app/layout.tsx
+++ b/apps/web/src/app/layout.tsx
@@ -11,6 +11,9 @@ export const dynamic = "force-dynamic";
export const metadata: Metadata = {
title: "Mosaic Stack",
description: "Mosaic Stack Web Application",
+ icons: {
+ icon: "/favicon.ico",
+ },
};
const outfit = Outfit({
diff --git a/apps/web/src/components/chat/Chat.tsx b/apps/web/src/components/chat/Chat.tsx
index c7c711d..a06a538 100644
--- a/apps/web/src/components/chat/Chat.tsx
+++ b/apps/web/src/components/chat/Chat.tsx
@@ -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,7 +90,11 @@ export const Chat = forwardRef(function Chat(
...(initialProjectId !== undefined && { projectId: initialProjectId }),
});
- const { isConnected: isWsConnected } = useWebSocket(user?.id ?? "", "", {});
+ // 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 = useWorkspaceId() ?? "";
+ const { isConnected: isWsConnected } = useWebSocket(workspaceId, "", {});
const { isCommand, executeCommand } = useOrchestratorCommands();
diff --git a/apps/web/src/components/layout/AppSidebar.tsx b/apps/web/src/components/layout/AppSidebar.tsx
index 67ea1ec..b678795 100644
--- a/apps/web/src/components/layout/AppSidebar.tsx
+++ b/apps/web/src/components/layout/AppSidebar.tsx
@@ -254,7 +254,7 @@ const NAV_GROUPS: NavGroup[] = [
badge: { label: "live", pulse: true },
},
{
- href: "#terminal",
+ href: "/terminal",
label: "Terminal",
icon: ,
},
@@ -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 (
{
setIsOpen(!isOpen);
}}
- className={`flex h-10 w-full items-center justify-between rounded-md border border-gray-300 bg-white px-3 py-2 text-sm ${className}`}
+ className={`flex h-10 w-full items-center justify-between rounded-md border border-border bg-bg px-3 py-2 text-sm text-text ${className}`}
>
{children}
@@ -110,7 +110,7 @@ export function SelectContent({ children }: SelectContentProps): React.JSX.Eleme
if (!isOpen) return null;
return (
-
+
{children}
);
@@ -122,7 +122,7 @@ export function SelectItem({ value, children }: SelectItemProps): React.JSX.Elem
return (
onValueChange?.(value)}
- className="cursor-pointer px-3 py-2 text-sm hover:bg-gray-100"
+ className="cursor-pointer px-3 py-2 text-sm text-text hover:bg-surface-2"
>
{children}
diff --git a/apps/web/src/components/widgets/ActiveProjectsWidget.tsx b/apps/web/src/components/widgets/ActiveProjectsWidget.tsx
index 1db97d5..068ed71 100644
--- a/apps/web/src/components/widgets/ActiveProjectsWidget.tsx
+++ b/apps/web/src/components/widgets/ActiveProjectsWidget.tsx
@@ -7,6 +7,7 @@ import { useState, useEffect } from "react";
import { FolderOpen, Bot, Activity, Clock, AlertCircle, CheckCircle2 } from "lucide-react";
import type { WidgetProps } from "@mosaic/shared";
import { apiPost } from "@/lib/api/client";
+import { useWorkspaceId } from "@/lib/hooks";
interface ActiveProject {
id: string;
@@ -34,6 +35,7 @@ interface AgentSession {
}
export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps): React.JSX.Element {
+ const workspaceId = useWorkspaceId();
const [projects, setProjects] = useState
([]);
const [agentSessions, setAgentSessions] = useState([]);
const [isLoadingProjects, setIsLoadingProjects] = useState(true);
@@ -48,7 +50,11 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
try {
setProjectsError(null);
// Use API client to ensure CSRF token is included
- const data = await apiPost("/api/widgets/data/active-projects");
+ const data = await apiPost(
+ "/api/widgets/data/active-projects",
+ undefined,
+ workspaceId ?? undefined
+ );
setProjects(data);
} catch (error) {
console.error("Failed to fetch active projects:", error);
@@ -67,7 +73,7 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
return (): void => {
clearInterval(interval);
};
- }, []);
+ }, [workspaceId]);
// Fetch agent chains
useEffect(() => {
@@ -75,7 +81,11 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
try {
setAgentsError(null);
// Use API client to ensure CSRF token is included
- const data = await apiPost("/api/widgets/data/agent-chains");
+ const data = await apiPost(
+ "/api/widgets/data/agent-chains",
+ undefined,
+ workspaceId ?? undefined
+ );
setAgentSessions(data);
} catch (error) {
console.error("Failed to fetch agent sessions:", error);
@@ -94,7 +104,7 @@ export function ActiveProjectsWidget({ id: _id, config: _config }: WidgetProps):
return (): void => {
clearInterval(interval);
};
- }, []);
+ }, [workspaceId]);
const getStatusIcon = (status: string): React.JSX.Element => {
const statusUpper = status.toUpperCase();
diff --git a/apps/web/src/hooks/useWebSocket.test.tsx b/apps/web/src/hooks/useWebSocket.test.tsx
index 242e0f9..3954bd0 100644
--- a/apps/web/src/hooks/useWebSocket.test.tsx
+++ b/apps/web/src/hooks/useWebSocket.test.tsx
@@ -47,6 +47,7 @@ describe("useWebSocket", (): void => {
expect(io).toHaveBeenCalledWith(expect.any(String), {
auth: { token },
query: { workspaceId },
+ withCredentials: true,
});
});
diff --git a/apps/web/src/hooks/useWebSocket.ts b/apps/web/src/hooks/useWebSocket.ts
index eff6d39..75f72e9 100644
--- a/apps/web/src/hooks/useWebSocket.ts
+++ b/apps/web/src/hooks/useWebSocket.ts
@@ -97,9 +97,12 @@ export function useWebSocket(
setConnectionError(null);
// Create socket connection
+ // withCredentials sends session cookies cross-origin so the gateway can
+ // authenticate via cookie when no explicit token is provided.
const newSocket = io(wsUrl, {
auth: { token },
query: { workspaceId },
+ withCredentials: true,
});
setSocket(newSocket);
diff --git a/apps/web/src/lib/api/client.ts b/apps/web/src/lib/api/client.ts
index 7bdd8fc..9d4d158 100644
--- a/apps/web/src/lib/api/client.ts
+++ b/apps/web/src/lib/api/client.ts
@@ -202,9 +202,13 @@ export async function apiRequest(endpoint: string, options: ApiRequestOptions
...baseHeaders,
};
- // Add workspace ID header if provided (recommended over query string)
- if (workspaceId) {
- headers["X-Workspace-Id"] = workspaceId;
+ // Add workspace ID header — use explicit value, or auto-detect from localStorage
+ const resolvedWorkspaceId =
+ workspaceId ??
+ (typeof window !== "undefined" ? localStorage.getItem("mosaic-workspace-id") : null) ??
+ undefined;
+ if (resolvedWorkspaceId) {
+ headers["X-Workspace-Id"] = resolvedWorkspaceId;
}
// Add CSRF token for state-changing requests (POST, PUT, PATCH, DELETE)
@@ -246,6 +250,11 @@ export async function apiRequest(endpoint: string, options: ApiRequestOptions
throw new Error(error.message);
}
+ // 204 No Content responses have no body — return undefined cast to T
+ if (response.status === 204) {
+ return undefined as T;
+ }
+
return await (response.json() as Promise);
} catch (err: unknown) {
if (err instanceof DOMException && err.name === "AbortError") {
diff --git a/apps/web/src/lib/api/domains.ts b/apps/web/src/lib/api/domains.ts
index 741bc1b..256f0a3 100644
--- a/apps/web/src/lib/api/domains.ts
+++ b/apps/web/src/lib/api/domains.ts
@@ -44,7 +44,10 @@ export interface DomainFilters {
/**
* Fetch all domains
*/
-export async function fetchDomains(filters?: DomainFilters): Promise> {
+export async function fetchDomains(
+ filters?: DomainFilters,
+ workspaceId?: string
+): Promise> {
const params = new URLSearchParams();
if (filters?.search) {
@@ -60,7 +63,7 @@ export async function fetchDomains(filters?: DomainFilters): Promise>(endpoint);
+ return apiGet>(endpoint, workspaceId);
}
/**
@@ -73,20 +76,27 @@ export async function fetchDomain(id: string): Promise {
/**
* Create a new domain
*/
-export async function createDomain(data: CreateDomainDto): Promise {
- return apiPost("/api/domains", data);
+export async function createDomain(data: CreateDomainDto, workspaceId?: string): Promise {
+ return apiPost("/api/domains", data, workspaceId);
}
/**
* Update a domain
*/
-export async function updateDomain(id: string, data: UpdateDomainDto): Promise {
- return apiPatch(`/api/domains/${id}`, data);
+export async function updateDomain(
+ id: string,
+ data: UpdateDomainDto,
+ workspaceId?: string
+): Promise {
+ return apiPatch(`/api/domains/${id}`, data, workspaceId);
}
/**
* Delete a domain
*/
-export async function deleteDomain(id: string): Promise> {
- return apiDelete>(`/api/domains/${id}`);
+export async function deleteDomain(
+ id: string,
+ workspaceId?: string
+): Promise> {
+ return apiDelete>(`/api/domains/${id}`, workspaceId);
}
diff --git a/apps/web/src/lib/api/index.ts b/apps/web/src/lib/api/index.ts
index 0f2fb79..b062b6e 100644
--- a/apps/web/src/lib/api/index.ts
+++ b/apps/web/src/lib/api/index.ts
@@ -15,3 +15,4 @@ export * from "./personalities";
export * from "./telemetry";
export * from "./dashboard";
export * from "./projects";
+export * from "./workspaces";
diff --git a/apps/web/src/lib/api/personalities.ts b/apps/web/src/lib/api/personalities.ts
index 624fcb8..c194202 100644
--- a/apps/web/src/lib/api/personalities.ts
+++ b/apps/web/src/lib/api/personalities.ts
@@ -73,7 +73,8 @@ export async function updatePersonality(
/**
* Delete a personality
+ * The DELETE endpoint returns 204 No Content on success.
*/
-export async function deletePersonality(id: string): Promise> {
- return apiDelete>(`/api/personalities/${id}`);
+export async function deletePersonality(id: string): Promise {
+ await apiDelete(`/api/personalities/${id}`);
}
diff --git a/apps/web/src/lib/api/projects.ts b/apps/web/src/lib/api/projects.ts
index 89a11ca..8d68448 100644
--- a/apps/web/src/lib/api/projects.ts
+++ b/apps/web/src/lib/api/projects.ts
@@ -65,7 +65,8 @@ export interface UpdateProjectDto {
* Fetch all projects for a workspace
*/
export async function fetchProjects(workspaceId?: string): Promise {
- return apiGet("/api/projects", workspaceId);
+ const response = await apiGet<{ data: Project[]; meta?: unknown }>("/api/projects", workspaceId);
+ return response.data;
}
/**
diff --git a/apps/web/src/lib/api/workspaces.ts b/apps/web/src/lib/api/workspaces.ts
new file mode 100644
index 0000000..fe38a61
--- /dev/null
+++ b/apps/web/src/lib/api/workspaces.ts
@@ -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 {
+ return apiGet("/api/workspaces");
+}
diff --git a/apps/web/src/lib/auth/auth-context.test.tsx b/apps/web/src/lib/auth/auth-context.test.tsx
index f2a1861..fd15c70 100644
--- a/apps/web/src/lib/auth/auth-context.test.tsx
+++ b/apps/web/src/lib/auth/auth-context.test.tsx
@@ -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 {
@@ -691,4 +697,225 @@ describe("AuthContext", (): void => {
});
});
});
+
+ describe("workspace ID persistence", (): void => {
+ // ---------------------------------------------------------------------------
+ // localStorage mock for workspace persistence tests
+ // ---------------------------------------------------------------------------
+ interface MockLocalStorage {
+ getItem: ReturnType;
+ setItem: ReturnType;
+ removeItem: ReturnType;
+ clear: ReturnType;
+ readonly length: number;
+ key: ReturnType;
+ }
+
+ let localStorageMock: MockLocalStorage;
+
+ beforeEach((): void => {
+ let store: Record = {};
+ localStorageMock = {
+ getItem: vi.fn((key: string): string | null => store[key] ?? null),
+ setItem: vi.fn((key: string, value: string): void => {
+ store[key] = value;
+ }),
+ removeItem: vi.fn((key: string): void => {
+ store = Object.fromEntries(Object.entries(store).filter(([k]) => k !== key));
+ }),
+ clear: vi.fn((): void => {
+ store = {};
+ }),
+ get length(): number {
+ return Object.keys(store).length;
+ },
+ key: vi.fn((_index: number): string | null => null),
+ };
+
+ Object.defineProperty(window, "localStorage", {
+ value: localStorageMock,
+ writable: true,
+ configurable: true,
+ });
+
+ vi.resetAllMocks();
+ });
+
+ afterEach((): void => {
+ vi.restoreAllMocks();
+ });
+
+ it("should call fetchUserWorkspaces after successful session check", async (): Promise => {
+ 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",
+ },
+ ]);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
+ });
+
+ expect(fetchUserWorkspaces).toHaveBeenCalledTimes(1);
+ });
+
+ it("should persist the first workspace ID to localStorage", async (): Promise => {
+ 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-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(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
+ });
+
+ expect(localStorageMock.setItem).toHaveBeenCalledWith("mosaic-workspace-id", "ws-abc-123");
+ });
+
+ it("should not write localStorage when fetchUserWorkspaces returns empty array", async (): Promise => {
+ 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([]);
+
+ render(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
+ });
+
+ expect(localStorageMock.setItem).not.toHaveBeenCalledWith(
+ "mosaic-workspace-id",
+ expect.anything()
+ );
+ });
+
+ it("should handle fetchUserWorkspaces failure gracefully — auth still succeeds", async (): Promise => {
+ 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).mockRejectedValueOnce(new Error("Network error"));
+
+ render(
+
+
+
+ );
+
+ 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 => {
+ 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(
+
+
+
+ );
+
+ await waitFor(() => {
+ expect(screen.getByTestId("auth-status")).toHaveTextContent("Authenticated");
+ });
+
+ const signOutButton = screen.getByRole("button", { name: "Sign Out" });
+ signOutButton.click();
+
+ await waitFor(() => {
+ expect(screen.getByTestId("auth-status")).toHaveTextContent("Not Authenticated");
+ });
+
+ expect(localStorageMock.removeItem).toHaveBeenCalledWith("mosaic-workspace-id");
+ });
+ });
});
diff --git a/apps/web/src/lib/auth/auth-context.tsx b/apps/web/src/lib/auth/auth-context.tsx
index 1282b0f..cd2d5e5 100644
--- a/apps/web/src/lib/auth/auth-context.tsx
+++ b/apps/web/src/lib/auth/auth-context.tsx
@@ -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";
@@ -24,6 +25,43 @@ const SESSION_EXPIRY_WARNING_MINUTES = 5;
/** Interval in milliseconds to check session expiry */
const SESSION_CHECK_INTERVAL_MS = 60_000;
+
+/**
+ * localStorage key for the active workspace ID.
+ * Must match the WORKSPACE_KEY constant in useLayout.ts and the key read
+ * by apiRequest in client.ts.
+ */
+const WORKSPACE_STORAGE_KEY = "mosaic-workspace-id";
+
+/**
+ * Persist the workspace ID to localStorage so it is available to
+ * useWorkspaceId and apiRequest on the next render / request cycle.
+ * Silently ignores localStorage errors (private browsing, storage full).
+ */
+function persistWorkspaceId(workspaceId: string | undefined): void {
+ if (typeof window === "undefined") return;
+ try {
+ if (workspaceId) {
+ localStorage.setItem(WORKSPACE_STORAGE_KEY, workspaceId);
+ }
+ } catch {
+ // localStorage unavailable — not fatal
+ }
+}
+
+/**
+ * Remove the workspace ID from localStorage on sign-out so stale workspace
+ * context is not sent on subsequent unauthenticated requests.
+ */
+function clearWorkspaceId(): void {
+ if (typeof window === "undefined") return;
+ try {
+ localStorage.removeItem(WORKSPACE_STORAGE_KEY);
+ } catch {
+ // localStorage unavailable — not fatal
+ }
+}
+
const MOCK_AUTH_USER: AuthUser = {
id: "dev-user-local",
email: "dev@localhost",
@@ -97,6 +135,19 @@ function RealAuthProvider({ children }: { children: ReactNode }): React.JSX.Elem
setUser(session.user);
setAuthError(null);
+ // 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);
@@ -128,6 +179,9 @@ function RealAuthProvider({ children }: { children: ReactNode }): React.JSX.Elem
setUser(null);
expiresAtRef.current = null;
setSessionExpiring(false);
+ // Clear persisted workspace ID so stale context is not sent on
+ // subsequent unauthenticated API requests.
+ clearWorkspaceId();
}
}, []);
diff --git a/docker-compose.coolify.yml b/docker-compose.coolify.yml
index 60f0cc5..d8fbb3e 100644
--- a/docker-compose.coolify.yml
+++ b/docker-compose.coolify.yml
@@ -158,6 +158,8 @@ services:
- NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
- NEXT_PUBLIC_ORCHESTRATOR_URL=${NEXT_PUBLIC_ORCHESTRATOR_URL:-}
- NEXT_PUBLIC_AUTH_MODE=${NEXT_PUBLIC_AUTH_MODE:-real}
+ # Server-side orchestrator proxy (API routes forward to orchestrator service over internal network)
+ - ORCHESTRATOR_URL=http://orchestrator:3001
- ORCHESTRATOR_API_KEY=${ORCHESTRATOR_API_KEY:-}
depends_on:
api:
@@ -222,6 +224,8 @@ services:
environment:
- NODE_ENV=production
- ORCHESTRATOR_PORT=3001
+ # Bind to all interfaces so the web container can reach it over Docker networking
+ - HOST=0.0.0.0
- AI_PROVIDER=${AI_PROVIDER:-ollama}
- OLLAMA_ENDPOINT=${OLLAMA_ENDPOINT:-}
- OLLAMA_MODEL=${OLLAMA_MODEL:-llama3.2}
diff --git a/docker-compose.swarm.portainer.yml b/docker-compose.swarm.portainer.yml
index 3f90de0..a5cc05e 100644
--- a/docker-compose.swarm.portainer.yml
+++ b/docker-compose.swarm.portainer.yml
@@ -176,6 +176,9 @@ services:
NODE_ENV: production
PORT: ${WEB_PORT:-3000}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
+ # Server-side orchestrator proxy (API routes forward to orchestrator service)
+ ORCHESTRATOR_URL: http://orchestrator:3001
+ ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
healthcheck:
test:
[
@@ -187,6 +190,7 @@ services:
retries: 3
start_period: 40s
networks:
+ - internal
- traefik-public
deploy:
restart_policy:
@@ -248,6 +252,8 @@ services:
environment:
NODE_ENV: production
ORCHESTRATOR_PORT: 3001
+ # Bind to all interfaces so the web container can reach it over Docker networking
+ HOST: 0.0.0.0
AI_PROVIDER: ${AI_PROVIDER:-ollama}
VALKEY_URL: redis://valkey:6379
VALKEY_HOST: valkey
@@ -259,6 +265,8 @@ services:
GIT_USER_EMAIL: "orchestrator@mosaicstack.dev"
KILLSWITCH_ENABLED: "true"
SANDBOX_ENABLED: "true"
+ # API key for authenticating requests from the web proxy
+ ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- orchestrator_workspace:/workspace
diff --git a/docker-compose.yml b/docker-compose.yml
index ee433f3..b1d95ef 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -433,6 +433,8 @@ services:
NODE_ENV: production
# Orchestrator Configuration
ORCHESTRATOR_PORT: 3001
+ # Bind to all interfaces so the web container can reach it over Docker networking
+ HOST: 0.0.0.0
AI_PROVIDER: ${AI_PROVIDER:-ollama}
# Valkey
VALKEY_URL: redis://valkey:6379
@@ -448,6 +450,8 @@ services:
# Security
KILLSWITCH_ENABLED: true
SANDBOX_ENABLED: true
+ # API key for authenticating requests from the web proxy
+ ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
ports:
- "3002:3001"
volumes:
@@ -498,6 +502,8 @@ services:
NODE_ENV: production
PORT: ${WEB_PORT:-3000}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:3001}
+ # Server-side orchestrator proxy (API routes forward to orchestrator service)
+ ORCHESTRATOR_URL: http://orchestrator:3001
ORCHESTRATOR_API_KEY: ${ORCHESTRATOR_API_KEY}
ports:
- "${WEB_PORT:-3000}:${WEB_PORT:-3000}"
@@ -515,6 +521,7 @@ services:
retries: 3
start_period: 40s
networks:
+ - mosaic-internal
- mosaic-public
labels:
- "com.mosaic.service=web"
diff --git a/docs/MISSION-MANIFEST.md b/docs/MISSION-MANIFEST.md
index 64b9f19..ed22e85 100644
--- a/docs/MISSION-MANIFEST.md
+++ b/docs/MISSION-MANIFEST.md
@@ -1,88 +1,52 @@
-# Mission Manifest — MS19 Chat & Terminal System
+# Mission Manifest — MS21 Multi-Tenant RBAC Data Migration
> Persistent document tracking full mission scope, status, and session history.
> Updated by the orchestrator at each phase transition and milestone completion.
## Mission
-**ID:** ms19-chat-terminal-20260225
-**Statement:** Implement MS19 (Chat & Terminal System) — real terminal with PTY backend, chat streaming, master chat polish, project-level orchestrator chat, and agent output integration
-**Phase:** Completion
-**Current Milestone:** MS19-ChatTerminal
-**Progress:** 1 / 1 milestones
-**Status:** completed
-**Last Updated:** 2026-02-26T04:20Z
+**ID:** ms21-multi-tenant-rbac-data-migration-20260228
+**Statement:** Build multi-tenant user/workspace/team management, break-glass auth, RBAC UI enforcement, and migrate jarvis-brain data into Mosaic Stack
+**Phase:** Intake
+**Current Milestone:** —
+**Progress:** 0 / 6 milestones
+**Status:** active
+**Last Updated:** 2026-02-28 17:10 UTC
## Success Criteria
-1. Terminal panel has real xterm.js with PTY backend via WebSocket — **DONE** (PR #518)
-2. Terminal supports multiple named sessions (create/close/rename tabs) — **DONE** (PR #520)
-3. Terminal sessions persist in PostgreSQL and recover on reconnect — **DONE** (PR #517)
-4. Chat streaming renders tokens in real-time via SSE — **DONE** (PR #516)
-5. Master chat sidebar accessible from any page (Cmd+Shift+J / Cmd+K) — **DONE** (PR #519)
-6. Master chat supports model selection, temperature, conversation management — **DONE** (PR #519)
-7. Project-level chat can trigger orchestrator actions (/spawn, /status, /jobs) — **DONE** (PR #521)
-8. Agent output from orchestrator viewable in terminal tabs — **DONE** (PR #522)
-9. All features support all 5 themes (Dark, Light, Nord, Dracula, Solarized) — **DONE** (CSS variables)
-10. Lint, typecheck, and tests pass — **DONE** (1441 web + 3303 API = 4744 tests)
-11. Deployed and smoke-tested at mosaic.woltje.com — **DONE** (CI #635 green, web image sha:7165e7a deployed)
-
-## Existing Infrastructure
-
-Key components already built that MS19 builds upon:
-
-| Component | Status | Location |
-| --------------------------------- | ------------------- | ------------------------------------ |
-| ChatOverlay + ConversationSidebar | ~95% complete | `apps/web/src/components/chat/` |
-| LLM Controller with SSE | Working | `apps/api/src/llm/` |
-| WebSocket Gateway | Production | `apps/api/src/websocket/` |
-| TerminalPanel UI (mock) | UI-only, no backend | `apps/web/src/components/terminal/` |
-| Orchestrator proxy routes | Working | `apps/web/src/app/api/orchestrator/` |
-| Speech Gateway (pattern ref) | Production | `apps/api/src/speech/` |
-| Ideas API (chat persistence) | Working | `apps/api/src/ideas/` |
+
## Milestones
-| # | ID | Name | Status | Branch | Issue | Started | Completed |
-| --- | ---- | ---------------------- | --------- | ------------------------- | ------------------------ | ---------- | ---------- |
-| 1 | MS19 | Chat & Terminal System | completed | per-task feature branches | #508,#509,#510,#511,#512 | 2026-02-25 | 2026-02-25 |
+| # | ID | Name | Status | Branch | Issue | Started | Completed |
+| --- | ------- | -------------------------- | ------- | ------ | ----- | ------- | --------- |
+| 1 | phase-1 | Schema and Admin API | pending | — | — | — | — |
+| 2 | phase-2 | Break-Glass Authentication | pending | — | — | — | — |
+| 3 | phase-3 | Data Migration | pending | — | — | — | — |
+| 4 | phase-4 | Admin UI | pending | — | — | — | — |
+| 5 | phase-5 | RBAC UI Enforcement | pending | — | — | — | — |
+| 6 | phase-6 | Verification | pending | — | — | — | — |
## Deployment
-| Target | URL | Method |
-| --------- | ----------------- | --------------------------- |
-| Portainer | mosaic.woltje.com | CI/CD pipeline (Woodpecker) |
+| Target | URL | Method |
+| ------ | --- | ------ |
+| — | — | — |
## Token Budget
-| Metric | Value |
-| ------ | ----------------- |
-| Budget | ~300K (estimated) |
-| Used | ~220K |
-| Mode | normal |
+| Metric | Value |
+| ------ | ------ |
+| Budget | — |
+| Used | 0 |
+| Mode | normal |
## Session History
-| Session | Runtime | Started | Duration | Ended Reason | Last Task |
-| ------- | --------------- | ----------------- | -------- | ------------ | ------------------------------------------------- |
-| S1 | Claude Opus 4.6 | 2026-02-25T20:00Z | ~1h | context | Planning (PLAN-001) |
-| S2 | Claude Opus 4.6 | 2026-02-25T21:00Z | ~2h | context | Wave 1+2 (5 tasks, PRs #515-518) |
-| S3 | Claude Opus 4.6 | 2026-02-25T23:00Z | ~1.5h | context | Wave 3+4 (TERM-004, CHAT-002, ORCH-001, ORCH-002) |
-| S4 | Claude Opus 4.6 | 2026-02-26T04:00Z | ~30m | completed | VER-001, DOC-001, VER-002 — mission complete |
-
-## PRs Merged
-
-| PR | Commit | Task | Description |
-| ---- | ------- | -------- | ---------------------------------------- |
-| #515 | 6290fc3 | TERM-001 | Terminal WebSocket gateway & PTY service |
-| #516 | 7de0e73 | CHAT-001 | SSE chat streaming |
-| #517 | 8128eb7 | TERM-002 | Terminal session persistence |
-| #518 | 417c6ab | TERM-003 | xterm.js integration |
-| #519 | 13aa52a | CHAT-002 | Master chat polish |
-| #520 | 859dcfc | TERM-004 | Terminal tab management |
-| #521 | b110c46 | ORCH-001 | Orchestrator command system |
-| #522 | 9b2520c | ORCH-002 | Agent output terminal tabs |
+| Session | Runtime | Started | Duration | Ended Reason | Last Task |
+| ------- | ------- | ------- | -------- | ------------ | --------- |
## Scratchpad
-Path: `docs/scratchpads/ms19-chat-terminal-20260225.md`
+Path: `docs/scratchpads/ms21-multi-tenant-rbac-data-migration-20260228.md`
diff --git a/docs/PRD-MS21.md b/docs/PRD-MS21.md
new file mode 100644
index 0000000..aa0b66f
--- /dev/null
+++ b/docs/PRD-MS21.md
@@ -0,0 +1,246 @@
+# PRD: MS21 — Multi-Tenant Platform, RBAC Enforcement, and Data Migration
+
+## Metadata
+
+- Owner: Jason Woltje
+- Orchestrator: Jarvis (OpenClaw)
+- Date: 2026-02-28
+- Status: in-progress
+- Version: 0.0.21
+
+## Problem Statement
+
+Mosaic Stack is a single-user deployment. The Workspace, Team, WorkspaceMember, and RBAC infrastructure exist in the schema and codebase (PermissionGuard, WorkspaceGuard, roles: OWNER/ADMIN/MEMBER/GUEST), but there is no admin UI for managing users, workspaces, or teams. There is no invitation flow, no user management page, and no break-glass authentication mechanism for emergency access without OIDC. Additionally, the platform has no real operational data — jarvis-brain contains 106 projects and 95 tasks that need migrating into Mosaic Stack to make the dashboard, kanban, and project pages useful.
+
+## Objectives
+
+1. Build admin UI for user management (list, invite, deactivate, assign roles)
+2. Build workspace management UI (create, configure, manage members)
+3. Build team management UI (create teams within workspaces, assign members)
+4. Implement user invitation flow (email or link-based)
+5. Implement break-glass local authentication (bypass OIDC for emergency access)
+6. Enforce RBAC across all UI surfaces (show/hide based on role)
+7. Build admin Settings pages for workspace and platform configuration
+8. Migrate jarvis-brain data (95 tasks, 106 projects) into Mosaic Stack PostgreSQL
+9. Build data import API endpoint for bulk task/project creation
+
+## Completed Work
+
+### Existing Infrastructure (Already Built)
+
+| Component | Status | Location |
+| ----------------------------------------------------------------- | -------- | ------------------------------------------------------- |
+| Prisma models: User, Workspace, WorkspaceMember, Team, TeamMember | Complete | apps/api/prisma/schema.prisma |
+| WorkspaceMemberRole enum (OWNER, ADMIN, MEMBER, GUEST) | Complete | schema.prisma |
+| TeamMemberRole enum (OWNER, ADMIN, MEMBER) | Complete | schema.prisma |
+| PermissionGuard with role hierarchy | Complete | apps/api/src/common/guards/permission.guard.ts |
+| Permission decorator (@RequirePermission) | Complete | apps/api/src/common/decorators/permissions.decorator.ts |
+| Permission enum (WORKSPACE_OWNER, ADMIN, MEMBER, ANY) | Complete | permissions.decorator.ts |
+| WorkspaceGuard | Complete | apps/api/src/common/guards/workspace.guard.ts |
+| RBAC applied to 18+ controllers | Complete | All resource controllers |
+| Workspaces CRUD API | Complete | apps/api/src/workspaces/ |
+| BetterAuth + Authentik OIDC | Complete | apps/api/src/auth/ |
+| AdminGuard | Complete | apps/api/src/auth/guards/admin.guard.ts |
+
+## Scope
+
+### In Scope (MS21)
+
+#### S1: Admin API Endpoints (Backend)
+
+1. GET /api/admin/users — List all users with workspace memberships and roles
+2. POST /api/admin/users/invite — Generate invitation (email or link)
+3. PATCH /api/admin/users/:id — Update user metadata (deactivate, change global role)
+4. DELETE /api/admin/users/:id — Deactivate user (soft delete, preserve data)
+5. POST /api/admin/workspaces — Create workspace with owner assignment
+6. PATCH /api/admin/workspaces/:id — Update workspace settings
+7. POST /api/workspaces/:id/members — Add member to workspace with role
+8. PATCH /api/workspaces/:id/members/:userId — Change member role
+9. DELETE /api/workspaces/:id/members/:userId — Remove member from workspace
+10. POST /api/workspaces/:id/teams — Create team within workspace
+11. POST /api/workspaces/:id/teams/:teamId/members — Add member to team
+12. DELETE /api/workspaces/:id/teams/:teamId/members/:userId — Remove from team
+13. POST /api/import/tasks — Bulk import tasks from jarvis-brain format
+14. POST /api/import/projects — Bulk import projects from jarvis-brain format
+
+#### S2: Break-Glass Authentication
+
+15. Add isLocalAuth boolean field to User model (Prisma migration)
+16. Add passwordHash optional field to User model (for local auth users)
+17. Implement /api/auth/local/login endpoint (email + password, bcrypt)
+18. Implement /api/auth/local/setup endpoint (first-time break-glass user creation, requires admin token from env)
+19. Break-glass user bypasses OIDC entirely — session-based auth via BetterAuth
+20. Environment variable BREAKGLASS_SETUP_TOKEN controls initial setup access
+
+#### S3: Admin UI Pages (Frontend)
+
+21. /settings/users — User management page (table: name, email, role, status, workspaces, last login)
+22. User detail/edit dialog (change role, deactivate, view workspace memberships)
+23. Invite user dialog (email input, workspace selection, role selection)
+24. /settings/workspaces — Workspace management page (list workspaces, member counts)
+25. Workspace detail page (members list, team list, settings)
+26. Add/remove workspace member dialog with role picker
+27. /settings/teams — Team management within workspace context
+28. Create team dialog, add/remove team members
+
+#### S4: RBAC UI Enforcement
+
+29. Navigation sidebar: show/hide admin items based on user role
+30. Settings pages: restrict access to admin-only pages
+31. Action buttons: disable/hide create/delete based on permission level
+32. User profile: show current role and workspace memberships
+
+#### S5: Data Migration
+
+33. Build scripts/migrate-brain.ts — TypeScript migration script
+34. Read jarvis-brain data/tasks/\*.json (v2.0 format: { version, domain, tasks: [...] })
+35. Read jarvis-brain data/projects/\*.json (format: { version, project: {...}, tasks: [...] })
+36. Map brain status to Mosaic TaskStatus (done->COMPLETED, in-progress->IN_PROGRESS, backlog/pending/scheduled/not-started/planned->NOT_STARTED, blocked/on-hold->PAUSED, cancelled->ARCHIVED)
+37. Map brain priority to Mosaic TaskPriority (critical/high->HIGH, medium->MEDIUM, low->LOW)
+38. Create Domain records for unique domains (work, homelab, finances, family, etc.)
+39. Create Project records from project data, linking to domains
+40. Create Task records linking to projects, preserving brain-specific fields in metadata JSON
+41. Preserve blocks, blocked_by, repo, branch, current_milestone, notes in task/project metadata
+42. Dry-run mode with validation report before actual import
+43. Idempotent — skip records that already exist (match by metadata.brainId)
+
+### Out of Scope
+
+- Federation (MS22)
+- Agent task mapping and telemetry (MS23)
+- Playwright E2E tests (MS24)
+- Email delivery service (invitations stored as links for now)
+- User self-registration (admin-only invitation model)
+
+## User/Stakeholder Requirements
+
+1. Jason (admin) can invite Melanie to a shared workspace
+2. Jason can create a "USC IT" workspace and invite employees
+3. Each user sees only their workspace(s) data
+4. Break-glass user can log in without Authentik being available
+5. Admin pages are only visible to OWNER/ADMIN roles
+6. Data from jarvis-brain appears in the dashboard, kanban, and project pages after migration
+7. All existing tests continue to pass after changes
+
+## Functional Requirements
+
+### FR-001: Admin User Management API
+
+- AdminGuard protects all /api/admin/\* routes
+- List users returns: id, name, email, emailVerified, createdAt, workspace memberships with roles
+- Invite creates a User record with a pending invitation token
+- Deactivate sets a deactivatedAt timestamp (new field), does not delete data
+- ASSUMPTION: User deactivation is soft-delete via new deactivatedAt field on User model. Rationale: Hard delete would cascade and destroy workspace data.
+
+### FR-002: Workspace Member Management API
+
+- Add member requires WORKSPACE_ADMIN or WORKSPACE_OWNER permission
+- Role changes require equal or higher permission (MEMBER cannot promote to ADMIN)
+- Cannot remove the last OWNER of a workspace
+- Cannot change own role to lower than OWNER if sole owner
+
+### FR-003: Break-Glass Authentication
+
+- Local auth is opt-in per deployment via ENABLE_LOCAL_AUTH=true env var
+- Break-glass setup requires a one-time setup token from environment
+- Password stored as bcrypt hash, minimum 12 characters
+- Break-glass user gets OWNER role on default workspace
+- Session management identical to OIDC users (BetterAuth session tokens)
+- ASSUMPTION: BetterAuth supports custom credential providers alongside OIDC. Rationale: BetterAuth documentation confirms credential-based auth as a built-in feature.
+
+### FR-004: Admin UI
+
+- User management table with search, sort, filter by role/status
+- Inline role editing via dropdown
+- Confirmation dialog for destructive actions (deactivate user, remove from workspace)
+- PDA-friendly language: "Deactivate" not "Delete", "Pending" not "Expired"
+- Responsive design matching existing Settings page patterns
+- Dark/light theme support via existing design token system
+
+### FR-005: Data Migration Script
+
+- Standalone TypeScript script in scripts/migrate-brain.ts
+- Reads from jarvis-brain path (configurable via --brain-path flag)
+- Connects to Mosaic Stack database via DATABASE_URL
+- Requires target workspace ID (--workspace-id flag)
+- Requires creator user ID (--user-id flag)
+- Outputs validation report before writing (dry-run by default)
+- --apply flag to execute the migration
+- Creates Activity log entries for all imported records
+
+## Technical Design
+
+### Schema Changes (Prisma Migration)
+
+Add to User model:
+
+- deactivatedAt DateTime? (soft delete)
+- isLocalAuth Boolean default(false)
+- passwordHash String? (bcrypt hash for local auth)
+- invitedBy String? Uuid (FK to inviting user)
+- invitationToken String? unique
+- invitedAt DateTime?
+
+### New NestJS Module: AdminModule
+
+Location: apps/api/src/admin/
+Files: admin.module.ts, admin.controller.ts, admin.service.ts, DTOs, specs
+
+### New NestJS Module: LocalAuthModule (Break-Glass)
+
+Location: apps/api/src/auth/local/
+Files: local-auth.controller.ts, local-auth.service.ts, DTOs, specs
+
+### Frontend Pages
+
+Location: apps/web/app/(authenticated)/settings/
+
+- users/page.tsx — User management
+- workspaces/page.tsx — Workspace list
+- workspaces/[id]/page.tsx — Workspace detail (members, teams)
+- teams/page.tsx — Team management
+
+## Testing and Verification
+
+1. Baseline: pnpm lint && pnpm build && pnpm test must pass
+2. Unit tests for AdminService (user CRUD, invitation flow, permission checks)
+3. Unit tests for LocalAuthService (bcrypt hashing, token validation)
+4. Unit tests for AdminController (route guards, DTO validation)
+5. Integration test: invitation -> acceptance -> workspace access
+6. Integration test: break-glass setup -> login -> workspace access
+7. Integration test: RBAC prevents unauthorized role changes
+8. Migration script test: dry-run produces valid report, apply creates correct records
+9. Frontend: admin pages render with correct role gating
+10. All 4,772+ existing tests continue to pass
+
+## Quality Gates
+
+pnpm lint && pnpm build && pnpm test
+
+## Delivery Phases
+
+| Phase | Focus | Tasks |
+| ----------------------- | ----------------------------------------------------------------------- | ----------------- |
+| P1: Schema + Admin API | Prisma migration, AdminModule, user/workspace/team management endpoints | S1 items 1-12 |
+| P2: Break-Glass Auth | LocalAuthModule, credential provider, setup flow | S2 items 15-20 |
+| P3: Data Migration | Migration script, jarvis-brain to PostgreSQL | S5 items 33-43 |
+| P4: Admin UI | Settings pages for users, workspaces, teams | S3 items 21-28 |
+| P5: RBAC UI Enforcement | Frontend permission gating, navigation filtering | S4 items 29-32 |
+| P6: Verification | Full test pass, deployment, smoke test | All quality gates |
+
+## Risks and Open Questions
+
+1. Risk: BetterAuth credential provider may require specific adapter configuration. Mitigation: Review BetterAuth docs for credential auth alongside OIDC; test in isolation first.
+2. Risk: Schema migration on production database. Mitigation: Use Prisma migration with --create-only for review before applying.
+3. Risk: jarvis-brain data has fields that don't map 1:1 to Mosaic schema. Mitigation: Use metadata JSON field for overflow; validate with dry-run.
+4. Open: Should deactivated users retain active sessions? ASSUMPTION: No, deactivation immediately invalidates all sessions. Rationale: Security best practice.
+5. Open: Should break-glass user be auto-created on first deployment? ASSUMPTION: No, requires explicit setup via API with env token. Rationale: Prevents accidental exposure.
+
+## Assumptions
+
+1. User deactivation is soft-delete via deactivatedAt field. Hard delete cascades and destroys workspace data.
+2. BetterAuth supports custom credential providers alongside OIDC.
+3. Invitation flow uses link-based tokens (no email service required initially).
+4. Break-glass auth is off by default, controlled by ENABLE_LOCAL_AUTH env var.
+5. Migration script runs outside the API server as a standalone script.
+6. Admin pages follow existing Settings page UI patterns (cards, tables, dialogs).
diff --git a/docs/PRD.md b/docs/PRD.md
index 8c1937c..f24e333 100644
--- a/docs/PRD.md
+++ b/docs/PRD.md
@@ -134,21 +134,28 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
13. Global terminal: project/orchestrator level, smart (MS19)
14. Project-level orchestrator chat (MS19)
15. Master chat session: collapsible sidebar/slideout, always available (MS19)
-16. Settings page for ALL environment variables, dynamically configurable via webUI (MS20)
-17. Multi-tenant configuration with admin user management (MS20)
-18. Team management with shared data spaces and chat rooms (MS20)
-19. RBAC for file access, resources, models (MS20)
-20. Federation: master-master and master-slave with key exchange (MS21)
-21. Federation testing: 3 instances on Portainer (woltje.com domain) (MS21)
-22. Agent task mapping configuration: system-level defaults, user-level overrides (MS22)
-23. Telemetry: opt-out, customizable endpoint, sanitized data (MS22)
-24. File manager with WYSIWYG editing: system/user/project levels (MS18)
-25. User-level and project-level Kanban with filtering (MS18)
-26. Break-glass authentication user (MS20)
-27. Playwright E2E tests for all pages (MS23)
-28. API documentation via Swagger (MS23)
-29. Backend endpoints for all dashboard data (MS17 — already complete for existing modules)
-30. Profile page linked from user card (MS16)
+16. Site stabilization: workspace context propagation for mutations (MS20)
+17. Site stabilization: personalities API + UI (MS20)
+18. Site stabilization: user preferences API endpoint (MS20)
+19. Site stabilization: orchestrator 502 and WebSocket connectivity (MS20)
+20. Site stabilization: credential management UI (MS20)
+21. Site stabilization: terminal page route (MS20)
+22. Site stabilization: favicon, dark mode dropdown fix (MS20)
+23. Settings page for ALL environment variables, dynamically configurable via webUI (MS21)
+24. Multi-tenant configuration with admin user management (MS21)
+25. Team management with shared data spaces and chat rooms (MS21)
+26. RBAC for file access, resources, models (MS21)
+27. Federation: master-master and master-slave with key exchange (MS22)
+28. Federation testing: 3 instances on Portainer (woltje.com domain) (MS22)
+29. Agent task mapping configuration: system-level defaults, user-level overrides (MS23)
+30. Telemetry: opt-out, customizable endpoint, sanitized data (MS23)
+31. File manager with WYSIWYG editing: system/user/project levels (MS18)
+32. User-level and project-level Kanban with filtering (MS18)
+33. Break-glass authentication user (MS20)
+34. Playwright E2E tests for all pages (MS23)
+35. API documentation via Swagger (MS23)
+36. Backend endpoints for all dashboard data (MS17 — already complete for existing modules)
+37. Profile page linked from user card (MS16)
### Out of Scope
@@ -334,7 +341,46 @@ This is the active mission scope. MS16 (Pages) and MS17 (Backend Integration) ar
- ASSUMPTION: Orchestrator commands route through existing web proxy (/api/orchestrator/\*) to orchestrator service. Rationale: Proxy routes already exist and handle auth.
- **Status: COMPLETE (MS19) — PRs #521 (commands), #522 (agent terminal). /status, /agents, /jobs, /pause, /resume, /help commands. Agent output streaming via SSE. 113 web tests.**
-### FR-020: Settings Configuration (Future — MS20)
+### FR-020: Site Stabilization & Feature Gaps (MS20) — IN PROGRESS
+
+Runtime bugs and feature gaps discovered during live testing of mosaic.woltje.com.
+
+**Workspace Context Propagation:**
+
+- Domains page: "Workspace ID is required" when creating domains
+- Projects page: "Workspace ID is required" when creating projects
+- Credentials page: unable to add credentials (button disabled, feature stub)
+- ASSUMPTION: The `useWorkspaceId()` hook + auto-detect in `apiRequest` from PR #532 handles reads, but mutation endpoints on some pages don't pass workspace ID correctly. Rationale: GET requests work after PR #532 but POST/mutation requests still fail on domains and projects pages.
+
+**Missing API Endpoints:**
+
+- `/api/personalities` — no controller/service exists; frontend expects GET/POST/PATCH/DELETE
+- `/users/me/preferences` — listed in PRD API table but returns 404; frontend profile page depends on it
+- ASSUMPTION: Personalities API follows existing NestJS module patterns (controller + service + DTO + Prisma model). Rationale: Consistent with all other API modules in the codebase.
+- ASSUMPTION: User preferences endpoint is part of the existing users module but route is not registered. Rationale: PRD lists it as an existing endpoint.
+
+**Orchestrator Connectivity:**
+
+- All orchestrator-proxied endpoints return HTTP 502
+- Orchestrator WebSocket connection fails ("Reconnecting to server...")
+- Dashboard widgets: Agent Status, Task Progress, Orchestrator Events all error
+- ASSUMPTION: The orchestrator service container runs but the Next.js API proxy cannot reach it. Root cause is likely environment variable or network configuration in Docker Swarm. Rationale: The orchestrator container exists in the compose file and has Traefik labels.
+
+**UI/UX Issues:**
+
+- Dark mode theming on Formality Level dropdown in Personalities page incorrect
+- favicon.ico missing (404)
+- Terminal sidebar link uses `#terminal` anchor instead of page route
+- `useWorkspaceId` warning in console: no workspace ID in localStorage on fresh sessions
+- ASSUMPTION: Terminal should have a dedicated page route `/terminal` that renders the terminal panel full-screen. Rationale: The sidebar has a Terminal link in the Operations section alongside Logs, implying it should be a navigable page.
+
+**Credential Management:**
+
+- "Add Credential" button is `disabled` in code — feature was stubbed as "coming soon"
+- Need to implement credential creation UI and wire to existing `/api/credentials` CRUD endpoints
+- ASSUMPTION: Credential CRUD frontend can use the existing `/api/credentials` API which was built during M7-CredentialSecurity. Rationale: Backend endpoints exist per audit.
+
+### FR-021: Settings Configuration (Future — MS21)
- All environment variables configurable via UI
- Minimal launch env vars, rest configurable dynamically
@@ -496,10 +542,11 @@ These 19 NestJS modules are already implemented with Prisma and available for fr
| MS16+MS17-PagesDataIntegration | 0.0.17 | All pages built + wired to real API data | COMPLETE |
| MS18-ThemeWidgets | 0.0.18 | Theme package system, widget registry, WYSIWYG, Kanban filtering | COMPLETE |
| MS19-ChatTerminal | 0.0.19 | Global terminal, project chat, master chat session | COMPLETE |
-| MS20-MultiTenant | 0.0.20 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth | NOT STARTED |
-| MS21-Federation | 0.0.21 | Federation (M-M, M-S), 3 instances, key exchange, data separation | NOT STARTED |
-| MS22-AgentTelemetry | 0.0.22 | Agent task mapping, telemetry, wide-event logging | NOT STARTED |
-| MS23-Testing | 0.0.23 | Playwright E2E, federation tests, documentation finalization | NOT STARTED |
+| MS20-SiteStabilization | 0.0.20 | Runtime bug fixes, missing endpoints, orchestrator connectivity | IN PROGRESS |
+| MS21-MultiTenant | 0.0.21 | Multi-tenant, teams, RBAC, RLS enforcement, break-glass auth | NOT STARTED |
+| MS22-Federation | 0.0.22 | Federation (M-M, M-S), 3 instances, key exchange, data separation | NOT STARTED |
+| MS23-AgentTelemetry | 0.0.23 | Agent task mapping, telemetry, wide-event logging | NOT STARTED |
+| MS24-Testing | 0.0.24 | Playwright E2E, federation tests, documentation finalization | NOT STARTED |
## Assumptions
@@ -511,3 +558,9 @@ These 19 NestJS modules are already implemented with Prisma and available for fr
6. ASSUMPTION: Theme packages are code-level TypeScript files (not runtime-installable npm packages). Each theme exports CSS variable overrides. Rationale: Keeps the system simple for MS18; runtime package loading can be added in a future milestone.
7. ASSUMPTION: WYSIWYG editor uses Tiptap (ProseMirror-based, headless). Rationale: Headless approach integrates naturally with the CSS variable design system, excellent markdown import/export, TypeScript-first, battle-tested.
8. ASSUMPTION: MS18 includes WYSIWYG editing for knowledge entries and Kanban filtering enhancements in addition to themes and widgets. These were originally listed separately but are grouped into MS18 per PRD scope items 24-25. Rationale: All are frontend-focused enhancements that build on the existing page infrastructure.
+9. ASSUMPTION: The `useWorkspaceId()` hook + auto-detect in `apiRequest` from PR #532 handles reads, but mutation endpoints on some pages don't pass workspace ID correctly. Rationale: GET requests work after PR #532 but POST/mutation requests still fail on domains and projects pages.
+10. ASSUMPTION: Personalities API follows existing NestJS module patterns (controller + service + DTO + Prisma model). Rationale: Consistent with all other API modules in the codebase.
+11. ASSUMPTION: User preferences endpoint is part of the existing users module but route is not registered. Rationale: PRD lists it as an existing endpoint.
+12. ASSUMPTION: The orchestrator service container runs but the Next.js API proxy cannot reach it. Root cause is likely environment variable or network configuration in Docker Swarm. Rationale: The orchestrator container exists in the compose file and has Traefik labels.
+13. ASSUMPTION: Terminal should have a dedicated page route `/terminal` that renders the terminal panel full-screen. Rationale: The sidebar has a Terminal link in the Operations section alongside Logs, implying it should be a navigable page.
+14. ASSUMPTION: Credential CRUD frontend can use the existing `/api/credentials` API which was built during M7-CredentialSecurity. Rationale: Backend endpoints exist per audit.
diff --git a/docs/TASKS.md b/docs/TASKS.md
index 3bd3d6f..71c2883 100644
--- a/docs/TASKS.md
+++ b/docs/TASKS.md
@@ -1,54 +1,37 @@
-# Tasks — MS19 Chat & Terminal System
+# Tasks — MS21 Multi-Tenant RBAC Data Migration
-> Single-writer: orchestrator only. Workers read but never modify.
+> Single-writer: orchestrator (Jarvis/OpenClaw) only. Workers read but never modify.
-| id | status | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
-| ----------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----- | ------- | ------------------------------ | ----------------------------------------------- | ----------------------------------------------- | ------------ | ---------- | ------------ | -------- | ---- | ----------------------------------------------------------------- |
-| CT-PLAN-001 | done | Plan MS19 task breakdown, create milestone + issues, populate TASKS.md | — | — | — | | CT-TERM-001,CT-TERM-002,CT-CHAT-001,CT-CHAT-002 | orchestrator | 2026-02-25 | 2026-02-25 | 15K | ~15K | Planning complete |
-| CT-TERM-001 | done | Terminal WebSocket gateway & PTY session service — NestJS gateway (namespace: /terminal), node-pty spawn/kill/resize, workspace-scoped rooms, auth via token | #508 | api | feat/ms19-terminal-gateway | CT-PLAN-001 | CT-TERM-003,CT-TERM-004,CT-ORCH-002 | sonnet | 2026-02-25 | 2026-02-25 | 30K | ~30K | PR #515 merged (6290fc3), 48 tests |
-| CT-TERM-002 | done | Terminal session persistence — Prisma model (TerminalSession: id, workspaceId, name, status, createdAt, closedAt), migration, CRUD service | #508 | api | feat/ms19-terminal-persistence | CT-PLAN-001 | CT-TERM-004 | sonnet | 2026-02-25 | 2026-02-25 | 15K | ~15K | PR #517 merged (8128eb7), 12 tests, #508 closed |
-| CT-TERM-003 | done | xterm.js integration — Replace mock TerminalPanel with real xterm.js, WebSocket connection to /terminal namespace, resize handling, copy/paste, theme support | #509 | web | feat/ms19-xterm-integration | CT-TERM-001 | CT-TERM-004 | sonnet | 2026-02-25 | 2026-02-25 | 30K | ~30K | PR #518 merged (417c6ab), 40 tests |
-| CT-TERM-004 | done | Terminal tab management — Multiple named sessions, create/close/rename tabs, tab switching, session list from API, reconnect on page reload | #509 | web | feat/ms19-terminal-tabs | CT-TERM-001,CT-TERM-002,CT-TERM-003 | CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 20K | ~20K | PR #520 merged (859dcfc), 76 tests, #509 closed |
-| CT-CHAT-001 | done | Complete SSE chat streaming — Wire streamChatMessage() in frontend, token-by-token rendering in MessageList, streaming state indicators, abort/cancel support | #510 | web | feat/ms19-chat-streaming-v2 | CT-PLAN-001 | CT-CHAT-002,CT-ORCH-001 | sonnet | 2026-02-25 | 2026-02-25 | 25K | ~25K | PR #516 merged (7de0e73), streaming+fallback+abort |
-| CT-CHAT-002 | done | Master chat polish — Model selector dropdown, temperature/params config, conversation search in sidebar, keyboard shortcut improvements, empty state design | #510 | web | feat/ms19-chat-polish | CT-CHAT-001 | CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 15K | ~15K | PR #519 merged (13aa52a), 46 tests, #510 closed |
-| CT-ORCH-001 | done | Project-level orchestrator chat — Chat context scoped to project, command prefix parsing (/spawn, /status, /jobs, /kill), route commands through orchestrator proxy, display structured responses | #511 | web | feat/ms19-orchestrator-chat | CT-CHAT-001 | CT-ORCH-002,CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 30K | ~25K | PR #521 merged (b110c46), 34 tests |
-| CT-ORCH-002 | done | Agent output in terminal — View orchestrator agent sessions as terminal tabs, stream agent stdout/stderr via SSE (/agents/events), agent lifecycle indicators (spawning/running/done) | #511 | web | feat/ms19-agent-terminal | CT-TERM-001,CT-ORCH-001 | CT-VER-001 | sonnet | 2026-02-25 | 2026-02-25 | 25K | ~25K | PR #522 merged (9b2520c), 79 tests, #511 closed |
-| CT-VER-001 | done | Unit tests — Tests for terminal gateway, xterm component, chat streaming, orchestrator chat, agent terminal integration | #512 | web,api | — | CT-TERM-004,CT-CHAT-002,CT-ORCH-001,CT-ORCH-002 | CT-DOC-001 | orchestrator | 2026-02-25 | 2026-02-25 | 20K | ~5K | 328 MS19 tests (268 web + 60 API), all inline with tasks |
-| CT-DOC-001 | done | Documentation updates — TASKS.md, manifest, scratchpad, PRD status updates | #512 | — | — | CT-VER-001 | CT-VER-002 | orchestrator | 2026-02-25 | 2026-02-25 | 10K | ~5K | Updated PRD, manifest, scratchpad, TASKS.md |
-| CT-VER-002 | done | Deploy + smoke test — Deploy to Portainer, verify terminal, chat streaming, orchestrator chat, agent output all functional | #512 | — | — | CT-DOC-001 | | orchestrator | 2026-02-25 | 2026-02-25 | 15K | ~5K | CI #635 green, web deployed (sha:7165e7a), API crash pre-existing |
-
-## Summary
-
-| Metric | Value |
-| --------------- | ----------------- |
-| Total tasks | 12 |
-| Completed | 12 |
-| In Progress | 0 |
-| Remaining | 0 |
-| Estimated total | ~250K tokens |
-| Used | ~215K tokens |
-| Milestone | MS19-ChatTerminal |
-
-## Dependency Graph
-
-```
-PLAN-001 ──┬──→ TERM-001 ──┬──→ TERM-003 ──→ TERM-004 ──→ VER-001 ──→ DOC-001 ──→ VER-002
- │ │ ↑
- │ └──→ ORCH-002 ───────┘
- │ ↑
- ├──→ TERM-002 ────────→ TERM-004
- │
- ├──→ CHAT-001 ──┬──→ CHAT-002 ──→ VER-001
- │ │
- │ └──→ ORCH-001 ──→ ORCH-002
- │
- └──→ CHAT-002 (also depends on CHAT-001)
-```
-
-## Parallel Execution Opportunities
-
-- **Wave 1** (after PLAN-001): TERM-001 + TERM-002 + CHAT-001 can run in parallel (3 independent tracks)
-- **Wave 2**: TERM-003 (after TERM-001) + CHAT-002 (after CHAT-001) + ORCH-001 (after CHAT-001) can overlap
-- **Wave 3**: TERM-004 (after TERM-001+002+003) + ORCH-002 (after TERM-001+ORCH-001)
-- **Wave 4**: VER-001 (after all implementation)
-- **Wave 5**: DOC-001 → VER-002 (sequential)
+| id | status | milestone | description | pr | agent | notes |
+| ------------- | ----------- | --------- | ------------------------------------------------------------------------------------------------------------------- | --- | ------------ | ------------------------------- |
+| MS21-PLAN-001 | done | phase-1 | Write PRD, init mission, populate TASKS.md | — | orchestrator | PRD at docs/PRD-MS21.md |
+| MS21-DB-001 | not-started | phase-1 | Prisma migration: add deactivatedAt, isLocalAuth, passwordHash, invitedBy, invitationToken, invitedAt to User model | — | — | Schema changes for auth + admin |
+| MS21-API-001 | not-started | phase-1 | AdminModule: admin.module.ts, admin.service.ts, admin.controller.ts with AdminGuard | — | — | Full CRUD for user management |
+| MS21-API-002 | not-started | phase-1 | Admin user endpoints: GET /admin/users, POST /admin/users/invite, PATCH /admin/users/:id, DELETE /admin/users/:id | — | — | Requires MS21-DB-001 |
+| MS21-API-003 | not-started | phase-1 | Workspace member management: POST/PATCH/DELETE /workspaces/:id/members endpoints | — | — | Role hierarchy enforcement |
+| MS21-API-004 | not-started | phase-1 | Team management: POST /workspaces/:id/teams, team member CRUD | — | — | Extends existing Team model |
+| MS21-API-005 | not-started | phase-1 | Admin workspace endpoints: POST/PATCH /admin/workspaces with owner assignment | — | — | |
+| MS21-TEST-001 | not-started | phase-1 | Unit tests for AdminService and AdminController (spec files) | — | — | Minimum coverage: 85% |
+| MS21-AUTH-001 | not-started | phase-2 | LocalAuthModule: local-auth.controller.ts, local-auth.service.ts | — | — | bcrypt password hashing |
+| MS21-AUTH-002 | not-started | phase-2 | Break-glass setup endpoint: /api/auth/local/setup with BREAKGLASS_SETUP_TOKEN validation | — | — | First-time admin creation |
+| MS21-AUTH-003 | not-started | phase-2 | Break-glass login endpoint: /api/auth/local/login with session creation | — | — | BetterAuth session compat |
+| MS21-AUTH-004 | not-started | phase-2 | Deactivation session invalidation: deactivating user kills all active sessions | — | — | Security requirement |
+| MS21-TEST-002 | not-started | phase-2 | Unit tests for LocalAuthService and LocalAuthController | — | — | |
+| MS21-MIG-001 | not-started | phase-3 | Migration script: scripts/migrate-brain.ts — read jarvis-brain data files | — | — | v2.0 format parsing |
+| MS21-MIG-002 | not-started | phase-3 | Migration mapping: status/priority/domain mapping + metadata preservation | — | — | See PRD field mapping |
+| MS21-MIG-003 | not-started | phase-3 | Migration execution: dry-run + apply modes, idempotent, activity logging | — | — | |
+| MS21-MIG-004 | not-started | phase-3 | Import API endpoints: POST /api/import/tasks, POST /api/import/projects | — | — | For future bulk imports |
+| MS21-TEST-003 | not-started | phase-3 | Migration script tests: validate dry-run output, mapping accuracy | — | — | |
+| MS21-UI-001 | not-started | phase-4 | Settings/users page: user management table with search, sort, filter | — | — | |
+| MS21-UI-002 | not-started | phase-4 | User detail/edit dialog and invite user dialog | — | — | |
+| MS21-UI-003 | not-started | phase-4 | Settings/workspaces page: workspace list, member counts, detail view | — | — | |
+| MS21-UI-004 | not-started | phase-4 | Workspace member management: add/remove dialog with role picker | — | — | |
+| MS21-UI-005 | not-started | phase-4 | Settings/teams page: team list, create dialog, member management | — | — | |
+| MS21-TEST-004 | not-started | phase-4 | Frontend component tests for admin pages | — | — | |
+| MS21-RBAC-001 | not-started | phase-5 | Sidebar navigation: show/hide admin items based on user role | — | — | |
+| MS21-RBAC-002 | not-started | phase-5 | Settings pages: restrict access to admin-only routes | — | — | |
+| MS21-RBAC-003 | not-started | phase-5 | Action buttons: disable/hide based on permission level | — | — | |
+| MS21-RBAC-004 | not-started | phase-5 | User profile: show current role and workspace memberships | — | — | |
+| MS21-VER-001 | not-started | phase-6 | Full quality gate pass: pnpm lint && pnpm build && pnpm test | — | — | All 4772+ tests + new |
+| MS21-VER-002 | not-started | phase-6 | Deploy to mosaic.woltje.com, smoke test all pages | — | — | |
+| MS21-VER-003 | not-started | phase-6 | Tag v0.0.21, update PRD status to complete | — | — | |
diff --git a/docs/scratchpads/ms20-site-stabilization-20260227.md b/docs/scratchpads/ms20-site-stabilization-20260227.md
new file mode 100644
index 0000000..814968e
--- /dev/null
+++ b/docs/scratchpads/ms20-site-stabilization-20260227.md
@@ -0,0 +1,103 @@
+# Mission Scratchpad — MS20 Site Stabilization
+
+> Append-only log. NEVER delete entries. NEVER overwrite sections.
+> This is the orchestrator's working memory across sessions.
+
+## Original Mission Prompt
+
+```
+User tested every aspect of mosaic.woltje.com and found:
+
+settings/personalities:
+- Unable to save new personality
+- Dark mode theming on Formality Level dropdown not correct
+- Error: Cannot GET /api/personalities?isActive=true
+
+settings/credentials:
+- "Loading credentials" displayed, none populated, unable to add
+- favicon.ico 404
+- useWorkspaceId warning in console
+
+settings/domains:
+- Workspace ID is required error
+
+projects:
+- Unable to create new project
+- Workspace ID is required error
+
+Additional:
+- Fix Orchestrator 502
+- Fix Orchestrator WebSocket connection
+- /users/me/preferences endpoint needs implemented
+- #terminal anchor panel toggle needs page route
+```
+
+## Planning Decisions
+
+### S1 — 2026-02-27
+
+1. **Mission scope**: Stabilization mission covering runtime bugs and feature gaps from live testing. NOT the originally planned MS20-MultiTenant. Bumped MultiTenant to MS21.
+
+2. **Task categorization**:
+ - P1 (Critical — blocking core functionality): Workspace context for mutations, orchestrator 502
+ - P2 (High — important features): Personalities API, preferences endpoint, credentials UI, terminal route
+ - P3 (Medium — polish): Dark mode dropdown, favicon, workspace ID warning
+
+3. **PRD updated**: Added FR-020 (Site Stabilization) with 6 new assumptions. Shifted MS20-MultiTenant to MS21, renumbered subsequent milestones.
+
+4. **Prior fixes already merged**:
+ - PR #531: RLS context SQL, workspace guard crash, projects response unwrapping
+ - PR #532: Widget endpoints workspace context + auto-detect workspace ID + credentials pages
+ - PR #533: Knowledge entry query DTO — sortBy, sortOrder, search, visibility
+
+## Session Log
+
+| Session | Date | Milestone | Tasks Done | Outcome |
+| ------- | ---------- | --------- | ---------- | ----------- |
+| S1 | 2026-02-27 | MS20 | Planning | In progress |
+
+## Open Questions
+
+- Orchestrator 502: Is the orchestrator container actually running? Need to check Docker service status.
+- Workspace ID lifecycle: When does the workspace ID first get set in localStorage? Is it during login/auth callback?
+- Credentials backend: Do the M7 credential CRUD endpoints still work, or has something changed since?
+
+### S2 — 2026-02-27
+
+1. **Completed tasks**: WS-001 (PR #536), WS-002 (already working), API-001 (PR #537), API-002 (PR #539), UI-003 (PR #538)
+2. **Session ended**: Context exhaustion after dispatching 5 workers across 2 waves
+3. **Dirty state at exit**: SS-UI-002 worker left uncommitted changes (Select dark mode fix, 204 handler, personalities API client fix). SS-API-002 worker completed autonomously (PR #539 merged).
+4. **Variance**: SS-WS-001 estimated 15K used ~37K (146% over). SS-API-001 estimated 30K used ~45K (50% over). Both due to QA remediation cycles.
+
+### S3 — 2026-02-27
+
+1. **Dirty state recovery**: Recovered uncommitted S2 worker changes. Committed SS-API-002 to feat/user-preferences-endpoint (PR #539 already merged by old worker). Committed SS-UI-002 partial to fix/personalities-page (PR #540 open).
+2. **Dispatched workers**: SS-ORCH-001 (orchestrator 502 fix), SS-UI-004 (favicon)
+3. **Remaining**: WS-003, ORCH-001 (dispatched), ORCH-002 (blocked), UI-001, UI-004 (dispatched), VER-001, DOC-001
+
+## Corrections
+
+### S3 — TASKS.md revert
+
+TASKS.md had reverted to S1 state (only PLAN-001 done) despite S2 completing 5 tasks. Root cause: S2 doc commits were on main but TASKS.md edits were local and lost when worktree workers caused git state issues. Rewrote TASKS.md from scratch in S3.
+
+### S4 — 2026-02-27
+
+1. **Completed tasks**: SS-WS-003 (already in main), SS-UI-001 (PR #545 merged by S3 worker), SS-ORCH-002 (PR #547 merged by worker + PR #548 test fix + PR #549 CORS fix by orchestrator), SS-VER-001 (full site verification + deploy)
+2. **Key findings during verification**:
+ - WebSocket test failure: PR #547 added `withCredentials: true` but test expected old options. Fixed in PR #548.
+ - WebSocket CORS: Gateway used `process.env.WEB_URL ?? "http://localhost:3000"` for CORS origin. WEB_URL not set in prod, causing localhost CORS rejection. Fixed in PR #549 to use `getTrustedOrigins()` matching main API.
+ - SS-WS-003 was already in main from S2 worker that co-committed with favicon fix. PR #546 closed as redundant.
+3. **Deployment**: Portainer stack 121 (mosaic-stack) redeployed twice — first for PR #548 merge, second for PR #549 CORS fix.
+4. **Smoke test results**: All 8 key pages return 200. Chat WebSocket connected (no more "Reconnecting"). Favicon valid RGBA ICO. No CORS errors in console. Only remaining errors are orchestrator 502s (expected — service not active in prod).
+5. **Variance**: SS-ORCH-002 estimated 15K, used ~25K (67% over) due to CORS follow-up fix discovered during verification.
+6. **Total mission PRs**: 13 code PRs + 1 doc PR = 14 merged.
+
+## Session Log (Updated)
+
+| Session | Date | Milestone | Tasks Done | Outcome |
+| ------- | ---------- | --------- | ------------------------------------------ | --------- |
+| S1 | 2026-02-27 | MS20 | Planning | Completed |
+| S2 | 2026-02-27 | MS20 | WS-001, WS-002, API-001, API-002, UI-003 | Completed |
+| S3 | 2026-02-27 | MS20 | UI-002, UI-004, ORCH-001 dispatched | Completed |
+| S4 | 2026-02-27 | MS20 | WS-003, UI-001, ORCH-002, VER-001, DOC-001 | Completed |
diff --git a/docs/scratchpads/ms21-multi-tenant-rbac-data-migration-20260228.md b/docs/scratchpads/ms21-multi-tenant-rbac-data-migration-20260228.md
new file mode 100644
index 0000000..1365799
--- /dev/null
+++ b/docs/scratchpads/ms21-multi-tenant-rbac-data-migration-20260228.md
@@ -0,0 +1,49 @@
+# Mission Scratchpad — MS21 Multi-Tenant RBAC Data Migration
+
+> Append-only log. NEVER delete entries. NEVER overwrite sections.
+
+## Original Mission Prompt
+
+```
+Build multi-tenant user/workspace/team management with admin UI, break-glass
+local authentication (bypass OIDC for emergencies), enforce RBAC across all
+UI surfaces, and migrate jarvis-brain data (95 tasks, 106 projects) into
+Mosaic Stack PostgreSQL. This unlocks multi-user access for Melanie and
+USC employees.
+```
+
+## Planning Decisions
+
+### 2026-02-28 — Initial Planning (Orchestrator: Jarvis/OpenClaw)
+
+1. **Phase order**: Schema+API first, then break-glass auth, then data migration, then UI, then RBAC enforcement, then verification. Rationale: Backend must exist before frontend can wire to it; migration can run independently once schema is ready.
+
+2. **Worker strategy**: Up to 6 parallel workers (2 Claude, 2 Codex, 2 GLM). Claude for complex multi-file implementations. Codex for targeted single-file tasks. GLM for documentation and test writing.
+
+3. **Phase 1 parallelization plan**:
+ - Worker A (Claude): MS21-DB-001 (Prisma migration) — must complete first
+ - After DB-001 done:
+ - Worker B (Claude): MS21-API-001 + MS21-API-002 (AdminModule + user endpoints)
+ - Worker C (Codex): MS21-API-003 (workspace member management)
+ - Worker D (Codex): MS21-API-004 (team management)
+ - Worker E (Claude): MS21-API-005 (admin workspace endpoints)
+ - Worker F (GLM): MS21-TEST-001 (unit tests for admin module)
+
+4. **PRD location**: docs/PRD-MS21.md (separate from main PRD.md to preserve history)
+
+5. **Orchestrator is Jarvis (OpenClaw)** — not a Claude Code session. This is the first hybrid orchestration: OpenClaw manages mission, dispatches workers via mosaic yolo claude, codex exec, and OpenClaw subagents.
+
+## Session Log
+
+| Session | Date | Milestone | Tasks Done | Outcome |
+| ------- | ---------- | --------- | ------------- | --------------------------------------------- |
+| S1 | 2026-02-28 | Planning | MS21-PLAN-001 | PRD written, mission init, TASKS.md populated |
+
+## Open Questions
+
+- BetterAuth credential provider config alongside OIDC — needs verification in worker task
+- Exact sidebar items to gate behind admin role — review during RBAC phase
+
+## Corrections
+
+(none yet)
diff --git a/package.json b/package.json
index c33fffe..1655a39 100644
--- a/package.json
+++ b/package.json
@@ -63,7 +63,7 @@
],
"overrides": {
"@isaacs/brace-expansion": ">=5.0.1",
- "minimatch": ">=10.2.1",
+ "minimatch": ">=10.2.3",
"tar": ">=7.5.8",
"form-data": ">=2.5.4",
"lodash": ">=4.17.23",
@@ -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"
}
}
}
diff --git a/packages/shared/src/types/auth.types.ts b/packages/shared/src/types/auth.types.ts
index 329fd92..989c192 100644
--- a/packages/shared/src/types/auth.types.ts
+++ b/packages/shared/src/types/auth.types.ts
@@ -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;
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 177b0a4..bf55d88 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -6,7 +6,7 @@ settings:
overrides:
'@isaacs/brace-expansion': '>=5.0.1'
- minimatch: '>=10.2.1'
+ minimatch: '>=10.2.3'
tar: '>=7.5.8'
form-data: '>=2.5.4'
lodash: '>=4.17.23'
@@ -16,6 +16,7 @@ overrides:
tough-cookie: '>=4.1.3'
undici: '>=6.23.0'
rollup: '>=4.59.0'
+ serialize-javascript: '>=7.0.3'
importers:
@@ -1596,6 +1597,7 @@ packages:
'@mosaicstack/telemetry-client@0.1.1':
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
+ engines: {node: '>=18'}
'@mrleebo/prisma-ast@0.13.1':
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
@@ -5776,9 +5778,9 @@ packages:
minimalistic-assert@1.0.1:
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
- minimatch@10.2.1:
- resolution: {integrity: sha512-MClCe8IL5nRRmawL6ib/eT4oLyeKMGCghibcDWK+J0hh0Q8kqSdia6BvbRMVk6mPa6WqUa5uR2oxt6C5jd533A==}
- engines: {node: 20 || >=22}
+ minimatch@10.2.4:
+ resolution: {integrity: sha512-oRjTw/97aTBN0RHbYCdtF1MQfvusSIBQM0IZEgzl6426+8jSC0nF1a/GmnVLpfB9yyr6g6FTqWqiZVbxrtaCIg==}
+ engines: {node: 18 || 20 || >=22}
minimist@1.2.8:
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
@@ -6388,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'}
@@ -6678,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==}
@@ -7965,7 +7965,7 @@ snapshots:
chalk: 5.6.2
commander: 12.1.0
dotenv: 17.2.4
- drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
+ drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
open: 10.2.0
pg: 8.17.2
prettier: 3.8.1
@@ -8303,7 +8303,7 @@ snapshots:
dependencies:
'@eslint/object-schema': 2.1.7
debug: 4.4.3
- minimatch: 10.2.1
+ minimatch: 10.2.4
transitivePeerDependencies:
- supports-color
@@ -8324,7 +8324,7 @@ snapshots:
ignore: 5.3.2
import-fresh: 3.3.1
js-yaml: 4.1.1
- minimatch: 10.2.1
+ minimatch: 10.2.4
strip-json-comments: 3.1.1
transitivePeerDependencies:
- supports-color
@@ -10780,7 +10780,7 @@ snapshots:
'@typescript-eslint/types': 8.54.0
'@typescript-eslint/visitor-keys': 8.54.0
debug: 4.4.3
- minimatch: 10.2.1
+ minimatch: 10.2.4
semver: 7.7.3
tinyglobby: 0.2.15
ts-api-utils: 2.4.0(typescript@5.9.3)
@@ -11291,7 +11291,7 @@ snapshots:
optionalDependencies:
'@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
better-sqlite3: 12.6.2
- drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
+ drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
@@ -11316,7 +11316,7 @@ snapshots:
optionalDependencies:
'@prisma/client': 6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3)
better-sqlite3: 12.6.2
- drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
+ drizzle-orm: 0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
next: 16.1.6(@babel/core@7.28.6)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
pg: 8.17.2
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
@@ -12135,17 +12135,6 @@ snapshots:
dotenv@17.2.4: {}
- drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
- optionalDependencies:
- '@opentelemetry/api': 1.9.0
- '@prisma/client': 5.22.0(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))
- '@types/pg': 8.16.0
- better-sqlite3: 12.6.2
- kysely: 0.28.10
- pg: 8.17.2
- postgres: 3.4.8
- prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
-
drizzle-orm@0.41.0(@opentelemetry/api@1.9.0)(@prisma/client@6.19.2(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3))(typescript@5.9.3))(@types/pg@8.16.0)(better-sqlite3@12.6.2)(kysely@0.28.10)(pg@8.17.2)(postgres@3.4.8)(prisma@6.19.2(magicast@0.3.5)(typescript@5.9.3)):
optionalDependencies:
'@opentelemetry/api': 1.9.0
@@ -12156,7 +12145,6 @@ snapshots:
pg: 8.17.2
postgres: 3.4.8
prisma: 6.19.2(magicast@0.3.5)(typescript@5.9.3)
- optional: true
dunder-proto@1.0.1:
dependencies:
@@ -12362,7 +12350,7 @@ snapshots:
is-glob: 4.0.3
json-stable-stringify-without-jsonify: 1.0.1
lodash.merge: 4.6.2
- minimatch: 10.2.1
+ minimatch: 10.2.4
natural-compare: 1.4.0
optionator: 0.9.4
optionalDependencies:
@@ -12605,7 +12593,7 @@ snapshots:
deepmerge: 4.3.1
fs-extra: 10.1.0
memfs: 3.5.3
- minimatch: 10.2.1
+ minimatch: 10.2.4
node-abort-controller: 3.1.1
schema-utils: 3.3.0
semver: 7.7.3
@@ -12731,14 +12719,14 @@ snapshots:
dependencies:
foreground-child: 3.3.1
jackspeak: 3.4.3
- minimatch: 10.2.1
+ minimatch: 10.2.4
minipass: 7.1.2
package-json-from-dist: 1.0.1
path-scurry: 1.11.1
glob@13.0.0:
dependencies:
- minimatch: 10.2.1
+ minimatch: 10.2.4
minipass: 7.1.2
path-scurry: 2.0.1
@@ -13374,7 +13362,7 @@ snapshots:
minimalistic-assert@1.0.1: {}
- minimatch@10.2.1:
+ minimatch@10.2.4:
dependencies:
brace-expansion: 5.0.2
@@ -14001,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:
@@ -14110,7 +14094,7 @@ snapshots:
readdir-glob@1.1.3:
dependencies:
- minimatch: 10.2.1
+ minimatch: 10.2.4
readdirp@3.6.0:
dependencies:
@@ -14373,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:
@@ -14780,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:
@@ -14797,7 +14779,7 @@ snapshots:
dependencies:
'@istanbuljs/schema': 0.1.3
glob: 10.5.0
- minimatch: 10.2.1
+ minimatch: 10.2.4
text-decoder@1.2.3:
dependencies: