Compare commits

...

41 Commits

Author SHA1 Message Date
572e0592b1 feat(web): add project detail page (/projects/[id])
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-01 14:09:22 -06:00
e898551814 fix(web): correct Add Provider form to match fleet-settings DTO (#626)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 20:00:50 +00:00
3607554902 fix(api): MS22 Phase 1 post-coding audit (#625)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 19:53:49 +00:00
a25a77a43c fix(api): widget throttling and orchestrator endpoints (#624)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 19:22:20 +00:00
861eff4686 fix(web): correct Add Provider form DTO field mapping (#623)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 19:19:04 +00:00
99a4567e32 fix(api): skip CSRF for Bearer-authenticated API clients (#622)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 19:06:14 +00:00
559c6b3831 fix(api): add AuthModule to FleetSettingsModule and ChatProxyModule (#621)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 18:06:49 +00:00
631e5010b5 fix(api): add ConfigModule to ContainerLifecycleModule imports (#620)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 17:52:10 +00:00
09e377ecd7 fix(deploy): add MOSAIC_SECRET_KEY + docker socket to api service (MS22) (#619)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 17:42:29 +00:00
deafcdc84b chore(orchestrator): MS22 Phase 1 complete — all 11 tasks done (#618)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 16:33:05 +00:00
66d401461c feat(web): fleet settings UI (MS22-P1h) (#617)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 16:22:22 +00:00
01ae164b61 feat(web): onboarding wizard (MS22-P1f) (#616)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 16:07:22 +00:00
029c190c05 feat(api): chat proxy (MS22-P1i) (#615)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 15:59:00 +00:00
477d0c8fdf feat(api): idle container reaper (MS22-P1k) (#614)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 15:50:34 +00:00
03af39def9 feat(docker): core compose + entrypoint (MS22-P1j) (#613)
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 15:50:33 +00:00
dc7e0c805c feat(api): onboarding API (MS22-P1e) (#612)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 15:43:43 +00:00
2b010fadda feat(api): fleet settings API (MS22-P1g) (#611)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 15:37:04 +00:00
c25e753f35 feat(api): ContainerLifecycleService (MS22-P1d) (#610)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 15:24:42 +00:00
d3c8b8cadd feat(api): internal agent config endpoint (MS22-P1c) (#609)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 15:14:06 +00:00
a3a0d7afca chore(orchestrator): add MS22 PRD, mark P1a+P1b done (#608)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 15:05:35 +00:00
ab2b68c93c Merge pull request 'feat(api): agent fleet DB schema + migration (MS22-P1a)' (#607) from feat/ms22-p1a-schema into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Reviewed-on: #607
2026-03-01 15:03:23 +00:00
c1ec0ad7ef Merge pull request 'feat(api): CryptoService for API key encryption (MS22-P1b)' (#606) from feat/ms22-p1b-crypto into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Reviewed-on: #606
2026-03-01 15:02:50 +00:00
e5b772f7cb Merge pull request 'chore(orchestrator): MS22 Phase 1 task breakdown' (#605) from chore/ms22-p1-tasks into main
Reviewed-on: #605
2026-03-01 15:02:27 +00:00
7a46c81897 feat(api): add agent fleet Prisma schema (MS22-P1a)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-01 08:42:10 -06:00
3688f89c37 feat(api): add CryptoService for secret encryption (MS22-P1b)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
2026-03-01 08:41:28 -06:00
e59e517d5c feat(api): add CryptoService for secret encryption (MS22-P1b) 2026-03-01 08:40:40 -06:00
fab833a710 chore(orchestrator): add MS22 Phase 1 task breakdown (11 tasks) 2026-03-01 08:36:19 -06:00
4294deda49 docs(design): MS22 DB-centric agent fleet architecture (#604)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 14:35:14 +00:00
2fe858d61a chore(orchestrator): MS21 complete — UI-001-QA and TEST-004 done (#602)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 14:16:11 +00:00
512a29a240 fix(web): QA fixes on users settings page (MS21-UI-001-QA) (#599)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
fix(web): QA fixes on users settings page (MS21-UI-001-QA)

Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 13:52:15 +00:00
8ea3c3ee67 Merge pull request 'chore(orchestrator): sync TASKS.md — mark MS21 completed tasks as done' (#597) from chore/ms21-tasks-sync into main
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Reviewed-on: #597
2026-03-01 13:41:45 +00:00
c4a6be5b6b Merge pull request 'chore(orchestrator): mark MS22 Phase 0 complete' (#596) from chore/ms22-phase0-complete into main
Reviewed-on: #596
2026-03-01 13:41:29 +00:00
f4c1c9d816 chore(orchestrator): sync TASKS.md — mark UI-002,004,005,RBAC-001,002 done; UI-001-QA+TEST-004 in-progress 2026-03-01 07:38:51 -06:00
ac67697fe4 chore(orchestrator): mark MS22 Phase 0 complete — all tasks done 2026-02-28 22:55:18 -06:00
6521f655a8 feat(web): add teams page and RBAC navigation/route gating (MS21-UI-005, RBAC-001, RBAC-002) (#595)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 04:54:25 +00:00
0e74b03d9c test(api): integration tests for MS22 knowledge layer modules (MS22-TEST-001) (#594)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 04:54:23 +00:00
a925f91062 feat: add OpenClaw session log ingestion script (MS22-INGEST-001) (#593)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 03:54:36 +00:00
7106512fa9 feat(web): add user edit/invite dialogs and workspace member management (MS21-UI-002, MS21-UI-004) (#592)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 03:54:32 +00:00
1df20f0e13 feat(api): add assigned_agent to Task model (MS22-DB-003, MS22-API-003) (#591)
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 03:54:28 +00:00
8dab20c022 chore(orchestrator): add MS22 Phase 0 tasks to TASKS.md (#590)
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
ci/woodpecker/push/coordinator Pipeline was successful
ci/woodpecker/push/ci Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 03:14:55 +00:00
7073057e8d fix: bump openbao 2.5.0→2.5.1 (CVE-2026-24051 otel/sdk PATH hijack) (#589)
All checks were successful
ci/woodpecker/push/infra Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-03-01 03:14:49 +00:00
89 changed files with 12162 additions and 560 deletions

View File

@@ -36,6 +36,7 @@
"@nestjs/mapped-types": "^2.1.0", "@nestjs/mapped-types": "^2.1.0",
"@nestjs/platform-express": "^11.1.12", "@nestjs/platform-express": "^11.1.12",
"@nestjs/platform-socket.io": "^11.1.12", "@nestjs/platform-socket.io": "^11.1.12",
"@nestjs/schedule": "^6.1.1",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@nestjs/websockets": "^11.1.12", "@nestjs/websockets": "^11.1.12",
"@opentelemetry/api": "^1.9.0", "@opentelemetry/api": "^1.9.0",
@@ -59,6 +60,7 @@
"class-validator": "^0.14.3", "class-validator": "^0.14.3",
"cookie-parser": "^1.4.7", "cookie-parser": "^1.4.7",
"discord.js": "^14.25.1", "discord.js": "^14.25.1",
"dockerode": "^4.0.9",
"gray-matter": "^4.0.3", "gray-matter": "^4.0.3",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"ioredis": "^5.9.2", "ioredis": "^5.9.2",
@@ -88,6 +90,7 @@
"@types/archiver": "^7.0.0", "@types/archiver": "^7.0.0",
"@types/bcryptjs": "^3.0.0", "@types/bcryptjs": "^3.0.0",
"@types/cookie-parser": "^1.4.10", "@types/cookie-parser": "^1.4.10",
"@types/dockerode": "^3.3.47",
"@types/express": "^5.0.1", "@types/express": "^5.0.1",
"@types/highlight.js": "^10.1.0", "@types/highlight.js": "^10.1.0",
"@types/node": "^22.13.4", "@types/node": "^22.13.4",

View File

@@ -0,0 +1,109 @@
-- CreateTable
CREATE TABLE "SystemConfig" (
"id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" TEXT NOT NULL,
"encrypted" BOOLEAN NOT NULL DEFAULT false,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemConfig_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "BreakglassUser" (
"id" TEXT NOT NULL,
"username" TEXT NOT NULL,
"passwordHash" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "BreakglassUser_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LlmProvider" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"displayName" TEXT NOT NULL,
"type" TEXT NOT NULL,
"baseUrl" TEXT,
"apiKey" TEXT,
"apiType" TEXT NOT NULL DEFAULT 'openai-completions',
"models" JSONB NOT NULL DEFAULT '[]',
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "LlmProvider_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserContainer" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"containerId" TEXT,
"containerName" TEXT NOT NULL,
"gatewayPort" INTEGER,
"gatewayToken" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'stopped',
"lastActiveAt" TIMESTAMP(3),
"idleTimeoutMin" INTEGER NOT NULL DEFAULT 30,
"config" JSONB NOT NULL DEFAULT '{}',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserContainer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SystemContainer" (
"id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"role" TEXT NOT NULL,
"containerId" TEXT,
"gatewayPort" INTEGER,
"gatewayToken" TEXT NOT NULL,
"status" TEXT NOT NULL DEFAULT 'stopped',
"primaryModel" TEXT NOT NULL,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SystemContainer_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserAgentConfig" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"primaryModel" TEXT,
"fallbackModels" JSONB NOT NULL DEFAULT '[]',
"personality" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserAgentConfig_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "SystemConfig_key_key" ON "SystemConfig"("key");
-- CreateIndex
CREATE UNIQUE INDEX "BreakglassUser_username_key" ON "BreakglassUser"("username");
-- CreateIndex
CREATE INDEX "LlmProvider_userId_idx" ON "LlmProvider"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "LlmProvider_userId_name_key" ON "LlmProvider"("userId", "name");
-- CreateIndex
CREATE UNIQUE INDEX "UserContainer_userId_key" ON "UserContainer"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "SystemContainer_name_key" ON "SystemContainer"("name");
-- CreateIndex
CREATE UNIQUE INDEX "UserAgentConfig_userId_key" ON "UserAgentConfig"("userId");

View File

@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "tasks" ADD COLUMN "assigned_agent" TEXT;

View File

@@ -379,6 +379,7 @@ model Task {
creatorId String @map("creator_id") @db.Uuid creatorId String @map("creator_id") @db.Uuid
projectId String? @map("project_id") @db.Uuid projectId String? @map("project_id") @db.Uuid
parentId String? @map("parent_id") @db.Uuid parentId String? @map("parent_id") @db.Uuid
assignedAgent String? @map("assigned_agent")
domainId String? @map("domain_id") @db.Uuid domainId String? @map("domain_id") @db.Uuid
sortOrder Int @default(0) @map("sort_order") sortOrder Int @default(0) @map("sort_order")
metadata Json @default("{}") metadata Json @default("{}")
@@ -1624,3 +1625,81 @@ model ConversationArchive {
@@index([startedAt]) @@index([startedAt])
@@map("conversation_archives") @@map("conversation_archives")
} }
// ============================================
// AGENT FLEET MODULE
// ============================================
model SystemConfig {
id String @id @default(cuid())
key String @unique
value String
encrypted Boolean @default(false)
updatedAt DateTime @updatedAt
}
model BreakglassUser {
id String @id @default(cuid())
username String @unique
passwordHash String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model LlmProvider {
id String @id @default(cuid())
userId String
name String
displayName String
type String
baseUrl String?
apiKey String?
apiType String @default("openai-completions")
models Json @default("[]")
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, name])
@@index([userId])
}
model UserContainer {
id String @id @default(cuid())
userId String @unique
containerId String?
containerName String
gatewayPort Int?
gatewayToken String
status String @default("stopped")
lastActiveAt DateTime?
idleTimeoutMin Int @default(30)
config Json @default("{}")
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SystemContainer {
id String @id @default(cuid())
name String @unique
role String
containerId String?
gatewayPort Int?
gatewayToken String
status String @default("stopped")
primaryModel String
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model UserAgentConfig {
id String @id @default(cuid())
userId String @unique
primaryModel String?
fallbackModels Json @default("[]")
personality String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

View File

@@ -0,0 +1,40 @@
import {
Controller,
ForbiddenException,
Get,
Param,
Req,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import { AgentConfigService } from "./agent-config.service";
import { AgentConfigGuard, type AgentConfigRequest } from "./agent-config.guard";
@Controller("internal")
@UseGuards(AgentConfigGuard)
export class AgentConfigController {
constructor(private readonly agentConfigService: AgentConfigService) {}
// GET /api/internal/agent-config/:id
// Auth: Bearer token (validated against UserContainer.gatewayToken or SystemContainer.gatewayToken)
// Returns: assembled openclaw.json
//
// The :id param is the container record ID (cuid)
// Token must match the container requesting its own config
@Get("agent-config/:id")
async getAgentConfig(
@Param("id") id: string,
@Req() request: AgentConfigRequest
): Promise<object> {
const containerAuth = request.containerAuth;
if (!containerAuth) {
throw new UnauthorizedException("Missing container authentication context");
}
if (containerAuth.id !== id) {
throw new ForbiddenException("Token is not authorized for the requested container");
}
return this.agentConfigService.generateConfigForContainer(containerAuth.type, id);
}
}

View File

@@ -0,0 +1,43 @@
import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common";
import type { Request } from "express";
import { AgentConfigService, type ContainerTokenValidation } from "./agent-config.service";
export interface AgentConfigRequest extends Request {
containerAuth?: ContainerTokenValidation;
}
@Injectable()
export class AgentConfigGuard implements CanActivate {
constructor(private readonly agentConfigService: AgentConfigService) {}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<AgentConfigRequest>();
const token = this.extractBearerToken(request.headers.authorization);
if (!token) {
throw new UnauthorizedException("Missing Bearer token");
}
const containerAuth = await this.agentConfigService.validateContainerToken(token);
if (!containerAuth) {
throw new UnauthorizedException("Invalid container token");
}
request.containerAuth = containerAuth;
return true;
}
private extractBearerToken(headerValue: string | string[] | undefined): string | null {
const normalizedHeader = Array.isArray(headerValue) ? headerValue[0] : headerValue;
if (!normalizedHeader) {
return null;
}
const [scheme, token] = normalizedHeader.split(" ");
if (!scheme || !token || scheme.toLowerCase() !== "bearer") {
return null;
}
return token;
}
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { PrismaModule } from "../prisma/prisma.module";
import { CryptoModule } from "../crypto/crypto.module";
import { AgentConfigController } from "./agent-config.controller";
import { AgentConfigService } from "./agent-config.service";
import { AgentConfigGuard } from "./agent-config.guard";
@Module({
imports: [PrismaModule, CryptoModule],
controllers: [AgentConfigController],
providers: [AgentConfigService, AgentConfigGuard],
exports: [AgentConfigService],
})
export class AgentConfigModule {}

View File

@@ -0,0 +1,215 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { AgentConfigService } from "./agent-config.service";
import { PrismaService } from "../prisma/prisma.service";
import { CryptoService } from "../crypto/crypto.service";
describe("AgentConfigService", () => {
let service: AgentConfigService;
const mockPrismaService = {
userAgentConfig: {
findUnique: vi.fn(),
},
llmProvider: {
findMany: vi.fn(),
},
userContainer: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
systemContainer: {
findUnique: vi.fn(),
findMany: vi.fn(),
},
};
const mockCryptoService = {
isEncrypted: vi.fn((value: string) => value.startsWith("enc:")),
decrypt: vi.fn((value: string) => value.replace(/^enc:/, "")),
};
beforeEach(() => {
vi.clearAllMocks();
service = new AgentConfigService(
mockPrismaService as unknown as PrismaService,
mockCryptoService as unknown as CryptoService
);
});
it("generateUserConfig returns valid openclaw.json structure", async () => {
mockPrismaService.userAgentConfig.findUnique.mockResolvedValue({
id: "cfg-1",
userId: "user-1",
primaryModel: "my-zai/glm-5",
});
mockPrismaService.userContainer.findUnique.mockResolvedValue({
id: "container-1",
userId: "user-1",
gatewayPort: 19001,
});
mockPrismaService.llmProvider.findMany.mockResolvedValue([
{
id: "provider-1",
userId: "user-1",
name: "my-zai",
displayName: "Z.ai",
type: "zai",
baseUrl: "https://api.z.ai/v1",
apiKey: "enc:secret-zai-key",
apiType: "openai-completions",
models: [{ id: "glm-5" }],
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
const result = await service.generateUserConfig("user-1");
expect(result).toEqual({
gateway: {
mode: "local",
port: 19001,
bind: "lan",
auth: { mode: "token" },
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
agents: {
defaults: {
model: {
primary: "my-zai/glm-5",
},
},
},
models: {
providers: {
"my-zai": {
apiKey: "secret-zai-key",
baseUrl: "https://api.z.ai/v1",
models: {
"glm-5": {},
},
},
},
},
});
});
it("generateUserConfig decrypts API keys correctly", async () => {
mockPrismaService.userAgentConfig.findUnique.mockResolvedValue({
id: "cfg-1",
userId: "user-1",
primaryModel: "openai-work/gpt-4.1",
});
mockPrismaService.userContainer.findUnique.mockResolvedValue({
id: "container-1",
userId: "user-1",
gatewayPort: 18789,
});
mockPrismaService.llmProvider.findMany.mockResolvedValue([
{
id: "provider-1",
userId: "user-1",
name: "openai-work",
displayName: "OpenAI Work",
type: "openai",
baseUrl: "https://api.openai.com/v1",
apiKey: "enc:encrypted-openai-key",
apiType: "openai-completions",
models: [{ id: "gpt-4.1" }],
isActive: true,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
const result = await service.generateUserConfig("user-1");
expect(mockCryptoService.decrypt).toHaveBeenCalledWith("enc:encrypted-openai-key");
expect(result.models.providers["openai-work"]?.apiKey).toBe("encrypted-openai-key");
});
it("generateUserConfig handles user with no providers", async () => {
mockPrismaService.userAgentConfig.findUnique.mockResolvedValue({
id: "cfg-1",
userId: "user-2",
primaryModel: "openai/gpt-4o-mini",
});
mockPrismaService.userContainer.findUnique.mockResolvedValue({
id: "container-2",
userId: "user-2",
gatewayPort: null,
});
mockPrismaService.llmProvider.findMany.mockResolvedValue([]);
const result = await service.generateUserConfig("user-2");
expect(result.models.providers).toEqual({});
expect(result.gateway.port).toBe(18789);
});
it("validateContainerToken returns correct type for user container", async () => {
mockPrismaService.userContainer.findMany.mockResolvedValue([
{
id: "user-container-1",
gatewayToken: "enc:user-token-1",
},
]);
mockPrismaService.systemContainer.findMany.mockResolvedValue([]);
const result = await service.validateContainerToken("user-token-1");
expect(result).toEqual({
type: "user",
id: "user-container-1",
});
});
it("validateContainerToken returns correct type for system container", async () => {
mockPrismaService.userContainer.findMany.mockResolvedValue([]);
mockPrismaService.systemContainer.findMany.mockResolvedValue([
{
id: "system-container-1",
gatewayToken: "enc:system-token-1",
},
]);
const result = await service.validateContainerToken("system-token-1");
expect(result).toEqual({
type: "system",
id: "system-container-1",
});
});
it("validateContainerToken returns null for invalid token", async () => {
mockPrismaService.userContainer.findMany.mockResolvedValue([
{
id: "user-container-1",
gatewayToken: "enc:user-token-1",
},
]);
mockPrismaService.systemContainer.findMany.mockResolvedValue([
{
id: "system-container-1",
gatewayToken: "enc:system-token-1",
},
]);
const result = await service.validateContainerToken("no-match");
expect(result).toBeNull();
});
});

View File

@@ -0,0 +1,285 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import type { LlmProvider } from "@prisma/client";
import { createHash, timingSafeEqual } from "node:crypto";
import { PrismaService } from "../prisma/prisma.service";
import { CryptoService } from "../crypto/crypto.service";
const DEFAULT_GATEWAY_PORT = 18789;
const DEFAULT_PRIMARY_MODEL = "openai/gpt-4o-mini";
type ContainerType = "user" | "system";
export interface ContainerTokenValidation {
type: ContainerType;
id: string;
}
type OpenClawModelMap = Record<string, Record<string, never>>;
interface OpenClawProviderConfig {
apiKey?: string;
baseUrl?: string;
models: OpenClawModelMap;
}
interface OpenClawConfig {
gateway: {
mode: "local";
port: number;
bind: "lan";
auth: { mode: "token" };
http: {
endpoints: {
chatCompletions: { enabled: true };
};
};
};
agents: {
defaults: {
model: {
primary: string;
};
};
};
models: {
providers: Record<string, OpenClawProviderConfig>;
};
}
@Injectable()
export class AgentConfigService {
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CryptoService
) {}
// Generate complete openclaw.json for a user container
async generateUserConfig(userId: string): Promise<OpenClawConfig> {
const [userAgentConfig, providers, userContainer] = await Promise.all([
this.prisma.userAgentConfig.findUnique({
where: { userId },
}),
this.prisma.llmProvider.findMany({
where: {
userId,
isActive: true,
},
orderBy: {
createdAt: "asc",
},
}),
this.prisma.userContainer.findUnique({
where: { userId },
}),
]);
if (!userContainer) {
throw new NotFoundException(`User container not found for user ${userId}`);
}
const primaryModel =
userAgentConfig?.primaryModel ??
this.resolvePrimaryModelFromProviders(providers) ??
DEFAULT_PRIMARY_MODEL;
return this.buildOpenClawConfig(primaryModel, userContainer.gatewayPort, providers);
}
// Generate config for a system container
async generateSystemConfig(containerId: string): Promise<OpenClawConfig> {
const systemContainer = await this.prisma.systemContainer.findUnique({
where: { id: containerId },
});
if (!systemContainer) {
throw new NotFoundException(`System container ${containerId} not found`);
}
return this.buildOpenClawConfig(
systemContainer.primaryModel || DEFAULT_PRIMARY_MODEL,
systemContainer.gatewayPort,
[]
);
}
async generateConfigForContainer(
type: ContainerType,
containerId: string
): Promise<OpenClawConfig> {
if (type === "system") {
return this.generateSystemConfig(containerId);
}
const userContainer = await this.prisma.userContainer.findUnique({
where: { id: containerId },
select: { userId: true },
});
if (!userContainer) {
throw new NotFoundException(`User container ${containerId} not found`);
}
return this.generateUserConfig(userContainer.userId);
}
// Validate a container's bearer token
async validateContainerToken(token: string): Promise<ContainerTokenValidation | null> {
if (!token) {
return null;
}
const [userContainers, systemContainers] = await Promise.all([
this.prisma.userContainer.findMany({
select: {
id: true,
gatewayToken: true,
},
}),
this.prisma.systemContainer.findMany({
select: {
id: true,
gatewayToken: true,
},
}),
]);
let match: ContainerTokenValidation | null = null;
for (const container of userContainers) {
const storedToken = this.decryptContainerToken(container.gatewayToken);
if (!match && storedToken && this.tokensEqual(storedToken, token)) {
match = { type: "user", id: container.id };
}
}
for (const container of systemContainers) {
const storedToken = this.decryptContainerToken(container.gatewayToken);
if (!match && storedToken && this.tokensEqual(storedToken, token)) {
match = { type: "system", id: container.id };
}
}
return match;
}
private buildOpenClawConfig(
primaryModel: string,
gatewayPort: number | null,
providers: LlmProvider[]
): OpenClawConfig {
return {
gateway: {
mode: "local",
port: gatewayPort ?? DEFAULT_GATEWAY_PORT,
bind: "lan",
auth: { mode: "token" },
http: {
endpoints: {
chatCompletions: { enabled: true },
},
},
},
agents: {
defaults: {
model: {
primary: primaryModel,
},
},
},
models: {
providers: this.buildProviderConfig(providers),
},
};
}
private buildProviderConfig(providers: LlmProvider[]): Record<string, OpenClawProviderConfig> {
const providerConfig: Record<string, OpenClawProviderConfig> = {};
for (const provider of providers) {
const config: OpenClawProviderConfig = {
models: this.extractModels(provider.models),
};
const apiKey = this.decryptIfNeeded(provider.apiKey);
if (apiKey) {
config.apiKey = apiKey;
}
if (provider.baseUrl) {
config.baseUrl = provider.baseUrl;
}
providerConfig[provider.name] = config;
}
return providerConfig;
}
private extractModels(models: unknown): OpenClawModelMap {
const modelMap: OpenClawModelMap = {};
if (!Array.isArray(models)) {
return modelMap;
}
for (const modelEntry of models) {
if (typeof modelEntry === "string") {
modelMap[modelEntry] = {};
continue;
}
if (this.hasModelId(modelEntry)) {
modelMap[modelEntry.id] = {};
}
}
return modelMap;
}
private resolvePrimaryModelFromProviders(providers: LlmProvider[]): string | null {
for (const provider of providers) {
const modelIds = Object.keys(this.extractModels(provider.models));
const firstModelId = modelIds[0];
if (firstModelId) {
return `${provider.name}/${firstModelId}`;
}
}
return null;
}
private decryptIfNeeded(value: string | null | undefined): string | undefined {
if (!value) {
return undefined;
}
if (this.crypto.isEncrypted(value)) {
return this.crypto.decrypt(value);
}
return value;
}
private decryptContainerToken(value: string): string | null {
try {
return this.decryptIfNeeded(value) ?? null;
} catch {
return null;
}
}
private tokensEqual(left: string, right: string): boolean {
const leftDigest = createHash("sha256").update(left, "utf8").digest();
const rightDigest = createHash("sha256").update(right, "utf8").digest();
return timingSafeEqual(leftDigest, rightDigest);
}
private hasModelId(modelEntry: unknown): modelEntry is { id: string } {
if (typeof modelEntry !== "object" || modelEntry === null || !("id" in modelEntry)) {
return false;
}
return typeof (modelEntry as { id?: unknown }).id === "string";
}
}

View File

@@ -0,0 +1,198 @@
import { beforeAll, beforeEach, describe, expect, it, afterAll } from "vitest";
import { randomUUID as uuid } from "crypto";
import { Test, TestingModule } from "@nestjs/testing";
import { NotFoundException } from "@nestjs/common";
import { PrismaClient } from "@prisma/client";
import { AgentMemoryService } from "./agent-memory.service";
import { PrismaService } from "../prisma/prisma.service";
const shouldRunDbIntegrationTests =
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
async function createWorkspace(
prisma: PrismaClient,
label: string
): Promise<{ workspaceId: string; ownerId: string }> {
const workspace = await prisma.workspace.create({
data: {
name: `${label} ${Date.now()}`,
owner: {
create: {
email: `${label.toLowerCase().replace(/\s+/g, "-")}-${Date.now()}@example.com`,
name: `${label} Owner`,
},
},
},
});
return {
workspaceId: workspace.id,
ownerId: workspace.ownerId,
};
}
describeFn("AgentMemoryService Integration", () => {
let moduleRef: TestingModule;
let prisma: PrismaClient;
let service: AgentMemoryService;
let setupComplete = false;
let workspaceAId: string;
let workspaceAOwnerId: string;
let workspaceBId: string;
let workspaceBOwnerId: string;
beforeAll(async () => {
prisma = new PrismaClient();
await prisma.$connect();
const workspaceA = await createWorkspace(prisma, "Agent Memory Integration A");
workspaceAId = workspaceA.workspaceId;
workspaceAOwnerId = workspaceA.ownerId;
const workspaceB = await createWorkspace(prisma, "Agent Memory Integration B");
workspaceBId = workspaceB.workspaceId;
workspaceBOwnerId = workspaceB.ownerId;
moduleRef = await Test.createTestingModule({
providers: [
AgentMemoryService,
{
provide: PrismaService,
useValue: prisma,
},
],
}).compile();
service = moduleRef.get<AgentMemoryService>(AgentMemoryService);
setupComplete = true;
});
beforeEach(async () => {
if (!setupComplete) {
return;
}
await prisma.agentMemory.deleteMany({
where: {
workspaceId: {
in: [workspaceAId, workspaceBId],
},
},
});
});
afterAll(async () => {
if (!prisma) {
return;
}
const workspaceIds = [workspaceAId, workspaceBId].filter(
(id): id is string => typeof id === "string"
);
const ownerIds = [workspaceAOwnerId, workspaceBOwnerId].filter(
(id): id is string => typeof id === "string"
);
if (workspaceIds.length > 0) {
await prisma.agentMemory.deleteMany({
where: {
workspaceId: {
in: workspaceIds,
},
},
});
await prisma.workspace.deleteMany({ where: { id: { in: workspaceIds } } });
}
if (ownerIds.length > 0) {
await prisma.user.deleteMany({ where: { id: { in: ownerIds } } });
}
if (moduleRef) {
await moduleRef.close();
}
await prisma.$disconnect();
});
it("upserts and lists memory entries", async () => {
if (!setupComplete) {
return;
}
const agentId = `agent-${uuid()}`;
const entry = await service.upsert(workspaceAId, agentId, "session-context", {
value: { intent: "create-tests", depth: "integration" },
});
expect(entry.workspaceId).toBe(workspaceAId);
expect(entry.agentId).toBe(agentId);
expect(entry.key).toBe("session-context");
const listed = await service.findAll(workspaceAId, agentId);
expect(listed).toHaveLength(1);
expect(listed[0]?.id).toBe(entry.id);
expect(listed[0]?.value).toMatchObject({ intent: "create-tests" });
});
it("updates existing key via upsert without creating duplicates", async () => {
if (!setupComplete) {
return;
}
const agentId = `agent-${uuid()}`;
const first = await service.upsert(workspaceAId, agentId, "preferences", {
value: { model: "fast" },
});
const second = await service.upsert(workspaceAId, agentId, "preferences", {
value: { model: "accurate" },
});
expect(second.id).toBe(first.id);
expect(second.value).toMatchObject({ model: "accurate" });
const rowCount = await prisma.agentMemory.count({
where: {
workspaceId: workspaceAId,
agentId,
key: "preferences",
},
});
expect(rowCount).toBe(1);
});
it("lists keys in sorted order and isolates by workspace", async () => {
if (!setupComplete) {
return;
}
const agentId = `agent-${uuid()}`;
await service.upsert(workspaceAId, agentId, "beta", { value: { v: 2 } });
await service.upsert(workspaceAId, agentId, "alpha", { value: { v: 1 } });
await service.upsert(workspaceBId, agentId, "alpha", { value: { v: 99 } });
const workspaceAEntries = await service.findAll(workspaceAId, agentId);
const workspaceBEntries = await service.findAll(workspaceBId, agentId);
expect(workspaceAEntries.map((row) => row.key)).toEqual(["alpha", "beta"]);
expect(workspaceBEntries).toHaveLength(1);
expect(workspaceBEntries[0]?.value).toMatchObject({ v: 99 });
});
it("throws NotFoundException when requesting unknown key", async () => {
if (!setupComplete) {
return;
}
await expect(service.findOne(workspaceAId, `agent-${uuid()}`, "missing")).rejects.toThrow(
NotFoundException
);
});
});

View File

@@ -2,6 +2,7 @@ import { Module } from "@nestjs/common";
import { APP_INTERCEPTOR, APP_GUARD } from "@nestjs/core"; import { APP_INTERCEPTOR, APP_GUARD } from "@nestjs/core";
import { ThrottlerModule } from "@nestjs/throttler"; import { ThrottlerModule } from "@nestjs/throttler";
import { BullModule } from "@nestjs/bullmq"; import { BullModule } from "@nestjs/bullmq";
import { ScheduleModule } from "@nestjs/schedule";
import { ThrottlerValkeyStorageService, ThrottlerApiKeyGuard } from "./common/throttler"; import { ThrottlerValkeyStorageService, ThrottlerApiKeyGuard } from "./common/throttler";
import { CsrfGuard } from "./common/guards/csrf.guard"; import { CsrfGuard } from "./common/guards/csrf.guard";
import { CsrfService } from "./common/services/csrf.service"; import { CsrfService } from "./common/services/csrf.service";
@@ -39,6 +40,7 @@ import { JobStepsModule } from "./job-steps/job-steps.module";
import { CoordinatorIntegrationModule } from "./coordinator-integration/coordinator-integration.module"; import { CoordinatorIntegrationModule } from "./coordinator-integration/coordinator-integration.module";
import { FederationModule } from "./federation/federation.module"; import { FederationModule } from "./federation/federation.module";
import { CredentialsModule } from "./credentials/credentials.module"; import { CredentialsModule } from "./credentials/credentials.module";
import { CryptoModule } from "./crypto/crypto.module";
import { MosaicTelemetryModule } from "./mosaic-telemetry"; import { MosaicTelemetryModule } from "./mosaic-telemetry";
import { SpeechModule } from "./speech/speech.module"; import { SpeechModule } from "./speech/speech.module";
import { DashboardModule } from "./dashboard/dashboard.module"; import { DashboardModule } from "./dashboard/dashboard.module";
@@ -50,6 +52,13 @@ import { TeamsModule } from "./teams/teams.module";
import { ImportModule } from "./import/import.module"; import { ImportModule } from "./import/import.module";
import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module"; import { ConversationArchiveModule } from "./conversation-archive/conversation-archive.module";
import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor"; import { RlsContextInterceptor } from "./common/interceptors/rls-context.interceptor";
import { AgentConfigModule } from "./agent-config/agent-config.module";
import { ContainerLifecycleModule } from "./container-lifecycle/container-lifecycle.module";
import { ContainerReaperModule } from "./container-reaper/container-reaper.module";
import { FleetSettingsModule } from "./fleet-settings/fleet-settings.module";
import { OnboardingModule } from "./onboarding/onboarding.module";
import { ChatProxyModule } from "./chat-proxy/chat-proxy.module";
import { OrchestratorModule } from "./orchestrator/orchestrator.module";
@Module({ @Module({
imports: [ imports: [
@@ -80,6 +89,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
}; };
})(), })(),
}), }),
ScheduleModule.forRoot(),
TelemetryModule, TelemetryModule,
PrismaModule, PrismaModule,
DatabaseModule, DatabaseModule,
@@ -111,6 +121,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
CoordinatorIntegrationModule, CoordinatorIntegrationModule,
FederationModule, FederationModule,
CredentialsModule, CredentialsModule,
CryptoModule,
MosaicTelemetryModule, MosaicTelemetryModule,
SpeechModule, SpeechModule,
DashboardModule, DashboardModule,
@@ -121,6 +132,13 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
TeamsModule, TeamsModule,
ImportModule, ImportModule,
ConversationArchiveModule, ConversationArchiveModule,
AgentConfigModule,
ContainerLifecycleModule,
ContainerReaperModule,
FleetSettingsModule,
OnboardingModule,
ChatProxyModule,
OrchestratorModule,
], ],
controllers: [AppController, CsrfController], controllers: [AppController, CsrfController],
providers: [ providers: [

View File

@@ -0,0 +1,102 @@
import {
Body,
Controller,
HttpException,
Logger,
Post,
Req,
Res,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import type { Response } from "express";
import { AuthGuard } from "../auth/guards/auth.guard";
import type { MaybeAuthenticatedRequest } from "../auth/types/better-auth-request.interface";
import { ChatStreamDto } from "./chat-proxy.dto";
import { ChatProxyService } from "./chat-proxy.service";
@Controller("chat")
@UseGuards(AuthGuard)
export class ChatProxyController {
private readonly logger = new Logger(ChatProxyController.name);
constructor(private readonly chatProxyService: ChatProxyService) {}
// POST /api/chat/stream
// Request: { messages: Array<{role, content}> }
// Response: SSE stream of chat completion events
@Post("stream")
async streamChat(
@Body() body: ChatStreamDto,
@Req() req: MaybeAuthenticatedRequest,
@Res() res: Response
): Promise<void> {
const userId = req.user?.id;
if (!userId) {
throw new UnauthorizedException("No authenticated user found on request");
}
const abortController = new AbortController();
req.once("close", () => {
abortController.abort();
});
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
try {
const upstreamResponse = await this.chatProxyService.proxyChat(
userId,
body.messages,
abortController.signal
);
const upstreamContentType = upstreamResponse.headers.get("content-type");
if (upstreamContentType) {
res.setHeader("Content-Type", upstreamContentType);
}
if (!upstreamResponse.body) {
throw new Error("OpenClaw response did not include a stream body");
}
for await (const chunk of upstreamResponse.body as unknown as AsyncIterable<Uint8Array>) {
if (res.writableEnded || res.destroyed) {
break;
}
res.write(Buffer.from(chunk));
}
} catch (error: unknown) {
this.logStreamError(error);
if (!res.writableEnded && !res.destroyed) {
res.write("event: error\n");
res.write(`data: ${JSON.stringify({ error: this.toSafeClientMessage(error) })}\n\n`);
}
} finally {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
}
}
private toSafeClientMessage(error: unknown): string {
if (error instanceof HttpException && error.getStatus() < 500) {
return "Chat request was rejected";
}
return "Chat stream failed";
}
private logStreamError(error: unknown): void {
if (error instanceof Error) {
this.logger.warn(`Chat stream failed: ${error.message}`);
return;
}
this.logger.warn(`Chat stream failed: ${String(error)}`);
}
}

View File

@@ -0,0 +1,25 @@
import { Type } from "class-transformer";
import { ArrayMinSize, IsArray, IsNotEmpty, IsString, ValidateNested } from "class-validator";
export interface ChatMessage {
role: string;
content: string;
}
export class ChatMessageDto implements ChatMessage {
@IsString({ message: "role must be a string" })
@IsNotEmpty({ message: "role is required" })
role!: string;
@IsString({ message: "content must be a string" })
@IsNotEmpty({ message: "content is required" })
content!: string;
}
export class ChatStreamDto {
@IsArray({ message: "messages must be an array" })
@ArrayMinSize(1, { message: "messages must contain at least one message" })
@ValidateNested({ each: true })
@Type(() => ChatMessageDto)
messages!: ChatMessageDto[];
}

View File

@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { AgentConfigModule } from "../agent-config/agent-config.module";
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
import { PrismaModule } from "../prisma/prisma.module";
import { ChatProxyController } from "./chat-proxy.controller";
import { ChatProxyService } from "./chat-proxy.service";
@Module({
imports: [AuthModule, PrismaModule, ContainerLifecycleModule, AgentConfigModule],
controllers: [ChatProxyController],
providers: [ChatProxyService],
exports: [ChatProxyService],
})
export class ChatProxyModule {}

View File

@@ -0,0 +1,108 @@
import { ServiceUnavailableException } from "@nestjs/common";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ChatProxyService } from "./chat-proxy.service";
describe("ChatProxyService", () => {
const userId = "user-123";
const prisma = {
userAgentConfig: {
findUnique: vi.fn(),
},
};
const containerLifecycle = {
ensureRunning: vi.fn(),
touch: vi.fn(),
};
let service: ChatProxyService;
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
service = new ChatProxyService(prisma as never, containerLifecycle as never);
});
afterEach(() => {
vi.unstubAllGlobals();
vi.clearAllMocks();
});
describe("getContainerUrl", () => {
it("calls ensureRunning and touch for the user", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
const url = await service.getContainerUrl(userId);
expect(url).toBe("http://mosaic-user-user-123:19000");
expect(containerLifecycle.ensureRunning).toHaveBeenCalledWith(userId);
expect(containerLifecycle.touch).toHaveBeenCalledWith(userId);
});
});
describe("proxyChat", () => {
it("forwards the request to the user's OpenClaw container", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
fetchMock.mockResolvedValue(new Response("event: token\ndata: hello\n\n"));
const messages = [{ role: "user", content: "Hello from Mosaic" }];
const response = await service.proxyChat(userId, messages);
expect(response).toBeInstanceOf(Response);
expect(fetchMock).toHaveBeenCalledWith(
"http://mosaic-user-user-123:19000/v1/chat/completions",
expect.objectContaining({
method: "POST",
headers: {
Authorization: "Bearer gateway-token",
"Content-Type": "application/json",
},
})
);
const [, request] = fetchMock.mock.calls[0] as [string, RequestInit];
const parsedBody = JSON.parse(String(request.body));
expect(parsedBody).toEqual({
messages,
model: "openclaw:default",
stream: true,
});
});
it("throws ServiceUnavailableException on connection refused errors", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
fetchMock.mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:19000"));
await expect(service.proxyChat(userId, [])).rejects.toBeInstanceOf(
ServiceUnavailableException
);
});
it("throws ServiceUnavailableException on timeout errors", async () => {
containerLifecycle.ensureRunning.mockResolvedValue({
url: "http://mosaic-user-user-123:19000",
token: "gateway-token",
});
containerLifecycle.touch.mockResolvedValue(undefined);
fetchMock.mockRejectedValue(new Error("The operation was aborted due to timeout"));
await expect(service.proxyChat(userId, [])).rejects.toBeInstanceOf(
ServiceUnavailableException
);
});
});
});

View File

@@ -0,0 +1,110 @@
import {
BadGatewayException,
Injectable,
Logger,
ServiceUnavailableException,
} from "@nestjs/common";
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
import { PrismaService } from "../prisma/prisma.service";
import type { ChatMessage } from "./chat-proxy.dto";
const DEFAULT_OPENCLAW_MODEL = "openclaw:default";
interface ContainerConnection {
url: string;
token: string;
}
@Injectable()
export class ChatProxyService {
private readonly logger = new Logger(ChatProxyService.name);
constructor(
private readonly prisma: PrismaService,
private readonly containerLifecycle: ContainerLifecycleService
) {}
// Get the user's OpenClaw container URL and mark it active.
async getContainerUrl(userId: string): Promise<string> {
const { url } = await this.getContainerConnection(userId);
return url;
}
// Proxy chat request to OpenClaw.
async proxyChat(
userId: string,
messages: ChatMessage[],
signal?: AbortSignal
): Promise<Response> {
const { url: containerUrl, token: gatewayToken } = await this.getContainerConnection(userId);
const model = await this.getPreferredModel(userId);
const requestInit: RequestInit = {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${gatewayToken}`,
},
body: JSON.stringify({
messages,
model,
stream: true,
}),
};
if (signal) {
requestInit.signal = signal;
}
try {
const response = await fetch(`${containerUrl}/v1/chat/completions`, requestInit);
if (!response.ok) {
const detail = await this.readResponseText(response);
const status = `${String(response.status)} ${response.statusText}`.trim();
this.logger.warn(
detail ? `OpenClaw returned ${status}: ${detail}` : `OpenClaw returned ${status}`
);
throw new BadGatewayException(`OpenClaw returned ${status}`);
}
return response;
} catch (error: unknown) {
if (error instanceof BadGatewayException) {
throw error;
}
const message = error instanceof Error ? error.message : String(error);
this.logger.warn(`Failed to proxy chat request: ${message}`);
throw new ServiceUnavailableException("Failed to proxy chat to OpenClaw");
}
}
private async getContainerConnection(userId: string): Promise<ContainerConnection> {
const connection = await this.containerLifecycle.ensureRunning(userId);
await this.containerLifecycle.touch(userId);
return connection;
}
private async getPreferredModel(userId: string): Promise<string> {
const config = await this.prisma.userAgentConfig.findUnique({
where: { userId },
select: { primaryModel: true },
});
const primaryModel = config?.primaryModel?.trim();
if (!primaryModel) {
return DEFAULT_OPENCLAW_MODEL;
}
return primaryModel;
}
private async readResponseText(response: Response): Promise<string | null> {
try {
const text = (await response.text()).trim();
return text.length > 0 ? text : null;
} catch {
return null;
}
}
}

View File

@@ -87,6 +87,17 @@ describe("CsrfGuard", () => {
}); });
describe("State-changing methods requiring CSRF", () => { describe("State-changing methods requiring CSRF", () => {
it("should allow POST with Bearer auth without CSRF token", () => {
const context = createContext(
"POST",
{},
{ authorization: "Bearer api-token" },
false,
"user-123"
);
expect(guard.canActivate(context)).toBe(true);
});
it("should reject POST without CSRF token", () => { it("should reject POST without CSRF token", () => {
const context = createContext("POST", {}, {}, false, "user-123"); const context = createContext("POST", {}, {}, false, "user-123");
expect(() => guard.canActivate(context)).toThrow(ForbiddenException); expect(() => guard.canActivate(context)).toThrow(ForbiddenException);

View File

@@ -57,6 +57,11 @@ export class CsrfGuard implements CanActivate {
return true; return true;
} }
const authHeader = request.headers.authorization;
if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) {
return true;
}
// Get CSRF token from cookie and header // Get CSRF token from cookie and header
const cookies = request.cookies as Record<string, string> | undefined; const cookies = request.cookies as Record<string, string> | undefined;
const cookieToken = cookies?.["csrf-token"]; const cookieToken = cookies?.["csrf-token"];

View File

@@ -0,0 +1,12 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "../prisma/prisma.module";
import { CryptoModule } from "../crypto/crypto.module";
import { ContainerLifecycleService } from "./container-lifecycle.service";
@Module({
imports: [ConfigModule, PrismaModule, CryptoModule],
providers: [ContainerLifecycleService],
exports: [ContainerLifecycleService],
})
export class ContainerLifecycleModule {}

View File

@@ -0,0 +1,593 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ConfigService } from "@nestjs/config";
import type { PrismaService } from "../prisma/prisma.service";
import type { CryptoService } from "../crypto/crypto.service";
interface MockUserContainerRecord {
id: string;
userId: string;
containerId: string | null;
containerName: string;
gatewayPort: number | null;
gatewayToken: string;
status: string;
lastActiveAt: Date | null;
idleTimeoutMin: number;
config: Record<string, unknown>;
createdAt: Date;
updatedAt: Date;
}
const dockerMock = vi.hoisted(() => {
interface MockDockerContainerState {
id: string;
name: string;
running: boolean;
port: number;
}
const containers = new Map<string, MockDockerContainerState>();
const handles = new Map<
string,
{
inspect: ReturnType<typeof vi.fn>;
start: ReturnType<typeof vi.fn>;
stop: ReturnType<typeof vi.fn>;
}
>();
const ensureHandle = (id: string) => {
const existing = handles.get(id);
if (existing) {
return existing;
}
const handle = {
inspect: vi.fn(async () => {
const container = containers.get(id);
if (!container) {
throw { statusCode: 404 };
}
return {
Id: container.id,
State: {
Running: container.running,
},
NetworkSettings: {
Ports: {
"18789/tcp": [{ HostPort: String(container.port) }],
},
},
};
}),
start: vi.fn(async () => {
const container = containers.get(id);
if (!container) {
throw { statusCode: 404 };
}
container.running = true;
}),
stop: vi.fn(async () => {
const container = containers.get(id);
if (!container) {
throw { statusCode: 404 };
}
container.running = false;
}),
};
handles.set(id, handle);
return handle;
};
const listContainers = vi.fn(
async (options?: { all?: boolean; filters?: { name?: string[] } }) => {
const nameFilter = options?.filters?.name?.[0];
return [...containers.values()]
.filter((container) => (nameFilter ? container.name.includes(nameFilter) : true))
.map((container) => ({
Id: container.id,
Names: [`/${container.name}`],
}));
}
);
const getContainer = vi.fn((id: string) => ensureHandle(id));
const createContainer = vi.fn(
async (options: {
name?: string;
HostConfig?: { PortBindings?: Record<string, Array<{ HostPort?: string }>> };
}) => {
const id = `ctr-${containers.size + 1}`;
const name = options.name ?? id;
const hostPort = options.HostConfig?.PortBindings?.["18789/tcp"]?.[0]?.HostPort;
const port = hostPort ? Number.parseInt(hostPort, 10) : 0;
containers.set(id, {
id,
name,
running: false,
port,
});
return ensureHandle(id);
}
);
const dockerInstance = {
listContainers,
getContainer,
createContainer,
};
const constructorSpy = vi.fn();
class DockerConstructorMock {
constructor(options?: unknown) {
constructorSpy(options);
return dockerInstance;
}
}
const registerContainer = (container: MockDockerContainerState) => {
containers.set(container.id, { ...container });
ensureHandle(container.id);
};
const reset = () => {
containers.clear();
handles.clear();
constructorSpy.mockClear();
listContainers.mockClear();
getContainer.mockClear();
createContainer.mockClear();
};
return {
DockerConstructorMock,
constructorSpy,
createContainer,
handles,
registerContainer,
reset,
};
});
vi.mock("dockerode", () => ({
default: dockerMock.DockerConstructorMock,
}));
import { ContainerLifecycleService } from "./container-lifecycle.service";
function createConfigMock(values: Record<string, string> = {}) {
return {
get: vi.fn((key: string) => values[key]),
};
}
function createCryptoMock() {
return {
generateToken: vi.fn(() => "generated-token"),
encrypt: vi.fn((value: string) => `enc:${value}`),
decrypt: vi.fn((value: string) => value.replace(/^enc:/, "")),
isEncrypted: vi.fn((value: string) => value.startsWith("enc:")),
};
}
function projectRecord(
record: MockUserContainerRecord,
select?: Record<string, boolean>
): Partial<MockUserContainerRecord> {
if (!select) {
return { ...record };
}
const projection: Partial<MockUserContainerRecord> = {};
for (const [field, enabled] of Object.entries(select)) {
if (enabled) {
const key = field as keyof MockUserContainerRecord;
projection[key] = record[key];
}
}
return projection;
}
function createPrismaMock(initialRecords: MockUserContainerRecord[] = []) {
const records = new Map<string, MockUserContainerRecord>();
for (const record of initialRecords) {
records.set(record.userId, { ...record });
}
const userContainer = {
findUnique: vi.fn(
async (args: {
where: { userId?: string; id?: string };
select?: Record<string, boolean>;
}) => {
let record: MockUserContainerRecord | undefined;
if (args.where.userId) {
record = records.get(args.where.userId);
} else if (args.where.id) {
record = [...records.values()].find((entry) => entry.id === args.where.id);
}
if (!record) {
return null;
}
return projectRecord(record, args.select);
}
),
create: vi.fn(
async (args: {
data: Partial<MockUserContainerRecord> & {
userId: string;
containerName: string;
gatewayToken: string;
};
}) => {
const now = new Date();
const next: MockUserContainerRecord = {
id: args.data.id ?? `uc-${records.size + 1}`,
userId: args.data.userId,
containerId: args.data.containerId ?? null,
containerName: args.data.containerName,
gatewayPort: args.data.gatewayPort ?? null,
gatewayToken: args.data.gatewayToken,
status: args.data.status ?? "stopped",
lastActiveAt: args.data.lastActiveAt ?? null,
idleTimeoutMin: args.data.idleTimeoutMin ?? 30,
config: args.data.config ?? {},
createdAt: now,
updatedAt: now,
};
records.set(next.userId, next);
return { ...next };
}
),
update: vi.fn(
async (args: { where: { userId: string }; data: Partial<MockUserContainerRecord> }) => {
const record = records.get(args.where.userId);
if (!record) {
throw new Error(`Record ${args.where.userId} not found`);
}
const updated: MockUserContainerRecord = {
...record,
...args.data,
updatedAt: new Date(),
};
records.set(updated.userId, updated);
return { ...updated };
}
),
updateMany: vi.fn(
async (args: { where: { userId: string }; data: Partial<MockUserContainerRecord> }) => {
const record = records.get(args.where.userId);
if (!record) {
return { count: 0 };
}
const updated: MockUserContainerRecord = {
...record,
...args.data,
updatedAt: new Date(),
};
records.set(updated.userId, updated);
return { count: 1 };
}
),
findMany: vi.fn(
async (args?: {
where?: {
status?: string;
lastActiveAt?: { not: null };
gatewayPort?: { not: null };
};
select?: Record<string, boolean>;
}) => {
let rows = [...records.values()];
if (args?.where?.status) {
rows = rows.filter((record) => record.status === args.where?.status);
}
if (args?.where?.lastActiveAt?.not === null) {
rows = rows.filter((record) => record.lastActiveAt !== null);
}
if (args?.where?.gatewayPort?.not === null) {
rows = rows.filter((record) => record.gatewayPort !== null);
}
return rows.map((record) => projectRecord(record, args?.select));
}
),
};
return {
prisma: {
userContainer,
},
records,
};
}
function createRecord(overrides: Partial<MockUserContainerRecord>): MockUserContainerRecord {
const now = new Date();
return {
id: overrides.id ?? "uc-default",
userId: overrides.userId ?? "user-default",
containerId: overrides.containerId ?? null,
containerName: overrides.containerName ?? "mosaic-user-user-default",
gatewayPort: overrides.gatewayPort ?? null,
gatewayToken: overrides.gatewayToken ?? "enc:token-default",
status: overrides.status ?? "stopped",
lastActiveAt: overrides.lastActiveAt ?? null,
idleTimeoutMin: overrides.idleTimeoutMin ?? 30,
config: overrides.config ?? {},
createdAt: overrides.createdAt ?? now,
updatedAt: overrides.updatedAt ?? now,
};
}
describe("ContainerLifecycleService", () => {
beforeEach(() => {
dockerMock.reset();
});
it("ensureRunning creates container when none exists", async () => {
const { prisma, records } = createPrismaMock();
const crypto = createCryptoMock();
const config = createConfigMock();
const service = new ContainerLifecycleService(
prisma as unknown as PrismaService,
crypto as unknown as CryptoService,
config as unknown as ConfigService
);
const result = await service.ensureRunning("user-1");
expect(result).toEqual({
url: "http://mosaic-user-user-1:19000",
token: "generated-token",
});
const updatedRecord = records.get("user-1");
expect(updatedRecord?.status).toBe("running");
expect(updatedRecord?.containerId).toBe("ctr-1");
expect(updatedRecord?.gatewayPort).toBe(19000);
expect(updatedRecord?.gatewayToken).toBe("enc:generated-token");
expect(dockerMock.createContainer).toHaveBeenCalledTimes(1);
const [createCall] = dockerMock.createContainer.mock.calls[0] as [
{
name: string;
Image: string;
Env: string[];
HostConfig: { Binds: string[]; NetworkMode: string };
},
];
expect(createCall.name).toBe("mosaic-user-user-1");
expect(createCall.Image).toBe("alpine/openclaw:latest");
expect(createCall.HostConfig.Binds).toEqual(["mosaic-user-user-1-state:/home/node/.openclaw"]);
expect(createCall.HostConfig.NetworkMode).toBe("mosaic-internal");
expect(createCall.Env).toContain("AGENT_TOKEN=generated-token");
});
it("ensureRunning starts existing stopped container", async () => {
const { prisma, records } = createPrismaMock([
createRecord({
id: "uc-1",
userId: "user-2",
containerId: "ctr-stopped",
containerName: "mosaic-user-user-2",
gatewayToken: "enc:existing-token",
status: "stopped",
}),
]);
const crypto = createCryptoMock();
const config = createConfigMock();
const service = new ContainerLifecycleService(
prisma as unknown as PrismaService,
crypto as unknown as CryptoService,
config as unknown as ConfigService
);
dockerMock.registerContainer({
id: "ctr-stopped",
name: "mosaic-user-user-2",
running: false,
port: 19042,
});
const result = await service.ensureRunning("user-2");
expect(result).toEqual({
url: "http://mosaic-user-user-2:19042",
token: "existing-token",
});
const handle = dockerMock.handles.get("ctr-stopped");
expect(handle?.start).toHaveBeenCalledTimes(1);
expect(records.get("user-2")?.status).toBe("running");
expect(records.get("user-2")?.gatewayPort).toBe(19042);
});
it("ensureRunning returns existing running container", async () => {
const { prisma } = createPrismaMock([
createRecord({
id: "uc-2",
userId: "user-3",
containerId: "ctr-running",
containerName: "mosaic-user-user-3",
gatewayPort: 19043,
gatewayToken: "enc:running-token",
status: "running",
}),
]);
const crypto = createCryptoMock();
const config = createConfigMock();
const service = new ContainerLifecycleService(
prisma as unknown as PrismaService,
crypto as unknown as CryptoService,
config as unknown as ConfigService
);
dockerMock.registerContainer({
id: "ctr-running",
name: "mosaic-user-user-3",
running: true,
port: 19043,
});
const result = await service.ensureRunning("user-3");
expect(result).toEqual({
url: "http://mosaic-user-user-3:19043",
token: "running-token",
});
expect(dockerMock.createContainer).not.toHaveBeenCalled();
const handle = dockerMock.handles.get("ctr-running");
expect(handle?.start).not.toHaveBeenCalled();
});
it("stop gracefully stops container and updates DB", async () => {
const { prisma, records } = createPrismaMock([
createRecord({
id: "uc-stop",
userId: "user-stop",
containerId: "ctr-stop",
containerName: "mosaic-user-user-stop",
gatewayPort: 19044,
status: "running",
}),
]);
const crypto = createCryptoMock();
const config = createConfigMock();
const service = new ContainerLifecycleService(
prisma as unknown as PrismaService,
crypto as unknown as CryptoService,
config as unknown as ConfigService
);
dockerMock.registerContainer({
id: "ctr-stop",
name: "mosaic-user-user-stop",
running: true,
port: 19044,
});
await service.stop("user-stop");
const handle = dockerMock.handles.get("ctr-stop");
expect(handle?.stop).toHaveBeenCalledWith({ t: 10 });
const updatedRecord = records.get("user-stop");
expect(updatedRecord?.status).toBe("stopped");
expect(updatedRecord?.containerId).toBeNull();
expect(updatedRecord?.gatewayPort).toBeNull();
});
it("reapIdle stops only containers past their idle timeout", async () => {
const now = Date.now();
const { prisma, records } = createPrismaMock([
createRecord({
id: "uc-old",
userId: "user-old",
containerId: "ctr-old",
containerName: "mosaic-user-user-old",
gatewayPort: 19045,
status: "running",
lastActiveAt: new Date(now - 60 * 60 * 1000),
idleTimeoutMin: 30,
}),
createRecord({
id: "uc-fresh",
userId: "user-fresh",
containerId: "ctr-fresh",
containerName: "mosaic-user-user-fresh",
gatewayPort: 19046,
status: "running",
lastActiveAt: new Date(now - 5 * 60 * 1000),
idleTimeoutMin: 30,
}),
]);
const crypto = createCryptoMock();
const config = createConfigMock();
const service = new ContainerLifecycleService(
prisma as unknown as PrismaService,
crypto as unknown as CryptoService,
config as unknown as ConfigService
);
dockerMock.registerContainer({
id: "ctr-old",
name: "mosaic-user-user-old",
running: true,
port: 19045,
});
dockerMock.registerContainer({
id: "ctr-fresh",
name: "mosaic-user-user-fresh",
running: true,
port: 19046,
});
const result = await service.reapIdle();
expect(result).toEqual({
stopped: ["user-old"],
});
expect(records.get("user-old")?.status).toBe("stopped");
expect(records.get("user-fresh")?.status).toBe("running");
const oldHandle = dockerMock.handles.get("ctr-old");
const freshHandle = dockerMock.handles.get("ctr-fresh");
expect(oldHandle?.stop).toHaveBeenCalledTimes(1);
expect(freshHandle?.stop).not.toHaveBeenCalled();
});
it("touch updates lastActiveAt", async () => {
const { prisma, records } = createPrismaMock([
createRecord({
id: "uc-touch",
userId: "user-touch",
containerName: "mosaic-user-user-touch",
lastActiveAt: null,
}),
]);
const crypto = createCryptoMock();
const config = createConfigMock();
const service = new ContainerLifecycleService(
prisma as unknown as PrismaService,
crypto as unknown as CryptoService,
config as unknown as ConfigService
);
await service.touch("user-touch");
const updatedRecord = records.get("user-touch");
expect(updatedRecord?.lastActiveAt).toBeInstanceOf(Date);
});
it("getStatus returns null for unknown user", async () => {
const { prisma } = createPrismaMock();
const crypto = createCryptoMock();
const config = createConfigMock();
const service = new ContainerLifecycleService(
prisma as unknown as PrismaService,
crypto as unknown as CryptoService,
config as unknown as ConfigService
);
const status = await service.getStatus("missing-user");
expect(status).toBeNull();
});
});

View File

@@ -0,0 +1,532 @@
import { Injectable, Logger } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import Docker from "dockerode";
import { PrismaService } from "../prisma/prisma.service";
import { CryptoService } from "../crypto/crypto.service";
const DEFAULT_DOCKER_SOCKET_PATH = "/var/run/docker.sock";
const DEFAULT_DOCKER_TCP_PORT = 2375;
const DEFAULT_OPENCLAW_IMAGE = "alpine/openclaw:latest";
const DEFAULT_OPENCLAW_NETWORK = "mosaic-internal";
const DEFAULT_OPENCLAW_PORT_RANGE_START = 19000;
const DEFAULT_MOSAIC_API_URL = "http://mosaic-api:3000/api";
const OPENCLAW_GATEWAY_PORT_KEY = "18789/tcp";
const OPENCLAW_STATE_PATH = "/home/node/.openclaw";
const CONTAINER_STOP_TIMEOUT_SECONDS = 10;
interface ContainerHandle {
inspect(): Promise<DockerInspect>;
start(): Promise<void>;
stop(options?: { t?: number }): Promise<void>;
}
interface DockerInspect {
Id?: string;
State?: {
Running?: boolean;
Health?: {
Status?: string;
};
};
NetworkSettings?: {
Ports?: Record<string, { HostPort?: string }[] | null>;
};
HostConfig?: {
PortBindings?: Record<string, { HostPort?: string }[] | null>;
};
}
interface UserContainerRecord {
id: string;
userId: string;
containerId: string | null;
containerName: string;
gatewayPort: number | null;
gatewayToken: string;
status: string;
lastActiveAt: Date | null;
idleTimeoutMin: number;
}
interface ContainerLookup {
containerId: string | null;
containerName: string;
}
@Injectable()
export class ContainerLifecycleService {
private readonly logger = new Logger(ContainerLifecycleService.name);
private readonly docker: Docker;
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CryptoService,
private readonly config: ConfigService
) {
const dockerHost = this.config.get<string>("DOCKER_HOST");
this.docker = this.createDockerClient(dockerHost);
}
// Ensure a user's container is running. Creates if needed, starts if stopped.
// Returns the container's internal URL and gateway token.
async ensureRunning(userId: string): Promise<{ url: string; token: string }> {
const containerRecord = await this.getOrCreateContainerRecord(userId);
const token = this.getGatewayToken(containerRecord.gatewayToken);
const existingContainer = await this.resolveContainer(containerRecord);
let container: ContainerHandle;
if (existingContainer) {
container = existingContainer;
const inspect = await container.inspect();
if (!inspect.State?.Running) {
await container.start();
}
} else {
const port = await this.findAvailableGatewayPort();
container = await this.createContainer(containerRecord, token, port);
await container.start();
}
const inspect = await container.inspect();
const containerId = inspect.Id;
if (!containerId) {
throw new Error(
`Docker inspect did not return container ID for ${containerRecord.containerName}`
);
}
const gatewayPort = this.extractGatewayPort(inspect);
if (!gatewayPort) {
throw new Error(`Could not determine gateway port for ${containerRecord.containerName}`);
}
const now = new Date();
await this.prisma.userContainer.update({
where: { userId },
data: {
containerId,
gatewayPort,
status: "running",
lastActiveAt: now,
},
});
return {
url: `http://${containerRecord.containerName}:${String(gatewayPort)}`,
token,
};
}
// Stop a user's container
async stop(userId: string): Promise<void> {
const containerRecord = await this.prisma.userContainer.findUnique({
where: { userId },
});
if (!containerRecord) {
return;
}
const container = await this.resolveContainer(containerRecord);
if (container) {
try {
await container.stop({ t: CONTAINER_STOP_TIMEOUT_SECONDS });
} catch (error) {
if (!this.isDockerNotFound(error) && !this.isAlreadyStopped(error)) {
throw error;
}
}
}
await this.prisma.userContainer.update({
where: { userId },
data: {
status: "stopped",
containerId: null,
gatewayPort: null,
},
});
}
// Stop idle containers (called by cron/scheduler)
async reapIdle(): Promise<{ stopped: string[] }> {
const now = Date.now();
const runningContainers = await this.prisma.userContainer.findMany({
where: {
status: "running",
lastActiveAt: { not: null },
},
select: {
userId: true,
lastActiveAt: true,
idleTimeoutMin: true,
},
});
const stopped: string[] = [];
for (const container of runningContainers) {
const lastActiveAt = container.lastActiveAt;
if (!lastActiveAt) {
continue;
}
const idleLimitMs = container.idleTimeoutMin * 60 * 1000;
if (now - lastActiveAt.getTime() < idleLimitMs) {
continue;
}
try {
await this.stop(container.userId);
stopped.push(container.userId);
} catch (error) {
this.logger.warn(
`Failed to stop idle container for user ${container.userId}: ${this.getErrorMessage(error)}`
);
}
}
return { stopped };
}
// Health check all running containers
async healthCheckAll(): Promise<{ userId: string; healthy: boolean; error?: string }[]> {
const runningContainers = await this.prisma.userContainer.findMany({
where: {
status: "running",
},
select: {
userId: true,
containerId: true,
containerName: true,
},
});
const results: { userId: string; healthy: boolean; error?: string }[] = [];
for (const containerRecord of runningContainers) {
const container = await this.resolveContainer(containerRecord);
if (!container) {
results.push({
userId: containerRecord.userId,
healthy: false,
error: "Container not found",
});
continue;
}
try {
const inspect = await container.inspect();
const isRunning = inspect.State?.Running === true;
const healthState = inspect.State?.Health?.Status;
const healthy = isRunning && healthState !== "unhealthy";
if (healthy) {
results.push({
userId: containerRecord.userId,
healthy: true,
});
continue;
}
results.push({
userId: containerRecord.userId,
healthy: false,
error:
healthState === "unhealthy" ? "Container healthcheck failed" : "Container not running",
});
} catch (error) {
results.push({
userId: containerRecord.userId,
healthy: false,
error: this.getErrorMessage(error),
});
}
}
return results;
}
// Restart a container with fresh config (for config updates)
async restart(userId: string): Promise<void> {
await this.stop(userId);
await this.ensureRunning(userId);
}
// Update lastActiveAt timestamp (called on each chat request)
async touch(userId: string): Promise<void> {
await this.prisma.userContainer.updateMany({
where: { userId },
data: {
lastActiveAt: new Date(),
},
});
}
// Get container status for a user
async getStatus(
userId: string
): Promise<{ status: string; port?: number; lastActive?: Date } | null> {
const container = await this.prisma.userContainer.findUnique({
where: { userId },
select: {
status: true,
gatewayPort: true,
lastActiveAt: true,
},
});
if (!container) {
return null;
}
const status: { status: string; port?: number; lastActive?: Date } = {
status: container.status,
};
if (container.gatewayPort !== null) {
status.port = container.gatewayPort;
}
if (container.lastActiveAt !== null) {
status.lastActive = container.lastActiveAt;
}
return status;
}
private createDockerClient(dockerHost?: string): Docker {
if (!dockerHost || dockerHost.trim().length === 0) {
return new Docker({ socketPath: DEFAULT_DOCKER_SOCKET_PATH });
}
if (dockerHost.startsWith("unix://")) {
return new Docker({ socketPath: dockerHost.slice("unix://".length) });
}
if (dockerHost.startsWith("tcp://")) {
const parsed = new URL(dockerHost.replace("tcp://", "http://"));
return new Docker({
host: parsed.hostname,
port: this.parseInteger(parsed.port, DEFAULT_DOCKER_TCP_PORT),
protocol: "http",
});
}
if (dockerHost.startsWith("http://") || dockerHost.startsWith("https://")) {
const parsed = new URL(dockerHost);
const protocol = parsed.protocol.replace(":", "");
return new Docker({
host: parsed.hostname,
port: this.parseInteger(parsed.port, DEFAULT_DOCKER_TCP_PORT),
protocol: protocol === "https" ? "https" : "http",
});
}
return new Docker({ socketPath: dockerHost });
}
private async getOrCreateContainerRecord(userId: string): Promise<UserContainerRecord> {
const existingContainer = await this.prisma.userContainer.findUnique({
where: { userId },
});
if (existingContainer) {
return existingContainer;
}
const token = this.crypto.generateToken();
const containerName = this.getContainerName(userId);
return this.prisma.userContainer.create({
data: {
userId,
containerName,
gatewayToken: this.crypto.encrypt(token),
status: "stopped",
},
});
}
private getContainerName(userId: string): string {
return `mosaic-user-${userId}`;
}
private getVolumeName(userId: string): string {
return `mosaic-user-${userId}-state`;
}
private getOpenClawImage(): string {
return this.config.get<string>("OPENCLAW_IMAGE") ?? DEFAULT_OPENCLAW_IMAGE;
}
private getOpenClawNetwork(): string {
return this.config.get<string>("OPENCLAW_NETWORK") ?? DEFAULT_OPENCLAW_NETWORK;
}
private getMosaicApiUrl(): string {
return this.config.get<string>("MOSAIC_API_URL") ?? DEFAULT_MOSAIC_API_URL;
}
private getPortRangeStart(): number {
return this.parseInteger(
this.config.get<string>("OPENCLAW_PORT_RANGE_START"),
DEFAULT_OPENCLAW_PORT_RANGE_START
);
}
private async resolveContainer(record: ContainerLookup): Promise<ContainerHandle | null> {
if (record.containerId) {
const byId = this.docker.getContainer(record.containerId) as unknown as ContainerHandle;
if (await this.containerExists(byId)) {
return byId;
}
}
const byName = await this.findContainerByName(record.containerName);
if (byName) {
return byName;
}
return null;
}
private async findContainerByName(containerName: string): Promise<ContainerHandle | null> {
const containers = await this.docker.listContainers({
all: true,
filters: {
name: [containerName],
},
});
const match = containers.find((container) => {
const names = container.Names;
return names.some((name) => name === `/${containerName}` || name.includes(containerName));
});
if (!match?.Id) {
return null;
}
return this.docker.getContainer(match.Id) as unknown as ContainerHandle;
}
private async containerExists(container: ContainerHandle): Promise<boolean> {
try {
await container.inspect();
return true;
} catch (error) {
if (this.isDockerNotFound(error)) {
return false;
}
throw error;
}
}
private async createContainer(
containerRecord: UserContainerRecord,
token: string,
gatewayPort: number
): Promise<ContainerHandle> {
const container = await this.docker.createContainer({
name: containerRecord.containerName,
Image: this.getOpenClawImage(),
Env: [
`MOSAIC_API_URL=${this.getMosaicApiUrl()}`,
`AGENT_TOKEN=${token}`,
`AGENT_ID=${containerRecord.id}`,
],
ExposedPorts: {
[OPENCLAW_GATEWAY_PORT_KEY]: {},
},
HostConfig: {
Binds: [`${this.getVolumeName(containerRecord.userId)}:${OPENCLAW_STATE_PATH}`],
PortBindings: {
[OPENCLAW_GATEWAY_PORT_KEY]: [{ HostPort: String(gatewayPort) }],
},
NetworkMode: this.getOpenClawNetwork(),
},
});
return container as unknown as ContainerHandle;
}
private extractGatewayPort(inspect: DockerInspect): number | null {
const networkPort = inspect.NetworkSettings?.Ports?.[OPENCLAW_GATEWAY_PORT_KEY]?.[0]?.HostPort;
if (networkPort) {
return this.parseInteger(networkPort, 0) || null;
}
const hostPort = inspect.HostConfig?.PortBindings?.[OPENCLAW_GATEWAY_PORT_KEY]?.[0]?.HostPort;
if (hostPort) {
return this.parseInteger(hostPort, 0) || null;
}
return null;
}
private async findAvailableGatewayPort(): Promise<number> {
const usedPorts = await this.prisma.userContainer.findMany({
where: {
gatewayPort: { not: null },
},
select: {
gatewayPort: true,
},
});
const takenPorts = new Set<number>();
for (const entry of usedPorts) {
if (entry.gatewayPort !== null) {
takenPorts.add(entry.gatewayPort);
}
}
let candidate = this.getPortRangeStart();
while (takenPorts.has(candidate)) {
candidate += 1;
}
return candidate;
}
private getGatewayToken(storedToken: string): string {
if (this.crypto.isEncrypted(storedToken)) {
return this.crypto.decrypt(storedToken);
}
return storedToken;
}
private parseInteger(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
private isDockerNotFound(error: unknown): boolean {
return this.getDockerStatusCode(error) === 404;
}
private isAlreadyStopped(error: unknown): boolean {
return this.getDockerStatusCode(error) === 304;
}
private getDockerStatusCode(error: unknown): number | null {
if (typeof error !== "object" || error === null || !("statusCode" in error)) {
return null;
}
const statusCode = error.statusCode;
return typeof statusCode === "number" ? statusCode : null;
}
private getErrorMessage(error: unknown): string {
if (error instanceof Error) {
return error.message;
}
return "Unknown error";
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { ScheduleModule } from "@nestjs/schedule";
import { ContainerLifecycleModule } from "../container-lifecycle/container-lifecycle.module";
import { ContainerReaperService } from "./container-reaper.service";
@Module({
imports: [ScheduleModule, ContainerLifecycleModule],
providers: [ContainerReaperService],
})
export class ContainerReaperModule {}

View File

@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
import { ContainerReaperService } from "./container-reaper.service";
describe("ContainerReaperService", () => {
let service: ContainerReaperService;
let containerLifecycle: Pick<ContainerLifecycleService, "reapIdle">;
beforeEach(() => {
containerLifecycle = {
reapIdle: vi.fn(),
};
service = new ContainerReaperService(containerLifecycle as ContainerLifecycleService);
});
it("reapIdleContainers calls containerLifecycle.reapIdle()", async () => {
vi.mocked(containerLifecycle.reapIdle).mockResolvedValue({ stopped: [] });
await service.reapIdleContainers();
expect(containerLifecycle.reapIdle).toHaveBeenCalledTimes(1);
});
it("reapIdleContainers handles errors gracefully", async () => {
const error = new Error("reap failure");
vi.mocked(containerLifecycle.reapIdle).mockRejectedValue(error);
const loggerError = vi.spyOn(service["logger"], "error").mockImplementation(() => {});
await expect(service.reapIdleContainers()).resolves.toBeUndefined();
expect(loggerError).toHaveBeenCalledWith(
"Failed to reap idle containers",
expect.stringContaining("reap failure")
);
});
it("reapIdleContainers logs stopped container count", async () => {
vi.mocked(containerLifecycle.reapIdle).mockResolvedValue({ stopped: ["user-1", "user-2"] });
const loggerLog = vi.spyOn(service["logger"], "log").mockImplementation(() => {});
await service.reapIdleContainers();
expect(loggerLog).toHaveBeenCalledWith("Stopped 2 idle containers: user-1, user-2");
});
});

View File

@@ -0,0 +1,30 @@
import { Injectable, Logger } from "@nestjs/common";
import { Cron, CronExpression } from "@nestjs/schedule";
import { ContainerLifecycleService } from "../container-lifecycle/container-lifecycle.service";
@Injectable()
export class ContainerReaperService {
private readonly logger = new Logger(ContainerReaperService.name);
constructor(private readonly containerLifecycle: ContainerLifecycleService) {}
@Cron(CronExpression.EVERY_5_MINUTES)
async reapIdleContainers(): Promise<void> {
this.logger.log("Running idle container reap cycle...");
try {
const result = await this.containerLifecycle.reapIdle();
if (result.stopped.length > 0) {
this.logger.log(
`Stopped ${String(result.stopped.length)} idle containers: ${result.stopped.join(", ")}`
);
} else {
this.logger.debug("No idle containers to stop");
}
} catch (error) {
this.logger.error(
"Failed to reap idle containers",
error instanceof Error ? error.stack : String(error)
);
}
}
}

View File

@@ -0,0 +1,239 @@
import { beforeAll, beforeEach, describe, expect, it, afterAll, vi } from "vitest";
import { randomUUID as uuid } from "crypto";
import { Test, TestingModule } from "@nestjs/testing";
import { ConflictException } from "@nestjs/common";
import { PrismaClient, Prisma } from "@prisma/client";
import { EMBEDDING_DIMENSION } from "@mosaic/shared";
import { ConversationArchiveService } from "./conversation-archive.service";
import { PrismaService } from "../prisma/prisma.service";
import { EmbeddingService } from "../knowledge/services/embedding.service";
const shouldRunDbIntegrationTests =
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
function vector(value: number): number[] {
return Array.from({ length: EMBEDDING_DIMENSION }, () => value);
}
function toVectorLiteral(input: number[]): string {
return `[${input.join(",")}]`;
}
describeFn("ConversationArchiveService Integration", () => {
let moduleRef: TestingModule;
let prisma: PrismaClient;
let service: ConversationArchiveService;
let workspaceId: string;
let ownerId: string;
let setupComplete = false;
const embeddingServiceMock = {
isConfigured: vi.fn(),
generateEmbedding: vi.fn(),
};
beforeAll(async () => {
prisma = new PrismaClient();
await prisma.$connect();
const workspace = await prisma.workspace.create({
data: {
name: `Conversation Archive Integration ${Date.now()}`,
owner: {
create: {
email: `conversation-archive-integration-${Date.now()}@example.com`,
name: "Conversation Archive Integration Owner",
},
},
},
});
workspaceId = workspace.id;
ownerId = workspace.ownerId;
moduleRef = await Test.createTestingModule({
providers: [
ConversationArchiveService,
{
provide: PrismaService,
useValue: prisma,
},
{
provide: EmbeddingService,
useValue: embeddingServiceMock,
},
],
}).compile();
service = moduleRef.get<ConversationArchiveService>(ConversationArchiveService);
setupComplete = true;
});
beforeEach(async () => {
vi.clearAllMocks();
embeddingServiceMock.isConfigured.mockReturnValue(false);
if (!setupComplete) {
return;
}
await prisma.conversationArchive.deleteMany({ where: { workspaceId } });
});
afterAll(async () => {
if (!prisma) {
return;
}
if (workspaceId) {
await prisma.conversationArchive.deleteMany({ where: { workspaceId } });
await prisma.workspace.deleteMany({ where: { id: workspaceId } });
}
if (ownerId) {
await prisma.user.deleteMany({ where: { id: ownerId } });
}
if (moduleRef) {
await moduleRef.close();
}
await prisma.$disconnect();
});
it("ingests a conversation log", async () => {
if (!setupComplete) {
return;
}
const sessionId = `session-${uuid()}`;
const result = await service.ingest(workspaceId, {
sessionId,
agentId: "agent-conversation-ingest",
messages: [
{ role: "user", content: "Can you summarize deployment issues?" },
{ role: "assistant", content: "Yes, three retries timed out in staging." },
],
summary: "Deployment retry failures discussed",
startedAt: "2026-02-28T21:00:00.000Z",
endedAt: "2026-02-28T21:05:00.000Z",
metadata: { source: "integration-test" },
});
expect(result.id).toBeDefined();
const stored = await prisma.conversationArchive.findUnique({
where: {
id: result.id,
},
});
expect(stored).toBeTruthy();
expect(stored?.workspaceId).toBe(workspaceId);
expect(stored?.sessionId).toBe(sessionId);
expect(stored?.messageCount).toBe(2);
expect(stored?.summary).toBe("Deployment retry failures discussed");
});
it("rejects duplicate session ingest per workspace", async () => {
if (!setupComplete) {
return;
}
const sessionId = `session-${uuid()}`;
const dto = {
sessionId,
agentId: "agent-conversation-duplicate",
messages: [{ role: "user", content: "hello" }],
summary: "simple conversation",
startedAt: "2026-02-28T22:00:00.000Z",
};
await service.ingest(workspaceId, dto);
await expect(service.ingest(workspaceId, dto)).rejects.toThrow(ConflictException);
});
it("rejects semantic search when embeddings are disabled", async () => {
if (!setupComplete) {
return;
}
embeddingServiceMock.isConfigured.mockReturnValue(false);
await expect(
service.search(workspaceId, {
query: "deployment retries",
})
).rejects.toThrow(ConflictException);
});
it("searches archived conversations by vector similarity", async () => {
if (!setupComplete) {
return;
}
const near = vector(0.02);
const far = vector(0.85);
const matching = await prisma.conversationArchive.create({
data: {
workspaceId,
sessionId: `session-search-${uuid()}`,
agentId: "agent-conversation-search-a",
messages: [
{ role: "user", content: "What caused deployment retries?" },
{ role: "assistant", content: "A connection pool timeout." },
] as unknown as Prisma.InputJsonValue,
messageCount: 2,
summary: "Deployment retries caused by connection pool timeout",
startedAt: new Date("2026-02-28T23:00:00.000Z"),
metadata: { channel: "cli" } as Prisma.InputJsonValue,
},
});
const nonMatching = await prisma.conversationArchive.create({
data: {
workspaceId,
sessionId: `session-search-${uuid()}`,
agentId: "agent-conversation-search-b",
messages: [
{ role: "user", content: "How is billing configured?" },
] as unknown as Prisma.InputJsonValue,
messageCount: 1,
summary: "Billing and quotas conversation",
startedAt: new Date("2026-02-28T23:10:00.000Z"),
metadata: { channel: "cli" } as Prisma.InputJsonValue,
},
});
await prisma.$executeRaw`
UPDATE conversation_archives
SET embedding = ${toVectorLiteral(near)}::vector(${EMBEDDING_DIMENSION})
WHERE id = ${matching.id}::uuid
`;
await prisma.$executeRaw`
UPDATE conversation_archives
SET embedding = ${toVectorLiteral(far)}::vector(${EMBEDDING_DIMENSION})
WHERE id = ${nonMatching.id}::uuid
`;
embeddingServiceMock.isConfigured.mockReturnValue(true);
embeddingServiceMock.generateEmbedding.mockResolvedValue(near);
const result = await service.search(workspaceId, {
query: "deployment retries timeout",
agentId: "agent-conversation-search-a",
similarityThreshold: 0,
limit: 10,
});
const rows = result.data as Array<{ id: string; agent_id: string; similarity: number }>;
expect(result.pagination.total).toBe(1);
expect(rows).toHaveLength(1);
expect(rows[0]?.id).toBe(matching.id);
expect(rows[0]?.agent_id).toBe("agent-conversation-search-a");
expect(rows[0]?.similarity).toBeGreaterThan(0);
});
});

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { CryptoService } from "./crypto.service";
@Module({
imports: [ConfigModule],
providers: [CryptoService],
exports: [CryptoService],
})
export class CryptoModule {}

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach } from "vitest";
import { ConfigService } from "@nestjs/config";
import { CryptoService } from "./crypto.service";
function createConfigService(secret?: string): ConfigService {
return {
get: (key: string) => {
if (key === "MOSAIC_SECRET_KEY") {
return secret;
}
return undefined;
},
} as unknown as ConfigService;
}
describe("CryptoService", () => {
let service: CryptoService;
beforeEach(() => {
service = new CryptoService(createConfigService("this-is-a-test-secret-key-with-32+chars"));
});
it("encrypt -> decrypt roundtrip", () => {
const plaintext = "my-secret-api-key";
const encrypted = service.encrypt(plaintext);
const decrypted = service.decrypt(encrypted);
expect(encrypted.startsWith("enc:")).toBe(true);
expect(decrypted).toBe(plaintext);
});
it("decrypt rejects tampered ciphertext", () => {
const encrypted = service.encrypt("sensitive-token");
const payload = encrypted.slice(4);
const bytes = Buffer.from(payload, "base64");
bytes[bytes.length - 1] = bytes[bytes.length - 1]! ^ 0xff;
const tampered = `enc:${bytes.toString("base64")}`;
expect(() => service.decrypt(tampered)).toThrow();
});
it("decrypt rejects non-encrypted string", () => {
expect(() => service.decrypt("plain-text-value")).toThrow();
});
it("isEncrypted detects prefix correctly", () => {
expect(service.isEncrypted("enc:abc")).toBe(true);
expect(service.isEncrypted("ENC:abc")).toBe(false);
expect(service.isEncrypted("plain-text")).toBe(false);
});
it("generateToken returns 64-char hex string", () => {
const token = service.generateToken();
expect(token).toMatch(/^[0-9a-f]{64}$/);
});
it("different plaintexts produce different ciphertexts (random IV)", () => {
const encryptedA = service.encrypt("value-a");
const encryptedB = service.encrypt("value-b");
expect(encryptedA).not.toBe(encryptedB);
});
it("missing MOSAIC_SECRET_KEY throws on construction", () => {
expect(() => new CryptoService(createConfigService(undefined))).toThrow();
});
});

View File

@@ -0,0 +1,82 @@
import { Injectable } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { createCipheriv, createDecipheriv, hkdfSync, randomBytes } from "crypto";
const ALGORITHM = "aes-256-gcm";
const ENCRYPTED_PREFIX = "enc:";
const IV_LENGTH = 12;
const AUTH_TAG_LENGTH = 16;
const DERIVED_KEY_LENGTH = 32;
const HKDF_SALT = "mosaic.crypto.v1";
const HKDF_INFO = "mosaic-db-secret-encryption";
@Injectable()
export class CryptoService {
private readonly key: Buffer;
constructor(private readonly config: ConfigService) {
const secret = this.config.get<string>("MOSAIC_SECRET_KEY");
if (!secret) {
throw new Error("MOSAIC_SECRET_KEY environment variable is required");
}
if (secret.length < 32) {
throw new Error("MOSAIC_SECRET_KEY must be at least 32 characters");
}
this.key = Buffer.from(
hkdfSync(
"sha256",
Buffer.from(secret, "utf8"),
Buffer.from(HKDF_SALT, "utf8"),
Buffer.from(HKDF_INFO, "utf8"),
DERIVED_KEY_LENGTH
)
);
}
encrypt(plaintext: string): string {
const iv = randomBytes(IV_LENGTH);
const cipher = createCipheriv(ALGORITHM, this.key, iv);
const ciphertext = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
const authTag = cipher.getAuthTag();
const payload = Buffer.concat([iv, ciphertext, authTag]).toString("base64");
return `${ENCRYPTED_PREFIX}${payload}`;
}
decrypt(encrypted: string): string {
if (!this.isEncrypted(encrypted)) {
throw new Error("Value is not encrypted");
}
const payloadBase64 = encrypted.slice(ENCRYPTED_PREFIX.length);
try {
const payload = Buffer.from(payloadBase64, "base64");
if (payload.length < IV_LENGTH + AUTH_TAG_LENGTH) {
throw new Error("Encrypted payload is too short");
}
const iv = payload.subarray(0, IV_LENGTH);
const authTag = payload.subarray(payload.length - AUTH_TAG_LENGTH);
const ciphertext = payload.subarray(IV_LENGTH, payload.length - AUTH_TAG_LENGTH);
const decipher = createDecipheriv(ALGORITHM, this.key, iv);
decipher.setAuthTag(authTag);
return Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString("utf8");
} catch {
throw new Error("Failed to decrypt value");
}
}
isEncrypted(value: string): boolean {
return value.startsWith(ENCRYPTED_PREFIX);
}
generateToken(): string {
return randomBytes(32).toString("hex");
}
}

View File

@@ -0,0 +1,226 @@
import { beforeAll, beforeEach, describe, expect, it, afterAll, vi } from "vitest";
import { randomUUID as uuid } from "crypto";
import { Test, TestingModule } from "@nestjs/testing";
import { BadRequestException, NotFoundException } from "@nestjs/common";
import { PrismaClient, Prisma } from "@prisma/client";
import { FindingsService } from "./findings.service";
import { PrismaService } from "../prisma/prisma.service";
import { EmbeddingService } from "../knowledge/services/embedding.service";
const shouldRunDbIntegrationTests =
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
const EMBEDDING_DIMENSION = 1536;
function vector(value: number): number[] {
return Array.from({ length: EMBEDDING_DIMENSION }, () => value);
}
function toVectorLiteral(input: number[]): string {
return `[${input.join(",")}]`;
}
describeFn("FindingsService Integration", () => {
let moduleRef: TestingModule;
let prisma: PrismaClient;
let service: FindingsService;
let workspaceId: string;
let ownerId: string;
let setupComplete = false;
const embeddingServiceMock = {
isConfigured: vi.fn(),
generateEmbedding: vi.fn(),
};
beforeAll(async () => {
prisma = new PrismaClient();
await prisma.$connect();
const workspace = await prisma.workspace.create({
data: {
name: `Findings Integration ${Date.now()}`,
owner: {
create: {
email: `findings-integration-${Date.now()}@example.com`,
name: "Findings Integration Owner",
},
},
},
});
workspaceId = workspace.id;
ownerId = workspace.ownerId;
moduleRef = await Test.createTestingModule({
providers: [
FindingsService,
{
provide: PrismaService,
useValue: prisma,
},
{
provide: EmbeddingService,
useValue: embeddingServiceMock,
},
],
}).compile();
service = moduleRef.get<FindingsService>(FindingsService);
setupComplete = true;
});
beforeEach(() => {
vi.clearAllMocks();
embeddingServiceMock.isConfigured.mockReturnValue(false);
});
afterAll(async () => {
if (!prisma) {
return;
}
if (workspaceId) {
await prisma.finding.deleteMany({ where: { workspaceId } });
await prisma.workspace.deleteMany({ where: { id: workspaceId } });
}
if (ownerId) {
await prisma.user.deleteMany({ where: { id: ownerId } });
}
if (moduleRef) {
await moduleRef.close();
}
await prisma.$disconnect();
});
it("creates, lists, fetches, and deletes findings", async () => {
if (!setupComplete) {
return;
}
const created = await service.create(workspaceId, {
agentId: "agent-findings-crud",
type: "security",
title: "Unescaped SQL fragment",
data: { severity: "high" },
summary: "Potential injection risk in dynamic query path.",
});
expect(created.id).toBeDefined();
expect(created.workspaceId).toBe(workspaceId);
expect(created.taskId).toBeNull();
const listed = await service.findAll(workspaceId, {
page: 1,
limit: 10,
agentId: "agent-findings-crud",
});
expect(listed.meta.total).toBeGreaterThanOrEqual(1);
expect(listed.data.some((row) => row.id === created.id)).toBe(true);
const found = await service.findOne(created.id, workspaceId);
expect(found.id).toBe(created.id);
expect(found.title).toBe("Unescaped SQL fragment");
await expect(service.findOne(created.id, uuid())).rejects.toThrow(NotFoundException);
await expect(service.remove(created.id, workspaceId)).resolves.toEqual({
message: "Finding deleted successfully",
});
await expect(service.findOne(created.id, workspaceId)).rejects.toThrow(NotFoundException);
});
it("rejects create when taskId does not exist in workspace", async () => {
if (!setupComplete) {
return;
}
await expect(
service.create(workspaceId, {
taskId: uuid(),
agentId: "agent-findings-missing-task",
type: "bug",
title: "Invalid task id",
data: { source: "integration-test" },
summary: "Should fail when task relation is not found.",
})
).rejects.toThrow(NotFoundException);
});
it("rejects vector search when embeddings are disabled", async () => {
if (!setupComplete) {
return;
}
embeddingServiceMock.isConfigured.mockReturnValue(false);
await expect(
service.search(workspaceId, {
query: "security issue",
})
).rejects.toThrow(BadRequestException);
});
it("searches findings by vector similarity with filters", async () => {
if (!setupComplete) {
return;
}
const near = vector(0.01);
const far = vector(0.9);
const matchedFinding = await prisma.finding.create({
data: {
workspaceId,
agentId: "agent-findings-search-a",
type: "incident",
title: "Authentication bypass",
data: { score: 0.9 } as Prisma.InputJsonValue,
summary: "Bypass risk found in login checks.",
},
});
const otherFinding = await prisma.finding.create({
data: {
workspaceId,
agentId: "agent-findings-search-b",
type: "incident",
title: "Retry timeout",
data: { score: 0.2 } as Prisma.InputJsonValue,
summary: "Timeout issue in downstream retries.",
},
});
await prisma.$executeRaw`
UPDATE findings
SET embedding = ${toVectorLiteral(near)}::vector(1536)
WHERE id = ${matchedFinding.id}::uuid
`;
await prisma.$executeRaw`
UPDATE findings
SET embedding = ${toVectorLiteral(far)}::vector(1536)
WHERE id = ${otherFinding.id}::uuid
`;
embeddingServiceMock.isConfigured.mockReturnValue(true);
embeddingServiceMock.generateEmbedding.mockResolvedValue(near);
const result = await service.search(workspaceId, {
query: "authentication bypass risk",
agentId: "agent-findings-search-a",
limit: 10,
similarityThreshold: 0,
});
expect(result.query).toBe("authentication bypass risk");
expect(result.meta.total).toBe(1);
expect(result.data).toHaveLength(1);
expect(result.data[0]?.id).toBe(matchedFinding.id);
expect(result.data[0]?.agentId).toBe("agent-findings-search-a");
expect(result.data.find((row) => row.id === otherFinding.id)).toBeUndefined();
});
});

View File

@@ -0,0 +1,115 @@
import {
Body,
Controller,
Delete,
Get,
HttpCode,
HttpStatus,
Param,
Patch,
Post,
Put,
UseGuards,
} from "@nestjs/common";
import type { AuthUser } from "@mosaic/shared";
import { CurrentUser } from "../auth/decorators/current-user.decorator";
import { AdminGuard } from "../auth/guards/admin.guard";
import { AuthGuard } from "../auth/guards/auth.guard";
import type {
CreateProviderDto,
ResetPasswordDto,
UpdateAgentConfigDto,
UpdateOidcDto,
UpdateProviderDto,
} from "./fleet-settings.dto";
import { FleetSettingsService } from "./fleet-settings.service";
@Controller("fleet-settings")
@UseGuards(AuthGuard)
export class FleetSettingsController {
constructor(private readonly fleetSettingsService: FleetSettingsService) {}
// --- Provider endpoints (user-scoped) ---
// GET /api/fleet-settings/providers — list user's providers
@Get("providers")
async listProviders(@CurrentUser() user: AuthUser) {
return this.fleetSettingsService.listProviders(user.id);
}
// GET /api/fleet-settings/providers/:id — get single provider
@Get("providers/:id")
async getProvider(@CurrentUser() user: AuthUser, @Param("id") id: string) {
return this.fleetSettingsService.getProvider(user.id, id);
}
// POST /api/fleet-settings/providers — create provider
@Post("providers")
async createProvider(@CurrentUser() user: AuthUser, @Body() dto: CreateProviderDto) {
return this.fleetSettingsService.createProvider(user.id, dto);
}
// PATCH /api/fleet-settings/providers/:id — update provider
@Patch("providers/:id")
@HttpCode(HttpStatus.NO_CONTENT)
async updateProvider(
@CurrentUser() user: AuthUser,
@Param("id") id: string,
@Body() dto: UpdateProviderDto
) {
await this.fleetSettingsService.updateProvider(user.id, id, dto);
}
// DELETE /api/fleet-settings/providers/:id — delete provider
@Delete("providers/:id")
@HttpCode(HttpStatus.NO_CONTENT)
async deleteProvider(@CurrentUser() user: AuthUser, @Param("id") id: string) {
await this.fleetSettingsService.deleteProvider(user.id, id);
}
// --- Agent config endpoints (user-scoped) ---
// GET /api/fleet-settings/agent-config — get user's agent config
@Get("agent-config")
async getAgentConfig(@CurrentUser() user: AuthUser) {
return this.fleetSettingsService.getAgentConfig(user.id);
}
// PATCH /api/fleet-settings/agent-config — update user's agent config
@Patch("agent-config")
@HttpCode(HttpStatus.NO_CONTENT)
async updateAgentConfig(@CurrentUser() user: AuthUser, @Body() dto: UpdateAgentConfigDto) {
await this.fleetSettingsService.updateAgentConfig(user.id, dto);
}
// --- OIDC endpoints (admin only — use AdminGuard) ---
// GET /api/fleet-settings/oidc — get OIDC config
@Get("oidc")
@UseGuards(AdminGuard)
async getOidcConfig() {
return this.fleetSettingsService.getOidcConfig();
}
// PUT /api/fleet-settings/oidc — update OIDC config
@Put("oidc")
@UseGuards(AdminGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async updateOidcConfig(@Body() dto: UpdateOidcDto) {
await this.fleetSettingsService.updateOidcConfig(dto);
}
// DELETE /api/fleet-settings/oidc — remove OIDC config
@Delete("oidc")
@UseGuards(AdminGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async deleteOidcConfig() {
await this.fleetSettingsService.deleteOidcConfig();
}
// --- Breakglass endpoints (admin only) ---
// POST /api/fleet-settings/breakglass/reset-password — reset admin password
@Post("breakglass/reset-password")
@UseGuards(AdminGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async resetBreakglassPassword(@Body() dto: ResetPasswordDto) {
await this.fleetSettingsService.resetBreakglassPassword(dto.username, dto.newPassword);
}
}

View File

@@ -0,0 +1,122 @@
import {
ArrayNotEmpty,
IsArray,
IsBoolean,
IsNotEmpty,
IsObject,
IsOptional,
IsString,
IsUrl,
MaxLength,
MinLength,
} from "class-validator";
export class CreateProviderDto {
@IsString({ message: "name must be a string" })
@IsNotEmpty({ message: "name is required" })
@MaxLength(100, { message: "name must not exceed 100 characters" })
name!: string;
@IsString({ message: "displayName must be a string" })
@IsNotEmpty({ message: "displayName is required" })
@MaxLength(255, { message: "displayName must not exceed 255 characters" })
displayName!: string;
@IsString({ message: "type must be a string" })
@IsNotEmpty({ message: "type is required" })
@MaxLength(100, { message: "type must not exceed 100 characters" })
type!: string;
@IsOptional()
@IsUrl(
{ require_tld: false },
{ message: "baseUrl must be a valid URL (for example: https://api.example.com/v1)" }
)
baseUrl?: string;
@IsOptional()
@IsString({ message: "apiKey must be a string" })
apiKey?: string;
@IsOptional()
@IsString({ message: "apiType must be a string" })
@MaxLength(100, { message: "apiType must not exceed 100 characters" })
apiType?: string;
@IsOptional()
@IsArray({ message: "models must be an array" })
@IsObject({ each: true, message: "each model must be an object" })
models?: Record<string, unknown>[];
}
export class UpdateProviderDto {
@IsOptional()
@IsString({ message: "displayName must be a string" })
@MaxLength(255, { message: "displayName must not exceed 255 characters" })
displayName?: string;
@IsOptional()
@IsUrl(
{ require_tld: false },
{ message: "baseUrl must be a valid URL (for example: https://api.example.com/v1)" }
)
baseUrl?: string;
@IsOptional()
@IsString({ message: "apiKey must be a string" })
apiKey?: string;
@IsOptional()
@IsBoolean({ message: "isActive must be a boolean" })
isActive?: boolean;
@IsOptional()
@IsArray({ message: "models must be an array" })
@IsObject({ each: true, message: "each model must be an object" })
models?: Record<string, unknown>[];
}
export class UpdateAgentConfigDto {
@IsOptional()
@IsString({ message: "primaryModel must be a string" })
@MaxLength(255, { message: "primaryModel must not exceed 255 characters" })
primaryModel?: string;
@IsOptional()
@IsArray({ message: "fallbackModels must be an array" })
@ArrayNotEmpty({ message: "fallbackModels cannot be empty" })
@IsString({ each: true, message: "each fallback model must be a string" })
fallbackModels?: string[];
@IsOptional()
@IsString({ message: "personality must be a string" })
personality?: string;
}
export class UpdateOidcDto {
@IsString({ message: "issuerUrl must be a string" })
@IsNotEmpty({ message: "issuerUrl is required" })
@IsUrl(
{ require_tld: false },
{ message: "issuerUrl must be a valid URL (for example: https://issuer.example.com)" }
)
issuerUrl!: string;
@IsString({ message: "clientId must be a string" })
@IsNotEmpty({ message: "clientId is required" })
clientId!: string;
@IsString({ message: "clientSecret must be a string" })
@IsNotEmpty({ message: "clientSecret is required" })
clientSecret!: string;
}
export class ResetPasswordDto {
@IsString({ message: "username must be a string" })
@IsNotEmpty({ message: "username is required" })
username!: string;
@IsString({ message: "newPassword must be a string" })
@MinLength(8, { message: "newPassword must be at least 8 characters" })
newPassword!: string;
}

View File

@@ -0,0 +1,14 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { PrismaModule } from "../prisma/prisma.module";
import { CryptoModule } from "../crypto/crypto.module";
import { FleetSettingsController } from "./fleet-settings.controller";
import { FleetSettingsService } from "./fleet-settings.service";
@Module({
imports: [AuthModule, PrismaModule, CryptoModule],
controllers: [FleetSettingsController],
providers: [FleetSettingsService],
exports: [FleetSettingsService],
})
export class FleetSettingsModule {}

View File

@@ -0,0 +1,200 @@
import { NotFoundException } from "@nestjs/common";
import { compare } from "bcryptjs";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { FleetSettingsService } from "./fleet-settings.service";
import type { PrismaService } from "../prisma/prisma.service";
import type { CryptoService } from "../crypto/crypto.service";
describe("FleetSettingsService", () => {
let service: FleetSettingsService;
const mockPrisma = {
llmProvider: {
findMany: vi.fn(),
findFirst: vi.fn(),
findUnique: vi.fn(),
create: vi.fn(),
update: vi.fn(),
delete: vi.fn(),
},
userAgentConfig: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
systemConfig: {
findMany: vi.fn(),
upsert: vi.fn(),
deleteMany: vi.fn(),
},
breakglassUser: {
findUnique: vi.fn(),
update: vi.fn(),
},
};
const mockCrypto = {
encrypt: vi.fn((value: string) => `enc:${value}`),
};
beforeEach(() => {
vi.clearAllMocks();
service = new FleetSettingsService(
mockPrisma as unknown as PrismaService,
mockCrypto as unknown as CryptoService
);
});
it("listProviders returns only providers for the given userId", async () => {
mockPrisma.llmProvider.findMany.mockResolvedValue([
{
id: "prov-1",
name: "openai-main",
displayName: "OpenAI",
type: "openai",
baseUrl: "https://api.openai.com/v1",
isActive: true,
models: [{ id: "gpt-4.1" }],
},
]);
const result = await service.listProviders("user-1");
expect(mockPrisma.llmProvider.findMany).toHaveBeenCalledWith({
where: { userId: "user-1" },
select: {
id: true,
name: true,
displayName: true,
type: true,
baseUrl: true,
isActive: true,
models: true,
},
orderBy: { createdAt: "asc" },
});
expect(result).toEqual([
{
id: "prov-1",
name: "openai-main",
displayName: "OpenAI",
type: "openai",
baseUrl: "https://api.openai.com/v1",
isActive: true,
models: [{ id: "gpt-4.1" }],
},
]);
});
it("createProvider encrypts apiKey", async () => {
mockPrisma.llmProvider.create.mockResolvedValue({
id: "prov-2",
});
const result = await service.createProvider("user-1", {
name: "zai-main",
displayName: "Z.ai",
type: "zai",
apiKey: "plaintext-key",
models: [],
});
expect(mockCrypto.encrypt).toHaveBeenCalledWith("plaintext-key");
expect(mockPrisma.llmProvider.create).toHaveBeenCalledWith({
data: {
userId: "user-1",
name: "zai-main",
displayName: "Z.ai",
type: "zai",
baseUrl: null,
apiKey: "enc:plaintext-key",
apiType: "openai-completions",
models: [],
},
select: {
id: true,
},
});
expect(result).toEqual({ id: "prov-2" });
});
it("updateProvider rejects if not owned by user", async () => {
mockPrisma.llmProvider.findFirst.mockResolvedValue(null);
await expect(
service.updateProvider("user-1", "provider-1", {
displayName: "New Name",
})
).rejects.toBeInstanceOf(NotFoundException);
expect(mockPrisma.llmProvider.update).not.toHaveBeenCalled();
});
it("deleteProvider rejects if not owned by user", async () => {
mockPrisma.llmProvider.findFirst.mockResolvedValue(null);
await expect(service.deleteProvider("user-1", "provider-1")).rejects.toBeInstanceOf(
NotFoundException
);
expect(mockPrisma.llmProvider.delete).not.toHaveBeenCalled();
});
it("getOidcConfig never returns clientSecret", async () => {
mockPrisma.systemConfig.findMany.mockResolvedValue([
{
key: "oidc.issuerUrl",
value: "https://issuer.example.com",
},
{
key: "oidc.clientId",
value: "client-id-1",
},
{
key: "oidc.clientSecret",
value: "enc:very-secret",
},
]);
const result = await service.getOidcConfig();
expect(result).toEqual({
issuerUrl: "https://issuer.example.com",
clientId: "client-id-1",
configured: true,
});
expect(result).not.toHaveProperty("clientSecret");
});
it("updateOidcConfig encrypts clientSecret", async () => {
await service.updateOidcConfig({
issuerUrl: "https://issuer.example.com",
clientId: "client-id-1",
clientSecret: "super-secret",
});
expect(mockCrypto.encrypt).toHaveBeenCalledWith("super-secret");
expect(mockPrisma.systemConfig.upsert).toHaveBeenCalledTimes(3);
expect(mockPrisma.systemConfig.upsert).toHaveBeenCalledWith({
where: { key: "oidc.clientSecret" },
update: { value: "enc:super-secret", encrypted: true },
create: { key: "oidc.clientSecret", value: "enc:super-secret", encrypted: true },
});
});
it("resetBreakglassPassword hashes new password", async () => {
mockPrisma.breakglassUser.findUnique.mockResolvedValue({
id: "bg-1",
username: "admin",
passwordHash: "old-hash",
});
await service.resetBreakglassPassword("admin", "new-password-123");
expect(mockPrisma.breakglassUser.update).toHaveBeenCalledOnce();
const updateCall = mockPrisma.breakglassUser.update.mock.calls[0]?.[0];
const newHash = updateCall?.data?.passwordHash;
expect(newHash).toBeTypeOf("string");
expect(newHash).not.toBe("new-password-123");
expect(await compare("new-password-123", newHash as string)).toBe(true);
});
});

View File

@@ -0,0 +1,296 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { hash } from "bcryptjs";
import type { Prisma } from "@prisma/client";
import { PrismaService } from "../prisma/prisma.service";
import { CryptoService } from "../crypto/crypto.service";
import type {
CreateProviderDto,
ResetPasswordDto,
UpdateAgentConfigDto,
UpdateOidcDto,
UpdateProviderDto,
} from "./fleet-settings.dto";
const BCRYPT_ROUNDS = 12;
const DEFAULT_PROVIDER_API_TYPE = "openai-completions";
const OIDC_ISSUER_KEY = "oidc.issuerUrl";
const OIDC_CLIENT_ID_KEY = "oidc.clientId";
const OIDC_CLIENT_SECRET_KEY = "oidc.clientSecret";
const OIDC_KEYS = [OIDC_ISSUER_KEY, OIDC_CLIENT_ID_KEY, OIDC_CLIENT_SECRET_KEY] as const;
export interface FleetProviderResponse {
id: string;
name: string;
displayName: string;
type: string;
baseUrl: string | null;
isActive: boolean;
models: unknown;
}
export interface FleetAgentConfigResponse {
primaryModel: string | null;
fallbackModels: unknown[];
personality: string | null;
}
export interface OidcConfigResponse {
issuerUrl?: string;
clientId?: string;
configured: boolean;
}
@Injectable()
export class FleetSettingsService {
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CryptoService
) {}
// --- LLM Provider CRUD (per-user scoped) ---
async listProviders(userId: string): Promise<FleetProviderResponse[]> {
return this.prisma.llmProvider.findMany({
where: { userId },
select: {
id: true,
name: true,
displayName: true,
type: true,
baseUrl: true,
isActive: true,
models: true,
},
orderBy: { createdAt: "asc" },
});
}
async getProvider(userId: string, providerId: string): Promise<FleetProviderResponse> {
const provider = await this.prisma.llmProvider.findFirst({
where: {
id: providerId,
userId,
},
select: {
id: true,
name: true,
displayName: true,
type: true,
baseUrl: true,
isActive: true,
models: true,
},
});
if (!provider) {
throw new NotFoundException(`Provider ${providerId} not found`);
}
return provider;
}
async createProvider(userId: string, data: CreateProviderDto): Promise<{ id: string }> {
const provider = await this.prisma.llmProvider.create({
data: {
userId,
name: data.name,
displayName: data.displayName,
type: data.type,
baseUrl: data.baseUrl ?? null,
apiKey: data.apiKey ? this.crypto.encrypt(data.apiKey) : null,
apiType: data.apiType ?? DEFAULT_PROVIDER_API_TYPE,
models: (data.models ?? []) as Prisma.InputJsonValue,
},
select: {
id: true,
},
});
return provider;
}
async updateProvider(userId: string, providerId: string, data: UpdateProviderDto): Promise<void> {
await this.assertProviderOwnership(userId, providerId);
const updateData: Prisma.LlmProviderUpdateInput = {};
if (data.displayName !== undefined) {
updateData.displayName = data.displayName;
}
if (data.baseUrl !== undefined) {
updateData.baseUrl = data.baseUrl;
}
if (data.isActive !== undefined) {
updateData.isActive = data.isActive;
}
if (data.models !== undefined) {
updateData.models = data.models as Prisma.InputJsonValue;
}
if (data.apiKey !== undefined) {
updateData.apiKey = data.apiKey.length > 0 ? this.crypto.encrypt(data.apiKey) : null;
}
await this.prisma.llmProvider.update({
where: { id: providerId },
data: updateData,
});
}
async deleteProvider(userId: string, providerId: string): Promise<void> {
await this.assertProviderOwnership(userId, providerId);
await this.prisma.llmProvider.delete({
where: { id: providerId },
});
}
// --- User Agent Config ---
async getAgentConfig(userId: string): Promise<FleetAgentConfigResponse> {
const config = await this.prisma.userAgentConfig.findUnique({
where: { userId },
select: {
primaryModel: true,
fallbackModels: true,
personality: true,
},
});
if (!config) {
return {
primaryModel: null,
fallbackModels: [],
personality: null,
};
}
return {
primaryModel: config.primaryModel,
fallbackModels: this.normalizeJsonArray(config.fallbackModels),
personality: config.personality,
};
}
async updateAgentConfig(userId: string, data: UpdateAgentConfigDto): Promise<void> {
const updateData: Prisma.UserAgentConfigUpdateInput = {};
if (data.primaryModel !== undefined) {
updateData.primaryModel = data.primaryModel;
}
if (data.personality !== undefined) {
updateData.personality = data.personality;
}
if (data.fallbackModels !== undefined) {
updateData.fallbackModels = data.fallbackModels as Prisma.InputJsonValue;
}
const createData: Prisma.UserAgentConfigCreateInput = {
userId,
fallbackModels: (data.fallbackModels ?? []) as Prisma.InputJsonValue,
...(data.primaryModel !== undefined ? { primaryModel: data.primaryModel } : {}),
...(data.personality !== undefined ? { personality: data.personality } : {}),
};
await this.prisma.userAgentConfig.upsert({
where: { userId },
create: createData,
update: updateData,
});
}
// --- OIDC Config (admin only) ---
async getOidcConfig(): Promise<OidcConfigResponse> {
const entries = await this.prisma.systemConfig.findMany({
where: {
key: {
in: [...OIDC_KEYS],
},
},
select: {
key: true,
value: true,
},
});
const byKey = new Map(entries.map((entry) => [entry.key, entry.value]));
const issuerUrl = byKey.get(OIDC_ISSUER_KEY);
const clientId = byKey.get(OIDC_CLIENT_ID_KEY);
const hasSecret = byKey.has(OIDC_CLIENT_SECRET_KEY);
return {
...(issuerUrl ? { issuerUrl } : {}),
...(clientId ? { clientId } : {}),
configured: Boolean(issuerUrl && clientId && hasSecret),
};
}
async updateOidcConfig(data: UpdateOidcDto): Promise<void> {
const encryptedSecret = this.crypto.encrypt(data.clientSecret);
await Promise.all([
this.upsertSystemConfig(OIDC_ISSUER_KEY, data.issuerUrl, false),
this.upsertSystemConfig(OIDC_CLIENT_ID_KEY, data.clientId, false),
this.upsertSystemConfig(OIDC_CLIENT_SECRET_KEY, encryptedSecret, true),
]);
}
async deleteOidcConfig(): Promise<void> {
await this.prisma.systemConfig.deleteMany({
where: {
key: {
in: [...OIDC_KEYS],
},
},
});
}
// --- Breakglass (admin only) ---
async resetBreakglassPassword(
username: ResetPasswordDto["username"],
newPassword: ResetPasswordDto["newPassword"]
): Promise<void> {
const user = await this.prisma.breakglassUser.findUnique({
where: { username },
select: { id: true },
});
if (!user) {
throw new NotFoundException(`Breakglass user ${username} not found`);
}
const passwordHash = await hash(newPassword, BCRYPT_ROUNDS);
await this.prisma.breakglassUser.update({
where: { id: user.id },
data: { passwordHash },
});
}
private async assertProviderOwnership(userId: string, providerId: string): Promise<void> {
const provider = await this.prisma.llmProvider.findFirst({
where: {
id: providerId,
userId,
},
select: {
id: true,
},
});
if (!provider) {
throw new NotFoundException(`Provider ${providerId} not found`);
}
}
private async upsertSystemConfig(key: string, value: string, encrypted: boolean): Promise<void> {
await this.prisma.systemConfig.upsert({
where: { key },
update: { value, encrypted },
create: { key, value, encrypted },
});
}
private normalizeJsonArray(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
}

View File

@@ -0,0 +1,63 @@
import { Body, Controller, Get, HttpCode, HttpStatus, Post, UseGuards } from "@nestjs/common";
import {
AddProviderDto,
ConfigureOidcDto,
CreateBreakglassDto,
TestProviderDto,
} from "./onboarding.dto";
import { OnboardingGuard } from "./onboarding.guard";
import { OnboardingService } from "./onboarding.service";
@Controller("onboarding")
export class OnboardingController {
constructor(private readonly onboardingService: OnboardingService) {}
// GET /api/onboarding/status — returns { completed: boolean }
@Get("status")
async status(): Promise<{ completed: boolean }> {
return {
completed: await this.onboardingService.isCompleted(),
};
}
// POST /api/onboarding/breakglass — body: { username, password } → create admin
@Post("breakglass")
@UseGuards(OnboardingGuard)
async createBreakglass(
@Body() body: CreateBreakglassDto
): Promise<{ id: string; username: string }> {
return this.onboardingService.createBreakglassUser(body.username, body.password);
}
// POST /api/onboarding/oidc — body: { issuerUrl, clientId, clientSecret } → save OIDC
@Post("oidc")
@UseGuards(OnboardingGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async configureOidc(@Body() body: ConfigureOidcDto): Promise<void> {
await this.onboardingService.configureOidc(body.issuerUrl, body.clientId, body.clientSecret);
}
// POST /api/onboarding/provider — body: { name, displayName, type, baseUrl?, apiKey?, models? } → add provider
@Post("provider")
@UseGuards(OnboardingGuard)
async addProvider(@Body() body: AddProviderDto): Promise<{ id: string }> {
const userId = await this.onboardingService.getBreakglassUserId();
return this.onboardingService.addProvider(userId, body);
}
// POST /api/onboarding/provider/test — body: { type, baseUrl?, apiKey? } → test connection
@Post("provider/test")
@UseGuards(OnboardingGuard)
async testProvider(@Body() body: TestProviderDto): Promise<{ success: boolean; error?: string }> {
return this.onboardingService.testProvider(body.type, body.baseUrl, body.apiKey);
}
// POST /api/onboarding/complete — mark done
@Post("complete")
@UseGuards(OnboardingGuard)
@HttpCode(HttpStatus.NO_CONTENT)
async complete(): Promise<void> {
await this.onboardingService.complete();
}
}

View File

@@ -0,0 +1,71 @@
import { Type } from "class-transformer";
import { IsArray, IsOptional, IsString, IsUrl, MinLength, ValidateNested } from "class-validator";
export class CreateBreakglassDto {
@IsString()
@MinLength(3)
username!: string;
@IsString()
@MinLength(8)
password!: string;
}
export class ConfigureOidcDto {
@IsString()
@IsUrl({ require_tld: false })
issuerUrl!: string;
@IsString()
clientId!: string;
@IsString()
clientSecret!: string;
}
export class ProviderModelDto {
@IsString()
id!: string;
@IsOptional()
@IsString()
name?: string;
}
export class AddProviderDto {
@IsString()
name!: string;
@IsString()
displayName!: string;
@IsString()
type!: string;
@IsOptional()
@IsString()
baseUrl?: string;
@IsOptional()
@IsString()
apiKey?: string;
@IsOptional()
@IsArray()
@ValidateNested({ each: true })
@Type(() => ProviderModelDto)
models?: ProviderModelDto[];
}
export class TestProviderDto {
@IsString()
type!: string;
@IsOptional()
@IsString()
baseUrl?: string;
@IsOptional()
@IsString()
apiKey?: string;
}

View File

@@ -0,0 +1,17 @@
import { CanActivate, ExecutionContext, ForbiddenException, Injectable } from "@nestjs/common";
import { OnboardingService } from "./onboarding.service";
@Injectable()
export class OnboardingGuard implements CanActivate {
constructor(private readonly onboardingService: OnboardingService) {}
async canActivate(_context: ExecutionContext): Promise<boolean> {
const completed = await this.onboardingService.isCompleted();
if (completed) {
throw new ForbiddenException("Onboarding already completed");
}
return true;
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config";
import { PrismaModule } from "../prisma/prisma.module";
import { CryptoModule } from "../crypto/crypto.module";
import { OnboardingController } from "./onboarding.controller";
import { OnboardingService } from "./onboarding.service";
import { OnboardingGuard } from "./onboarding.guard";
@Module({
imports: [PrismaModule, CryptoModule, ConfigModule],
controllers: [OnboardingController],
providers: [OnboardingService, OnboardingGuard],
exports: [OnboardingService],
})
export class OnboardingModule {}

View File

@@ -0,0 +1,206 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { hash } from "bcryptjs";
import { OnboardingService } from "./onboarding.service";
import { PrismaService } from "../prisma/prisma.service";
import { CryptoService } from "../crypto/crypto.service";
vi.mock("bcryptjs", () => ({
hash: vi.fn(),
}));
describe("OnboardingService", () => {
let service: OnboardingService;
const mockPrismaService = {
systemConfig: {
findUnique: vi.fn(),
upsert: vi.fn(),
},
breakglassUser: {
count: vi.fn(),
create: vi.fn(),
findFirst: vi.fn(),
},
llmProvider: {
create: vi.fn(),
},
};
const mockCryptoService = {
encrypt: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
service = new OnboardingService(
mockPrismaService as unknown as PrismaService,
mockCryptoService as unknown as CryptoService
);
});
it("isCompleted returns false when no config exists", async () => {
mockPrismaService.systemConfig.findUnique.mockResolvedValue(null);
await expect(service.isCompleted()).resolves.toBe(false);
expect(mockPrismaService.systemConfig.findUnique).toHaveBeenCalledWith({
where: { key: "onboarding.completed" },
});
});
it("isCompleted returns true when completed", async () => {
mockPrismaService.systemConfig.findUnique.mockResolvedValue({
id: "cfg-1",
key: "onboarding.completed",
value: "true",
encrypted: false,
updatedAt: new Date(),
});
await expect(service.isCompleted()).resolves.toBe(true);
});
it("createBreakglassUser hashes password and creates record", async () => {
const mockedHash = vi.mocked(hash);
mockedHash.mockResolvedValue("hashed-password");
mockPrismaService.breakglassUser.count.mockResolvedValue(0);
mockPrismaService.breakglassUser.create.mockResolvedValue({
id: "breakglass-1",
username: "admin",
});
const result = await service.createBreakglassUser("admin", "supersecret123");
expect(mockedHash).toHaveBeenCalledWith("supersecret123", 12);
expect(mockPrismaService.breakglassUser.create).toHaveBeenCalledWith({
data: {
username: "admin",
passwordHash: "hashed-password",
},
select: {
id: true,
username: true,
},
});
expect(result).toEqual({ id: "breakglass-1", username: "admin" });
});
it("createBreakglassUser rejects if user already exists", async () => {
mockPrismaService.breakglassUser.count.mockResolvedValue(1);
await expect(service.createBreakglassUser("admin", "supersecret123")).rejects.toThrow(
"Breakglass user already exists"
);
});
it("configureOidc encrypts secret and saves to SystemConfig", async () => {
mockCryptoService.encrypt.mockReturnValue("enc:oidc-secret");
mockPrismaService.systemConfig.upsert.mockResolvedValue({
id: "cfg",
key: "oidc.clientSecret",
value: "enc:oidc-secret",
encrypted: true,
updatedAt: new Date(),
});
await service.configureOidc("https://auth.example.com", "client-id", "client-secret");
expect(mockCryptoService.encrypt).toHaveBeenCalledWith("client-secret");
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledTimes(3);
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
where: { key: "oidc.issuerUrl" },
create: {
key: "oidc.issuerUrl",
value: "https://auth.example.com",
encrypted: false,
},
update: {
value: "https://auth.example.com",
encrypted: false,
},
});
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
where: { key: "oidc.clientId" },
create: {
key: "oidc.clientId",
value: "client-id",
encrypted: false,
},
update: {
value: "client-id",
encrypted: false,
},
});
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
where: { key: "oidc.clientSecret" },
create: {
key: "oidc.clientSecret",
value: "enc:oidc-secret",
encrypted: true,
},
update: {
value: "enc:oidc-secret",
encrypted: true,
},
});
});
it("addProvider encrypts apiKey and creates LlmProvider", async () => {
mockCryptoService.encrypt.mockReturnValue("enc:api-key");
mockPrismaService.llmProvider.create.mockResolvedValue({
id: "provider-1",
});
const result = await service.addProvider("breakglass-1", {
name: "my-openai",
displayName: "OpenAI",
type: "openai",
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-test",
models: [{ id: "gpt-4o-mini", name: "GPT-4o Mini" }],
});
expect(mockCryptoService.encrypt).toHaveBeenCalledWith("sk-test");
expect(mockPrismaService.llmProvider.create).toHaveBeenCalledWith({
data: {
userId: "breakglass-1",
name: "my-openai",
displayName: "OpenAI",
type: "openai",
baseUrl: "https://api.openai.com/v1",
apiKey: "enc:api-key",
models: [{ id: "gpt-4o-mini", name: "GPT-4o Mini" }],
},
select: {
id: true,
},
});
expect(result).toEqual({ id: "provider-1" });
});
it("complete sets SystemConfig flag", async () => {
mockPrismaService.systemConfig.upsert.mockResolvedValue({
id: "cfg-1",
key: "onboarding.completed",
value: "true",
encrypted: false,
updatedAt: new Date(),
});
await service.complete();
expect(mockPrismaService.systemConfig.upsert).toHaveBeenCalledWith({
where: { key: "onboarding.completed" },
create: {
key: "onboarding.completed",
value: "true",
encrypted: false,
},
update: {
value: "true",
encrypted: false,
},
});
});
});

View File

@@ -0,0 +1,191 @@
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
import type { InputJsonValue } from "@prisma/client/runtime/library";
import { hash } from "bcryptjs";
import { PrismaService } from "../prisma/prisma.service";
import { CryptoService } from "../crypto/crypto.service";
const BCRYPT_ROUNDS = 12;
const TEST_PROVIDER_TIMEOUT_MS = 8000;
const ONBOARDING_COMPLETED_KEY = "onboarding.completed";
const OIDC_ISSUER_URL_KEY = "oidc.issuerUrl";
const OIDC_CLIENT_ID_KEY = "oidc.clientId";
const OIDC_CLIENT_SECRET_KEY = "oidc.clientSecret";
interface ProviderModelInput {
id: string;
name?: string;
}
interface AddProviderInput {
name: string;
displayName: string;
type: string;
baseUrl?: string;
apiKey?: string;
models?: ProviderModelInput[];
}
@Injectable()
export class OnboardingService {
constructor(
private readonly prisma: PrismaService,
private readonly crypto: CryptoService
) {}
// Check if onboarding is completed
async isCompleted(): Promise<boolean> {
const completedFlag = await this.prisma.systemConfig.findUnique({
where: { key: ONBOARDING_COMPLETED_KEY },
});
return completedFlag?.value === "true";
}
// Step 1: Create breakglass admin user
async createBreakglassUser(
username: string,
password: string
): Promise<{ id: string; username: string }> {
const breakglassCount = await this.prisma.breakglassUser.count();
if (breakglassCount > 0) {
throw new ConflictException("Breakglass user already exists");
}
const passwordHash = await hash(password, BCRYPT_ROUNDS);
return this.prisma.breakglassUser.create({
data: {
username,
passwordHash,
},
select: {
id: true,
username: true,
},
});
}
// Step 2: Configure OIDC provider (optional)
async configureOidc(issuerUrl: string, clientId: string, clientSecret: string): Promise<void> {
const encryptedSecret = this.crypto.encrypt(clientSecret);
await Promise.all([
this.upsertSystemConfig(OIDC_ISSUER_URL_KEY, issuerUrl, false),
this.upsertSystemConfig(OIDC_CLIENT_ID_KEY, clientId, false),
this.upsertSystemConfig(OIDC_CLIENT_SECRET_KEY, encryptedSecret, true),
]);
}
// Step 3: Add first LLM provider
async addProvider(userId: string, data: AddProviderInput): Promise<{ id: string }> {
const encryptedApiKey = data.apiKey ? this.crypto.encrypt(data.apiKey) : undefined;
return this.prisma.llmProvider.create({
data: {
userId,
name: data.name,
displayName: data.displayName,
type: data.type,
baseUrl: data.baseUrl ?? null,
apiKey: encryptedApiKey ?? null,
models: (data.models ?? []) as unknown as InputJsonValue,
},
select: {
id: true,
},
});
}
// Step 3b: Test LLM provider connection
async testProvider(
type: string,
baseUrl?: string,
apiKey?: string
): Promise<{ success: boolean; error?: string }> {
const normalizedType = type.trim().toLowerCase();
if (!normalizedType) {
return { success: false, error: "Provider type is required" };
}
let probeUrl: string;
try {
probeUrl = this.buildProbeUrl(normalizedType, baseUrl);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
}
const headers: Record<string, string> = {
Accept: "application/json",
};
if (apiKey) {
headers.Authorization = `Bearer ${apiKey}`;
}
try {
const response = await fetch(probeUrl, {
method: "GET",
headers,
signal: AbortSignal.timeout(TEST_PROVIDER_TIMEOUT_MS),
});
if (!response.ok) {
return {
success: false,
error: `Provider returned ${String(response.status)} ${response.statusText}`.trim(),
};
}
return { success: true };
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { success: false, error: message };
}
}
// Step 4: Mark onboarding complete
async complete(): Promise<void> {
await this.upsertSystemConfig(ONBOARDING_COMPLETED_KEY, "true", false);
}
async getBreakglassUserId(): Promise<string> {
const user = await this.prisma.breakglassUser.findFirst({
where: { isActive: true },
orderBy: { createdAt: "asc" },
select: { id: true },
});
if (!user) {
throw new BadRequestException("Create a breakglass user before adding a provider");
}
return user.id;
}
private async upsertSystemConfig(key: string, value: string, encrypted: boolean): Promise<void> {
await this.prisma.systemConfig.upsert({
where: { key },
create: { key, value, encrypted },
update: { value, encrypted },
});
}
private buildProbeUrl(type: string, baseUrl?: string): string {
const resolvedBaseUrl = baseUrl ?? this.getDefaultProviderBaseUrl(type);
const normalizedBaseUrl = resolvedBaseUrl.endsWith("/")
? resolvedBaseUrl
: `${resolvedBaseUrl}/`;
const endpointPath = type === "ollama" ? "api/tags" : "models";
return new URL(endpointPath, normalizedBaseUrl).toString();
}
private getDefaultProviderBaseUrl(type: string): string {
if (type === "ollama") {
return "http://localhost:11434";
}
return "https://api.openai.com/v1";
}
}

View File

@@ -0,0 +1,194 @@
import { beforeEach, describe, expect, it, vi, afterEach } from "vitest";
import type { Response } from "express";
import { AgentStatus } from "@prisma/client";
import { OrchestratorController } from "./orchestrator.controller";
import { PrismaService } from "../prisma/prisma.service";
import { AuthGuard } from "../auth/guards/auth.guard";
describe("OrchestratorController", () => {
const mockPrismaService = {
agent: {
findMany: vi.fn(),
},
};
let controller: OrchestratorController;
beforeEach(() => {
vi.clearAllMocks();
controller = new OrchestratorController(mockPrismaService as unknown as PrismaService);
});
afterEach(() => {
vi.useRealTimers();
});
describe("getAgents", () => {
it("returns active agents with API widget shape", async () => {
mockPrismaService.agent.findMany.mockResolvedValue([
{
id: "agent-1",
name: "Planner",
status: AgentStatus.WORKING,
role: "planner",
createdAt: new Date("2026-02-28T10:00:00.000Z"),
},
]);
const result = await controller.getAgents();
expect(result).toEqual([
{
id: "agent-1",
name: "Planner",
status: AgentStatus.WORKING,
type: "planner",
createdAt: new Date("2026-02-28T10:00:00.000Z"),
},
]);
expect(mockPrismaService.agent.findMany).toHaveBeenCalledWith({
where: {
status: {
not: AgentStatus.TERMINATED,
},
},
orderBy: {
createdAt: "desc",
},
select: {
id: true,
name: true,
status: true,
role: true,
createdAt: true,
},
});
});
it("falls back to type=agent when role is missing", async () => {
mockPrismaService.agent.findMany.mockResolvedValue([
{
id: "agent-2",
name: null,
status: AgentStatus.IDLE,
role: null,
createdAt: new Date("2026-02-28T11:00:00.000Z"),
},
]);
const result = await controller.getAgents();
expect(result[0]).toMatchObject({
id: "agent-2",
type: "agent",
});
});
});
describe("streamEvents", () => {
it("sets SSE headers and writes initial data payload", async () => {
const onHandlers: Record<string, (() => void) | undefined> = {};
const mockRes = {
setHeader: vi.fn(),
write: vi.fn(),
end: vi.fn(),
on: vi.fn((event: string, handler: () => void) => {
onHandlers[event] = handler;
return mockRes;
}),
} as unknown as Response;
mockPrismaService.agent.findMany.mockResolvedValue([
{
id: "agent-1",
name: "Worker",
status: AgentStatus.WORKING,
role: "worker",
createdAt: new Date("2026-02-28T12:00:00.000Z"),
},
]);
await controller.streamEvents(mockRes);
expect(mockRes.setHeader).toHaveBeenCalledWith("Content-Type", "text/event-stream");
expect(mockRes.setHeader).toHaveBeenCalledWith("Cache-Control", "no-cache");
expect(mockRes.setHeader).toHaveBeenCalledWith("Connection", "keep-alive");
expect(mockRes.setHeader).toHaveBeenCalledWith("X-Accel-Buffering", "no");
expect(mockRes.write).toHaveBeenCalledWith(
expect.stringContaining('"type":"agents:updated"')
);
expect(typeof onHandlers.close).toBe("function");
});
it("polls every 5 seconds and only emits when payload changes", async () => {
vi.useFakeTimers();
const onHandlers: Record<string, (() => void) | undefined> = {};
const mockRes = {
setHeader: vi.fn(),
write: vi.fn(),
end: vi.fn(),
on: vi.fn((event: string, handler: () => void) => {
onHandlers[event] = handler;
return mockRes;
}),
} as unknown as Response;
const firstPayload = [
{
id: "agent-1",
name: "Worker",
status: AgentStatus.WORKING,
role: "worker",
createdAt: new Date("2026-02-28T12:00:00.000Z"),
},
];
const secondPayload = [
{
id: "agent-1",
name: "Worker",
status: AgentStatus.WAITING,
role: "worker",
createdAt: new Date("2026-02-28T12:00:00.000Z"),
},
];
mockPrismaService.agent.findMany
.mockResolvedValueOnce(firstPayload)
.mockResolvedValueOnce(firstPayload)
.mockResolvedValueOnce(secondPayload);
await controller.streamEvents(mockRes);
// 1 initial data event
const getDataEventCalls = () =>
mockRes.write.mock.calls.filter(
(call) => typeof call[0] === "string" && call[0].startsWith("data: ")
);
expect(getDataEventCalls()).toHaveLength(1);
// No change after first poll => no new data event
await vi.advanceTimersByTimeAsync(5000);
expect(getDataEventCalls()).toHaveLength(1);
// Status changed on second poll => emits new data event
await vi.advanceTimersByTimeAsync(5000);
expect(getDataEventCalls()).toHaveLength(2);
onHandlers.close?.();
expect(mockRes.end).toHaveBeenCalledTimes(1);
});
});
describe("security", () => {
it("uses AuthGuard at the controller level", () => {
const guards = Reflect.getMetadata("__guards__", OrchestratorController) as unknown[];
const guardClasses = guards.map((guard) => guard);
expect(guardClasses).toContain(AuthGuard);
});
});
});

View File

@@ -0,0 +1,115 @@
import { Controller, Get, Res, UseGuards } from "@nestjs/common";
import { AgentStatus } from "@prisma/client";
import type { Response } from "express";
import { AuthGuard } from "../auth/guards/auth.guard";
import { PrismaService } from "../prisma/prisma.service";
const AGENT_POLL_INTERVAL_MS = 5_000;
const SSE_HEARTBEAT_MS = 15_000;
interface OrchestratorAgentDto {
id: string;
name: string | null;
status: AgentStatus;
type: string;
createdAt: Date;
}
@Controller("orchestrator")
@UseGuards(AuthGuard)
export class OrchestratorController {
constructor(private readonly prisma: PrismaService) {}
@Get("agents")
async getAgents(): Promise<OrchestratorAgentDto[]> {
return this.fetchActiveAgents();
}
@Get("events")
async streamEvents(@Res() res: Response): Promise<void> {
res.setHeader("Content-Type", "text/event-stream");
res.setHeader("Cache-Control", "no-cache");
res.setHeader("Connection", "keep-alive");
res.setHeader("X-Accel-Buffering", "no");
if (typeof res.flushHeaders === "function") {
res.flushHeaders();
}
let isClosed = false;
let previousSnapshot = "";
const emitSnapshotIfChanged = async (): Promise<void> => {
if (isClosed) {
return;
}
try {
const agents = await this.fetchActiveAgents();
const snapshot = JSON.stringify(agents);
if (snapshot !== previousSnapshot) {
previousSnapshot = snapshot;
res.write(
`data: ${JSON.stringify({
type: "agents:updated",
agents,
timestamp: new Date().toISOString(),
})}\n\n`
);
}
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
res.write(`event: error\n`);
res.write(`data: ${JSON.stringify({ error: message })}\n\n`);
}
};
await emitSnapshotIfChanged();
const pollInterval = setInterval(() => {
void emitSnapshotIfChanged();
}, AGENT_POLL_INTERVAL_MS);
const heartbeatInterval = setInterval(() => {
if (!isClosed) {
res.write(": keepalive\n\n");
}
}, SSE_HEARTBEAT_MS);
res.on("close", () => {
isClosed = true;
clearInterval(pollInterval);
clearInterval(heartbeatInterval);
res.end();
});
}
private async fetchActiveAgents(): Promise<OrchestratorAgentDto[]> {
const agents = await this.prisma.agent.findMany({
where: {
status: {
not: AgentStatus.TERMINATED,
},
},
orderBy: {
createdAt: "desc",
},
select: {
id: true,
name: true,
status: true,
role: true,
createdAt: true,
},
});
return agents.map((agent) => ({
id: agent.id,
name: agent.name,
status: agent.status,
type: agent.role ?? "agent",
createdAt: agent.createdAt,
}));
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module";
import { PrismaModule } from "../prisma/prisma.module";
import { OrchestratorController } from "./orchestrator.controller";
@Module({
imports: [AuthModule, PrismaModule],
controllers: [OrchestratorController],
})
export class OrchestratorModule {}

View File

@@ -50,6 +50,12 @@ export class CreateTaskDto {
@IsUUID("4", { message: "parentId must be a valid UUID" }) @IsUUID("4", { message: "parentId must be a valid UUID" })
parentId?: string; parentId?: string;
@IsOptional()
@IsString({ message: "assignedAgent must be a string" })
@MinLength(1, { message: "assignedAgent must not be empty" })
@MaxLength(255, { message: "assignedAgent must not exceed 255 characters" })
assignedAgent?: string;
@IsOptional() @IsOptional()
@IsInt({ message: "sortOrder must be an integer" }) @IsInt({ message: "sortOrder must be an integer" })
@Min(0, { message: "sortOrder must be at least 0" }) @Min(0, { message: "sortOrder must be at least 0" })

View File

@@ -52,6 +52,12 @@ export class UpdateTaskDto {
@IsUUID("4", { message: "parentId must be a valid UUID" }) @IsUUID("4", { message: "parentId must be a valid UUID" })
parentId?: string | null; parentId?: string | null;
@IsOptional()
@IsString({ message: "assignedAgent must be a string" })
@MinLength(1, { message: "assignedAgent must not be empty" })
@MaxLength(255, { message: "assignedAgent must not exceed 255 characters" })
assignedAgent?: string | null;
@IsOptional() @IsOptional()
@IsInt({ message: "sortOrder must be an integer" }) @IsInt({ message: "sortOrder must be an integer" })
@Min(0, { message: "sortOrder must be at least 0" }) @Min(0, { message: "sortOrder must be at least 0" })

View File

@@ -0,0 +1,162 @@
import { beforeAll, beforeEach, describe, expect, it, afterAll, vi } from "vitest";
import { randomUUID as uuid } from "crypto";
import { Test, TestingModule } from "@nestjs/testing";
import { PrismaClient } from "@prisma/client";
import { TasksService } from "./tasks.service";
import { PrismaService } from "../prisma/prisma.service";
import { ActivityService } from "../activity/activity.service";
const shouldRunDbIntegrationTests =
process.env.RUN_DB_TESTS === "true" && Boolean(process.env.DATABASE_URL);
const describeFn = shouldRunDbIntegrationTests ? describe : describe.skip;
describeFn("TasksService assignedAgent Integration", () => {
let moduleRef: TestingModule;
let prisma: PrismaClient;
let service: TasksService;
let workspaceId: string;
let ownerId: string;
let setupComplete = false;
const activityServiceMock = {
logTaskCreated: vi.fn().mockResolvedValue(undefined),
logTaskUpdated: vi.fn().mockResolvedValue(undefined),
logTaskDeleted: vi.fn().mockResolvedValue(undefined),
logTaskCompleted: vi.fn().mockResolvedValue(undefined),
logTaskAssigned: vi.fn().mockResolvedValue(undefined),
};
beforeAll(async () => {
prisma = new PrismaClient();
await prisma.$connect();
const workspace = await prisma.workspace.create({
data: {
name: `Tasks Assigned Agent Integration ${Date.now()}`,
owner: {
create: {
email: `tasks-assigned-agent-integration-${Date.now()}@example.com`,
name: "Tasks Assigned Agent Integration Owner",
},
},
},
});
workspaceId = workspace.id;
ownerId = workspace.ownerId;
moduleRef = await Test.createTestingModule({
providers: [
TasksService,
{
provide: PrismaService,
useValue: prisma,
},
{
provide: ActivityService,
useValue: activityServiceMock,
},
],
}).compile();
service = moduleRef.get<TasksService>(TasksService);
setupComplete = true;
});
beforeEach(async () => {
vi.clearAllMocks();
if (!setupComplete) {
return;
}
await prisma.task.deleteMany({ where: { workspaceId } });
});
afterAll(async () => {
if (!prisma) {
return;
}
if (workspaceId) {
await prisma.task.deleteMany({ where: { workspaceId } });
await prisma.workspace.deleteMany({ where: { id: workspaceId } });
}
if (ownerId) {
await prisma.user.deleteMany({ where: { id: ownerId } });
}
if (moduleRef) {
await moduleRef.close();
}
await prisma.$disconnect();
});
it("persists assignedAgent on create", async () => {
if (!setupComplete) {
return;
}
const task = await service.create(workspaceId, ownerId, {
title: `Assigned agent create ${uuid()}`,
assignedAgent: "fleet-worker-1",
});
expect(task.assignedAgent).toBe("fleet-worker-1");
const stored = await prisma.task.findUnique({
where: {
id: task.id,
},
select: {
id: true,
assignedAgent: true,
},
});
expect(stored).toMatchObject({
id: task.id,
assignedAgent: "fleet-worker-1",
});
const listed = await service.findAll({ workspaceId, page: 1, limit: 10 }, ownerId);
const listedTask = listed.data.find((row) => row.id === task.id);
expect(listedTask?.assignedAgent).toBe("fleet-worker-1");
});
it("updates and clears assignedAgent", async () => {
if (!setupComplete) {
return;
}
const created = await service.create(workspaceId, ownerId, {
title: `Assigned agent update ${uuid()}`,
});
expect(created.assignedAgent).toBeNull();
const updated = await service.update(created.id, workspaceId, ownerId, {
assignedAgent: "fleet-worker-2",
});
expect(updated.assignedAgent).toBe("fleet-worker-2");
const cleared = await service.update(created.id, workspaceId, ownerId, {
assignedAgent: null,
});
expect(cleared.assignedAgent).toBeNull();
const stored = await prisma.task.findUnique({
where: {
id: created.id,
},
select: {
assignedAgent: true,
},
});
expect(stored?.assignedAgent).toBeNull();
});
});

View File

@@ -48,6 +48,7 @@ describe("TasksService", () => {
creatorId: mockUserId, creatorId: mockUserId,
projectId: null, projectId: null,
parentId: null, parentId: null,
assignedAgent: null,
sortOrder: 0, sortOrder: 0,
metadata: {}, metadata: {},
createdAt: new Date(), createdAt: new Date(),
@@ -158,6 +159,28 @@ describe("TasksService", () => {
}) })
); );
}); });
it("should include assignedAgent when provided", async () => {
const createDto = {
title: "Agent-owned Task",
assignedAgent: "fleet-worker-1",
};
mockPrismaService.task.create.mockResolvedValue({
...mockTask,
assignedAgent: createDto.assignedAgent,
});
await service.create(mockWorkspaceId, mockUserId, createDto);
expect(prisma.task.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
assignedAgent: createDto.assignedAgent,
}),
})
);
});
}); });
describe("findAll", () => { describe("findAll", () => {
@@ -469,6 +492,26 @@ describe("TasksService", () => {
service.update(mockTaskId, mockWorkspaceId, mockUserId, { title: "Test" }) service.update(mockTaskId, mockWorkspaceId, mockUserId, { title: "Test" })
).rejects.toThrow(NotFoundException); ).rejects.toThrow(NotFoundException);
}); });
it("should update assignedAgent when provided", async () => {
const updateDto = { assignedAgent: "fleet-worker-2" };
mockPrismaService.task.findUnique.mockResolvedValue(mockTask);
mockPrismaService.task.update.mockResolvedValue({
...mockTask,
assignedAgent: updateDto.assignedAgent,
});
await service.update(mockTaskId, mockWorkspaceId, mockUserId, updateDto);
expect(prisma.task.update).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
assignedAgent: updateDto.assignedAgent,
}),
})
);
});
}); });
describe("remove", () => { describe("remove", () => {

View File

@@ -67,6 +67,9 @@ export class TasksService {
metadata: createTaskDto.metadata metadata: createTaskDto.metadata
? (createTaskDto.metadata as unknown as Prisma.InputJsonValue) ? (createTaskDto.metadata as unknown as Prisma.InputJsonValue)
: {}, : {},
...(createTaskDto.assignedAgent !== undefined && {
assignedAgent: createTaskDto.assignedAgent,
}),
...(assigneeConnection && { assignee: assigneeConnection }), ...(assigneeConnection && { assignee: assigneeConnection }),
...(projectConnection && { project: projectConnection }), ...(projectConnection && { project: projectConnection }),
...(parentConnection && { parent: parentConnection }), ...(parentConnection && { parent: parentConnection }),
@@ -291,6 +294,9 @@ export class TasksService {
if (updateTaskDto.parentId !== undefined && updateTaskDto.parentId !== null) { if (updateTaskDto.parentId !== undefined && updateTaskDto.parentId !== null) {
data.parent = { connect: { id: updateTaskDto.parentId } }; data.parent = { connect: { id: updateTaskDto.parentId } };
} }
if (updateTaskDto.assignedAgent !== undefined) {
data.assignedAgent = updateTaskDto.assignedAgent;
}
// Handle completedAt based on status changes // Handle completedAt based on status changes
if (updateTaskDto.status) { if (updateTaskDto.status) {

View File

@@ -0,0 +1,31 @@
import { describe, expect, it } from "vitest";
import { WidgetsController } from "./widgets.controller";
const THROTTLER_SKIP_DEFAULT_KEY = "THROTTLER:SKIPdefault";
describe("WidgetsController throttler metadata", () => {
it("marks widget data polling endpoints to skip throttling", () => {
const pollingHandlers = [
WidgetsController.prototype.getStatCardData,
WidgetsController.prototype.getChartData,
WidgetsController.prototype.getListData,
WidgetsController.prototype.getCalendarPreviewData,
WidgetsController.prototype.getActiveProjectsData,
WidgetsController.prototype.getAgentChainsData,
];
for (const handler of pollingHandlers) {
expect(Reflect.getMetadata(THROTTLER_SKIP_DEFAULT_KEY, handler)).toBe(true);
}
});
it("does not skip throttling for non-polling widget routes", () => {
expect(
Reflect.getMetadata(THROTTLER_SKIP_DEFAULT_KEY, WidgetsController.prototype.findAll)
).toBe(undefined);
expect(
Reflect.getMetadata(THROTTLER_SKIP_DEFAULT_KEY, WidgetsController.prototype.findByName)
).toBe(undefined);
});
});

View File

@@ -1,4 +1,5 @@
import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common"; import { Controller, Get, Post, Body, Param, UseGuards, Request } from "@nestjs/common";
import { SkipThrottle as SkipThrottler } from "@nestjs/throttler";
import { WidgetsService } from "./widgets.service"; import { WidgetsService } from "./widgets.service";
import { WidgetDataService } from "./widget-data.service"; import { WidgetDataService } from "./widget-data.service";
import { AuthGuard } from "../auth/guards/auth.guard"; import { AuthGuard } from "../auth/guards/auth.guard";
@@ -43,6 +44,7 @@ export class WidgetsController {
* Get stat card widget data * Get stat card widget data
*/ */
@Post("data/stat-card") @Post("data/stat-card")
@SkipThrottler()
@UseGuards(WorkspaceGuard) @UseGuards(WorkspaceGuard)
async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) { async getStatCardData(@Request() req: RequestWithWorkspace, @Body() query: StatCardQueryDto) {
return this.widgetDataService.getStatCardData(req.workspace.id, query); return this.widgetDataService.getStatCardData(req.workspace.id, query);
@@ -53,6 +55,7 @@ export class WidgetsController {
* Get chart widget data * Get chart widget data
*/ */
@Post("data/chart") @Post("data/chart")
@SkipThrottler()
@UseGuards(WorkspaceGuard) @UseGuards(WorkspaceGuard)
async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) { async getChartData(@Request() req: RequestWithWorkspace, @Body() query: ChartQueryDto) {
return this.widgetDataService.getChartData(req.workspace.id, query); return this.widgetDataService.getChartData(req.workspace.id, query);
@@ -63,6 +66,7 @@ export class WidgetsController {
* Get list widget data * Get list widget data
*/ */
@Post("data/list") @Post("data/list")
@SkipThrottler()
@UseGuards(WorkspaceGuard) @UseGuards(WorkspaceGuard)
async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) { async getListData(@Request() req: RequestWithWorkspace, @Body() query: ListQueryDto) {
return this.widgetDataService.getListData(req.workspace.id, query); return this.widgetDataService.getListData(req.workspace.id, query);
@@ -73,6 +77,7 @@ export class WidgetsController {
* Get calendar preview widget data * Get calendar preview widget data
*/ */
@Post("data/calendar-preview") @Post("data/calendar-preview")
@SkipThrottler()
@UseGuards(WorkspaceGuard) @UseGuards(WorkspaceGuard)
async getCalendarPreviewData( async getCalendarPreviewData(
@Request() req: RequestWithWorkspace, @Request() req: RequestWithWorkspace,
@@ -86,6 +91,7 @@ export class WidgetsController {
* Get active projects widget data * Get active projects widget data
*/ */
@Post("data/active-projects") @Post("data/active-projects")
@SkipThrottler()
@UseGuards(WorkspaceGuard) @UseGuards(WorkspaceGuard)
async getActiveProjectsData(@Request() req: RequestWithWorkspace) { async getActiveProjectsData(@Request() req: RequestWithWorkspace) {
return this.widgetDataService.getActiveProjectsData(req.workspace.id); return this.widgetDataService.getActiveProjectsData(req.workspace.id);
@@ -96,6 +102,7 @@ export class WidgetsController {
* Get agent chains widget data (active agent sessions) * Get agent chains widget data (active agent sessions)
*/ */
@Post("data/agent-chains") @Post("data/agent-chains")
@SkipThrottler()
@UseGuards(WorkspaceGuard) @UseGuards(WorkspaceGuard)
async getAgentChainsData(@Request() req: RequestWithWorkspace) { async getAgentChainsData(@Request() req: RequestWithWorkspace) {
return this.widgetDataService.getAgentChainsData(req.workspace.id); return this.widgetDataService.getAgentChainsData(req.workspace.id);

View File

@@ -0,0 +1,491 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import type { ReactElement } from "react";
import { useParams, useRouter } from "next/navigation";
import { ArrowLeft } from "lucide-react";
import { MosaicSpinner } from "@/components/ui/MosaicSpinner";
import { fetchProject, type ProjectDetail } from "@/lib/api/projects";
import { useWorkspaceId } from "@/lib/hooks";
interface BadgeStyle {
label: string;
bg: string;
color: string;
}
interface StatusBadgeProps {
style: BadgeStyle;
}
interface MetaItemProps {
label: string;
value: string;
}
function getProjectStatusStyle(status: string): BadgeStyle {
switch (status) {
case "PLANNING":
return { label: "Planning", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" };
case "ACTIVE":
return { label: "Active", bg: "rgba(20,184,166,0.15)", color: "var(--success)" };
case "PAUSED":
return { label: "Paused", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
case "COMPLETED":
return { label: "Completed", bg: "rgba(139,92,246,0.15)", color: "var(--purple)" };
case "ARCHIVED":
return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
default:
return { label: status, bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
}
}
function getPriorityStyle(priority: string | null | undefined): BadgeStyle {
switch (priority) {
case "HIGH":
return { label: "High", bg: "rgba(229,72,77,0.15)", color: "var(--danger)" };
case "MEDIUM":
return { label: "Medium", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
case "LOW":
return { label: "Low", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
default:
return { label: "Unspecified", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
}
}
function getTaskStatusStyle(status: string): BadgeStyle {
switch (status) {
case "NOT_STARTED":
return { label: "Not Started", bg: "rgba(47,128,255,0.15)", color: "var(--primary)" };
case "IN_PROGRESS":
return { label: "In Progress", bg: "rgba(245,158,11,0.15)", color: "var(--warn)" };
case "PAUSED":
return { label: "Paused", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
case "COMPLETED":
return { label: "Completed", bg: "rgba(20,184,166,0.15)", color: "var(--success)" };
case "ARCHIVED":
return { label: "Archived", bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
default:
return { label: status, bg: "rgba(143,157,183,0.15)", color: "var(--muted)" };
}
}
function formatDate(iso: string | null | undefined): string {
if (!iso) return "Not set";
try {
return new Date(iso).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
} catch {
return iso;
}
}
function formatDateTime(iso: string | null | undefined): string {
if (!iso) return "Not set";
try {
return new Date(iso).toLocaleString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
hour: "numeric",
minute: "2-digit",
});
} catch {
return iso;
}
}
function toFriendlyErrorMessage(error: unknown): string {
const fallback = "We had trouble loading this project. Please try again when you're ready.";
if (!(error instanceof Error)) {
return fallback;
}
const message = error.message.trim();
if (message.toLowerCase().includes("not found")) {
return "Project not found. It may have been deleted or you may not have access to it.";
}
return message || fallback;
}
function StatusBadge({ style: statusStyle }: StatusBadgeProps): ReactElement {
return (
<span
style={{
display: "inline-flex",
alignItems: "center",
padding: "2px 10px",
borderRadius: "var(--r)",
background: statusStyle.bg,
color: statusStyle.color,
fontSize: "0.75rem",
fontWeight: 500,
}}
>
{statusStyle.label}
</span>
);
}
function MetaItem({ label, value }: MetaItemProps): ReactElement {
return (
<div
style={{
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: "var(--r)",
padding: "10px 12px",
}}
>
<p style={{ margin: "0 0 4px", fontSize: "0.75rem", color: "var(--muted)" }}>{label}</p>
<p style={{ margin: 0, fontSize: "0.85rem", color: "var(--text)" }}>{value}</p>
</div>
);
}
export default function ProjectDetailPage(): ReactElement {
const router = useRouter();
const params = useParams<{ id: string | string[] }>();
const workspaceId = useWorkspaceId();
const rawProjectId = params.id;
const projectId = Array.isArray(rawProjectId) ? (rawProjectId[0] ?? null) : rawProjectId;
const [project, setProject] = useState<ProjectDetail | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadProject = useCallback(async (id: string, wsId: string): Promise<void> => {
try {
setIsLoading(true);
setError(null);
const data = await fetchProject(id, wsId);
setProject(data);
} catch (err: unknown) {
console.error("[ProjectDetail] Failed to fetch project:", err);
setProject(null);
setError(toFriendlyErrorMessage(err));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
if (!projectId) {
setProject(null);
setError("The project link is invalid. Please return to the projects page.");
setIsLoading(false);
return;
}
if (!workspaceId) {
setProject(null);
setError("Select a workspace to view this project.");
setIsLoading(false);
return;
}
const id = projectId;
const wsId = workspaceId;
let cancelled = false;
async function load(): Promise<void> {
try {
setIsLoading(true);
setError(null);
const data = await fetchProject(id, wsId);
if (!cancelled) {
setProject(data);
}
} catch (err: unknown) {
console.error("[ProjectDetail] Failed to fetch project:", err);
if (!cancelled) {
setProject(null);
setError(toFriendlyErrorMessage(err));
}
} finally {
if (!cancelled) {
setIsLoading(false);
}
}
}
void load();
return (): void => {
cancelled = true;
};
}, [projectId, workspaceId]);
function handleRetry(): void {
if (!projectId || !workspaceId) return;
void loadProject(projectId, workspaceId);
}
function handleBack(): void {
router.push("/projects");
}
const projectStatus = project ? getProjectStatusStyle(project.status) : null;
const projectPriority = project ? getPriorityStyle(project.priority) : null;
const dueDate = project?.dueDate ?? project?.endDate;
const creator =
project?.creator.name && project.creator.name.trim().length > 0
? `${project.creator.name} (${project.creator.email})`
: (project?.creator.email ?? "Unknown");
return (
<main className="container mx-auto px-4 py-8" style={{ maxWidth: 960 }}>
<button
onClick={handleBack}
style={{
display: "inline-flex",
alignItems: "center",
gap: 8,
marginBottom: 20,
padding: "8px 12px",
borderRadius: "var(--r)",
border: "1px solid var(--border)",
background: "var(--surface)",
color: "var(--text-2)",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
}}
>
<ArrowLeft size={16} />
Back to projects
</button>
{isLoading ? (
<div className="flex justify-center py-16">
<MosaicSpinner label="Loading project..." />
</div>
) : error !== null ? (
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
padding: 32,
textAlign: "center",
}}
>
<p style={{ color: "var(--danger)", margin: "0 0 20px" }}>{error}</p>
<div style={{ display: "flex", gap: 12, justifyContent: "center", flexWrap: "wrap" }}>
<button
onClick={handleBack}
style={{
padding: "8px 16px",
background: "transparent",
border: "1px solid var(--border)",
borderRadius: "var(--r)",
color: "var(--text-2)",
fontSize: "0.85rem",
cursor: "pointer",
}}
>
Back to projects
</button>
<button
onClick={handleRetry}
style={{
padding: "8px 16px",
background: "var(--danger)",
border: "none",
borderRadius: "var(--r)",
color: "#fff",
fontSize: "0.85rem",
fontWeight: 500,
cursor: "pointer",
}}
>
Try again
</button>
</div>
</div>
) : project === null ? (
<div
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
padding: 32,
textAlign: "center",
}}
>
<p style={{ color: "var(--muted)", margin: 0 }}>Project details are not available.</p>
</div>
) : (
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<section
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
padding: 24,
}}
>
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: 12,
flexWrap: "wrap",
}}
>
<div style={{ minWidth: 0 }}>
<h1
style={{ margin: 0, fontSize: "1.875rem", fontWeight: 700, color: "var(--text)" }}
>
{project.name}
</h1>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
{projectStatus && <StatusBadge style={projectStatus} />}
{projectPriority && <StatusBadge style={projectPriority} />}
</div>
</div>
{project.description ? (
<p
style={{
margin: "14px 0 0",
color: "var(--muted)",
fontSize: "0.9rem",
lineHeight: 1.6,
}}
>
{project.description}
</p>
) : (
<p
style={{
margin: "14px 0 0",
color: "var(--muted)",
fontSize: "0.9rem",
lineHeight: 1.6,
fontStyle: "italic",
}}
>
No description provided.
</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-3" style={{ marginTop: 18 }}>
<MetaItem label="Start date" value={formatDate(project.startDate)} />
<MetaItem label="Due date" value={formatDate(dueDate)} />
<MetaItem label="Created" value={formatDateTime(project.createdAt)} />
<MetaItem label="Updated" value={formatDateTime(project.updatedAt)} />
<MetaItem label="Creator" value={creator} />
<MetaItem
label="Work items"
value={`${String(project._count.tasks)} tasks · ${String(project._count.events)} events`}
/>
</div>
</section>
<section
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
padding: 24,
}}
>
<h2 style={{ margin: "0 0 12px", fontSize: "1.1rem", color: "var(--text)" }}>
Tasks ({String(project._count.tasks)})
</h2>
{project.tasks.length === 0 ? (
<p style={{ margin: 0, color: "var(--muted)", fontSize: "0.9rem" }}>
No tasks yet for this project.
</p>
) : (
<div>
{project.tasks.map((task, index) => (
<div
key={task.id}
style={{
padding: "12px 0",
borderTop: index === 0 ? "none" : "1px solid var(--border)",
}}
>
<div
style={{
display: "flex",
alignItems: "flex-start",
justifyContent: "space-between",
gap: 12,
flexWrap: "wrap",
}}
>
<div style={{ minWidth: 0 }}>
<p style={{ margin: 0, color: "var(--text)", fontWeight: 500 }}>
{task.title}
</p>
<p style={{ margin: "4px 0 0", color: "var(--muted)", fontSize: "0.8rem" }}>
Due: {formatDate(task.dueDate)}
</p>
</div>
<div style={{ display: "flex", gap: 8, flexWrap: "wrap" }}>
<StatusBadge style={getTaskStatusStyle(task.status)} />
<StatusBadge style={getPriorityStyle(task.priority)} />
</div>
</div>
</div>
))}
</div>
)}
</section>
<section
style={{
background: "var(--surface)",
border: "1px solid var(--border)",
borderRadius: "var(--r-lg)",
padding: 24,
}}
>
<h2 style={{ margin: "0 0 12px", fontSize: "1.1rem", color: "var(--text)" }}>
Events ({String(project._count.events)})
</h2>
{project.events.length === 0 ? (
<p style={{ margin: 0, color: "var(--muted)", fontSize: "0.9rem" }}>
No events scheduled for this project.
</p>
) : (
<div>
{project.events.map((event, index) => (
<div
key={event.id}
style={{
padding: "12px 0",
borderTop: index === 0 ? "none" : "1px solid var(--border)",
}}
>
<p style={{ margin: 0, color: "var(--text)", fontWeight: 500 }}>
{event.title}
</p>
<p style={{ margin: "4px 0 0", color: "var(--muted)", fontSize: "0.8rem" }}>
{formatDateTime(event.startTime)} - {formatDateTime(event.endTime)}
</p>
</div>
))}
</div>
)}
</section>
</div>
)}
</main>
);
}

View File

@@ -0,0 +1,356 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useState,
type ChangeEvent,
type ReactElement,
type SyntheticEvent,
} from "react";
import { FleetSettingsNav } from "@/components/settings/FleetSettingsNav";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Textarea } from "@/components/ui/textarea";
import {
fetchFleetAgentConfig,
fetchFleetProviders,
updateFleetAgentConfig,
type FleetProvider,
type FleetProviderModel,
type UpdateFleetAgentConfigRequest,
} from "@/lib/api/fleet-settings";
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return fallback;
}
function normalizeProviderModels(models: unknown): FleetProviderModel[] {
if (!Array.isArray(models)) {
return [];
}
const parsed: FleetProviderModel[] = [];
models.forEach((entry) => {
if (typeof entry === "string" && entry.trim().length > 0) {
parsed.push({ id: entry.trim(), name: entry.trim() });
return;
}
if (entry && typeof entry === "object") {
const record = entry as Record<string, unknown>;
const id =
typeof record.id === "string"
? record.id.trim()
: typeof record.name === "string"
? record.name.trim()
: "";
if (id.length > 0) {
parsed.push({ id, name: id });
}
}
});
const seen = new Set<string>();
return parsed.filter((model) => {
if (seen.has(model.id)) {
return false;
}
seen.add(model.id);
return true;
});
}
function parseModelList(value: string): string[] {
const seen = new Set<string>();
return value
.split(/\n|,/g)
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0)
.filter((segment) => {
if (seen.has(segment)) {
return false;
}
seen.add(segment);
return true;
});
}
function deriveAvailableModels(providers: FleetProvider[]): string[] {
const seen = new Set<string>();
const models: string[] = [];
providers.forEach((provider) => {
normalizeProviderModels(provider.models).forEach((model) => {
if (seen.has(model.id)) {
return;
}
seen.add(model.id);
models.push(model.id);
});
});
return models.sort((left, right) => left.localeCompare(right));
}
export default function AgentConfigSettingsPage(): ReactElement {
const [providers, setProviders] = useState<FleetProvider[]>([]);
const [primaryModel, setPrimaryModel] = useState<string>("");
const [fallbackModelsText, setFallbackModelsText] = useState<string>("");
const [personality, setPersonality] = useState<string>("");
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const availableModels = useMemo(() => deriveAvailableModels(providers), [providers]);
const fallbackModels = useMemo(() => parseModelList(fallbackModelsText), [fallbackModelsText]);
const modelSelectOptions = useMemo(() => {
if (primaryModel.length > 0 && !availableModels.includes(primaryModel)) {
return [primaryModel, ...availableModels];
}
return availableModels;
}, [availableModels, primaryModel]);
const loadSettings = useCallback(async (): Promise<void> => {
setIsLoading(true);
try {
const [providerData, agentConfig] = await Promise.all([
fetchFleetProviders(),
fetchFleetAgentConfig(),
]);
setProviders(providerData);
setPrimaryModel(agentConfig.primaryModel ?? "");
setFallbackModelsText(agentConfig.fallbackModels.join("\n"));
setPersonality(agentConfig.personality ?? "");
setError(null);
} catch (loadError: unknown) {
setError(getErrorMessage(loadError, "Failed to load agent configuration."));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void loadSettings();
}, [loadSettings]);
function appendFallbackModel(model: string): void {
const current = parseModelList(fallbackModelsText);
if (current.includes(model)) {
return;
}
const next = [...current, model];
setFallbackModelsText(next.join("\n"));
}
async function handleSave(event: SyntheticEvent): Promise<void> {
event.preventDefault();
setError(null);
setSuccessMessage(null);
const updatePayload: UpdateFleetAgentConfigRequest = {
personality: personality.trim(),
};
if (primaryModel.trim().length > 0) {
updatePayload.primaryModel = primaryModel.trim();
}
const parsedFallbacks = parseModelList(fallbackModelsText).filter(
(model) => model !== primaryModel.trim()
);
if (parsedFallbacks.length > 0) {
updatePayload.fallbackModels = parsedFallbacks;
}
try {
setIsSaving(true);
await updateFleetAgentConfig(updatePayload);
setSuccessMessage("Agent configuration saved.");
await loadSettings();
} catch (saveError: unknown) {
setError(getErrorMessage(saveError, "Failed to save agent configuration."));
} finally {
setIsSaving(false);
}
}
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="space-y-4">
<div>
<h1 className="text-3xl font-bold">Agent Configuration</h1>
<p className="text-muted-foreground mt-1">
Assign primary and fallback models for your agent runtime behavior.
</p>
</div>
<FleetSettingsNav />
</div>
<Card>
<CardHeader>
<CardTitle>Current Assignment</CardTitle>
<CardDescription>
Snapshot of your currently saved model routing configuration.
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading configuration...</p>
) : (
<>
<div>
<p className="text-sm font-medium">Primary Model</p>
<p className="text-sm text-muted-foreground">
{primaryModel.length > 0 ? primaryModel : "No primary model configured"}
</p>
</div>
<div>
<p className="text-sm font-medium">Fallback Models</p>
{fallbackModels.length === 0 ? (
<p className="text-sm text-muted-foreground">No fallback models configured</p>
) : (
<div className="flex flex-wrap gap-2 mt-2">
{fallbackModels.map((model) => (
<Badge key={`current-${model}`} variant="outline">
{model}
</Badge>
))}
</div>
)}
</div>
</>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Update Agent Config</CardTitle>
<CardDescription>
Select a primary model and define fallback ordering. Models come from your provider
settings.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={(event) => void handleSave(event)} className="space-y-5">
<div className="space-y-2">
<Label htmlFor="primary-model">Primary Model</Label>
<Select
value={primaryModel.length > 0 ? primaryModel : "__none__"}
onValueChange={(value) => {
setPrimaryModel(value === "__none__" ? "" : value);
}}
disabled={isLoading || isSaving}
>
<SelectTrigger id="primary-model">
<SelectValue placeholder="Select a primary model" />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">No primary model selected</SelectItem>
{modelSelectOptions.map((model) => (
<SelectItem key={model} value={model}>
{model}
</SelectItem>
))}
</SelectContent>
</Select>
{availableModels.length === 0 ? (
<p className="text-xs text-muted-foreground">
No models available yet. Add provider models first in Providers settings.
</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="fallback-models">Fallback Models</Label>
<Textarea
id="fallback-models"
value={fallbackModelsText}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
setFallbackModelsText(event.target.value);
}}
rows={4}
placeholder={"One model per line\nExample: gpt-4.1-mini"}
disabled={isLoading || isSaving}
/>
{availableModels.length > 0 ? (
<div className="flex flex-wrap gap-2">
{availableModels
.filter((model) => model !== primaryModel)
.map((model) => (
<Button
key={`suggest-${model}`}
type="button"
variant="outline"
size="sm"
onClick={() => {
appendFallbackModel(model);
}}
disabled={fallbackModels.includes(model) || isSaving}
>
{fallbackModels.includes(model) ? `Added: ${model}` : `Add ${model}`}
</Button>
))}
</div>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="agent-personality">Personality / SOUL</Label>
<Textarea
id="agent-personality"
value={personality}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
setPersonality(event.target.value);
}}
rows={8}
placeholder="Optional system personality instructions..."
disabled={isLoading || isSaving}
/>
</div>
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
{successMessage ? <p className="text-sm text-emerald-600">{successMessage}</p> : null}
<Button type="submit" disabled={isLoading || isSaving}>
{isSaving ? "Saving..." : "Save Agent Config"}
</Button>
</form>
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,492 @@
"use client";
import {
useCallback,
useEffect,
useState,
type ChangeEvent,
type ReactElement,
type SyntheticEvent,
} from "react";
import { FleetSettingsNav } from "@/components/settings/FleetSettingsNav";
import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
deleteFleetOidcConfig,
fetchFleetOidcConfig,
resetBreakglassAdminPassword,
updateFleetOidcConfig,
type FleetOidcConfig,
} from "@/lib/api/fleet-settings";
import { fetchOnboardingStatus } from "@/lib/api/onboarding";
interface OidcFormState {
issuerUrl: string;
clientId: string;
clientSecret: string;
}
interface BreakglassFormState {
username: string;
currentPassword: string;
newPassword: string;
confirmPassword: string;
}
const INITIAL_OIDC_FORM: OidcFormState = {
issuerUrl: "",
clientId: "",
clientSecret: "",
};
const INITIAL_BREAKGLASS_FORM: BreakglassFormState = {
username: "",
currentPassword: "",
newPassword: "",
confirmPassword: "",
};
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return fallback;
}
function isAdminGuardError(error: unknown): boolean {
if (!(error instanceof Error)) {
return false;
}
const normalized = error.message.toLowerCase();
return (
normalized.includes("requires system administrator") ||
normalized.includes("forbidden") ||
normalized.includes("403")
);
}
export default function AuthSettingsPage(): ReactElement {
const [oidcConfig, setOidcConfig] = useState<FleetOidcConfig | null>(null);
const [oidcForm, setOidcForm] = useState<OidcFormState>(INITIAL_OIDC_FORM);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isSavingOidc, setIsSavingOidc] = useState<boolean>(false);
const [isDeletingOidc, setIsDeletingOidc] = useState<boolean>(false);
const [oidcError, setOidcError] = useState<string | null>(null);
const [oidcSuccessMessage, setOidcSuccessMessage] = useState<string | null>(null);
const [showRemoveOidcDialog, setShowRemoveOidcDialog] = useState<boolean>(false);
const [breakglassForm, setBreakglassForm] =
useState<BreakglassFormState>(INITIAL_BREAKGLASS_FORM);
const [breakglassStatus, setBreakglassStatus] = useState<"active" | "inactive">("inactive");
const [isResettingPassword, setIsResettingPassword] = useState<boolean>(false);
const [breakglassError, setBreakglassError] = useState<string | null>(null);
const [breakglassSuccessMessage, setBreakglassSuccessMessage] = useState<string | null>(null);
const [isAccessDenied, setIsAccessDenied] = useState<boolean>(false);
const loadAuthSettings = useCallback(async (): Promise<void> => {
setIsLoading(true);
try {
const [oidcResponse, onboardingStatus] = await Promise.all([
fetchFleetOidcConfig(),
fetchOnboardingStatus().catch(() => ({ completed: false })),
]);
setOidcConfig(oidcResponse);
setOidcForm({
issuerUrl: oidcResponse.issuerUrl ?? "",
clientId: oidcResponse.clientId ?? "",
clientSecret: "",
});
setBreakglassStatus(onboardingStatus.completed ? "active" : "inactive");
setIsAccessDenied(false);
setOidcError(null);
} catch (loadError: unknown) {
if (isAdminGuardError(loadError)) {
setIsAccessDenied(true);
return;
}
setOidcError(getErrorMessage(loadError, "Failed to load authentication settings."));
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
void loadAuthSettings();
}, [loadAuthSettings]);
async function handleSaveOidc(event: SyntheticEvent): Promise<void> {
event.preventDefault();
setOidcError(null);
setOidcSuccessMessage(null);
const issuerUrl = oidcForm.issuerUrl.trim();
const clientId = oidcForm.clientId.trim();
const clientSecret = oidcForm.clientSecret.trim();
if (issuerUrl.length === 0 || clientId.length === 0 || clientSecret.length === 0) {
setOidcError("Issuer URL, client ID, and client secret are required.");
return;
}
try {
setIsSavingOidc(true);
await updateFleetOidcConfig({
issuerUrl,
clientId,
clientSecret,
});
setOidcSuccessMessage("OIDC configuration updated.");
await loadAuthSettings();
} catch (saveError: unknown) {
setOidcError(getErrorMessage(saveError, "Failed to update OIDC configuration."));
} finally {
setIsSavingOidc(false);
}
}
async function handleRemoveOidc(): Promise<void> {
try {
setIsDeletingOidc(true);
await deleteFleetOidcConfig();
setOidcSuccessMessage("OIDC configuration removed.");
setShowRemoveOidcDialog(false);
await loadAuthSettings();
} catch (deleteError: unknown) {
setOidcError(getErrorMessage(deleteError, "Failed to remove OIDC configuration."));
} finally {
setIsDeletingOidc(false);
}
}
async function handleResetBreakglassPassword(event: SyntheticEvent): Promise<void> {
event.preventDefault();
setBreakglassError(null);
setBreakglassSuccessMessage(null);
const username = breakglassForm.username.trim();
const newPassword = breakglassForm.newPassword;
const confirmPassword = breakglassForm.confirmPassword;
if (username.length === 0) {
setBreakglassError("Username is required.");
return;
}
if (newPassword.length < 8) {
setBreakglassError("New password must be at least 8 characters.");
return;
}
if (newPassword !== confirmPassword) {
setBreakglassError("Password confirmation does not match.");
return;
}
try {
setIsResettingPassword(true);
await resetBreakglassAdminPassword({
username,
newPassword,
});
setBreakglassSuccessMessage(`Password reset for "${username}".`);
setBreakglassStatus("active");
setBreakglassForm((previous) => ({
...previous,
currentPassword: "",
newPassword: "",
confirmPassword: "",
}));
} catch (resetError: unknown) {
setBreakglassError(getErrorMessage(resetError, "Failed to reset breakglass password."));
} finally {
setIsResettingPassword(false);
}
}
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="space-y-4">
<div>
<h1 className="text-3xl font-bold">Authentication Settings</h1>
<p className="text-muted-foreground mt-1">
Configure OIDC and breakglass admin recovery credentials.
</p>
</div>
<FleetSettingsNav />
</div>
{isLoading ? (
<Card>
<CardContent className="py-8 text-sm text-muted-foreground">
Loading authentication settings...
</CardContent>
</Card>
) : null}
{!isLoading && isAccessDenied ? (
<SettingsAccessDenied message="Authentication settings require system administrator privileges." />
) : null}
{!isLoading && !isAccessDenied ? (
<>
<Card>
<CardHeader>
<CardTitle>OIDC Provider</CardTitle>
<CardDescription>
Manage your OpenID Connect issuer and OAuth client credentials.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="rounded-lg border p-4 space-y-2">
<div className="flex items-center gap-2">
<p className="font-medium">Configured</p>
<Badge variant={oidcConfig?.configured ? "default" : "secondary"}>
{oidcConfig?.configured ? "Yes" : "No"}
</Badge>
</div>
<p className="text-sm text-muted-foreground">
Issuer URL: {oidcConfig?.issuerUrl ?? "Not configured"}
</p>
<p className="text-sm text-muted-foreground">
Client ID: {oidcConfig?.clientId ?? "Not configured"}
</p>
<p className="text-sm text-muted-foreground">Client secret: hidden</p>
</div>
<form onSubmit={(event) => void handleSaveOidc(event)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="oidc-issuer-url">Issuer URL</Label>
<Input
id="oidc-issuer-url"
value={oidcForm.issuerUrl}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setOidcForm((previous) => ({ ...previous, issuerUrl: event.target.value }));
}}
placeholder="https://issuer.example.com"
disabled={isSavingOidc}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="oidc-client-id">Client ID</Label>
<Input
id="oidc-client-id"
value={oidcForm.clientId}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setOidcForm((previous) => ({ ...previous, clientId: event.target.value }));
}}
placeholder="mosaic-web"
disabled={isSavingOidc}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="oidc-client-secret">Client Secret</Label>
<Input
id="oidc-client-secret"
type="password"
value={oidcForm.clientSecret}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setOidcForm((previous) => ({
...previous,
clientSecret: event.target.value,
}));
}}
placeholder="Enter new secret"
autoComplete="new-password"
disabled={isSavingOidc}
required
/>
<p className="text-xs text-muted-foreground">
The secret is encrypted on save and never returned to the UI.
</p>
</div>
{oidcError ? (
<p className="text-sm text-destructive" role="alert">
{oidcError}
</p>
) : null}
{oidcSuccessMessage ? (
<p className="text-sm text-emerald-600">{oidcSuccessMessage}</p>
) : null}
<div className="flex items-center gap-2">
<Button type="submit" disabled={isSavingOidc}>
{isSavingOidc ? "Saving..." : "Save OIDC"}
</Button>
<Button
type="button"
variant="destructive"
onClick={() => {
setShowRemoveOidcDialog(true);
}}
disabled={isDeletingOidc || !oidcConfig?.configured}
>
Remove OIDC
</Button>
</div>
</form>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Breakglass Admin</CardTitle>
<CardDescription>
Reset breakglass credentials for emergency local access.
</CardDescription>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex items-center gap-2">
<p className="font-medium">Status</p>
<Badge variant={breakglassStatus === "active" ? "default" : "secondary"}>
{breakglassStatus}
</Badge>
</div>
<form
onSubmit={(event) => void handleResetBreakglassPassword(event)}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="breakglass-username">Username</Label>
<Input
id="breakglass-username"
value={breakglassForm.username}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setBreakglassForm((previous) => ({
...previous,
username: event.target.value,
}));
}}
placeholder="admin"
disabled={isResettingPassword}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="breakglass-current-password">Current Password (optional)</Label>
<Input
id="breakglass-current-password"
type="password"
value={breakglassForm.currentPassword}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setBreakglassForm((previous) => ({
...previous,
currentPassword: event.target.value,
}));
}}
placeholder="Optional operator confirmation"
autoComplete="current-password"
disabled={isResettingPassword}
/>
</div>
<div className="space-y-2">
<Label htmlFor="breakglass-new-password">New Password</Label>
<Input
id="breakglass-new-password"
type="password"
value={breakglassForm.newPassword}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setBreakglassForm((previous) => ({
...previous,
newPassword: event.target.value,
}));
}}
placeholder="At least 8 characters"
autoComplete="new-password"
disabled={isResettingPassword}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="breakglass-confirm-password">Confirm Password</Label>
<Input
id="breakglass-confirm-password"
type="password"
value={breakglassForm.confirmPassword}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setBreakglassForm((previous) => ({
...previous,
confirmPassword: event.target.value,
}));
}}
placeholder="Re-enter password"
autoComplete="new-password"
disabled={isResettingPassword}
required
/>
</div>
{breakglassError ? (
<p className="text-sm text-destructive" role="alert">
{breakglassError}
</p>
) : null}
{breakglassSuccessMessage ? (
<p className="text-sm text-emerald-600">{breakglassSuccessMessage}</p>
) : null}
<Button type="submit" disabled={isResettingPassword}>
{isResettingPassword ? "Resetting..." : "Reset Password"}
</Button>
</form>
</CardContent>
</Card>
</>
) : null}
<AlertDialog
open={showRemoveOidcDialog}
onOpenChange={(open) => {
if (!open && !isDeletingOidc) {
setShowRemoveOidcDialog(false);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Remove OIDC Configuration</AlertDialogTitle>
<AlertDialogDescription>
This will remove issuer URL, client ID, and client secret from system configuration.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeletingOidc}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleRemoveOidc} disabled={isDeletingOidc}>
{isDeletingOidc ? "Removing..." : "Remove OIDC"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -200,6 +200,82 @@ const categories: CategoryConfig[] = [
</svg> </svg>
), ),
}, },
{
title: "LLM Providers",
description:
"Add and manage LLM providers, encrypted API keys, base URLs, and model inventories.",
href: "/settings/providers",
accent: "var(--ms-blue-400)",
iconBg: "rgba(47, 128, 255, 0.12)",
icon: (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="2.5" y="4" width="15" height="12" rx="2" />
<path d="M2.5 8h15" />
<circle cx="6" cy="12" r="1" />
<circle cx="10" cy="12" r="1" />
<circle cx="14" cy="12" r="1" />
</svg>
),
},
{
title: "Agent Config",
description: "Choose primary and fallback models, plus optional personality/SOUL instructions.",
href: "/settings/agent-config",
accent: "var(--ms-teal-400)",
iconBg: "rgba(20, 184, 166, 0.12)",
icon: (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<path d="M4 5h12" />
<path d="M4 10h12" />
<path d="M4 15h7" />
<circle cx="14.5" cy="15" r="1.5" />
</svg>
),
},
{
title: "Authentication",
description: "Manage OIDC provider settings and breakglass admin password recovery.",
href: "/settings/auth",
accent: "var(--ms-amber-400)",
iconBg: "rgba(245, 158, 11, 0.12)",
icon: (
<svg
width="20"
height="20"
viewBox="0 0 20 20"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="5" y="8" width="10" height="8" rx="1.5" />
<path d="M7 8V6a3 3 0 0 1 6 0v2" />
<circle cx="10" cy="12" r="1" />
</svg>
),
},
{ {
title: "Users", title: "Users",
description: "Invite, manage roles, and deactivate users across your workspaces.", description: "Invite, manage roles, and deactivate users across your workspaces.",
@@ -230,6 +306,7 @@ const categories: CategoryConfig[] = [
title: "Teams", title: "Teams",
description: "Create and manage teams within your active workspace.", description: "Create and manage teams within your active workspace.",
href: "/settings/teams", href: "/settings/teams",
adminOnly: true,
accent: "var(--ms-blue-400)", accent: "var(--ms-blue-400)",
iconBg: "rgba(47, 128, 255, 0.12)", iconBg: "rgba(47, 128, 255, 0.12)",
icon: ( icon: (

View File

@@ -0,0 +1,634 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useState,
type ChangeEvent,
type ReactElement,
type SyntheticEvent,
} from "react";
import { Settings, Trash2 } from "lucide-react";
import { FleetSettingsNav } from "@/components/settings/FleetSettingsNav";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Switch } from "@/components/ui/switch";
import { Textarea } from "@/components/ui/textarea";
import {
createFleetProvider,
deleteFleetProvider,
fetchFleetProviders,
updateFleetProvider,
type CreateFleetProviderRequest,
type FleetProvider,
type FleetProviderModel,
type UpdateFleetProviderRequest,
} from "@/lib/api/fleet-settings";
interface ProviderTypeOption {
value: string;
label: string;
}
interface ProviderFormState {
type: string;
displayName: string;
apiKey: string;
baseUrl: string;
modelsText: string;
isActive: boolean;
}
const PROVIDER_TYPE_OPTIONS: ProviderTypeOption[] = [
{ value: "openai", label: "OpenAI Compatible" },
{ value: "claude", label: "Claude / Anthropic" },
{ value: "ollama", label: "Ollama" },
{ value: "zai", label: "Z.ai" },
{ value: "custom", label: "Custom" },
];
const INITIAL_FORM: ProviderFormState = {
type: "openai",
displayName: "",
apiKey: "",
baseUrl: "",
modelsText: "",
isActive: true,
};
function buildProviderName(displayName: string, type: string): string {
const slug = displayName
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "");
const candidate = `${type}-${slug.length > 0 ? slug : "provider"}`;
return candidate.slice(0, 100);
}
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return fallback;
}
function normalizeProviderModels(models: unknown): FleetProviderModel[] {
if (!Array.isArray(models)) {
return [];
}
const normalized: FleetProviderModel[] = [];
models.forEach((entry) => {
if (typeof entry === "string" && entry.trim().length > 0) {
normalized.push({ id: entry.trim(), name: entry.trim() });
return;
}
if (entry && typeof entry === "object") {
const record = entry as Record<string, unknown>;
const id =
typeof record.id === "string"
? record.id.trim()
: typeof record.name === "string"
? record.name.trim()
: "";
if (id.length > 0) {
const name =
typeof record.name === "string" && record.name.trim().length > 0
? record.name.trim()
: id;
normalized.push({ id, name });
}
}
});
const seen = new Set<string>();
return normalized.filter((model) => {
if (seen.has(model.id)) {
return false;
}
seen.add(model.id);
return true;
});
}
function modelsToEditorText(models: unknown): string {
return normalizeProviderModels(models)
.map((model) => model.id)
.join("\n");
}
function parseModelsText(value: string): string[] {
const seen = new Set<string>();
return value
.split(/\r?\n/g)
.map((segment) => segment.trim())
.filter((segment) => segment.length > 0)
.filter((segment) => {
if (seen.has(segment)) {
return false;
}
seen.add(segment);
return true;
});
}
function maskApiKey(value: string): string {
if (value.length === 0) {
return "Not set";
}
if (value.length <= 7) {
return "*".repeat(Math.max(4, value.length));
}
return `${value.slice(0, 3)}****...${value.slice(-4)}`;
}
export default function ProvidersSettingsPage(): ReactElement {
const [providers, setProviders] = useState<FleetProvider[]>([]);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState<boolean>(false);
const [editingProvider, setEditingProvider] = useState<FleetProvider | null>(null);
const [form, setForm] = useState<ProviderFormState>(INITIAL_FORM);
const [formError, setFormError] = useState<string | null>(null);
const [isSaving, setIsSaving] = useState<boolean>(false);
const [deleteTarget, setDeleteTarget] = useState<FleetProvider | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const loadProviders = useCallback(async (showLoadingState: boolean): Promise<void> => {
if (showLoadingState) {
setIsLoading(true);
} else {
setIsRefreshing(true);
}
try {
const data = await fetchFleetProviders();
setProviders(data);
setError(null);
} catch (loadError: unknown) {
setError(getErrorMessage(loadError, "Failed to load providers."));
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
useEffect(() => {
void loadProviders(true);
}, [loadProviders]);
const apiKeyHint = useMemo(() => {
const enteredKey = form.apiKey.trim();
if (enteredKey.length > 0) {
return `Masked preview: ${maskApiKey(enteredKey)}`;
}
if (editingProvider) {
return "Stored API key remains encrypted and hidden. Enter a new key only when rotating.";
}
return "API keys are never shown decrypted. Only masked previews are displayed while typing.";
}, [editingProvider, form.apiKey]);
function openCreateDialog(): void {
setEditingProvider(null);
setForm(INITIAL_FORM);
setFormError(null);
setIsDialogOpen(true);
}
function openEditDialog(provider: FleetProvider): void {
setEditingProvider(provider);
setForm({
type: provider.type,
displayName: provider.displayName,
apiKey: "",
baseUrl: provider.baseUrl ?? "",
modelsText: modelsToEditorText(provider.models),
isActive: provider.isActive,
});
setFormError(null);
setIsDialogOpen(true);
}
function closeDialog(): void {
if (isSaving) {
return;
}
setIsDialogOpen(false);
setEditingProvider(null);
setForm(INITIAL_FORM);
setFormError(null);
}
async function handleSubmit(event: SyntheticEvent): Promise<void> {
event.preventDefault();
setFormError(null);
setSuccessMessage(null);
const displayName = form.displayName.trim();
if (displayName.length === 0) {
setFormError("Display name is required.");
return;
}
const models = parseModelsText(form.modelsText);
const providerModels = models.map((id) => ({ id, name: id }));
const baseUrl = form.baseUrl.trim();
const apiKey = form.apiKey.trim();
try {
setIsSaving(true);
if (editingProvider) {
const updatePayload: UpdateFleetProviderRequest = {
displayName,
isActive: form.isActive,
models: providerModels,
};
if (baseUrl.length > 0) {
updatePayload.baseUrl = baseUrl;
}
if (apiKey.length > 0) {
updatePayload.apiKey = apiKey;
}
await updateFleetProvider(editingProvider.id, updatePayload);
setSuccessMessage(`Updated provider "${displayName}".`);
} else {
const createPayload: CreateFleetProviderRequest = {
name: buildProviderName(displayName, form.type),
displayName,
type: form.type,
};
if (baseUrl.length > 0) {
createPayload.baseUrl = baseUrl;
}
if (apiKey.length > 0) {
createPayload.apiKey = apiKey;
}
if (providerModels.length > 0) {
createPayload.models = providerModels;
}
await createFleetProvider(createPayload);
setSuccessMessage(`Added provider "${displayName}".`);
}
setIsDialogOpen(false);
setEditingProvider(null);
setForm(INITIAL_FORM);
await loadProviders(false);
} catch (saveError: unknown) {
setFormError(getErrorMessage(saveError, "Unable to save provider."));
} finally {
setIsSaving(false);
}
}
async function handleDeleteProvider(): Promise<void> {
if (!deleteTarget) {
return;
}
try {
setIsDeleting(true);
await deleteFleetProvider(deleteTarget.id);
setSuccessMessage(`Deleted provider "${deleteTarget.displayName}".`);
setDeleteTarget(null);
await loadProviders(false);
} catch (deleteError: unknown) {
setError(getErrorMessage(deleteError, "Failed to delete provider."));
} finally {
setIsDeleting(false);
}
}
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="space-y-4">
<div>
<h1 className="text-3xl font-bold">LLM Providers</h1>
<p className="text-muted-foreground mt-1">
Manage provider endpoints, model inventories, and encrypted API credentials.
</p>
</div>
<FleetSettingsNav />
</div>
<Card>
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<CardTitle>Provider Directory</CardTitle>
<CardDescription>
API keys are always encrypted in storage and never displayed in plaintext.
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => {
void loadProviders(false);
}}
disabled={isLoading || isRefreshing}
>
{isRefreshing ? "Refreshing..." : "Refresh"}
</Button>
<Button onClick={openCreateDialog}>Add Provider</Button>
</div>
</CardHeader>
<CardContent className="space-y-3">
{error ? (
<p className="text-sm text-destructive" role="alert">
{error}
</p>
) : null}
{successMessage ? <p className="text-sm text-emerald-600">{successMessage}</p> : null}
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading providers...</p>
) : providers.length === 0 ? (
<p className="text-sm text-muted-foreground">
No providers configured yet. Add one to make models available for agent assignment.
</p>
) : (
providers.map((provider) => {
const providerModels = normalizeProviderModels(provider.models);
return (
<div
key={provider.id}
className="rounded-lg border p-4 flex flex-col gap-4 md:flex-row md:items-start md:justify-between"
>
<div className="space-y-2 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<p className="font-semibold truncate">{provider.displayName}</p>
<Badge variant={provider.isActive ? "default" : "secondary"}>
{provider.isActive ? "Active" : "Inactive"}
</Badge>
<Badge variant="outline">{provider.type}</Badge>
</div>
<p className="text-sm text-muted-foreground">Name: {provider.name}</p>
<p className="text-sm text-muted-foreground">
Base URL: {provider.baseUrl ?? "Provider default"}
</p>
<p className="text-sm text-muted-foreground">
API Key: encrypted and hidden (never returned decrypted)
</p>
<div className="flex flex-wrap gap-2">
{providerModels.length === 0 ? (
<Badge variant="secondary">No models configured</Badge>
) : (
providerModels.map((model) => (
<Badge key={`${provider.id}-${model.id}`} variant="outline">
{model.id}
</Badge>
))
)}
</div>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
openEditDialog(provider);
}}
>
<Settings className="h-4 w-4 mr-2" />
Edit
</Button>
<Button
variant="destructive"
size="sm"
onClick={() => {
setDeleteTarget(provider);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
);
})
)}
</CardContent>
</Card>
<Dialog
open={isDialogOpen}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
closeDialog();
return;
}
setIsDialogOpen(true);
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingProvider ? "Edit Provider" : "Add Provider"}</DialogTitle>
<DialogDescription>
Configure connection details and model IDs. API keys are masked in the UI.
</DialogDescription>
</DialogHeader>
<form onSubmit={(event) => void handleSubmit(event)} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="provider-type">Type</Label>
<Select
value={form.type}
onValueChange={(value) => {
setForm((previous) => ({ ...previous, type: value }));
}}
disabled={Boolean(editingProvider)}
>
<SelectTrigger id="provider-type">
<SelectValue placeholder="Select provider type" />
</SelectTrigger>
<SelectContent>
{PROVIDER_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="provider-display-name">Display Name</Label>
<Input
id="provider-display-name"
value={form.displayName}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setForm((previous) => ({ ...previous, displayName: event.target.value }));
}}
placeholder="OpenAI Primary"
maxLength={255}
disabled={isSaving}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="provider-api-key">API Key</Label>
<Input
id="provider-api-key"
type="password"
value={form.apiKey}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setForm((previous) => ({ ...previous, apiKey: event.target.value }));
}}
placeholder={editingProvider ? "Enter new key to rotate" : "sk-..."}
autoComplete="new-password"
disabled={isSaving}
/>
<p className="text-xs text-muted-foreground">{apiKeyHint}</p>
</div>
<div className="space-y-2">
<Label htmlFor="provider-base-url">Base URL</Label>
<Input
id="provider-base-url"
value={form.baseUrl}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setForm((previous) => ({ ...previous, baseUrl: event.target.value }));
}}
placeholder="https://api.provider.com/v1"
disabled={isSaving}
/>
</div>
<div className="space-y-2">
<Label htmlFor="provider-models">Models</Label>
<Textarea
id="provider-models"
value={form.modelsText}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
setForm((previous) => ({ ...previous, modelsText: event.target.value }));
}}
placeholder={"One model ID per line\nExample: gpt-4.1-mini"}
rows={5}
disabled={isSaving}
/>
</div>
{editingProvider ? (
<div className="flex items-center justify-between rounded-md border px-3 py-2">
<div>
<Label htmlFor="provider-active">Provider Status</Label>
<p className="text-xs text-muted-foreground">
Disable to keep configuration without using this provider.
</p>
</div>
<Switch
id="provider-active"
checked={form.isActive}
onCheckedChange={(checked) => {
setForm((previous) => ({ ...previous, isActive: checked }));
}}
disabled={isSaving}
/>
</div>
) : null}
{formError ? (
<p className="text-sm text-destructive" role="alert">
{formError}
</p>
) : null}
<DialogFooter>
<Button type="button" variant="outline" onClick={closeDialog} disabled={isSaving}>
Cancel
</Button>
<Button type="submit" disabled={isSaving}>
{isSaving ? "Saving..." : editingProvider ? "Save Changes" : "Create Provider"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => {
if (!open && !isDeleting) {
setDeleteTarget(null);
}
}}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Provider</AlertDialogTitle>
<AlertDialogDescription>
Delete provider "{deleteTarget?.displayName}"? This removes its configuration and
model mappings.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteProvider} disabled={isDeleting}>
{isDeleting ? "Deleting..." : "Delete Provider"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}

View File

@@ -1,9 +1,12 @@
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import type { TeamRecord } from "@/lib/api/teams"; import type { TeamRecord } from "@/lib/api/teams";
import { render, screen } from "@testing-library/react"; import { WorkspaceMemberRole } from "@mosaic/shared";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchTeams } from "@/lib/api/teams"; import { createTeam, deleteTeam, fetchTeams, updateTeam } from "@/lib/api/teams";
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
import TeamsSettingsPage from "./page"; import TeamsSettingsPage from "./page";
@@ -22,9 +25,19 @@ vi.mock("next/link", () => ({
vi.mock("@/lib/api/teams", () => ({ vi.mock("@/lib/api/teams", () => ({
fetchTeams: vi.fn(), fetchTeams: vi.fn(),
createTeam: vi.fn(), createTeam: vi.fn(),
updateTeam: vi.fn(),
deleteTeam: vi.fn(),
}));
vi.mock("@/lib/api/workspaces", () => ({
fetchUserWorkspaces: vi.fn(),
})); }));
const fetchTeamsMock = vi.mocked(fetchTeams); const fetchTeamsMock = vi.mocked(fetchTeams);
const createTeamMock = vi.mocked(createTeam);
const updateTeamMock = vi.mocked(updateTeam);
const deleteTeamMock = vi.mocked(deleteTeam);
const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
const baseTeam: TeamRecord = { const baseTeam: TeamRecord = {
id: "team-1", id: "team-1",
@@ -42,6 +55,33 @@ const baseTeam: TeamRecord = {
describe("TeamsSettingsPage", () => { describe("TeamsSettingsPage", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
fetchTeamsMock.mockResolvedValue([]);
fetchUserWorkspacesMock.mockResolvedValue([
{
id: "workspace-1",
name: "Personal Workspace",
ownerId: "owner-1",
role: WorkspaceMemberRole.OWNER,
createdAt: "2026-01-01T00:00:00.000Z",
},
]);
});
it("shows access denied to non-admin users", async () => {
fetchUserWorkspacesMock.mockResolvedValueOnce([
{
id: "workspace-1",
name: "Personal Workspace",
ownerId: "owner-1",
role: WorkspaceMemberRole.MEMBER,
createdAt: "2026-01-01T00:00:00.000Z",
},
]);
render(<TeamsSettingsPage />);
expect(await screen.findByText("Access Denied")).toBeInTheDocument();
expect(fetchTeamsMock).not.toHaveBeenCalled();
}); });
it("loads and renders teams from the API", async () => { it("loads and renders teams from the API", async () => {
@@ -49,9 +89,7 @@ describe("TeamsSettingsPage", () => {
render(<TeamsSettingsPage />); render(<TeamsSettingsPage />);
expect(screen.getByText("Loading teams...")).toBeInTheDocument(); expect(await screen.findByText("Team Directory")).toBeInTheDocument();
expect(await screen.findByText("Your Teams (1)")).toBeInTheDocument();
expect(screen.getByText("Platform Team")).toBeInTheDocument(); expect(screen.getByText("Platform Team")).toBeInTheDocument();
expect(fetchTeamsMock).toHaveBeenCalledTimes(1); expect(fetchTeamsMock).toHaveBeenCalledTimes(1);
}); });
@@ -61,8 +99,8 @@ describe("TeamsSettingsPage", () => {
render(<TeamsSettingsPage />); render(<TeamsSettingsPage />);
expect(await screen.findByText("Your Teams (0)")).toBeInTheDocument(); expect(await screen.findByText("No Teams Yet")).toBeInTheDocument();
expect(screen.getByText("No teams yet")).toBeInTheDocument(); expect(screen.getByText("Create the first team to get started.")).toBeInTheDocument();
}); });
it("shows error state and does not show empty state", async () => { it("shows error state and does not show empty state", async () => {
@@ -71,6 +109,82 @@ describe("TeamsSettingsPage", () => {
render(<TeamsSettingsPage />); render(<TeamsSettingsPage />);
expect(await screen.findByText("Unable to load teams")).toBeInTheDocument(); expect(await screen.findByText("Unable to load teams")).toBeInTheDocument();
expect(screen.queryByText("No teams yet")).not.toBeInTheDocument(); });
it("creates a team from the create dialog", async () => {
const user = userEvent.setup();
fetchTeamsMock.mockResolvedValue([baseTeam]);
createTeamMock.mockResolvedValue({
...baseTeam,
id: "team-2",
name: "Design Team",
description: "Owns design quality",
});
render(<TeamsSettingsPage />);
expect(await screen.findByText("Platform Team")).toBeInTheDocument();
const triggerButton = screen.getByRole("button", { name: "Create Team" });
await user.click(triggerButton);
await user.type(screen.getByLabelText("Name"), "Design Team");
await user.type(screen.getByLabelText("Description"), "Owns design quality");
const submitButton = screen.getAllByRole("button", { name: "Create Team" })[1];
if (!submitButton) {
throw new Error("Expected create-team submit button to be rendered");
}
await user.click(submitButton);
await waitFor(() => {
expect(createTeamMock).toHaveBeenCalledWith({
name: "Design Team",
description: "Owns design quality",
});
});
});
it("opens team details and updates name", async () => {
const user = userEvent.setup();
fetchTeamsMock.mockResolvedValue([baseTeam]);
updateTeamMock.mockResolvedValue({
...baseTeam,
name: "Platform Engineering",
});
render(<TeamsSettingsPage />);
expect(await screen.findByText("Platform Team")).toBeInTheDocument();
await user.click(screen.getByText("Platform Team"));
const nameInput = await screen.findByLabelText("Name");
await user.clear(nameInput);
await user.type(nameInput, "Platform Engineering");
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(updateTeamMock).toHaveBeenCalledWith("team-1", {
name: "Platform Engineering",
});
});
});
it("deletes a team from the confirmation dialog", async () => {
const user = userEvent.setup();
fetchTeamsMock.mockResolvedValue([baseTeam]);
deleteTeamMock.mockResolvedValue();
render(<TeamsSettingsPage />);
expect(await screen.findByText("Platform Team")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Delete" }));
await user.click(screen.getByRole("button", { name: "Delete Team" }));
await waitFor(() => {
expect(deleteTeamMock).toHaveBeenCalledWith("team-1");
});
}); });
}); });

View File

@@ -1,244 +1,582 @@
"use client"; "use client";
import type { ReactElement, SyntheticEvent } from "react"; import {
useCallback,
import { useCallback, useEffect, useState } from "react"; useEffect,
useState,
type ChangeEvent,
type KeyboardEvent,
type ReactElement,
type SyntheticEvent,
} from "react";
import Link from "next/link"; import Link from "next/link";
import { createTeam, fetchTeams, type CreateTeamDto, type TeamRecord } from "@/lib/api/teams"; import { Plus, Trash2, Users } from "lucide-react";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import {
createTeam,
deleteTeam,
fetchTeams,
updateTeam,
type CreateTeamDto,
type TeamRecord,
type UpdateTeamDto,
} from "@/lib/api/teams";
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
function getErrorMessage(error: unknown, fallback: string): string { const INITIAL_CREATE_FORM = {
if (error instanceof Error) { name: "",
return error.message; description: "",
} };
return fallback; const INITIAL_DETAIL_FORM = {
name: "",
description: "",
};
interface DetailInitialState {
name: string;
description: string;
}
function toMemberLabel(count: number): string {
return `${String(count)} member${count === 1 ? "" : "s"}`;
} }
export default function TeamsSettingsPage(): ReactElement { export default function TeamsSettingsPage(): ReactElement {
const [teams, setTeams] = useState<TeamRecord[]>([]); const [teams, setTeams] = useState<TeamRecord[]>([]);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [loadError, setLoadError] = useState<string | null>(null); const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false); const [error, setError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false); const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [newTeamName, setNewTeamName] = useState("");
const [newTeamDescription, setNewTeamDescription] = useState(""); const [isCreateOpen, setIsCreateOpen] = useState<boolean>(false);
const [createForm, setCreateForm] = useState(INITIAL_CREATE_FORM);
const [createError, setCreateError] = useState<string | null>(null); const [createError, setCreateError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState<boolean>(false);
const loadTeams = useCallback(async (): Promise<void> => { const [detailTarget, setDetailTarget] = useState<TeamRecord | null>(null);
setIsLoading(true); const [detailForm, setDetailForm] = useState(INITIAL_DETAIL_FORM);
const [detailInitial, setDetailInitial] = useState<DetailInitialState | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const [isSavingDetails, setIsSavingDetails] = useState<boolean>(false);
const [deleteTarget, setDeleteTarget] = useState<TeamRecord | null>(null);
const [isDeleting, setIsDeleting] = useState<boolean>(false);
const loadTeams = useCallback(async (showLoadingState: boolean): Promise<void> => {
try { try {
if (showLoadingState) {
setIsLoading(true);
} else {
setIsRefreshing(true);
}
const data = await fetchTeams(); const data = await fetchTeams();
setTeams(data); setTeams(data);
setLoadError(null); setError(null);
} catch (error) { } catch (err: unknown) {
setLoadError(getErrorMessage(error, "Failed to load teams")); setError(err instanceof Error ? err.message : "Failed to load teams");
} finally { } finally {
setIsLoading(false); setIsLoading(false);
setIsRefreshing(false);
} }
}, []); }, []);
useEffect(() => { useEffect(() => {
void loadTeams(); fetchUserWorkspaces()
}, [loadTeams]); .then((workspaces) => {
const adminRoles: WorkspaceMemberRole[] = [
WorkspaceMemberRole.OWNER,
WorkspaceMemberRole.ADMIN,
];
const handleCreateTeam = async (e: SyntheticEvent<HTMLFormElement>): Promise<void> => { setIsAdmin(workspaces.some((workspace) => adminRoles.includes(workspace.role)));
e.preventDefault(); })
.catch(() => {
setIsAdmin(true); // fail open
});
}, []);
const teamName = newTeamName.trim(); useEffect(() => {
if (!teamName) return; if (isAdmin !== true) {
return;
}
setIsCreating(true); void loadTeams(true);
}, [isAdmin, loadTeams]);
function resetCreateForm(): void {
setCreateForm(INITIAL_CREATE_FORM);
setCreateError(null);
}
function openTeamDetails(team: TeamRecord): void {
const nextDetailForm = {
name: team.name,
description: team.description ?? "",
};
setDetailTarget(team);
setDetailForm(nextDetailForm);
setDetailInitial({
name: nextDetailForm.name,
description: nextDetailForm.description,
});
setDetailError(null);
}
function resetTeamDetails(): void {
setDetailTarget(null);
setDetailForm(INITIAL_DETAIL_FORM);
setDetailInitial(null);
setDetailError(null);
}
function handleTeamRowKeyDown(event: KeyboardEvent<HTMLDivElement>, team: TeamRecord): void {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openTeamDetails(team);
}
}
async function handleCreateSubmit(event: SyntheticEvent): Promise<void> {
event.preventDefault();
setCreateError(null); setCreateError(null);
try { const name = createForm.name.trim();
const description = newTeamDescription.trim(); if (!name) {
const dto: CreateTeamDto = { name: teamName }; setCreateError("Name is required.");
if (description.length > 0) { return;
dto.description = description; }
}
const description = createForm.description.trim();
const dto: CreateTeamDto = { name };
if (description) {
dto.description = description;
}
try {
setIsCreating(true);
await createTeam(dto); await createTeam(dto);
setNewTeamName(""); setIsCreateOpen(false);
setNewTeamDescription(""); resetCreateForm();
setIsCreateDialogOpen(false); await loadTeams(false);
await loadTeams(); } catch (err: unknown) {
} catch (error) { setCreateError(err instanceof Error ? err.message : "Failed to create team");
setCreateError(getErrorMessage(error, "Failed to create team"));
} finally { } finally {
setIsCreating(false); setIsCreating(false);
} }
}; }
async function handleDetailSubmit(event: SyntheticEvent): Promise<void> {
event.preventDefault();
if (detailTarget === null || detailInitial === null) {
return;
}
const name = detailForm.name.trim();
if (!name) {
setDetailError("Name is required.");
return;
}
const nextDescription = detailForm.description.trim();
const normalizedNextDescription = nextDescription.length > 0 ? nextDescription : null;
const normalizedInitialDescription =
detailInitial.description.trim().length > 0 ? detailInitial.description.trim() : null;
const dto: UpdateTeamDto = {};
if (name !== detailInitial.name) {
dto.name = name;
}
if (normalizedNextDescription !== normalizedInitialDescription) {
dto.description = normalizedNextDescription;
}
if (Object.keys(dto).length === 0) {
resetTeamDetails();
return;
}
try {
setIsSavingDetails(true);
setDetailError(null);
await updateTeam(detailTarget.id, dto);
resetTeamDetails();
await loadTeams(false);
} catch (err: unknown) {
setDetailError(err instanceof Error ? err.message : "Failed to update team");
} finally {
setIsSavingDetails(false);
}
}
async function confirmDelete(): Promise<void> {
if (!deleteTarget) {
return;
}
try {
setIsDeleting(true);
await deleteTeam(deleteTarget.id);
setDeleteTarget(null);
await loadTeams(false);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to delete team");
} finally {
setIsDeleting(false);
}
}
if (isAdmin === null) {
return (
<Card className="max-w-2xl mx-auto mt-8">
<CardContent className="py-12 text-center text-muted-foreground">
Checking permissions...
</CardContent>
</Card>
);
}
if (!isAdmin) {
return <SettingsAccessDenied message="You need Admin or Owner role to manage teams." />;
}
return ( return (
<main className="container mx-auto px-4 py-8 max-w-5xl"> <div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="mb-8"> <div className="flex items-start justify-between gap-4">
<div className="flex items-center justify-between mb-2"> <div>
<h1 className="text-3xl font-bold text-gray-900">Teams</h1> <div className="flex items-center gap-3">
<Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700"> <h1 className="text-3xl font-bold">Teams</h1>
{"<-"} Back to Settings <Badge variant="outline">{teams.length} total</Badge>
</Link>
</div>
<p className="text-gray-600">Manage teams in your active workspace</p>
</div>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6">
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-gray-900">Create New Team</h2>
<p className="text-sm text-gray-600 mt-1">
Add a team to organize members and permissions.
</p>
</div> </div>
<button <p className="text-muted-foreground mt-1">Create and manage workspace teams</p>
type="button" </div>
<div className="flex items-center gap-2">
<Button
variant="outline"
onClick={() => { onClick={() => {
setCreateError(null); void loadTeams(false);
setIsCreateDialogOpen(true);
}} }}
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium" disabled={isLoading || isRefreshing}
> >
Create Team {isRefreshing ? "Refreshing..." : "Refresh"}
</button> </Button>
<Dialog
open={isCreateOpen}
onOpenChange={(open) => {
if (!open && !isCreating) {
resetCreateForm();
}
setIsCreateOpen(open);
}}
>
<DialogTrigger asChild>
<Button>
<Plus className="h-4 w-4 mr-2" />
Create Team
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Team</DialogTitle>
<DialogDescription>
Create a team in the active workspace to organize members and permissions.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
void handleCreateSubmit(event);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="create-name">Name</Label>
<Input
id="create-name"
value={createForm.name}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setCreateForm((prev) => ({ ...prev, name: event.target.value }));
}}
placeholder="Platform Team"
maxLength={100}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="create-description">Description</Label>
<Textarea
id="create-description"
value={createForm.description}
onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
setCreateForm((prev) => ({ ...prev, description: event.target.value }));
}}
placeholder="Owns platform services and infrastructure"
maxLength={500}
rows={4}
/>
</div>
{createError ? (
<p className="text-sm text-destructive" role="alert">
{createError}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
if (!isCreating) {
setIsCreateOpen(false);
resetCreateForm();
}
}}
disabled={isCreating}
>
Cancel
</Button>
<Button type="submit" disabled={isCreating}>
{isCreating ? "Creating..." : "Create Team"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div> </div>
</div> </div>
{isCreateDialogOpen && ( <div>
<div <Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700">
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 px-4" Back to Settings
role="dialog" </Link>
> </div>
<div className="w-full max-w-lg rounded-lg border border-gray-200 bg-white p-6 shadow-xl">
<h3 className="text-lg font-semibold text-gray-900">Create New Team</h3> {error ? (
<p className="mt-1 text-sm text-gray-600"> <Card>
Enter a team name and optional description. <CardContent className="py-4">
<p className="text-sm text-destructive" role="alert">
{error}
</p> </p>
</CardContent>
</Card>
) : null}
<form onSubmit={handleCreateTeam} className="mt-4 space-y-4"> {isLoading ? (
<div> <Card>
<label htmlFor="team-name" className="mb-1 block text-sm font-medium text-gray-700"> <CardContent className="py-12 text-center text-muted-foreground">
Team Name Loading teams...
</label> </CardContent>
<input </Card>
id="team-name" ) : teams.length === 0 ? (
type="text" <Card>
value={newTeamName} <CardHeader>
onChange={(e) => { <CardTitle>No Teams Yet</CardTitle>
setNewTeamName(e.target.value); <CardDescription>Create the first team to get started.</CardDescription>
}} </CardHeader>
placeholder="Enter team name..." </Card>
disabled={isCreating} ) : (
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100" <Card>
autoFocus <CardHeader>
/> <CardTitle>Team Directory</CardTitle>
</div> <CardDescription>
Click a team to view details or edit name and description.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{teams.map((team) => {
const memberCount = team._count?.members ?? 0;
const description = team.description?.trim();
<div> return (
<label <div
htmlFor="team-description" key={team.id}
className="mb-1 block text-sm font-medium text-gray-700" className="rounded-md border p-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between cursor-pointer hover:bg-muted/30"
> role="button"
Description (optional) tabIndex={0}
</label>
<textarea
id="team-description"
value={newTeamDescription}
onChange={(e) => {
setNewTeamDescription(e.target.value);
}}
placeholder="Describe this team's purpose..."
disabled={isCreating}
rows={3}
className="w-full rounded-lg border border-gray-300 px-4 py-2 focus:border-transparent focus:ring-2 focus:ring-blue-500 disabled:bg-gray-100"
/>
</div>
{createError !== null && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{createError}
</div>
)}
<div className="flex justify-end gap-3">
<button
type="button"
onClick={() => { onClick={() => {
if (!isCreating) { openTeamDetails(team);
setIsCreateDialogOpen(false); }}
} onKeyDown={(event) => {
handleTeamRowKeyDown(event, team);
}} }}
disabled={isCreating}
className="px-4 py-2 rounded-lg border border-gray-300 text-gray-700 hover:bg-gray-50 disabled:cursor-not-allowed"
> >
Cancel <div className="space-y-1 min-w-0">
</button> <p className="font-semibold truncate">{team.name}</p>
<button <p className="text-sm text-muted-foreground truncate">
type="submit" {description && description.length > 0 ? description : "No description"}
disabled={isCreating || !newTeamName.trim()} </p>
className="px-5 py-2 rounded-lg bg-blue-600 text-white font-medium hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50" </div>
>
{isCreating ? "Creating..." : "Create Team"} <div className="flex items-center gap-2 flex-wrap md:justify-end">
</button> <Badge variant="outline">
</div> <Users className="h-3.5 w-3.5 mr-1" />
</form> {toMemberLabel(memberCount)}
</div> </Badge>
</div> <Badge variant="secondary">
Created {new Date(team.createdAt).toLocaleDateString()}
</Badge>
<Button
variant="destructive"
size="sm"
onClick={(event) => {
event.stopPropagation();
setDeleteTarget(team);
}}
>
<Trash2 className="h-4 w-4 mr-2" />
Delete
</Button>
</div>
</div>
);
})}
</CardContent>
</Card>
)} )}
<div className="space-y-4"> <Dialog
<h2 className="text-xl font-semibold text-gray-900"> open={detailTarget !== null}
Your Teams ({isLoading ? "..." : teams.length}) onOpenChange={(open) => {
</h2> if (!open && !isSavingDetails) {
{loadError !== null ? ( resetTeamDetails();
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-red-700"> }
{loadError} }}
</div> >
) : isLoading ? ( <DialogContent>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center text-gray-600"> <DialogHeader>
Loading teams... <DialogTitle>Team Details</DialogTitle>
</div> <DialogDescription>
) : teams.length === 0 ? ( Edit team details for {detailTarget?.name ?? "selected team"}.
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center"> </DialogDescription>
<svg </DialogHeader>
className="mx-auto h-12 w-12 text-gray-400 mb-4"
fill="none" <form
stroke="currentColor" onSubmit={(event) => {
viewBox="0 0 24 24" void handleDetailSubmit(event);
> }}
<path className="space-y-4"
strokeLinecap="round" >
strokeLinejoin="round" <div className="space-y-2">
strokeWidth={2} <Label htmlFor="detail-name">Name</Label>
d="M17 20h5V8H2v12h5m10 0v-4a3 3 0 10-6 0v4m6 0H7" <Input
id="detail-name"
value={detailForm.name}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setDetailForm((prev) => ({ ...prev, name: event.target.value }));
}}
placeholder="Team name"
maxLength={100}
disabled={isSavingDetails}
required
/> />
</svg> </div>
<h3 className="text-lg font-medium text-gray-900 mb-2">No teams yet</h3>
<p className="text-gray-600">Create your first team to get started</p> <div className="space-y-2">
</div> <Label htmlFor="detail-description">Description</Label>
) : ( <Textarea
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> id="detail-description"
{teams.map((team) => ( value={detailForm.description}
<article onChange={(event: ChangeEvent<HTMLTextAreaElement>) => {
key={team.id} setDetailForm((prev) => ({ ...prev, description: event.target.value }));
className="rounded-lg border border-gray-200 bg-white p-5 shadow-sm" }}
data-testid="team-card" placeholder="Describe this team"
maxLength={500}
rows={4}
disabled={isSavingDetails}
/>
</div>
{detailError !== null ? (
<p className="text-sm text-destructive" role="alert">
{detailError}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
if (!isSavingDetails) {
resetTeamDetails();
}
}}
disabled={isSavingDetails}
> >
<h3 className="text-lg font-semibold text-gray-900">{team.name}</h3> Cancel
{team.description ? ( </Button>
<p className="mt-1 text-sm text-gray-600">{team.description}</p> <Button type="submit" disabled={isSavingDetails}>
) : ( {isSavingDetails ? "Saving..." : "Save Changes"}
<p className="mt-1 text-sm text-gray-400 italic">No description</p> </Button>
)} </DialogFooter>
<div className="mt-4 flex items-center gap-3 text-xs text-gray-500"> </form>
<span>{team._count?.members ?? 0} members</span> </DialogContent>
<span>|</span> </Dialog>
<span>Created {new Date(team.createdAt).toLocaleDateString()}</span>
</div> <AlertDialog
</article> open={deleteTarget !== null}
))} onOpenChange={(open) => {
</div> if (!open && !isDeleting) {
)} setDeleteTarget(null);
</div> }
</main> }}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Delete Team</AlertDialogTitle>
<AlertDialogDescription>
Delete {deleteTarget?.name}? Team members will be removed from this team assignment.
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>Cancel</AlertDialogCancel>
<AlertDialogAction
disabled={isDeleting}
onClick={() => {
void confirmDelete();
}}
>
{isDeleting ? "Deleting..." : "Delete Team"}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
); );
} }

View File

@@ -0,0 +1,352 @@
import type { ReactElement, ReactNode } from "react";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
type AdminUser,
deactivateUser,
fetchAdminUsers,
inviteUser,
updateUser,
type AdminUsersResponse,
} from "@/lib/api/admin";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
import UsersSettingsPage from "./page";
vi.mock("next/link", () => ({
default: function LinkMock({
children,
href,
}: {
children: ReactNode;
href: string;
}): ReactElement {
return <a href={href}>{children}</a>;
},
}));
vi.mock("@/lib/api/admin", () => ({
fetchAdminUsers: vi.fn(),
inviteUser: vi.fn(),
updateUser: vi.fn(),
deactivateUser: vi.fn(),
}));
vi.mock("@/lib/api/workspaces", () => ({
fetchUserWorkspaces: vi.fn(),
updateWorkspaceMemberRole: vi.fn(),
}));
vi.mock("@/lib/auth/auth-context", () => ({
useAuth: vi.fn(),
}));
const fetchAdminUsersMock = vi.mocked(fetchAdminUsers);
const inviteUserMock = vi.mocked(inviteUser);
const updateUserMock = vi.mocked(updateUser);
const deactivateUserMock = vi.mocked(deactivateUser);
const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
const updateWorkspaceMemberRoleMock = vi.mocked(updateWorkspaceMemberRole);
const useAuthMock = vi.mocked(useAuth);
function makeAdminUser(overrides?: Partial<AdminUser>): AdminUser {
return {
id: "user-1",
name: "Alice",
email: "alice@example.com",
emailVerified: true,
image: null,
createdAt: "2026-01-01T00:00:00.000Z",
deactivatedAt: null,
isLocalAuth: false,
invitedAt: null,
invitedBy: null,
workspaceMemberships: [
{
workspaceId: "workspace-1",
workspaceName: "Personal Workspace",
role: WorkspaceMemberRole.ADMIN,
joinedAt: "2026-01-01T00:00:00.000Z",
},
],
...overrides,
};
}
function makeAdminUsersResponse(options?: {
data?: AdminUser[];
page?: number;
totalPages?: number;
total?: number;
limit?: number;
}): AdminUsersResponse {
const data = options?.data ?? [makeAdminUser()];
return {
data,
meta: {
total: options?.total ?? data.length,
page: options?.page ?? 1,
limit: options?.limit ?? 50,
totalPages: options?.totalPages ?? 1,
},
};
}
function makeAuthState(userId: string): ReturnType<typeof useAuth> {
return {
user: { id: userId, email: `${userId}@example.com`, name: "Current User" },
isLoading: false,
isAuthenticated: true,
authError: null,
sessionExpiring: false,
sessionMinutesRemaining: 0,
signOut: vi.fn(() => Promise.resolve()),
refreshSession: vi.fn(() => Promise.resolve()),
};
}
describe("UsersSettingsPage", () => {
beforeEach(() => {
vi.clearAllMocks();
const adminUsersResponse = makeAdminUsersResponse();
fetchAdminUsersMock.mockResolvedValue(adminUsersResponse);
fetchUserWorkspacesMock.mockResolvedValue([
{
id: "workspace-1",
name: "Personal Workspace",
ownerId: "owner-1",
role: WorkspaceMemberRole.OWNER,
createdAt: "2026-01-01T00:00:00.000Z",
},
]);
inviteUserMock.mockResolvedValue({
userId: "user-2",
invitationToken: "token-1",
email: "new@example.com",
invitedAt: "2026-01-02T00:00:00.000Z",
});
const firstUser = adminUsersResponse.data[0] ?? makeAdminUser();
updateUserMock.mockResolvedValue(firstUser);
deactivateUserMock.mockResolvedValue(firstUser);
updateWorkspaceMemberRoleMock.mockResolvedValue({
workspaceId: "workspace-1",
userId: "user-1",
role: WorkspaceMemberRole.ADMIN,
joinedAt: "2026-01-01T00:00:00.000Z",
user: {
id: "user-1",
email: "alice@example.com",
name: "Alice",
image: null,
},
});
useAuthMock.mockReturnValue(makeAuthState("user-current"));
});
it("shows access denied to non-admin users", async () => {
fetchUserWorkspacesMock.mockResolvedValueOnce([
{
id: "workspace-1",
name: "Personal Workspace",
ownerId: "owner-1",
role: WorkspaceMemberRole.MEMBER,
createdAt: "2026-01-01T00:00:00.000Z",
},
]);
render(<UsersSettingsPage />);
expect(await screen.findByText("Access Denied")).toBeInTheDocument();
expect(fetchAdminUsersMock).not.toHaveBeenCalled();
});
it("invites a user with email and role from the dialog", async () => {
const user = userEvent.setup();
render(<UsersSettingsPage />);
expect(await screen.findByText("User Directory")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Invite User" }));
await user.type(screen.getByLabelText("Email"), "new@example.com");
await user.click(screen.getByRole("button", { name: "Send Invite" }));
await waitFor(() => {
expect(inviteUserMock).toHaveBeenCalledWith({
email: "new@example.com",
role: WorkspaceMemberRole.MEMBER,
workspaceId: "workspace-1",
});
});
});
it("opens user detail dialog from row click and saves edited profile fields", async () => {
const user = userEvent.setup();
render(<UsersSettingsPage />);
expect(await screen.findByText("alice@example.com")).toBeInTheDocument();
await user.click(screen.getByText("Alice"));
const nameInput = await screen.findByLabelText("Name");
await user.clear(nameInput);
await user.type(nameInput, "Alice Updated");
await user.click(screen.getByRole("button", { name: "Save Changes" }));
await waitFor(() => {
expect(updateUserMock).toHaveBeenCalledWith("user-1", { name: "Alice Updated" });
});
expect(updateWorkspaceMemberRoleMock).not.toHaveBeenCalled();
});
it("caps pagination to the last valid page after deactivation shrinks the dataset", async () => {
const user = userEvent.setup();
const pageOneUser = makeAdminUser({
id: "user-1",
name: "Alice",
email: "alice@example.com",
});
const pageTwoUser = makeAdminUser({
id: "user-2",
name: "Bob",
email: "bob@example.com",
});
fetchAdminUsersMock.mockReset();
const responses = [
{
expectedPage: 1,
response: makeAdminUsersResponse({
data: [pageOneUser],
page: 1,
totalPages: 2,
total: 2,
}),
},
{
expectedPage: 2,
response: makeAdminUsersResponse({
data: [pageTwoUser],
page: 2,
totalPages: 2,
total: 2,
}),
},
{
expectedPage: 2,
response: makeAdminUsersResponse({
data: [],
page: 2,
totalPages: 1,
total: 1,
}),
},
{
expectedPage: 1,
response: makeAdminUsersResponse({
data: [pageOneUser],
page: 1,
totalPages: 1,
total: 1,
}),
},
];
fetchAdminUsersMock.mockImplementation((page = 1) => {
const next = responses.shift();
if (!next) {
throw new Error("Unexpected fetchAdminUsers call in pagination-cap test");
}
expect(page).toBe(next.expectedPage);
return Promise.resolve(next.response);
});
render(<UsersSettingsPage />);
expect(await screen.findByText("alice@example.com")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Next" }));
expect(await screen.findByText("bob@example.com")).toBeInTheDocument();
const pageTwoRow = screen.getByText("bob@example.com").closest('[role="button"]');
if (!(pageTwoRow instanceof HTMLElement)) {
throw new Error("Expected Bob's row to exist");
}
await user.click(within(pageTwoRow).getByRole("button", { name: "Deactivate" }));
const deactivateButtons = await screen.findAllByRole("button", { name: "Deactivate" });
const confirmDeactivateButton = deactivateButtons[deactivateButtons.length - 1];
if (!confirmDeactivateButton) {
throw new Error("Expected confirmation deactivate button to be rendered");
}
await user.click(confirmDeactivateButton);
expect(await screen.findByText("alice@example.com")).toBeInTheDocument();
expect(screen.queryByText("No Users Yet")).not.toBeInTheDocument();
expect(deactivateUserMock).toHaveBeenCalledWith("user-2");
const requestedPages = fetchAdminUsersMock.mock.calls.map(([requestedPage]) => requestedPage);
expect(requestedPages.slice(-2)).toEqual([2, 1]);
});
it("shows the API error state without rendering the empty-state message", async () => {
fetchAdminUsersMock.mockRejectedValueOnce(new Error("Unable to load users"));
render(<UsersSettingsPage />);
expect(await screen.findByText("Unable to load users")).toBeInTheDocument();
expect(screen.queryByText("No Users Yet")).not.toBeInTheDocument();
expect(screen.queryByText("Invite the first user to get started.")).not.toBeInTheDocument();
});
it("prevents the current user from deactivating their own account", async () => {
useAuthMock.mockReturnValue(makeAuthState("user-1"));
const selfUser = makeAdminUser({
id: "user-1",
name: "Alice",
email: "alice@example.com",
});
const otherUser = makeAdminUser({
id: "user-2",
name: "Bob",
email: "bob@example.com",
});
fetchAdminUsersMock.mockResolvedValueOnce(
makeAdminUsersResponse({
data: [selfUser, otherUser],
page: 1,
totalPages: 1,
total: 2,
})
);
render(<UsersSettingsPage />);
expect(await screen.findByText("alice@example.com")).toBeInTheDocument();
expect(screen.getByText("bob@example.com")).toBeInTheDocument();
const selfRow = screen.getByText("alice@example.com").closest('[role="button"]');
if (!(selfRow instanceof HTMLElement)) {
throw new Error("Expected current-user row to exist");
}
expect(within(selfRow).queryByRole("button", { name: "Deactivate" })).not.toBeInTheDocument();
const otherRow = screen.getByText("bob@example.com").closest('[role="button"]');
if (!(otherRow instanceof HTMLElement)) {
throw new Error("Expected other-user row to exist");
}
expect(within(otherRow).getByRole("button", { name: "Deactivate" })).toBeInTheDocument();
expect(deactivateUserMock).not.toHaveBeenCalled();
});
});

View File

@@ -5,12 +5,14 @@ import {
useEffect, useEffect,
useState, useState,
type ChangeEvent, type ChangeEvent,
type KeyboardEvent,
type ReactElement, type ReactElement,
type SyntheticEvent, type SyntheticEvent,
} from "react"; } from "react";
import Link from "next/link"; import Link from "next/link";
import { Pencil, UserPlus, UserX } from "lucide-react"; import { UserPlus, UserX } from "lucide-react";
import { WorkspaceMemberRole } from "@mosaic/shared"; import { WorkspaceMemberRole } from "@mosaic/shared";
import { isValidEmail } from "@/components/workspace/validation";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
@@ -42,7 +44,6 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
import { import {
deactivateUser, deactivateUser,
fetchAdminUsers, fetchAdminUsers,
@@ -50,9 +51,13 @@ import {
updateUser, updateUser,
type AdminUser, type AdminUser,
type AdminUsersResponse, type AdminUsersResponse,
type AdminWorkspaceMembership,
type InviteUserDto, type InviteUserDto,
type UpdateUserDto, type UpdateUserDto,
} from "@/lib/api/admin"; } from "@/lib/api/admin";
import { useAuth } from "@/lib/auth/auth-context";
import { fetchUserWorkspaces, updateWorkspaceMemberRole } from "@/lib/api/workspaces";
import { SettingsAccessDenied } from "@/components/settings/SettingsAccessDenied";
const ROLE_PRIORITY: Record<WorkspaceMemberRole, number> = { const ROLE_PRIORITY: Record<WorkspaceMemberRole, number> = {
[WorkspaceMemberRole.OWNER]: 4, [WorkspaceMemberRole.OWNER]: 4,
@@ -63,74 +68,99 @@ const ROLE_PRIORITY: Record<WorkspaceMemberRole, number> = {
const INITIAL_INVITE_FORM = { const INITIAL_INVITE_FORM = {
email: "", email: "",
name: "",
workspaceId: "",
role: WorkspaceMemberRole.MEMBER, role: WorkspaceMemberRole.MEMBER,
}; };
const INITIAL_DETAIL_FORM = {
name: "",
email: "",
role: WorkspaceMemberRole.MEMBER,
workspaceId: null as string | null,
workspaceName: null as string | null,
};
const USERS_PAGE_SIZE = 50;
interface DetailInitialState {
name: string;
email: string;
role: WorkspaceMemberRole;
workspaceId: string | null;
}
function toRoleLabel(role: WorkspaceMemberRole): string { function toRoleLabel(role: WorkspaceMemberRole): string {
return `${role.charAt(0)}${role.slice(1).toLowerCase()}`; return `${role.charAt(0)}${role.slice(1).toLowerCase()}`;
} }
function getPrimaryRole(user: AdminUser): WorkspaceMemberRole | null { function getPrimaryMembership(user: AdminUser): AdminWorkspaceMembership | null {
const [firstMembership, ...restMemberships] = user.workspaceMemberships; const [firstMembership, ...restMemberships] = user.workspaceMemberships;
if (!firstMembership) { if (!firstMembership) {
return null; return null;
} }
return restMemberships.reduce((highest, membership) => { return restMemberships.reduce((highest, membership) => {
if (ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highest]) { if (ROLE_PRIORITY[membership.role] > ROLE_PRIORITY[highest.role]) {
return membership.role; return membership;
} }
return highest; return highest;
}, firstMembership.role); }, firstMembership);
} }
export default function UsersSettingsPage(): ReactElement { export default function UsersSettingsPage(): ReactElement {
const { user: authUser } = useAuth();
const [users, setUsers] = useState<AdminUser[]>([]); const [users, setUsers] = useState<AdminUser[]>([]);
const [meta, setMeta] = useState<AdminUsersResponse["meta"] | null>(null); const [meta, setMeta] = useState<AdminUsersResponse["meta"] | null>(null);
const [page, setPage] = useState<number>(1);
const [isLoading, setIsLoading] = useState<boolean>(true); const [isLoading, setIsLoading] = useState<boolean>(true);
const [isRefreshing, setIsRefreshing] = useState<boolean>(false); const [isRefreshing, setIsRefreshing] = useState<boolean>(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [defaultWorkspaceId, setDefaultWorkspaceId] = useState<string | null>(null);
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const [isInviteOpen, setIsInviteOpen] = useState<boolean>(false); const [isInviteOpen, setIsInviteOpen] = useState<boolean>(false);
const [inviteForm, setInviteForm] = useState(INITIAL_INVITE_FORM); const [inviteForm, setInviteForm] = useState(INITIAL_INVITE_FORM);
const [inviteError, setInviteError] = useState<string | null>(null); const [inviteError, setInviteError] = useState<string | null>(null);
const [isInviting, setIsInviting] = useState<boolean>(false); const [isInviting, setIsInviting] = useState<boolean>(false);
const [detailTarget, setDetailTarget] = useState<AdminUser | null>(null);
const [detailForm, setDetailForm] = useState(INITIAL_DETAIL_FORM);
const [detailInitial, setDetailInitial] = useState<DetailInitialState | null>(null);
const [detailError, setDetailError] = useState<string | null>(null);
const [isSavingDetails, setIsSavingDetails] = useState<boolean>(false);
const [deactivateTarget, setDeactivateTarget] = useState<AdminUser | null>(null); const [deactivateTarget, setDeactivateTarget] = useState<AdminUser | null>(null);
const [isDeactivating, setIsDeactivating] = useState<boolean>(false); const [isDeactivating, setIsDeactivating] = useState<boolean>(false);
const [editTarget, setEditTarget] = useState<AdminUser | null>(null); const loadUsers = useCallback(
const [editName, setEditName] = useState<string>(""); async (showLoadingState: boolean): Promise<void> => {
const [editError, setEditError] = useState<string | null>(null); try {
const [isEditing, setIsEditing] = useState<boolean>(false); if (showLoadingState) {
setIsLoading(true);
} else {
setIsRefreshing(true);
}
const [isAdmin, setIsAdmin] = useState<boolean | null>(null); const response = await fetchAdminUsers(page, USERS_PAGE_SIZE);
const lastValidPage = Math.max(1, response.meta.totalPages);
const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => { if (page > lastValidPage) {
try { setPage(lastValidPage);
if (showLoadingState) { return;
setIsLoading(true); }
} else {
setIsRefreshing(true); setUsers(response.data);
setMeta(response.meta);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load admin users");
} finally {
setIsLoading(false);
setIsRefreshing(false);
} }
},
const response = await fetchAdminUsers(1, 50); [page]
setUsers(response.data); );
setMeta(response.meta);
setError(null);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : "Failed to load admin users");
} finally {
setIsLoading(false);
setIsRefreshing(false);
}
}, []);
useEffect(() => {
void loadUsers(true);
}, [loadUsers]);
useEffect(() => { useEffect(() => {
fetchUserWorkspaces() fetchUserWorkspaces()
@@ -139,27 +169,67 @@ export default function UsersSettingsPage(): ReactElement {
WorkspaceMemberRole.OWNER, WorkspaceMemberRole.OWNER,
WorkspaceMemberRole.ADMIN, WorkspaceMemberRole.ADMIN,
]; ];
setIsAdmin(workspaces.some((ws) => adminRoles.includes(ws.role)));
setDefaultWorkspaceId(workspaces[0]?.id ?? null);
setIsAdmin(workspaces.some((workspace) => adminRoles.includes(workspace.role)));
}) })
.catch(() => { .catch(() => {
setDefaultWorkspaceId(null);
setIsAdmin(true); // fail open setIsAdmin(true); // fail open
}); });
}, []); }, []);
useEffect(() => {
if (isAdmin !== true) {
return;
}
void loadUsers(true);
}, [isAdmin, loadUsers, page]);
function resetInviteForm(): void { function resetInviteForm(): void {
setInviteForm(INITIAL_INVITE_FORM); setInviteForm(INITIAL_INVITE_FORM);
setInviteError(null); setInviteError(null);
} }
function handleInviteOpenChange(open: boolean): void { function openUserDetails(user: AdminUser): void {
if (!open && !isInviting) { const primaryMembership = getPrimaryMembership(user);
resetInviteForm();
} const nextDetailForm = {
setIsInviteOpen(open); name: user.name,
email: user.email,
role: primaryMembership?.role ?? WorkspaceMemberRole.MEMBER,
workspaceId: primaryMembership?.workspaceId ?? null,
workspaceName: primaryMembership?.workspaceName ?? null,
};
setDetailTarget(user);
setDetailForm(nextDetailForm);
setDetailInitial({
name: nextDetailForm.name,
email: nextDetailForm.email,
role: nextDetailForm.role,
workspaceId: nextDetailForm.workspaceId,
});
setDetailError(null);
} }
async function handleInviteSubmit(e: SyntheticEvent): Promise<void> { function resetUserDetails(): void {
e.preventDefault(); setDetailTarget(null);
setDetailForm(INITIAL_DETAIL_FORM);
setDetailInitial(null);
setDetailError(null);
}
function handleUserRowKeyDown(event: KeyboardEvent<HTMLDivElement>, user: AdminUser): void {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
openUserDetails(user);
}
}
async function handleInviteSubmit(event: SyntheticEvent): Promise<void> {
event.preventDefault();
setInviteError(null); setInviteError(null);
const email = inviteForm.email.trim(); const email = inviteForm.email.trim();
@@ -168,17 +238,18 @@ export default function UsersSettingsPage(): ReactElement {
return; return;
} }
const dto: InviteUserDto = { email }; if (!isValidEmail(email)) {
setInviteError("Please enter a valid email address.");
const name = inviteForm.name.trim(); return;
if (name) {
dto.name = name;
} }
const workspaceId = inviteForm.workspaceId.trim(); const dto: InviteUserDto = {
if (workspaceId) { email,
dto.workspaceId = workspaceId; role: inviteForm.role,
dto.role = inviteForm.role; };
if (defaultWorkspaceId) {
dto.workspaceId = defaultWorkspaceId;
} }
try { try {
@@ -194,11 +265,86 @@ export default function UsersSettingsPage(): ReactElement {
} }
} }
async function handleDetailSubmit(event: SyntheticEvent): Promise<void> {
event.preventDefault();
if (detailTarget === null || detailInitial === null) {
return;
}
const name = detailForm.name.trim();
const email = detailForm.email.trim();
if (!name) {
setDetailError("Name is required.");
return;
}
if (!email) {
setDetailError("Email is required.");
return;
}
if (!isValidEmail(email)) {
setDetailError("Please enter a valid email address.");
return;
}
const didUpdateUser = name !== detailInitial.name || email !== detailInitial.email;
const didUpdateRole =
detailForm.workspaceId !== null &&
detailForm.workspaceId === detailInitial.workspaceId &&
detailForm.role !== detailInitial.role;
if (!didUpdateUser && !didUpdateRole) {
resetUserDetails();
return;
}
try {
setIsSavingDetails(true);
setDetailError(null);
if (didUpdateUser) {
const dto: UpdateUserDto = {};
if (name !== detailInitial.name) {
dto.name = name;
}
if (email !== detailInitial.email) {
dto.email = email;
}
await updateUser(detailTarget.id, dto);
}
if (didUpdateRole && detailForm.workspaceId !== null) {
await updateWorkspaceMemberRole(detailForm.workspaceId, detailTarget.id, {
role: detailForm.role,
});
}
resetUserDetails();
await loadUsers(false);
} catch (err: unknown) {
setDetailError(err instanceof Error ? err.message : "Failed to update user");
} finally {
setIsSavingDetails(false);
}
}
async function confirmDeactivate(): Promise<void> { async function confirmDeactivate(): Promise<void> {
if (!deactivateTarget) { if (!deactivateTarget) {
return; return;
} }
if (authUser?.id === deactivateTarget.id) {
setDeactivateTarget(null);
setError("You cannot deactivate your own account.");
return;
}
try { try {
setIsDeactivating(true); setIsDeactivating(true);
await deactivateUser(deactivateTarget.id); await deactivateUser(deactivateTarget.id);
@@ -212,32 +358,18 @@ export default function UsersSettingsPage(): ReactElement {
} }
} }
async function handleEditSubmit(): Promise<void> { if (isAdmin === null) {
if (editTarget === null) return; return (
setIsEditing(true); <Card className="max-w-2xl mx-auto mt-8">
setEditError(null); <CardContent className="py-12 text-center text-muted-foreground">
try { Checking permissions...
const dto: UpdateUserDto = {}; </CardContent>
if (editName.trim()) dto.name = editName.trim(); </Card>
await updateUser(editTarget.id, dto); );
setEditTarget(null);
await loadUsers(false);
} catch (err: unknown) {
setEditError(err instanceof Error ? err.message : "Failed to update user");
} finally {
setIsEditing(false);
}
} }
if (isAdmin === false) { if (!isAdmin) {
return ( return <SettingsAccessDenied message="You need Admin or Owner role to manage users." />;
<div className="p-8 max-w-2xl">
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
<p className="text-lg font-semibold text-red-700">Access Denied</p>
<p className="mt-2 text-sm text-red-600">You need Admin or Owner role to manage users.</p>
</div>
</div>
);
} }
return ( return (
@@ -262,7 +394,15 @@ export default function UsersSettingsPage(): ReactElement {
{isRefreshing ? "Refreshing..." : "Refresh"} {isRefreshing ? "Refreshing..." : "Refresh"}
</Button> </Button>
<Dialog open={isInviteOpen} onOpenChange={handleInviteOpenChange}> <Dialog
open={isInviteOpen}
onOpenChange={(open) => {
if (!open && !isInviting) {
resetInviteForm();
}
setIsInviteOpen(open);
}}
>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button>
<UserPlus className="h-4 w-4 mr-2" /> <UserPlus className="h-4 w-4 mr-2" />
@@ -273,13 +413,13 @@ export default function UsersSettingsPage(): ReactElement {
<DialogHeader> <DialogHeader>
<DialogTitle>Invite User</DialogTitle> <DialogTitle>Invite User</DialogTitle>
<DialogDescription> <DialogDescription>
Create an invited account and optionally assign workspace access. Invite a new user and assign their role for your default workspace.
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<form <form
onSubmit={(e) => { onSubmit={(event) => {
void handleInviteSubmit(e); void handleInviteSubmit(event);
}} }}
className="space-y-4" className="space-y-4"
> >
@@ -289,8 +429,8 @@ export default function UsersSettingsPage(): ReactElement {
id="invite-email" id="invite-email"
type="email" type="email"
value={inviteForm.email} value={inviteForm.email}
onChange={(e: ChangeEvent<HTMLInputElement>) => { onChange={(event: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, email: e.target.value })); setInviteForm((prev) => ({ ...prev, email: event.target.value }));
}} }}
placeholder="user@example.com" placeholder="user@example.com"
maxLength={255} maxLength={255}
@@ -298,33 +438,6 @@ export default function UsersSettingsPage(): ReactElement {
/> />
</div> </div>
<div className="space-y-2">
<Label htmlFor="invite-name">Name (optional)</Label>
<Input
id="invite-name"
type="text"
value={inviteForm.name}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, name: e.target.value }));
}}
placeholder="Jane Doe"
maxLength={255}
/>
</div>
<div className="space-y-2">
<Label htmlFor="invite-workspace-id">Workspace ID (optional)</Label>
<Input
id="invite-workspace-id"
type="text"
value={inviteForm.workspaceId}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setInviteForm((prev) => ({ ...prev, workspaceId: e.target.value }));
}}
placeholder="UUID workspace id"
/>
</div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="invite-role">Role</Label> <Label htmlFor="invite-role">Role</Label>
<Select <Select
@@ -344,9 +457,13 @@ export default function UsersSettingsPage(): ReactElement {
))} ))}
</SelectContent> </SelectContent>
</Select> </Select>
<p className="text-xs text-muted-foreground"> {defaultWorkspaceId ? (
Role is only applied when workspace ID is provided. <p className="text-xs text-muted-foreground">Role will be applied on invite.</p>
</p> ) : (
<p className="text-xs text-muted-foreground">
No default workspace found. User will be invited without workspace assignment.
</p>
)}
</div> </div>
{inviteError ? ( {inviteError ? (
@@ -360,7 +477,10 @@ export default function UsersSettingsPage(): ReactElement {
type="button" type="button"
variant="outline" variant="outline"
onClick={() => { onClick={() => {
handleInviteOpenChange(false); if (!isInviting) {
setIsInviteOpen(false);
resetInviteForm();
}
}} }}
disabled={isInviting} disabled={isInviting}
> >
@@ -382,7 +502,13 @@ export default function UsersSettingsPage(): ReactElement {
</Link> </Link>
</div> </div>
{error ? ( {isLoading ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Loading users...
</CardContent>
</Card>
) : error ? (
<Card> <Card>
<CardContent className="py-4"> <CardContent className="py-4">
<p className="text-sm text-destructive" role="alert"> <p className="text-sm text-destructive" role="alert">
@@ -390,14 +516,6 @@ export default function UsersSettingsPage(): ReactElement {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
) : null}
{isLoading ? (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
Loading users...
</CardContent>
</Card>
) : users.length === 0 ? ( ) : users.length === 0 ? (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -409,47 +527,52 @@ export default function UsersSettingsPage(): ReactElement {
<Card> <Card>
<CardHeader> <CardHeader>
<CardTitle>User Directory</CardTitle> <CardTitle>User Directory</CardTitle>
<CardDescription>Name, email, role, and account status.</CardDescription> <CardDescription>Click a user to view details or edit profile fields.</CardDescription>
</CardHeader> </CardHeader>
<CardContent className="space-y-3"> <CardContent className="space-y-3">
{users.map((user) => { {users.map((user) => {
const primaryRole = getPrimaryRole(user); const primaryMembership = getPrimaryMembership(user);
const isActive = user.deactivatedAt === null; const isActive = user.deactivatedAt === null;
const isCurrentUser = authUser?.id === user.id;
return ( return (
<div <div
key={user.id} key={user.id}
className="rounded-md border p-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between" className="rounded-md border p-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between cursor-pointer hover:bg-muted/30"
role="button"
tabIndex={0}
onClick={() => {
openUserDetails(user);
}}
onKeyDown={(event) => {
handleUserRowKeyDown(event, user);
}}
> >
<div className="space-y-1 min-w-0"> <div className="space-y-1 min-w-0">
<p className="font-semibold truncate">{user.name || "Unnamed User"}</p> <p className="font-semibold truncate">
{user.name || "Unnamed User"}
{isCurrentUser ? (
<span className="ml-2 text-xs font-normal text-muted-foreground">
(You)
</span>
) : null}
</p>
<p className="text-sm text-muted-foreground truncate">{user.email}</p> <p className="text-sm text-muted-foreground truncate">{user.email}</p>
</div> </div>
<div className="flex items-center gap-2 flex-wrap md:justify-end"> <div className="flex items-center gap-2 flex-wrap md:justify-end">
<Badge variant="outline"> <Badge variant="outline">
{primaryRole ? toRoleLabel(primaryRole) : "No role"} {primaryMembership ? toRoleLabel(primaryMembership.role) : "No role"}
</Badge> </Badge>
<Badge variant={isActive ? "secondary" : "destructive"}> <Badge variant={isActive ? "secondary" : "destructive"}>
{isActive ? "Active" : "Inactive"} {isActive ? "Active" : "Inactive"}
</Badge> </Badge>
<Button {isActive && !isCurrentUser ? (
variant="outline"
size="sm"
onClick={() => {
setEditTarget(user);
setEditName(user.name);
setEditError(null);
}}
>
<Pencil className="h-4 w-4 mr-2" />
Edit Role
</Button>
{isActive ? (
<Button <Button
variant="destructive" variant="destructive"
size="sm" size="sm"
onClick={() => { onClick={(event) => {
event.stopPropagation();
setDeactivateTarget(user); setDeactivateTarget(user);
}} }}
> >
@@ -461,10 +584,151 @@ export default function UsersSettingsPage(): ReactElement {
</div> </div>
); );
})} })}
{meta && meta.totalPages > 1 ? (
<div className="flex items-center justify-between pt-3 mt-1 border-t">
<p className="text-sm text-muted-foreground">
Page {page} of {meta.totalPages}
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 1}
onClick={() => {
setPage((previousPage) => Math.max(1, previousPage - 1));
}}
>
Previous
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= meta.totalPages}
onClick={() => {
setPage((previousPage) => Math.min(meta.totalPages, previousPage + 1));
}}
>
Next
</Button>
</div>
</div>
) : null}
</CardContent> </CardContent>
</Card> </Card>
)} )}
<Dialog
open={detailTarget !== null}
onOpenChange={(open) => {
if (!open && !isSavingDetails) {
resetUserDetails();
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>User Details</DialogTitle>
<DialogDescription>
Edit profile details for {detailTarget?.email ?? "selected user"}.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
void handleDetailSubmit(event);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="detail-name">Name</Label>
<Input
id="detail-name"
value={detailForm.name}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setDetailForm((prev) => ({ ...prev, name: event.target.value }));
}}
placeholder="Full name"
maxLength={255}
disabled={isSavingDetails}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="detail-email">Email</Label>
<Input
id="detail-email"
type="email"
value={detailForm.email}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
setDetailForm((prev) => ({ ...prev, email: event.target.value }));
}}
placeholder="user@example.com"
maxLength={255}
disabled={isSavingDetails}
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="detail-role">Role</Label>
<Select
value={detailForm.role}
disabled={detailForm.workspaceId === null || isSavingDetails}
onValueChange={(value) => {
setDetailForm((prev) => ({ ...prev, role: value as WorkspaceMemberRole }));
}}
>
<SelectTrigger id="detail-role">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{Object.values(WorkspaceMemberRole).map((role) => (
<SelectItem key={role} value={role}>
{toRoleLabel(role)}
</SelectItem>
))}
</SelectContent>
</Select>
{detailForm.workspaceName ? (
<p className="text-xs text-muted-foreground">
Role updates apply to: {detailForm.workspaceName}
</p>
) : (
<p className="text-xs text-muted-foreground">
This user has no workspace membership. Role cannot be updated.
</p>
)}
</div>
{detailError !== null ? (
<p className="text-sm text-destructive" role="alert">
{detailError}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
if (!isSavingDetails) {
resetUserDetails();
}
}}
disabled={isSavingDetails}
>
Cancel
</Button>
<Button type="submit" disabled={isSavingDetails}>
{isSavingDetails ? "Saving..." : "Save Changes"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
<AlertDialog <AlertDialog
open={deactivateTarget !== null} open={deactivateTarget !== null}
onOpenChange={(open) => { onOpenChange={(open) => {
@@ -496,55 +760,4 @@ export default function UsersSettingsPage(): ReactElement {
</AlertDialog> </AlertDialog>
</div> </div>
); );
<Dialog
open={editTarget !== null}
onOpenChange={(open) => {
if (!open && !isEditing) {
setEditTarget(null);
setEditError(null);
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Edit User Role</DialogTitle>
<DialogDescription>Change role for {editTarget?.email ?? "user"}.</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
{editError !== null ? <p className="text-sm text-destructive">{editError}</p> : null}
<div className="space-y-2">
<Label htmlFor="edit-name">Display Name</Label>
<Input
id="edit-name"
value={editName}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setEditName(e.target.value);
}}
placeholder="Full name"
disabled={isEditing}
/>
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setEditTarget(null);
}}
disabled={isEditing}
>
Cancel
</Button>
<Button
onClick={() => {
void handleEditSubmit();
}}
disabled={isEditing}
>
{isEditing ? "Saving..." : "Save"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>;
} }

View File

@@ -1,12 +1,12 @@
import type { UserWorkspace } from "@/lib/api/workspaces"; import type { UserWorkspace, WorkspaceMemberEntry } from "@/lib/api/workspaces";
import type { ReactElement, ReactNode } from "react"; import type { ReactElement, ReactNode } from "react";
import { WorkspaceMemberRole } from "@mosaic/shared"; import { WorkspaceMemberRole } from "@mosaic/shared";
import { render, screen, waitFor } from "@testing-library/react"; import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { createWorkspace, fetchUserWorkspaces } from "@/lib/api/workspaces";
import { createWorkspace, fetchUserWorkspaces, fetchWorkspaceMembers } from "@/lib/api/workspaces";
import WorkspacesPage from "./page"; import WorkspacesPage from "./page";
vi.mock("next/link", () => ({ vi.mock("next/link", () => ({
@@ -21,33 +21,23 @@ vi.mock("next/link", () => ({
}, },
})); }));
vi.mock("@/components/workspace/WorkspaceCard", () => ({
WorkspaceCard: function WorkspaceCardMock({
workspace,
userRole,
memberCount,
}: {
workspace: { name: string };
userRole: WorkspaceMemberRole;
memberCount: number;
}): ReactElement {
return (
<div data-testid="workspace-card">
{workspace.name} | {userRole} | {String(memberCount)}
</div>
);
},
}));
vi.mock("@/lib/api/workspaces", () => ({ vi.mock("@/lib/api/workspaces", () => ({
fetchUserWorkspaces: vi.fn(), fetchUserWorkspaces: vi.fn(),
createWorkspace: vi.fn(), createWorkspace: vi.fn(),
fetchWorkspaceMembers: vi.fn(),
addWorkspaceMember: vi.fn(),
removeWorkspaceMember: vi.fn(),
}));
vi.mock("@/lib/api/admin", () => ({
fetchAdminUsers: vi.fn(),
})); }));
const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces); const fetchUserWorkspacesMock = vi.mocked(fetchUserWorkspaces);
const createWorkspaceMock = vi.mocked(createWorkspace); const createWorkspaceMock = vi.mocked(createWorkspace);
const fetchWorkspaceMembersMock = vi.mocked(fetchWorkspaceMembers);
const baseWorkspace: UserWorkspace = { const workspaceA: UserWorkspace = {
id: "workspace-1", id: "workspace-1",
name: "Personal Workspace", name: "Personal Workspace",
ownerId: "owner-1", ownerId: "owner-1",
@@ -55,45 +45,93 @@ const baseWorkspace: UserWorkspace = {
createdAt: "2026-01-01T00:00:00.000Z", createdAt: "2026-01-01T00:00:00.000Z",
}; };
const workspaceB: UserWorkspace = {
id: "workspace-2",
name: "Client Workspace",
ownerId: "owner-2",
role: WorkspaceMemberRole.ADMIN,
createdAt: "2026-01-02T00:00:00.000Z",
};
const membersA: WorkspaceMemberEntry[] = [
{
workspaceId: "workspace-1",
userId: "user-a",
role: WorkspaceMemberRole.OWNER,
joinedAt: "2026-01-03T00:00:00.000Z",
user: {
id: "user-a",
email: "alice@example.com",
name: "Alice",
image: null,
},
},
];
const membersB: WorkspaceMemberEntry[] = [
{
workspaceId: "workspace-2",
userId: "user-b",
role: WorkspaceMemberRole.MEMBER,
joinedAt: "2026-01-04T00:00:00.000Z",
user: {
id: "user-b",
email: "bob@example.com",
name: "Bob",
image: null,
},
},
];
describe("WorkspacesPage", () => { describe("WorkspacesPage", () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it("loads and renders user workspaces from the API", async () => { it("loads workspaces and fetches members for the first workspace", async () => {
fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]); fetchUserWorkspacesMock.mockResolvedValue([workspaceA, workspaceB]);
fetchWorkspaceMembersMock.mockResolvedValue(membersA);
render(<WorkspacesPage />); render(<WorkspacesPage />);
expect(screen.getByText("Loading workspaces...")).toBeInTheDocument(); expect(await screen.findByText("Your Workspaces (2)")).toBeInTheDocument();
expect(await screen.findByText("Personal Workspace Members")).toBeInTheDocument();
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument(); await waitFor(() => {
expect(screen.getByTestId("workspace-card")).toHaveTextContent("Personal Workspace"); expect(fetchWorkspaceMembersMock).toHaveBeenCalledWith("workspace-1");
expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(1); });
expect(screen.getByText("alice@example.com")).toBeInTheDocument();
}); });
it("shows fetch errors in the UI", async () => { it("switches selected workspace and reloads member list", async () => {
fetchUserWorkspacesMock.mockRejectedValue(new Error("Unable to load workspaces")); fetchUserWorkspacesMock.mockResolvedValue([workspaceA, workspaceB]);
fetchWorkspaceMembersMock.mockResolvedValueOnce(membersA).mockResolvedValueOnce(membersB);
const user = userEvent.setup();
render(<WorkspacesPage />); render(<WorkspacesPage />);
expect(await screen.findByText("Unable to load workspaces")).toBeInTheDocument(); expect(await screen.findByText("Personal Workspace Members")).toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /client workspace/i }));
await waitFor(() => {
expect(fetchWorkspaceMembersMock).toHaveBeenLastCalledWith("workspace-2");
});
expect(await screen.findByText("Client Workspace Members")).toBeInTheDocument();
expect(screen.getByText("bob@example.com")).toBeInTheDocument();
}); });
it("creates a workspace and refreshes the list", async () => { it("creates a workspace and refreshes the list", async () => {
fetchUserWorkspacesMock.mockResolvedValueOnce([baseWorkspace]).mockResolvedValueOnce([ fetchUserWorkspacesMock
baseWorkspace, .mockResolvedValueOnce([workspaceA])
{ .mockResolvedValueOnce([workspaceA, workspaceB]);
...baseWorkspace, fetchWorkspaceMembersMock.mockResolvedValue(membersA);
id: "workspace-2",
name: "New Workspace",
role: WorkspaceMemberRole.MEMBER,
},
]);
createWorkspaceMock.mockResolvedValue({ createWorkspaceMock.mockResolvedValue({
id: "workspace-2", id: "workspace-2",
name: "New Workspace", name: "Client Workspace",
ownerId: "owner-1", ownerId: "owner-2",
settings: {}, settings: {},
createdAt: "2026-01-02T00:00:00.000Z", createdAt: "2026-01-02T00:00:00.000Z",
updatedAt: "2026-01-02T00:00:00.000Z", updatedAt: "2026-01-02T00:00:00.000Z",
@@ -105,31 +143,17 @@ describe("WorkspacesPage", () => {
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument(); expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument();
await user.type(screen.getByPlaceholderText("Enter workspace name..."), "New Workspace"); await user.type(screen.getByPlaceholderText("Enter workspace name..."), "Client Workspace");
await user.click(screen.getByRole("button", { name: "Create Workspace" })); await user.click(screen.getByRole("button", { name: "Create Workspace" }));
await waitFor(() => { await waitFor(() => {
expect(createWorkspaceMock).toHaveBeenCalledWith({ name: "New Workspace" }); expect(createWorkspaceMock).toHaveBeenCalledWith({ name: "Client Workspace" });
}); });
await waitFor(() => { await waitFor(() => {
expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(2); expect(fetchUserWorkspacesMock).toHaveBeenCalledTimes(2);
}); });
expect(await screen.findByText("Your Workspaces (2)")).toBeInTheDocument(); expect(await screen.findByText("Your Workspaces (2)")).toBeInTheDocument();
}); });
it("shows create errors in the UI", async () => {
fetchUserWorkspacesMock.mockResolvedValue([baseWorkspace]);
createWorkspaceMock.mockRejectedValue(new Error("Workspace creation failed"));
const user = userEvent.setup();
render(<WorkspacesPage />);
expect(await screen.findByText("Your Workspaces (1)")).toBeInTheDocument();
await user.type(screen.getByPlaceholderText("Enter workspace name..."), "Bad Workspace");
await user.click(screen.getByRole("button", { name: "Create Workspace" }));
expect(await screen.findByText("Workspace creation failed")).toBeInTheDocument();
});
}); });

View File

@@ -2,10 +2,51 @@
import type { ReactElement, SyntheticEvent } from "react"; import type { ReactElement, SyntheticEvent } from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { WorkspaceCard } from "@/components/workspace/WorkspaceCard";
import { createWorkspace, fetchUserWorkspaces, type UserWorkspace } from "@/lib/api/workspaces";
import Link from "next/link"; import Link from "next/link";
import { UserPlus, UserX } from "lucide-react";
import { WorkspaceMemberRole } from "@mosaic/shared";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
addWorkspaceMember,
createWorkspace,
fetchUserWorkspaces,
fetchWorkspaceMembers,
removeWorkspaceMember,
type UserWorkspace,
type WorkspaceMemberEntry,
} from "@/lib/api/workspaces";
import { fetchAdminUsers, type AdminUser } from "@/lib/api/admin";
function getErrorMessage(error: unknown, fallback: string): string { function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error) { if (error instanceof Error) {
@@ -15,18 +56,53 @@ function getErrorMessage(error: unknown, fallback: string): string {
return fallback; return fallback;
} }
/** function toRoleLabel(role: WorkspaceMemberRole): string {
* Workspaces Page return `${role.charAt(0)}${role.slice(1).toLowerCase()}`;
* Fetches and creates workspaces through the real API. }
*/
interface RemoveMemberTarget {
userId: string;
email: string;
}
const ROLE_BADGE_CLASS: Record<WorkspaceMemberRole, string> = {
[WorkspaceMemberRole.OWNER]: "border-purple-200 bg-purple-50 text-purple-700",
[WorkspaceMemberRole.ADMIN]: "border-blue-200 bg-blue-50 text-blue-700",
[WorkspaceMemberRole.MEMBER]: "border-green-200 bg-green-50 text-green-700",
[WorkspaceMemberRole.GUEST]: "border-gray-200 bg-gray-50 text-gray-700",
};
export default function WorkspacesPage(): ReactElement { export default function WorkspacesPage(): ReactElement {
const [workspaces, setWorkspaces] = useState<UserWorkspace[]>([]); const [workspaces, setWorkspaces] = useState<UserWorkspace[]>([]);
const [selectedWorkspaceId, setSelectedWorkspaceId] = useState<string | null>(null);
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [loadError, setLoadError] = useState<string | null>(null); const [loadError, setLoadError] = useState<string | null>(null);
const [isCreating, setIsCreating] = useState(false); const [isCreating, setIsCreating] = useState(false);
const [newWorkspaceName, setNewWorkspaceName] = useState(""); const [newWorkspaceName, setNewWorkspaceName] = useState("");
const [createError, setCreateError] = useState<string | null>(null); const [createError, setCreateError] = useState<string | null>(null);
const [members, setMembers] = useState<WorkspaceMemberEntry[]>([]);
const [isMembersLoading, setIsMembersLoading] = useState(false);
const [membersError, setMembersError] = useState<string | null>(null);
const [isAddMemberOpen, setIsAddMemberOpen] = useState(false);
const [isAddingMember, setIsAddingMember] = useState(false);
const [addMemberError, setAddMemberError] = useState<string | null>(null);
const [memberUserId, setMemberUserId] = useState<string>("");
const [memberRole, setMemberRole] = useState<WorkspaceMemberRole>(WorkspaceMemberRole.MEMBER);
const [availableUsers, setAvailableUsers] = useState<AdminUser[]>([]);
const [isLoadingUsers, setIsLoadingUsers] = useState(false);
const [removeTarget, setRemoveTarget] = useState<RemoveMemberTarget | null>(null);
const [isRemovingMember, setIsRemovingMember] = useState(false);
const selectedWorkspace = useMemo(
() => workspaces.find((workspace) => workspace.id === selectedWorkspaceId) ?? null,
[selectedWorkspaceId, workspaces]
);
const loadWorkspaces = useCallback(async (): Promise<void> => { const loadWorkspaces = useCallback(async (): Promise<void> => {
setIsLoading(true); setIsLoading(true);
@@ -34,31 +110,57 @@ export default function WorkspacesPage(): ReactElement {
const data = await fetchUserWorkspaces(); const data = await fetchUserWorkspaces();
setWorkspaces(data); setWorkspaces(data);
setLoadError(null); setLoadError(null);
setSelectedWorkspaceId((current) => {
if (current && data.some((workspace) => workspace.id === current)) {
return current;
}
return data[0]?.id ?? null;
});
} catch (error) { } catch (error) {
setLoadError(getErrorMessage(error, "Failed to load workspaces")); setLoadError(getErrorMessage(error, "Failed to load workspaces"));
setSelectedWorkspaceId(null);
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, []); }, []);
const loadMembers = useCallback(async (workspaceId: string): Promise<void> => {
setIsMembersLoading(true);
setMembersError(null);
try {
const data = await fetchWorkspaceMembers(workspaceId);
setMembers(data);
} catch (error) {
setMembersError(getErrorMessage(error, "Failed to load workspace members"));
setMembers([]);
} finally {
setIsMembersLoading(false);
}
}, []);
useEffect(() => { useEffect(() => {
void loadWorkspaces(); void loadWorkspaces();
}, [loadWorkspaces]); }, [loadWorkspaces]);
const workspacesWithRoles = workspaces.map((workspace) => ({ useEffect(() => {
...workspace, if (!selectedWorkspaceId) {
settings: {}, setMembers([]);
createdAt: new Date(workspace.createdAt), setMembersError(null);
updatedAt: new Date(workspace.createdAt), return;
userRole: workspace.role, }
memberCount: 1,
}));
const handleCreateWorkspace = async (e: SyntheticEvent<HTMLFormElement>): Promise<void> => { void loadMembers(selectedWorkspaceId);
e.preventDefault(); }, [loadMembers, selectedWorkspaceId]);
const handleCreateWorkspace = async (event: SyntheticEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
const workspaceName = newWorkspaceName.trim(); const workspaceName = newWorkspaceName.trim();
if (!workspaceName) return; if (!workspaceName) {
return;
}
setIsCreating(true); setIsCreating(true);
setCreateError(null); setCreateError(null);
@@ -74,91 +176,394 @@ export default function WorkspacesPage(): ReactElement {
} }
}; };
const eligibleUsers = useMemo(() => {
const memberIds = new Set(members.map((member) => member.userId));
return availableUsers.filter((user) => !memberIds.has(user.id));
}, [availableUsers, members]);
const loadAvailableUsers = useCallback(async (): Promise<void> => {
setIsLoadingUsers(true);
try {
const response = await fetchAdminUsers(1, 200);
const activeUsers = response.data.filter((user) => user.deactivatedAt === null);
setAvailableUsers(activeUsers);
if (memberUserId && activeUsers.some((user) => user.id === memberUserId)) {
return;
}
const memberIds = new Set(members.map((member) => member.userId));
const firstEligible = activeUsers.find((user) => !memberIds.has(user.id));
setMemberUserId(firstEligible?.id ?? "");
} catch (error) {
setAddMemberError(getErrorMessage(error, "Failed to load users for member assignment"));
setAvailableUsers([]);
setMemberUserId("");
} finally {
setIsLoadingUsers(false);
}
}, [memberUserId, members]);
const openAddMemberDialog = async (): Promise<void> => {
setAddMemberError(null);
setMemberRole(WorkspaceMemberRole.MEMBER);
setIsAddMemberOpen(true);
await loadAvailableUsers();
};
const handleAddMember = async (event: SyntheticEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
if (!selectedWorkspaceId) {
setAddMemberError("Select a workspace before adding members.");
return;
}
if (!memberUserId) {
setAddMemberError("Select a user to add.");
return;
}
setIsAddingMember(true);
setAddMemberError(null);
try {
await addWorkspaceMember(selectedWorkspaceId, {
userId: memberUserId,
role: memberRole,
});
setIsAddMemberOpen(false);
await loadMembers(selectedWorkspaceId);
} catch (error) {
setAddMemberError(getErrorMessage(error, "Failed to add member"));
} finally {
setIsAddingMember(false);
}
};
const handleRemoveMember = async (): Promise<void> => {
if (!selectedWorkspaceId || !removeTarget) {
return;
}
setIsRemovingMember(true);
try {
await removeWorkspaceMember(selectedWorkspaceId, removeTarget.userId);
setRemoveTarget(null);
await loadMembers(selectedWorkspaceId);
} catch (error) {
setMembersError(getErrorMessage(error, "Failed to remove member"));
} finally {
setIsRemovingMember(false);
}
};
return ( return (
<main className="container mx-auto px-4 py-8 max-w-5xl"> <main className="max-w-6xl mx-auto p-6 space-y-6">
<div className="mb-8"> <div className="flex items-start justify-between gap-4">
<div className="flex items-center justify-between mb-2"> <div>
<h1 className="text-3xl font-bold text-gray-900">Workspaces</h1> <h1 className="text-3xl font-bold">Workspaces</h1>
<Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700"> <p className="text-muted-foreground mt-1">Manage workspaces and workspace members</p>
Back to Settings
</Link>
</div> </div>
<p className="text-gray-600">Manage your workspaces and collaborate with your team</p> <Link href="/settings" className="text-sm text-blue-600 hover:text-blue-700">
Back to Settings
</Link>
</div> </div>
{/* Create New Workspace */} <Card>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-6 mb-6"> <CardHeader>
<h2 className="text-lg font-semibold text-gray-900 mb-4">Create New Workspace</h2> <CardTitle>Create New Workspace</CardTitle>
<form onSubmit={handleCreateWorkspace} className="flex gap-3"> <CardDescription>Create a workspace for a new team or project.</CardDescription>
<input </CardHeader>
type="text" <CardContent>
value={newWorkspaceName} <form onSubmit={handleCreateWorkspace} className="flex gap-3">
onChange={(e) => { <Input
setNewWorkspaceName(e.target.value); type="text"
}} value={newWorkspaceName}
placeholder="Enter workspace name..." onChange={(event) => {
disabled={isCreating} setNewWorkspaceName(event.target.value);
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100" }}
/> placeholder="Enter workspace name..."
<button disabled={isCreating}
type="submit" />
disabled={isCreating || !newWorkspaceName.trim()} <Button type="submit" disabled={isCreating || !newWorkspaceName.trim()}>
className="px-6 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed font-medium" {isCreating ? "Creating..." : "Create Workspace"}
> </Button>
{isCreating ? "Creating..." : "Create Workspace"} </form>
</button> {createError !== null ? (
</form> <p className="mt-3 text-sm text-destructive" role="alert">
{createError !== null && ( {createError}
<div className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700"> </p>
{createError} ) : null}
</div> </CardContent>
)} </Card>
{loadError !== null ? (
<Card>
<CardContent className="py-4">
<p className="text-sm text-destructive" role="alert">
{loadError}
</p>
</CardContent>
</Card>
) : null}
<div className="grid grid-cols-1 lg:grid-cols-5 gap-6">
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle>Your Workspaces ({isLoading ? "..." : workspaces.length})</CardTitle>
<CardDescription>Click a workspace to manage its members.</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading workspaces...</p>
) : workspaces.length === 0 ? (
<p className="text-sm text-muted-foreground">
No workspaces yet. Create one to begin.
</p>
) : (
workspaces.map((workspace) => {
const isSelected = selectedWorkspaceId === workspace.id;
return (
<button
key={workspace.id}
type="button"
onClick={() => {
setSelectedWorkspaceId(workspace.id);
}}
className={`w-full rounded-lg border p-4 text-left transition-colors ${
isSelected ? "border-primary bg-muted/40" : "border-border hover:bg-muted/20"
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-semibold truncate">{workspace.name}</p>
<p className="text-xs text-muted-foreground mt-1">
Created {new Date(workspace.createdAt).toLocaleDateString()}
</p>
</div>
<Badge variant="outline">{toRoleLabel(workspace.role)}</Badge>
</div>
</button>
);
})
)}
</CardContent>
</Card>
<Card className="lg:col-span-3">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div>
<CardTitle>
{selectedWorkspace ? `${selectedWorkspace.name} Members` : "Workspace Members"}
</CardTitle>
<CardDescription>
{selectedWorkspace
? "Manage member roles and access for this workspace."
: "Select a workspace to view its members."}
</CardDescription>
</div>
<Dialog
open={isAddMemberOpen}
onOpenChange={(open) => {
if (!open && !isAddingMember) {
setIsAddMemberOpen(false);
setAddMemberError(null);
}
}}
>
<DialogTrigger asChild>
<Button
onClick={() => {
void openAddMemberDialog();
}}
disabled={!selectedWorkspace}
>
<UserPlus className="h-4 w-4 mr-2" />
Add Member
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add Workspace Member</DialogTitle>
<DialogDescription>
Add an existing user to {selectedWorkspace?.name ?? "this workspace"}.
</DialogDescription>
</DialogHeader>
<form
onSubmit={(event) => {
void handleAddMember(event);
}}
className="space-y-4"
>
<div className="space-y-2">
<Label htmlFor="member-user">User</Label>
<Select
value={memberUserId}
onValueChange={(value) => {
setMemberUserId(value);
}}
disabled={isLoadingUsers || eligibleUsers.length === 0 || isAddingMember}
>
<SelectTrigger id="member-user">
<SelectValue
placeholder={
isLoadingUsers
? "Loading users..."
: eligibleUsers.length === 0
? "No eligible users"
: "Select a user"
}
/>
</SelectTrigger>
<SelectContent>
{eligibleUsers.map((user) => (
<SelectItem key={user.id} value={user.id}>
{user.name} ({user.email})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="member-role">Role</Label>
<Select
value={memberRole}
onValueChange={(value) => {
setMemberRole(value as WorkspaceMemberRole);
}}
disabled={isAddingMember}
>
<SelectTrigger id="member-role">
<SelectValue placeholder="Select role" />
</SelectTrigger>
<SelectContent>
{Object.values(WorkspaceMemberRole)
.filter((role) => role !== WorkspaceMemberRole.OWNER)
.map((role) => (
<SelectItem key={role} value={role}>
{toRoleLabel(role)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{addMemberError !== null ? (
<p className="text-sm text-destructive" role="alert">
{addMemberError}
</p>
) : null}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => {
if (!isAddingMember) {
setIsAddMemberOpen(false);
setAddMemberError(null);
}
}}
disabled={isAddingMember}
>
Cancel
</Button>
<Button type="submit" disabled={isAddingMember || !memberUserId}>
{isAddingMember ? "Adding..." : "Add Member"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
</div>
</CardHeader>
<CardContent className="space-y-3">
{selectedWorkspace === null ? (
<p className="text-sm text-muted-foreground">Select a workspace to view members.</p>
) : membersError !== null ? (
<p className="text-sm text-destructive" role="alert">
{membersError}
</p>
) : isMembersLoading ? (
<p className="text-sm text-muted-foreground">Loading members...</p>
) : members.length === 0 ? (
<p className="text-sm text-muted-foreground">No members found for this workspace.</p>
) : (
members.map((member) => (
<div
key={member.userId}
className="rounded-md border p-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between"
>
<div className="space-y-1 min-w-0">
<p className="font-semibold truncate">{member.user.name ?? "Unnamed User"}</p>
<p className="text-sm text-muted-foreground truncate">{member.user.email}</p>
</div>
<div className="flex items-center gap-2 md:justify-end">
<Badge variant="outline" className={ROLE_BADGE_CLASS[member.role]}>
{toRoleLabel(member.role)}
</Badge>
<Button
type="button"
variant="destructive"
size="sm"
onClick={() => {
setRemoveTarget({
userId: member.userId,
email: member.user.email,
});
}}
>
<UserX className="h-4 w-4 mr-2" />
Remove
</Button>
</div>
</div>
))
)}
</CardContent>
</Card>
</div> </div>
{/* Workspace List */} <AlertDialog
<div className="space-y-4"> open={removeTarget !== null}
<h2 className="text-xl font-semibold text-gray-900"> onOpenChange={(open) => {
Your Workspaces ({isLoading ? "..." : workspacesWithRoles.length}) if (!open && !isRemovingMember) {
</h2> setRemoveTarget(null);
{loadError !== null ? ( }
<div className="rounded-md border border-red-200 bg-red-50 px-4 py-3 text-red-700"> }}
{loadError} >
</div> <AlertDialogContent>
) : isLoading ? ( <AlertDialogHeader>
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center text-gray-600"> <AlertDialogTitle>Remove Workspace Member</AlertDialogTitle>
Loading workspaces... <AlertDialogDescription>
</div> Remove {removeTarget?.email} from {selectedWorkspace?.name}? They will lose access to
) : workspacesWithRoles.length === 0 ? ( this workspace.
<div className="bg-white rounded-lg shadow-sm border border-gray-200 p-12 text-center"> </AlertDialogDescription>
<svg </AlertDialogHeader>
className="mx-auto h-12 w-12 text-gray-400 mb-4" <AlertDialogFooter>
fill="none" <AlertDialogCancel disabled={isRemovingMember}>Cancel</AlertDialogCancel>
stroke="currentColor" <AlertDialogAction
viewBox="0 0 24 24" disabled={isRemovingMember}
onClick={() => {
void handleRemoveMember();
}}
> >
<path {isRemovingMember ? "Removing..." : "Remove"}
strokeLinecap="round" </AlertDialogAction>
strokeLinejoin="round" </AlertDialogFooter>
strokeWidth={2} </AlertDialogContent>
d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" </AlertDialog>
/>
</svg>
<h3 className="text-lg font-medium text-gray-900 mb-2">No workspaces yet</h3>
<p className="text-gray-600">Create your first workspace to get started</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{workspacesWithRoles.map((workspace) => (
<WorkspaceCard
key={workspace.id}
workspace={workspace}
userRole={workspace.userRole}
memberCount={workspace.memberCount}
/>
))}
</div>
)}
</div>
</main> </main>
); );
} }

View File

@@ -0,0 +1,9 @@
import type { ReactNode } from "react";
export default function OnboardingLayout({ children }: { children: ReactNode }): React.JSX.Element {
return (
<main className="flex min-h-screen items-center justify-center bg-gradient-to-b from-slate-50 to-white p-4 sm:p-6">
<div className="w-full max-w-3xl">{children}</div>
</main>
);
}

View File

@@ -0,0 +1,36 @@
import { redirect } from "next/navigation";
import { OnboardingWizard } from "@/components/onboarding/OnboardingWizard";
import { API_BASE_URL } from "@/lib/config";
export const dynamic = "force-dynamic";
interface OnboardingStatusResponse {
completed: boolean;
}
async function getOnboardingStatus(): Promise<OnboardingStatusResponse> {
try {
const response = await fetch(`${API_BASE_URL}/api/onboarding/status`, {
method: "GET",
cache: "no-store",
});
if (!response.ok) {
return { completed: false };
}
return (await response.json()) as OnboardingStatusResponse;
} catch {
return { completed: false };
}
}
export default async function OnboardingPage(): Promise<React.JSX.Element> {
const status = await getOnboardingStatus();
if (status.completed) {
redirect("/");
}
return <OnboardingWizard />;
}

View File

@@ -0,0 +1,106 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { OnboardingWizard } from "./OnboardingWizard";
const mockPush = vi.fn();
const mockGetStatus = vi.fn();
const mockCreateBreakglass = vi.fn();
const mockConfigureOidc = vi.fn();
const mockTestProvider = vi.fn();
const mockAddProvider = vi.fn();
const mockCompleteOnboarding = vi.fn();
vi.mock("next/navigation", () => ({
useRouter: (): { push: typeof mockPush } => ({
push: mockPush,
}),
}));
vi.mock("@/lib/api/onboarding", () => ({
fetchOnboardingStatus: (): ReturnType<typeof mockGetStatus> => mockGetStatus(),
createBreakglassAdmin: (...args: unknown[]): ReturnType<typeof mockCreateBreakglass> =>
mockCreateBreakglass(...args),
configureOidcProvider: (...args: unknown[]): ReturnType<typeof mockConfigureOidc> =>
mockConfigureOidc(...args),
testOnboardingProvider: (...args: unknown[]): ReturnType<typeof mockTestProvider> =>
mockTestProvider(...args),
addOnboardingProvider: (...args: unknown[]): ReturnType<typeof mockAddProvider> =>
mockAddProvider(...args),
completeOnboarding: (): ReturnType<typeof mockCompleteOnboarding> => mockCompleteOnboarding(),
}));
describe("OnboardingWizard", () => {
beforeEach(() => {
mockPush.mockReset();
mockGetStatus.mockReset();
mockCreateBreakglass.mockReset();
mockConfigureOidc.mockReset();
mockTestProvider.mockReset();
mockAddProvider.mockReset();
mockCompleteOnboarding.mockReset();
mockGetStatus.mockResolvedValue({ completed: false });
mockCreateBreakglass.mockResolvedValue({ id: "bg-1", username: "admin" });
mockConfigureOidc.mockResolvedValue(undefined);
mockTestProvider.mockResolvedValue({ success: true });
mockAddProvider.mockResolvedValue({ id: "provider-1" });
mockCompleteOnboarding.mockResolvedValue(undefined);
});
it("renders the first step with admin setup fields", async () => {
render(<OnboardingWizard />);
expect(
await screen.findByText("Welcome to Mosaic Stack. Let's get you set up.")
).toBeInTheDocument();
expect(screen.getByLabelText("Username")).toBeInTheDocument();
expect(screen.getByLabelText("Password")).toBeInTheDocument();
expect(screen.getByLabelText("Confirm Password")).toBeInTheDocument();
expect(screen.getByText("1. Admin")).toBeInTheDocument();
});
it("validates admin form fields before submit", async () => {
const user = userEvent.setup();
render(<OnboardingWizard />);
await screen.findByText("Welcome to Mosaic Stack. Let's get you set up.");
await user.click(screen.getByRole("button", { name: "Create Admin" }));
expect(screen.getByText("Username must be at least 3 characters.")).toBeInTheDocument();
expect(mockCreateBreakglass).not.toHaveBeenCalled();
});
it("supports happy path with OIDC skipped", async () => {
const user = userEvent.setup();
render(<OnboardingWizard />);
await screen.findByText("Welcome to Mosaic Stack. Let's get you set up.");
await user.type(screen.getByLabelText("Username"), "admin");
await user.type(screen.getByLabelText("Password"), "verysecurepassword");
await user.type(screen.getByLabelText("Confirm Password"), "verysecurepassword");
await user.click(screen.getByRole("button", { name: "Create Admin" }));
await screen.findByText("Configure OIDC Provider (Optional)");
await user.click(screen.getByRole("button", { name: "Skip" }));
await screen.findByText("Add Your First LLM Provider");
await user.type(screen.getByLabelText("Display Name"), "My OpenAI");
await user.type(screen.getByLabelText("API Key"), "sk-test-key");
await user.click(screen.getByRole("button", { name: "Test Connection" }));
await screen.findByText("Connection successful.");
const addProviderButton = screen.getByRole("button", { name: "Add Provider" });
expect(addProviderButton).toBeEnabled();
await user.click(addProviderButton);
await screen.findByText("You're all set");
await user.click(screen.getByRole("button", { name: "Launch Mosaic Stack" }));
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith("/");
});
});
});

View File

@@ -0,0 +1,791 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Check, Loader2 } from "lucide-react";
import { useRouter } from "next/navigation";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
addOnboardingProvider,
completeOnboarding,
configureOidcProvider,
createBreakglassAdmin,
fetchOnboardingStatus,
testOnboardingProvider,
} from "@/lib/api/onboarding";
type WizardStep = 1 | 2 | 3 | 4;
type ProviderType = "openai" | "anthropic" | "zai" | "ollama" | "custom";
interface StepDefinition {
id: WizardStep;
label: string;
}
interface ProviderOption {
value: ProviderType;
label: string;
}
const STEPS: StepDefinition[] = [
{ id: 1, label: "1. Admin" },
{ id: 2, label: "2. Auth" },
{ id: 3, label: "3. Provider" },
{ id: 4, label: "4. Launch" },
];
const PROVIDER_OPTIONS: ProviderOption[] = [
{ value: "openai", label: "OpenAI" },
{ value: "anthropic", label: "Anthropic" },
{ value: "zai", label: "Z.ai" },
{ value: "ollama", label: "Ollama" },
{ value: "custom", label: "Custom" },
];
const CLOUD_PROVIDER_TYPES = new Set<ProviderType>(["openai", "anthropic", "zai"]);
const BASE_URL_PROVIDER_TYPES = new Set<ProviderType>(["ollama", "custom"]);
function getErrorMessage(error: unknown, fallback: string): string {
if (error instanceof Error && error.message.trim().length > 0) {
return error.message;
}
return fallback;
}
function isValidHttpUrl(value: string): boolean {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
}
function mapProviderTypeToApi(type: ProviderType): string {
switch (type) {
case "anthropic":
return "claude";
case "zai":
return "openai";
case "custom":
return "openai";
default:
return type;
}
}
function getProviderDefaultBaseUrl(type: ProviderType): string | undefined {
switch (type) {
case "ollama":
return "http://localhost:11434";
case "anthropic":
return "https://api.anthropic.com/v1";
case "zai":
return "https://api.z.ai/v1";
default:
return undefined;
}
}
function buildProviderName(displayName: string, type: ProviderType): string {
const slug = displayName
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "");
if (slug.length > 0) {
return slug;
}
return `${type}-provider`;
}
function constantTimeEquals(left: string, right: string): boolean {
if (left.length !== right.length) {
return false;
}
let mismatch = 0;
for (let index = 0; index < left.length; index += 1) {
mismatch |= left.charCodeAt(index) ^ right.charCodeAt(index);
}
return mismatch === 0;
}
export function OnboardingWizard(): React.JSX.Element {
const router = useRouter();
const [currentStep, setCurrentStep] = useState<WizardStep>(1);
const [isCheckingStatus, setIsCheckingStatus] = useState(true);
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const [confirmPassword, setConfirmPassword] = useState("");
const [isCreatingAdmin, setIsCreatingAdmin] = useState(false);
const [configuredUsername, setConfiguredUsername] = useState<string | null>(null);
const [issuerUrl, setIssuerUrl] = useState("");
const [clientId, setClientId] = useState("");
const [clientSecret, setClientSecret] = useState("");
const [isConfiguringOidc, setIsConfiguringOidc] = useState(false);
const [oidcConfigured, setOidcConfigured] = useState(false);
const [providerType, setProviderType] = useState<ProviderType>("openai");
const [displayName, setDisplayName] = useState("");
const [providerApiKey, setProviderApiKey] = useState("");
const [providerBaseUrl, setProviderBaseUrl] = useState("");
const [isTestingProvider, setIsTestingProvider] = useState(false);
const [isAddingProvider, setIsAddingProvider] = useState(false);
const [providerConfigured, setProviderConfigured] = useState<{
displayName: string;
type: ProviderType;
} | null>(null);
const [providerTestMessage, setProviderTestMessage] = useState<string | null>(null);
const [providerTestSucceeded, setProviderTestSucceeded] = useState(false);
const [testedProviderSignature, setTestedProviderSignature] = useState<string | null>(null);
const [isCompleting, setIsCompleting] = useState(false);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const requiresApiKey = CLOUD_PROVIDER_TYPES.has(providerType);
const requiresBaseUrl = BASE_URL_PROVIDER_TYPES.has(providerType);
const apiProviderType = mapProviderTypeToApi(providerType);
const resolvedProviderBaseUrl =
requiresBaseUrl && providerBaseUrl.trim().length > 0
? providerBaseUrl.trim()
: getProviderDefaultBaseUrl(providerType);
const providerTestPayload = useMemo(() => {
const payload: { type: string; baseUrl?: string; apiKey?: string } = {
type: apiProviderType,
};
if (resolvedProviderBaseUrl !== undefined && resolvedProviderBaseUrl.length > 0) {
payload.baseUrl = resolvedProviderBaseUrl;
}
const trimmedApiKey = providerApiKey.trim();
if (requiresApiKey && trimmedApiKey.length > 0) {
payload.apiKey = trimmedApiKey;
}
return payload;
}, [apiProviderType, providerApiKey, requiresApiKey, resolvedProviderBaseUrl]);
const providerPayloadSignature = useMemo(
() => JSON.stringify(providerTestPayload),
[providerTestPayload]
);
const canAddProvider =
providerTestSucceeded &&
testedProviderSignature === providerPayloadSignature &&
!isTestingProvider &&
!isAddingProvider;
useEffect(() => {
let cancelled = false;
async function loadStatus(): Promise<void> {
try {
const status = await fetchOnboardingStatus();
if (!cancelled && status.completed) {
router.push("/");
return;
}
} catch {
// Status check failure should not block setup UI.
} finally {
if (!cancelled) {
setIsCheckingStatus(false);
}
}
}
void loadStatus();
return (): void => {
cancelled = true;
};
}, [router]);
const resetProviderVerification = (): void => {
setProviderTestSucceeded(false);
setTestedProviderSignature(null);
setProviderTestMessage(null);
};
const validateAdminStep = (): boolean => {
if (username.trim().length < 3) {
setErrorMessage("Username must be at least 3 characters.");
return false;
}
if (password.length < 8) {
setErrorMessage("Password must be at least 8 characters.");
return false;
}
if (!constantTimeEquals(password, confirmPassword)) {
setErrorMessage("Passwords do not match.");
return false;
}
return true;
};
const validateOidcStep = (): boolean => {
if (issuerUrl.trim().length === 0 || !isValidHttpUrl(issuerUrl.trim())) {
setErrorMessage("Issuer URL must be a valid URL.");
return false;
}
if (clientId.trim().length === 0) {
setErrorMessage("Client ID is required.");
return false;
}
if (clientSecret.trim().length === 0) {
setErrorMessage("Client secret is required.");
return false;
}
return true;
};
const validateProviderStep = (): boolean => {
if (displayName.trim().length === 0) {
setErrorMessage("Display name is required.");
return false;
}
if (requiresApiKey && providerApiKey.trim().length === 0) {
setErrorMessage("API key is required for this provider.");
return false;
}
if (requiresBaseUrl && providerBaseUrl.trim().length === 0) {
setErrorMessage("Base URL is required for this provider.");
return false;
}
if (requiresBaseUrl && !isValidHttpUrl(providerBaseUrl.trim())) {
setErrorMessage("Base URL must be a valid URL.");
return false;
}
return true;
};
const handleCreateAdmin = async (event: React.SyntheticEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault();
setErrorMessage(null);
if (!validateAdminStep()) {
return;
}
setIsCreatingAdmin(true);
try {
const result = await createBreakglassAdmin({
username: username.trim(),
password,
});
setConfiguredUsername(result.username);
setCurrentStep(2);
} catch (error) {
setErrorMessage(getErrorMessage(error, "Failed to create admin account."));
} finally {
setIsCreatingAdmin(false);
}
};
const handleConfigureOidc = async (
event: React.SyntheticEvent<HTMLFormElement>
): Promise<void> => {
event.preventDefault();
setErrorMessage(null);
if (!validateOidcStep()) {
return;
}
setIsConfiguringOidc(true);
try {
await configureOidcProvider({
issuerUrl: issuerUrl.trim(),
clientId: clientId.trim(),
clientSecret: clientSecret.trim(),
});
setOidcConfigured(true);
setCurrentStep(3);
} catch (error) {
setErrorMessage(getErrorMessage(error, "Failed to configure OIDC provider."));
} finally {
setIsConfiguringOidc(false);
}
};
const handleSkipOidc = (): void => {
setErrorMessage(null);
setOidcConfigured(false);
setCurrentStep(3);
};
const handleTestProvider = async (): Promise<void> => {
setErrorMessage(null);
setProviderTestMessage(null);
if (!validateProviderStep()) {
return;
}
setIsTestingProvider(true);
try {
const response = await testOnboardingProvider(providerTestPayload);
if (!response.success) {
setProviderTestSucceeded(false);
setTestedProviderSignature(null);
setErrorMessage(response.error ?? "Connection test failed.");
return;
}
setProviderTestSucceeded(true);
setTestedProviderSignature(providerPayloadSignature);
setProviderTestMessage("Connection successful.");
} catch (error) {
setProviderTestSucceeded(false);
setTestedProviderSignature(null);
setErrorMessage(getErrorMessage(error, "Connection test failed."));
} finally {
setIsTestingProvider(false);
}
};
const handleAddProvider = async (): Promise<void> => {
setErrorMessage(null);
if (!validateProviderStep()) {
return;
}
if (!canAddProvider) {
setErrorMessage("Test connection successfully before adding the provider.");
return;
}
setIsAddingProvider(true);
try {
const trimmedDisplayName = displayName.trim();
const payload: {
name: string;
displayName: string;
type: string;
baseUrl?: string;
apiKey?: string;
} = {
name: buildProviderName(trimmedDisplayName, providerType),
displayName: trimmedDisplayName,
type: apiProviderType,
};
if (resolvedProviderBaseUrl !== undefined && resolvedProviderBaseUrl.length > 0) {
payload.baseUrl = resolvedProviderBaseUrl;
}
const trimmedApiKey = providerApiKey.trim();
if (requiresApiKey && trimmedApiKey.length > 0) {
payload.apiKey = trimmedApiKey;
}
await addOnboardingProvider(payload);
setProviderConfigured({ displayName: trimmedDisplayName, type: providerType });
setCurrentStep(4);
} catch (error) {
setErrorMessage(getErrorMessage(error, "Failed to add provider."));
} finally {
setIsAddingProvider(false);
}
};
const handleCompleteOnboarding = async (): Promise<void> => {
setErrorMessage(null);
setIsCompleting(true);
try {
await completeOnboarding();
router.push("/");
} catch (error) {
setErrorMessage(getErrorMessage(error, "Failed to complete onboarding."));
} finally {
setIsCompleting(false);
}
};
const providerLabel =
PROVIDER_OPTIONS.find((option) => option.value === providerConfigured?.type)?.label ??
providerConfigured?.type ??
"Unknown";
return (
<Card className="mx-auto w-full max-w-2xl shadow-sm">
<CardHeader>
<CardTitle>First-boot onboarding</CardTitle>
<CardDescription>Set up your admin access, auth, and first provider.</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
{STEPS.map((step) => {
const isActive = currentStep === step.id;
const isComplete = currentStep > step.id;
const badgeClass = isComplete
? "bg-emerald-100 text-emerald-700 border-emerald-200"
: isActive
? "bg-blue-100 text-blue-700 border-blue-200"
: "bg-gray-100 text-gray-500 border-gray-200";
return (
<div
key={step.id}
className={`rounded-md border px-3 py-2 text-sm ${badgeClass}`}
aria-current={isActive ? "step" : undefined}
>
<div className="flex items-center gap-2 font-medium">
<span className="inline-flex h-5 w-5 items-center justify-center rounded-full border border-current text-xs">
{isComplete ? <Check className="h-3.5 w-3.5" aria-hidden="true" /> : step.id}
</span>
<span>{step.label}</span>
</div>
</div>
);
})}
</div>
{isCheckingStatus ? (
<div className="flex items-center gap-2 text-sm text-gray-600">
<Loader2 className="h-4 w-4 animate-spin" aria-hidden="true" />
<span>Checking onboarding status...</span>
</div>
) : (
<>
{currentStep === 1 && (
<form onSubmit={handleCreateAdmin} className="space-y-4" noValidate>
<div className="space-y-1">
<h2 className="text-xl font-semibold">
Welcome to Mosaic Stack. Let's get you set up.
</h2>
<p className="text-sm text-gray-600">
Create a breakglass admin account for emergency access.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-username">Username</Label>
<Input
id="onboarding-username"
value={username}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setUsername(event.target.value);
}}
disabled={isCreatingAdmin}
autoComplete="username"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-password">Password</Label>
<Input
id="onboarding-password"
type="password"
value={password}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setPassword(event.target.value);
}}
disabled={isCreatingAdmin}
autoComplete="new-password"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-confirm-password">Confirm Password</Label>
<Input
id="onboarding-confirm-password"
type="password"
value={confirmPassword}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setConfirmPassword(event.target.value);
}}
disabled={isCreatingAdmin}
autoComplete="new-password"
/>
</div>
<Button type="submit" disabled={isCreatingAdmin}>
{isCreatingAdmin && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
<span>Create Admin</span>
</Button>
</form>
)}
{currentStep === 2 && (
<form onSubmit={handleConfigureOidc} className="space-y-4" noValidate>
<div className="space-y-1">
<h2 className="text-xl font-semibold">Configure OIDC Provider (Optional)</h2>
<p className="text-sm text-gray-600">
You can skip this for now and continue with breakglass-only authentication.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-issuer-url">OIDC Issuer URL</Label>
<Input
id="onboarding-issuer-url"
value={issuerUrl}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setIssuerUrl(event.target.value);
}}
disabled={isConfiguringOidc}
placeholder="https://auth.example.com/application/o/mosaic/"
autoComplete="url"
/>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-client-id">Client ID</Label>
<Input
id="onboarding-client-id"
value={clientId}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setClientId(event.target.value);
}}
disabled={isConfiguringOidc}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-client-secret">Client Secret</Label>
<Input
id="onboarding-client-secret"
type="password"
value={clientSecret}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setClientSecret(event.target.value);
}}
disabled={isConfiguringOidc}
autoComplete="off"
/>
</div>
<div className="flex flex-wrap gap-2">
<Button type="submit" disabled={isConfiguringOidc}>
{isConfiguringOidc && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
<span>Configure OIDC</span>
</Button>
<Button
type="button"
variant="outline"
onClick={handleSkipOidc}
disabled={isConfiguringOidc}
>
Skip
</Button>
</div>
</form>
)}
{currentStep === 3 && (
<div className="space-y-4">
<div className="space-y-1">
<h2 className="text-xl font-semibold">Add Your First LLM Provider</h2>
<p className="text-sm text-gray-600">
Test the connection before adding your provider.
</p>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-provider-type">Provider Type</Label>
<Select
value={providerType}
onValueChange={(value) => {
const nextType = value as ProviderType;
setProviderType(nextType);
setProviderApiKey("");
setProviderBaseUrl(
BASE_URL_PROVIDER_TYPES.has(nextType)
? (getProviderDefaultBaseUrl(nextType) ?? "")
: ""
);
resetProviderVerification();
setErrorMessage(null);
}}
disabled={isTestingProvider || isAddingProvider}
>
<SelectTrigger id="onboarding-provider-type">
<SelectValue placeholder="Select provider type" />
</SelectTrigger>
<SelectContent>
{PROVIDER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="onboarding-provider-display-name">Display Name</Label>
<Input
id="onboarding-provider-display-name"
value={displayName}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setDisplayName(event.target.value);
resetProviderVerification();
setErrorMessage(null);
}}
disabled={isTestingProvider || isAddingProvider}
placeholder="My OpenAI Provider"
/>
</div>
{requiresApiKey && (
<div className="grid gap-2">
<Label htmlFor="onboarding-provider-api-key">API Key</Label>
<Input
id="onboarding-provider-api-key"
type="password"
value={providerApiKey}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setProviderApiKey(event.target.value);
resetProviderVerification();
setErrorMessage(null);
}}
disabled={isTestingProvider || isAddingProvider}
autoComplete="off"
/>
</div>
)}
{requiresBaseUrl && (
<div className="grid gap-2">
<Label htmlFor="onboarding-provider-base-url">Base URL</Label>
<Input
id="onboarding-provider-base-url"
value={providerBaseUrl}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
setProviderBaseUrl(event.target.value);
resetProviderVerification();
setErrorMessage(null);
}}
disabled={isTestingProvider || isAddingProvider}
placeholder="http://localhost:11434"
autoComplete="url"
/>
</div>
)}
{providerTestMessage && (
<p className="text-sm text-emerald-700" role="status">
{providerTestMessage}
</p>
)}
<div className="flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
onClick={() => {
void handleTestProvider();
}}
disabled={isTestingProvider || isAddingProvider}
>
{isTestingProvider && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
<span>Test Connection</span>
</Button>
<Button
type="button"
onClick={() => {
void handleAddProvider();
}}
disabled={!canAddProvider}
>
{isAddingProvider && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
<span>Add Provider</span>
</Button>
</div>
</div>
)}
{currentStep === 4 && (
<div className="space-y-4">
<div className="space-y-1">
<h2 className="text-xl font-semibold">You're all set</h2>
<p className="text-sm text-gray-600">
Review the setup summary and launch Mosaic Stack.
</p>
</div>
<div className="rounded-md border bg-gray-50 p-4">
<ul className="space-y-2 text-sm">
<li>
<span className="font-medium">Admin:</span>{" "}
{configuredUsername ? `${configuredUsername} configured` : "Not configured"}
</li>
<li>
<span className="font-medium">OIDC:</span>{" "}
{oidcConfigured ? "Configured" : "Skipped for now"}
</li>
<li>
<span className="font-medium">LLM Provider:</span>{" "}
{providerConfigured
? `${providerConfigured.displayName} (${providerLabel})`
: "Not configured"}
</li>
</ul>
</div>
<Button
type="button"
onClick={() => void handleCompleteOnboarding()}
disabled={isCompleting}
>
{isCompleting && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
)}
<span>Launch Mosaic Stack</span>
</Button>
</div>
)}
</>
)}
{errorMessage && (
<p className="text-sm text-red-600" role="alert">
{errorMessage}
</p>
)}
</CardContent>
</Card>
);
}
export default OnboardingWizard;

View File

@@ -0,0 +1,51 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { Card, CardContent } from "@/components/ui/card";
interface FleetSettingsLink {
href: string;
label: string;
}
const FLEET_SETTINGS_LINKS: FleetSettingsLink[] = [
{ href: "/settings/providers", label: "Providers" },
{ href: "/settings/agent-config", label: "Agent Config" },
{ href: "/settings/auth", label: "Authentication" },
];
export function FleetSettingsNav(): React.JSX.Element {
const pathname = usePathname();
return (
<Card>
<CardContent className="px-4 py-3 flex flex-wrap items-center gap-2">
<Link
href="/settings"
className="inline-flex h-9 items-center rounded-md px-3 text-sm font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors"
>
All Settings
</Link>
{FLEET_SETTINGS_LINKS.map((link) => {
const isActive = pathname === link.href;
return (
<Link
key={link.href}
href={link.href}
className={`inline-flex h-9 items-center rounded-md px-3 text-sm font-medium transition-colors ${
isActive
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
}`}
>
{link.label}
</Link>
);
})}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,16 @@
import type { ReactElement } from "react";
interface SettingsAccessDeniedProps {
message: string;
}
export function SettingsAccessDenied({ message }: SettingsAccessDeniedProps): ReactElement {
return (
<div className="p-8 max-w-2xl">
<div className="rounded-lg border border-red-200 bg-red-50 p-6 text-center">
<p className="text-lg font-semibold text-red-700">Access Denied</p>
<p className="mt-2 text-sm text-red-600">{message}</p>
</div>
</div>
);
}

View File

@@ -53,6 +53,7 @@ export interface InvitationResponse {
export interface UpdateUserDto { export interface UpdateUserDto {
name?: string; name?: string;
email?: string;
deactivatedAt?: string | null; deactivatedAt?: string | null;
emailVerified?: boolean; emailVerified?: boolean;
preferences?: Record<string, unknown>; preferences?: Record<string, unknown>;

View File

@@ -0,0 +1,172 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import * as client from "./client";
import {
createFleetProvider,
deleteFleetOidcConfig,
deleteFleetProvider,
fetchFleetAgentConfig,
fetchFleetOidcConfig,
fetchFleetProviders,
resetBreakglassAdminPassword,
updateFleetAgentConfig,
updateFleetOidcConfig,
updateFleetProvider,
} from "./fleet-settings";
vi.mock("./client");
beforeEach((): void => {
vi.clearAllMocks();
});
describe("fetchFleetProviders", (): void => {
it("calls providers list endpoint", async (): Promise<void> => {
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
await fetchFleetProviders();
expect(client.apiGet).toHaveBeenCalledWith("/api/fleet-settings/providers");
});
});
describe("createFleetProvider", (): void => {
it("posts provider payload", async (): Promise<void> => {
vi.mocked(client.apiPost).mockResolvedValueOnce({ id: "provider-1" } as never);
await createFleetProvider({
name: "openai-main",
displayName: "OpenAI Main",
type: "openai",
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-test",
models: [
{ id: "gpt-4.1-mini", name: "gpt-4.1-mini" },
{ id: "gpt-4o-mini", name: "gpt-4o-mini" },
],
});
expect(client.apiPost).toHaveBeenCalledWith("/api/fleet-settings/providers", {
name: "openai-main",
displayName: "OpenAI Main",
type: "openai",
baseUrl: "https://api.openai.com/v1",
apiKey: "sk-test",
models: [
{ id: "gpt-4.1-mini", name: "gpt-4.1-mini" },
{ id: "gpt-4o-mini", name: "gpt-4o-mini" },
],
});
});
});
describe("updateFleetProvider", (): void => {
it("patches provider endpoint with updates", async (): Promise<void> => {
vi.mocked(client.apiPatch).mockResolvedValueOnce(undefined as never);
await updateFleetProvider("provider-1", { displayName: "OpenAI Updated", isActive: true });
expect(client.apiPatch).toHaveBeenCalledWith("/api/fleet-settings/providers/provider-1", {
displayName: "OpenAI Updated",
isActive: true,
});
});
});
describe("deleteFleetProvider", (): void => {
it("deletes provider endpoint", async (): Promise<void> => {
vi.mocked(client.apiDelete).mockResolvedValueOnce(undefined as never);
await deleteFleetProvider("provider-1");
expect(client.apiDelete).toHaveBeenCalledWith("/api/fleet-settings/providers/provider-1");
});
});
describe("fetchFleetAgentConfig", (): void => {
it("calls agent config endpoint", async (): Promise<void> => {
vi.mocked(client.apiGet).mockResolvedValueOnce({
primaryModel: null,
fallbackModels: [],
personality: null,
} as never);
await fetchFleetAgentConfig();
expect(client.apiGet).toHaveBeenCalledWith("/api/fleet-settings/agent-config");
});
});
describe("updateFleetAgentConfig", (): void => {
it("patches agent config", async (): Promise<void> => {
vi.mocked(client.apiPatch).mockResolvedValueOnce(undefined as never);
await updateFleetAgentConfig({
primaryModel: "openai:gpt-4o-mini",
fallbackModels: ["openai:gpt-4.1-mini"],
personality: "System behavior",
});
expect(client.apiPatch).toHaveBeenCalledWith("/api/fleet-settings/agent-config", {
primaryModel: "openai:gpt-4o-mini",
fallbackModels: ["openai:gpt-4.1-mini"],
personality: "System behavior",
});
});
});
describe("fetchFleetOidcConfig", (): void => {
it("calls oidc endpoint", async (): Promise<void> => {
vi.mocked(client.apiGet).mockResolvedValueOnce({ configured: false } as never);
await fetchFleetOidcConfig();
expect(client.apiGet).toHaveBeenCalledWith("/api/fleet-settings/oidc");
});
});
describe("updateFleetOidcConfig", (): void => {
it("issues a PUT request with payload", async (): Promise<void> => {
vi.mocked(client.apiRequest).mockResolvedValueOnce(undefined as never);
await updateFleetOidcConfig({
issuerUrl: "https://issuer.example.com",
clientId: "mosaic-client",
clientSecret: "top-secret",
});
expect(client.apiRequest).toHaveBeenCalledWith("/api/fleet-settings/oidc", {
method: "PUT",
body: JSON.stringify({
issuerUrl: "https://issuer.example.com",
clientId: "mosaic-client",
clientSecret: "top-secret",
}),
});
});
});
describe("deleteFleetOidcConfig", (): void => {
it("deletes oidc endpoint", async (): Promise<void> => {
vi.mocked(client.apiDelete).mockResolvedValueOnce(undefined as never);
await deleteFleetOidcConfig();
expect(client.apiDelete).toHaveBeenCalledWith("/api/fleet-settings/oidc");
});
});
describe("resetBreakglassAdminPassword", (): void => {
it("posts breakglass reset payload", async (): Promise<void> => {
vi.mocked(client.apiPost).mockResolvedValueOnce(undefined as never);
await resetBreakglassAdminPassword({
username: "admin",
newPassword: "new-password-123",
});
expect(client.apiPost).toHaveBeenCalledWith("/api/fleet-settings/breakglass/reset-password", {
username: "admin",
newPassword: "new-password-123",
});
});
});

View File

@@ -0,0 +1,129 @@
import { apiDelete, apiGet, apiPatch, apiPost, apiRequest } from "./client";
export interface FleetProviderModel {
id: string;
name?: string;
}
export interface FleetProvider {
id: string;
name: string;
displayName: string;
type: string;
baseUrl: string | null;
isActive: boolean;
models: unknown;
}
export interface CreateFleetProviderRequest {
name: string;
displayName: string;
type: string;
baseUrl?: string;
apiKey?: string;
apiType?: string;
models?: FleetProviderModel[];
}
export interface UpdateFleetProviderRequest {
displayName?: string;
baseUrl?: string;
apiKey?: string;
isActive?: boolean;
models?: FleetProviderModel[];
}
export interface FleetAgentConfig {
primaryModel: string | null;
fallbackModels: string[];
personality: string | null;
}
interface FleetAgentConfigResponse {
primaryModel: string | null;
fallbackModels: unknown[];
personality: string | null;
}
export interface UpdateFleetAgentConfigRequest {
primaryModel?: string;
fallbackModels?: string[];
personality?: string;
}
export interface FleetOidcConfig {
issuerUrl?: string;
clientId?: string;
configured: boolean;
}
export interface UpdateFleetOidcConfigRequest {
issuerUrl: string;
clientId: string;
clientSecret: string;
}
export interface ResetBreakglassAdminPasswordRequest {
username: string;
newPassword: string;
}
function normalizeStringArray(value: unknown[]): string[] {
return value.filter((item): item is string => typeof item === "string");
}
export async function fetchFleetProviders(): Promise<FleetProvider[]> {
return apiGet<FleetProvider[]>("/api/fleet-settings/providers");
}
export async function createFleetProvider(
data: CreateFleetProviderRequest
): Promise<{ id: string }> {
return apiPost<{ id: string }>("/api/fleet-settings/providers", data);
}
export async function updateFleetProvider(
providerId: string,
data: UpdateFleetProviderRequest
): Promise<void> {
await apiPatch<unknown>(`/api/fleet-settings/providers/${providerId}`, data);
}
export async function deleteFleetProvider(providerId: string): Promise<void> {
await apiDelete<unknown>(`/api/fleet-settings/providers/${providerId}`);
}
export async function fetchFleetAgentConfig(): Promise<FleetAgentConfig> {
const response = await apiGet<FleetAgentConfigResponse>("/api/fleet-settings/agent-config");
return {
primaryModel: response.primaryModel,
fallbackModels: normalizeStringArray(response.fallbackModels),
personality: response.personality,
};
}
export async function updateFleetAgentConfig(data: UpdateFleetAgentConfigRequest): Promise<void> {
await apiPatch<unknown>("/api/fleet-settings/agent-config", data);
}
export async function fetchFleetOidcConfig(): Promise<FleetOidcConfig> {
return apiGet<FleetOidcConfig>("/api/fleet-settings/oidc");
}
export async function updateFleetOidcConfig(data: UpdateFleetOidcConfigRequest): Promise<void> {
await apiRequest<unknown>("/api/fleet-settings/oidc", {
method: "PUT",
body: JSON.stringify(data),
});
}
export async function deleteFleetOidcConfig(): Promise<void> {
await apiDelete<unknown>("/api/fleet-settings/oidc");
}
export async function resetBreakglassAdminPassword(
data: ResetBreakglassAdminPasswordRequest
): Promise<void> {
await apiPost<unknown>("/api/fleet-settings/breakglass/reset-password", data);
}

View File

@@ -17,3 +17,4 @@ export * from "./dashboard";
export * from "./projects"; export * from "./projects";
export * from "./workspaces"; export * from "./workspaces";
export * from "./admin"; export * from "./admin";
export * from "./fleet-settings";

View File

@@ -0,0 +1,80 @@
import { apiGet, apiPost } from "./client";
export interface OnboardingStatus {
completed: boolean;
}
export interface BreakglassAdminRequest {
username: string;
password: string;
}
export interface BreakglassAdminResponse {
id: string;
username: string;
}
export interface ConfigureOidcRequest {
issuerUrl: string;
clientId: string;
clientSecret: string;
}
export interface ProviderModel {
id: string;
name?: string;
}
export interface AddOnboardingProviderRequest {
name: string;
displayName: string;
type: string;
baseUrl?: string;
apiKey?: string;
models?: ProviderModel[];
}
export interface AddOnboardingProviderResponse {
id: string;
}
export interface TestOnboardingProviderRequest {
type: string;
baseUrl?: string;
apiKey?: string;
}
export interface TestOnboardingProviderResponse {
success: boolean;
error?: string;
}
export async function fetchOnboardingStatus(): Promise<OnboardingStatus> {
return apiGet<OnboardingStatus>("/api/onboarding/status");
}
export async function createBreakglassAdmin(
request: BreakglassAdminRequest
): Promise<BreakglassAdminResponse> {
return apiPost<BreakglassAdminResponse>("/api/onboarding/breakglass", request);
}
export async function configureOidcProvider(request: ConfigureOidcRequest): Promise<void> {
await apiPost<unknown>("/api/onboarding/oidc", request);
}
export async function addOnboardingProvider(
request: AddOnboardingProviderRequest
): Promise<AddOnboardingProviderResponse> {
return apiPost<AddOnboardingProviderResponse>("/api/onboarding/provider", request);
}
export async function testOnboardingProvider(
request: TestOnboardingProviderRequest
): Promise<TestOnboardingProviderResponse> {
return apiPost<TestOnboardingProviderResponse>("/api/onboarding/provider/test", request);
}
export async function completeOnboarding(): Promise<void> {
await apiPost<unknown>("/api/onboarding/complete");
}

View File

@@ -25,7 +25,9 @@ export interface Project {
name: string; name: string;
description: string | null; description: string | null;
status: ProjectStatus; status: ProjectStatus;
priority?: string | null;
startDate: string | null; startDate: string | null;
dueDate?: string | null;
endDate: string | null; endDate: string | null;
creatorId: string; creatorId: string;
domainId: string | null; domainId: string | null;
@@ -35,6 +37,54 @@ export interface Project {
updatedAt: string; updatedAt: string;
} }
/**
* Minimal creator details included on project detail response
*/
export interface ProjectCreator {
id: string;
name: string | null;
email: string;
}
/**
* Task row included on project detail response
*/
export interface ProjectTaskSummary {
id: string;
title: string;
status: string;
priority: string;
dueDate: string | null;
}
/**
* Event row included on project detail response
*/
export interface ProjectEventSummary {
id: string;
title: string;
startTime: string;
endTime: string | null;
}
/**
* Counts included on project detail response
*/
export interface ProjectDetailCounts {
tasks: number;
events: number;
}
/**
* Single-project response with related details
*/
export interface ProjectDetail extends Project {
creator: ProjectCreator;
tasks: ProjectTaskSummary[];
events: ProjectEventSummary[];
_count: ProjectDetailCounts;
}
/** /**
* DTO for creating a new project * DTO for creating a new project
*/ */
@@ -72,8 +122,8 @@ export async function fetchProjects(workspaceId?: string): Promise<Project[]> {
/** /**
* Fetch a single project by ID * Fetch a single project by ID
*/ */
export async function fetchProject(id: string, workspaceId?: string): Promise<Project> { export async function fetchProject(id: string, workspaceId?: string): Promise<ProjectDetail> {
return apiGet<Project>(`/api/projects/${id}`, workspaceId); return apiGet<ProjectDetail>(`/api/projects/${id}`, workspaceId);
} }
/** /**

View File

@@ -1,6 +1,6 @@
import { describe, it, expect, vi, beforeEach } from "vitest"; import { describe, it, expect, vi, beforeEach } from "vitest";
import * as client from "./client"; import * as client from "./client";
import { fetchTeams, createTeam, fetchTeamMembers } from "./teams"; import { fetchTeams, createTeam, fetchTeamMembers, updateTeam, deleteTeam } from "./teams";
vi.mock("./client"); vi.mock("./client");
@@ -44,6 +44,18 @@ describe("createTeam", (): void => {
}); });
}); });
describe("updateTeam", (): void => {
it("patches team endpoint", async (): Promise<void> => {
vi.mocked(client.apiPatch).mockResolvedValueOnce({ id: "t1", name: "Platform" } as never);
await updateTeam("t1", { name: "Platform" });
expect(client.apiPatch).toHaveBeenCalledWith(
"/api/workspaces/ws-1/teams/t1",
expect.objectContaining({ name: "Platform" }),
"ws-1"
);
});
});
describe("fetchTeamMembers", (): void => { describe("fetchTeamMembers", (): void => {
it("calls members endpoint for team", async (): Promise<void> => { it("calls members endpoint for team", async (): Promise<void> => {
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never); vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
@@ -51,3 +63,11 @@ describe("fetchTeamMembers", (): void => {
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces/ws-1/teams/t-1/members", "ws-1"); expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces/ws-1/teams/t-1/members", "ws-1");
}); });
}); });
describe("deleteTeam", (): void => {
it("deletes team endpoint", async (): Promise<void> => {
vi.mocked(client.apiDelete).mockResolvedValueOnce(undefined as never);
await deleteTeam("t1");
expect(client.apiDelete).toHaveBeenCalledWith("/api/workspaces/ws-1/teams/t1", "ws-1");
});
});

View File

@@ -4,7 +4,7 @@
*/ */
import type { TeamMemberRole } from "@mosaic/shared"; import type { TeamMemberRole } from "@mosaic/shared";
import { apiDelete, apiGet, apiPost } from "./client"; import { apiDelete, apiGet, apiPatch, apiPost } from "./client";
const WORKSPACE_STORAGE_KEY = "mosaic-workspace-id"; const WORKSPACE_STORAGE_KEY = "mosaic-workspace-id";
@@ -55,6 +55,11 @@ export interface CreateTeamDto {
description?: string; description?: string;
} }
export interface UpdateTeamDto {
name?: string;
description?: string | null;
}
export interface AddTeamMemberDto { export interface AddTeamMemberDto {
userId: string; userId: string;
role?: TeamMemberRole; role?: TeamMemberRole;
@@ -80,6 +85,22 @@ export async function createTeam(dto: CreateTeamDto, workspaceId?: string): Prom
); );
} }
/**
* Update a team in the active workspace.
*/
export async function updateTeam(
teamId: string,
dto: UpdateTeamDto,
workspaceId?: string
): Promise<TeamRecord> {
const resolvedWorkspaceId = resolveWorkspaceId(workspaceId);
return apiPatch<TeamRecord>(
`/api/workspaces/${resolvedWorkspaceId}/teams/${teamId}`,
dto,
resolvedWorkspaceId
);
}
/** /**
* Fetch team members for a team in the active workspace. * Fetch team members for a team in the active workspace.
* The current backend route shape is workspace-scoped team membership. * The current backend route shape is workspace-scoped team membership.

View File

@@ -121,6 +121,10 @@ services:
OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT} OLLAMA_ENDPOINT: ${OLLAMA_ENDPOINT}
OPENBAO_ADDR: ${OPENBAO_ADDR} OPENBAO_ADDR: ${OPENBAO_ADDR}
ENCRYPTION_KEY: ${ENCRYPTION_KEY} ENCRYPTION_KEY: ${ENCRYPTION_KEY}
# MS22: fleet encryption key (AES-256-GCM for provider API keys, agent tokens)
MOSAIC_SECRET_KEY: ${MOSAIC_SECRET_KEY}
# MS22: Docker socket for per-user container lifecycle (optional: set DOCKER_HOST for TCP)
DOCKER_HOST: ${DOCKER_HOST:-}
# Matrix bridge (optional — configure after Synapse is running) # Matrix bridge (optional — configure after Synapse is running)
MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL:-http://synapse:8008} MATRIX_HOMESERVER_URL: ${MATRIX_HOMESERVER_URL:-http://synapse:8008}
MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-} MATRIX_ACCESS_TOKEN: ${MATRIX_ACCESS_TOKEN:-}
@@ -142,6 +146,8 @@ services:
NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL} NEXT_PUBLIC_APP_URL: ${NEXT_PUBLIC_APP_URL}
NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL} NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL}
TRUSTED_ORIGINS: ${TRUSTED_ORIGINS:-} TRUSTED_ORIGINS: ${TRUSTED_ORIGINS:-}
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
healthcheck: healthcheck:
test: test:
[ [

3
docker/.env.example Normal file
View File

@@ -0,0 +1,3 @@
DATABASE_URL=postgresql://mosaic:changeme@postgres:5432/mosaic
DATABASE_PASSWORD=changeme
MOSAIC_SECRET_KEY=your-secret-key-at-least-32-characters-long

40
docker/README.md Normal file
View File

@@ -0,0 +1,40 @@
# Mosaic Docker (Core Services)
This folder includes the Compose stack for **core Mosaic services only**:
- `mosaic-api`
- `mosaic-web`
- `postgres`
User OpenClaw containers are **not** defined in Compose. They are created and managed dynamically by the API's `ContainerLifecycleService` through Docker socket access.
## Start the stack
```bash
docker compose -f docker/mosaic-compose.yml up -d
```
## Required environment variables
- `DATABASE_URL`
- `MOSAIC_SECRET_KEY`
- `DATABASE_PASSWORD`
Use [`docker/.env.example`](./.env.example) as a starting point.
## Architecture overview
See the design doc: [`docs/design/MS22-DB-CENTRIC-ARCHITECTURE.md`](../docs/design/MS22-DB-CENTRIC-ARCHITECTURE.md)
`mosaic-agents` is an internal-only bridge network reserved for dynamically created user containers.
## OpenClaw entrypoint behavior
`docker/openclaw-entrypoint.sh` is intended for dynamically created user OpenClaw containers:
1. Validates required env vars (`MOSAIC_API_URL`, `AGENT_TOKEN`, `AGENT_ID`).
2. Fetches agent-specific OpenClaw config from Mosaic API internal endpoint.
3. Writes the config to `/tmp/openclaw.json`.
4. Starts OpenClaw gateway with `OPENCLAW_CONFIG_PATH=/tmp/openclaw.json`.
`docker/openclaw-healthcheck.sh` probes `http://localhost:18789/health` for container health.

53
docker/mosaic-compose.yml Normal file
View File

@@ -0,0 +1,53 @@
services:
mosaic-api:
image: mosaic/api:latest
environment:
DATABASE_URL: ${DATABASE_URL}
MOSAIC_SECRET_KEY: ${MOSAIC_SECRET_KEY}
volumes:
- /var/run/docker.sock:/var/run/docker.sock
networks:
- internal
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:4000/api/health"]
interval: 30s
timeout: 10s
retries: 3
mosaic-web:
image: mosaic/web:latest
environment:
NEXT_PUBLIC_API_URL: http://mosaic-api:4000
ports:
- "3000:3000"
networks:
- internal
depends_on:
mosaic-api:
condition: service_healthy
postgres:
image: postgres:17-alpine
environment:
POSTGRES_DB: mosaic
POSTGRES_USER: mosaic
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- internal
healthcheck:
test: ["CMD-SHELL", "pg_isready -U mosaic"]
interval: 10s
timeout: 5s
retries: 5
networks:
internal:
driver: bridge
mosaic-agents:
driver: bridge
internal: true
volumes:
postgres-data:

View File

@@ -1,4 +1,4 @@
FROM quay.io/openbao/openbao:2.5.0 FROM quay.io/openbao/openbao:2.5.1
LABEL maintainer="Mosaic Stack <dev@mosaic.local>" LABEL maintainer="Mosaic Stack <dev@mosaic.local>"
LABEL description="OpenBao secrets management for Mosaic Stack" LABEL description="OpenBao secrets management for Mosaic Stack"

20
docker/openclaw-entrypoint.sh Executable file
View File

@@ -0,0 +1,20 @@
#!/bin/sh
set -e
: "${MOSAIC_API_URL:?MOSAIC_API_URL is required}"
: "${AGENT_TOKEN:?AGENT_TOKEN is required}"
: "${AGENT_ID:?AGENT_ID is required}"
echo "[entrypoint] Fetching config for agent ${AGENT_ID}..."
HTTP_CODE=$(curl -sf -w "%{http_code}" \
"${MOSAIC_API_URL}/api/internal/agent-config/${AGENT_ID}" \
-H "Authorization: Bearer ${AGENT_TOKEN}" \
-o /tmp/openclaw.json)
if [ "$HTTP_CODE" != "200" ]; then
echo "[entrypoint] ERROR: Config fetch failed with HTTP ${HTTP_CODE}"
exit 1
fi
echo "[entrypoint] Config loaded. Starting OpenClaw gateway..."
export OPENCLAW_CONFIG_PATH=/tmp/openclaw.json
exec openclaw gateway run --bind lan --auth token

2
docker/openclaw-healthcheck.sh Executable file
View File

@@ -0,0 +1,2 @@
#!/bin/sh
curl -sf http://localhost:18789/health || exit 1

114
docs/PRD-MS22.md Normal file
View File

@@ -0,0 +1,114 @@
# PRD: MS22 — Fleet Evolution (DB-Centric Agent Architecture)
## Metadata
- Owner: Jason Woltje
- Date: 2026-03-01
- Status: in-progress
- Design Doc: `docs/design/MS22-DB-CENTRIC-ARCHITECTURE.md`
## Problem Statement
Mosaic Stack needs a multi-user agent fleet where each user gets their own isolated OpenClaw instance with their own LLM provider credentials and agent config. The system must be Docker-first with minimal environment variables and all configuration managed through the WebUI.
## Objectives
1. **Minimal bootstrap** — 2 env vars (`DATABASE_URL`, `MOSAIC_SECRET_KEY`) to start the entire stack
2. **DB-centric config** — All runtime config in Postgres, managed via WebUI
3. **Per-user isolation** — Each user gets their own OpenClaw container with own API keys, memory, sessions
4. **Onboarding wizard** — First-boot experience: breakglass admin → OIDC → LLM provider → agent config
5. **Settings UI** — Runtime management of providers, agents, and auth config
6. **Mosaic as gatekeeper** — Users never talk to OpenClaw directly; Mosaic proxies all requests
7. **Zero cross-user access** — Full container, volume, and DB isolation between users
## Security Requirements
- User A cannot access User B's API keys, chat history, or agent memory
- All API keys stored encrypted (AES-256-GCM) in database
- Breakglass admin always works as OIDC fallback
- OIDC config stored in DB (not env vars) — configured via settings UI
- Container-to-container communication blocked by default
- Admin cannot decrypt other users' API keys
## Phase 0: Knowledge Layer — COMPLETE
- Findings API (pgvector, CRUD, similarity search)
- AgentMemory API (key/value store)
- ConversationArchive API (pgvector, ingest, search)
- OpenClaw mosaic skill
- Session log ingestion pipeline
## Phase 1: DB-Centric Agent Fleet
### Phase 1a: DB Schema — COMPLETE
- SystemConfig, BreakglassUser, LlmProvider, UserContainer, SystemContainer, UserAgentConfig tables
### Phase 1b: Encryption Service — COMPLETE
- CryptoService (AES-256-GCM using MOSAIC_SECRET_KEY)
### Phase 1c: Internal Config API
- `GET /api/internal/agent-config/:id` — assembles openclaw.json from DB
- Auth: bearer token (container's own gateway token)
- Returns complete openclaw.json with decrypted provider credentials
### Phase 1d: Container Lifecycle Manager
- Docker API integration via `dockerode` npm package
- Start/stop/health-check/reap user containers
- Auto-generate gateway tokens, assign ports
- Docker socket access required (`/var/run/docker.sock`)
### Phase 1e: Onboarding API
- First-boot detection (`SystemConfig.onboarding.completed`)
- `POST /api/onboarding/breakglass` — create admin user
- `POST /api/onboarding/oidc` — save OIDC provider config
- `POST /api/onboarding/provider` — add LLM provider + test connection
- `POST /api/onboarding/complete` — mark done
### Phase 1f: Onboarding Wizard UI
- Multi-step wizard component
- Skip-able OIDC step
- LLM provider connection test
### Phase 1g: Settings API
- CRUD: LLM providers (per-user scoped)
- CRUD: Agent config (model assignments, personalities)
- CRUD: OIDC config (admin only)
- Breakglass password reset (admin only)
### Phase 1h: Settings UI
- Settings/Providers page
- Settings/Agent Config page
- Settings/Auth page (OIDC + breakglass)
### Phase 1i: Chat Proxy
- Route WebUI chat to user's OpenClaw container
- SSE streaming pass-through
- Ensure container is running before proxying (auto-start)
### Phase 1j: Docker Compose + Entrypoint
- Simplified compose (core services only — user containers are dynamic)
- Entrypoint: fetch config from API, write openclaw.json, start gateway
- Health check integration
### Phase 1k: Idle Reaper
- Cron job to stop inactive user containers
- Configurable idle timeout (default 30min)
- Preserve state volumes
## Future Phases (out of scope)
- Phase 2: Agent fleet standup (predefined agent roles)
- Phase 3: WebUI chat + task management integration
- Phase 4: Multi-LLM provider management UI (advanced)
- Team workspaces (shared agent contexts) — explicitly out of scope

View File

@@ -25,14 +25,14 @@
| MS21-MIG-003 | not-started | phase-3 | Run migration on production database | #568 | api | — | MS21-MIG-001,MS21-TEST-003 | MS21-VER-001 | — | — | — | 5K | — | Needs deploy coordination; not automatable | | MS21-MIG-003 | not-started | phase-3 | Run migration on production database | #568 | api | — | MS21-MIG-001,MS21-TEST-003 | MS21-VER-001 | — | — | — | 5K | — | Needs deploy coordination; not automatable |
| MS21-MIG-004 | done | phase-3 | Import API endpoints (6/6 tests) | #568 | api | feat/ms21-import-api | MS21-DB-001 | — | codex | 2026-02-28 | 2026-02-28 | 20K | 24K | PR #567 merged, CI green. Review: 0 blockers, 4 should-fix, 1 medium sec (no audit log). | | MS21-MIG-004 | done | phase-3 | Import API endpoints (6/6 tests) | #568 | api | feat/ms21-import-api | MS21-DB-001 | — | codex | 2026-02-28 | 2026-02-28 | 20K | 24K | PR #567 merged, CI green. Review: 0 blockers, 4 should-fix, 1 medium sec (no audit log). |
| MS21-UI-001 | done | phase-4 | Settings/users page | #569 | web | feat/ms21-ui-users | MS21-API-001,MS21-API-002 | — | codex | 2026-02-28 | 2026-02-28 | 20K | ~30K | PR #573 merged. Review: 0 blockers, 4 should-fix → MS21-UI-001-QA | | MS21-UI-001 | done | phase-4 | Settings/users page | #569 | web | feat/ms21-ui-users | MS21-API-001,MS21-API-002 | — | codex | 2026-02-28 | 2026-02-28 | 20K | ~30K | PR #573 merged. Review: 0 blockers, 4 should-fix → MS21-UI-001-QA |
| MS21-UI-001-QA | not-started | phase-4 | QA: fix 4 review findings (pagination, error state, self-deactivate guard, tests) | #569 | web | fix/ms21-ui-001-qa | MS21-UI-001 | — | — | — | — | 15K | — | 0 blockers; merged per framework. Should-fix: pagination cap, error/empty collision, self-deactivate guard, no tests. | | MS21-UI-001-QA | done | phase-4 | QA: fix 4 review findings (pagination, error state, self-deactivate guard, tests) | #569 | web | fix/ms21-ui-001-qa | MS21-UI-001 | — | — | — | — | 15K | — | 0 blockers; merged per framework. Should-fix: pagination cap, error/empty collision, self-deactivate guard, no tests. |
| MS21-UI-002 | not-started | phase-4 | User detail/edit and invite dialogs | #569 | web | feat/ms21-ui-users | MS21-UI-001 | — | — | — | — | 15K | — | | | MS21-UI-002 | done | phase-4 | User detail/edit and invite dialogs | #569 | web | feat/ms21-ui-users | MS21-UI-001 | — | — | — | — | 15K | — | |
| MS21-UI-003 | done | phase-4 | Settings/workspaces page (wire to real API) | #569 | web | feat/ms21-ui-workspaces | MS21-API-003 | — | codex | 2026-02-28 | 2026-02-28 | 15K | ~25K | PR #574 merged. Review: 0 critical, 1 low (raw errors in UI) | | MS21-UI-003 | done | phase-4 | Settings/workspaces page (wire to real API) | #569 | web | feat/ms21-ui-workspaces | MS21-API-003 | — | codex | 2026-02-28 | 2026-02-28 | 15K | ~25K | PR #574 merged. Review: 0 critical, 1 low (raw errors in UI) |
| MS21-UI-004 | not-started | phase-4 | Workspace member management UI | #569 | web | feat/ms21-ui-workspaces | MS21-UI-003,MS21-API-003 | — | — | — | — | 15K | — | Components exist | | MS21-UI-004 | done | phase-4 | Workspace member management UI | #569 | web | feat/ms21-ui-workspaces | MS21-UI-003,MS21-API-003 | — | — | — | — | 15K | — | Components exist |
| MS21-UI-005 | not-started | phase-4 | Settings/teams page | #569 | web | feat/ms21-ui-teams | MS21-API-004 | — | — | — | — | 15K | — | | | MS21-UI-005 | done | phase-4 | Settings/teams page | #569 | web | feat/ms21-ui-teams | MS21-API-004 | — | — | — | — | 15K | — | |
| MS21-TEST-004 | not-started | phase-4 | Frontend component tests | #569 | web | test/ms21-ui | MS21-UI-001,MS21-UI-002,MS21-UI-003,MS21-UI-004,MS21-UI-005 | — | — | — | — | 20K | — | | | MS21-TEST-004 | done | phase-4 | Frontend component tests | #569 | web | test/ms21-ui | MS21-UI-001,MS21-UI-002,MS21-UI-003,MS21-UI-004,MS21-UI-005 | — | — | — | — | 20K | — | |
| MS21-RBAC-001 | not-started | phase-5 | Sidebar navigation role gating | #570 | web | feat/ms21-rbac | MS21-UI-001 | — | — | — | — | 10K | — | | | MS21-RBAC-001 | done | phase-5 | Sidebar navigation role gating | #570 | web | feat/ms21-rbac | MS21-UI-001 | — | — | — | — | 10K | — | |
| MS21-RBAC-002 | not-started | phase-5 | Settings page access restriction | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | | | MS21-RBAC-002 | done | phase-5 | Settings page access restriction | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
| MS21-RBAC-003 | done | phase-5 | Action button permission gating | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | | | MS21-RBAC-003 | done | phase-5 | Action button permission gating | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
| MS21-RBAC-004 | done | phase-5 | User profile role display | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 5K | — | | | MS21-RBAC-004 | done | phase-5 | User profile role display | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 5K | — | |
| MS21-VER-001 | done | phase-6 | Full quality gate pass | #571 | stack | — | MS21-TEST-004,MS21-RBAC-004,MS21-MIG-003 | MS21-VER-002 | — | — | — | 5K | — | | | MS21-VER-001 | done | phase-6 | Full quality gate pass | #571 | stack | — | MS21-TEST-004,MS21-RBAC-004,MS21-MIG-003 | MS21-VER-002 | — | — | — | 5K | — | |
@@ -52,3 +52,40 @@
| **Total** | **31** | **15** | **~371K** | **~175K** | | **Total** | **31** | **15** | **~371K** | **~175K** |
Remaining estimate: ~143K tokens (Codex budget). Remaining estimate: ~143K tokens (Codex budget).
## MS22 — Fleet Evolution (Phase 0: Knowledge Layer)
| id | status | milestone | description | issue | repo | branch | depends_on | blocks | agent | started_at | completed_at | estimate | used | notes |
| --------------- | ------ | ------------ | ------------------------------------------------------------ | -------- | ----- | ------------------------------ | --------------------------------------------------------- | ------------- | ------------ | ---------- | ------------ | -------- | ---- | --------------------------------------------- |
| MS22-PLAN-001 | done | p0-knowledge | PRD + mission bootstrap + TASKS.md | TASKS:P0 | stack | feat/ms22-knowledge-schema | — | MS22-DB-001 | orchestrator | 2026-02-28 | 2026-02-28 | 10K | 8K | PRD-MS22.md, mission fleet-evolution-20260228 |
| MS22-DB-001 | done | p0-knowledge | Findings module (pgvector, CRUD, similarity search) | TASKS:P0 | api | feat/ms22-findings | MS22-PLAN-001 | — | codex | 2026-02-28 | 2026-02-28 | 20K | ~22K | PR #585 merged, CI green |
| MS22-API-001 | done | p0-knowledge | Findings API endpoints | TASKS:P0 | api | feat/ms22-findings | MS22-DB-001 | — | codex | 2026-02-28 | 2026-02-28 | — | — | Combined with DB-001 |
| MS22-DB-002 | done | p0-knowledge | AgentMemory module (key/value store, upsert) | TASKS:P0 | api | feat/ms22-agent-memory | MS22-DB-001 | — | codex | 2026-02-28 | 2026-02-28 | 15K | ~16K | PR #586 merged, CI green |
| MS22-API-002 | done | p0-knowledge | AgentMemory API endpoints | TASKS:P0 | api | feat/ms22-agent-memory | MS22-DB-002 | — | codex | 2026-02-28 | 2026-02-28 | — | — | Combined with DB-002 |
| MS22-DB-004 | done | p0-knowledge | ConversationArchive module (pgvector, ingest, search) | TASKS:P0 | api | feat/ms22-conversation-archive | MS22-DB-001 | — | codex | 2026-02-28 | 2026-02-28 | 20K | ~18K | PR #587 merged, CI green |
| MS22-API-004 | done | p0-knowledge | ConversationArchive API endpoints | TASKS:P0 | api | feat/ms22-conversation-archive | MS22-DB-004 | — | codex | 2026-02-28 | 2026-02-28 | — | — | Combined with DB-004 |
| MS22-API-005 | done | p0-knowledge | EmbeddingService (reuse existing KnowledgeModule) | TASKS:P0 | api | — | — | — | orchestrator | 2026-02-28 | 2026-02-28 | 0 | 0 | Already existed; no work needed |
| MS22-DB-003 | done | p0-knowledge | Task model: add assigned_agent field + migration | TASKS:P0 | api | feat/ms22-task-agent | MS22-DB-001 | MS22-API-003 | — | — | — | 8K | — | Small schema + migration only |
| MS22-API-003 | done | p0-knowledge | Task API: expose assigned_agent in CRUD | TASKS:P0 | api | feat/ms22-task-agent | MS22-DB-003 | MS22-TEST-001 | — | — | — | 8K | — | Extend existing TaskModule |
| MS22-TEST-001 | done | p0-knowledge | Integration tests: Findings + AgentMemory + ConvArchive | TASKS:P0 | api | test/ms22-integration | MS22-API-001,MS22-API-002,MS22-API-004 | MS22-VER-P0 | — | — | — | 20K | — | E2E with live postgres |
| MS22-SKILL-001 | done | p0-knowledge | OpenClaw mosaic skill (agents read/write findings/memory) | TASKS:P0 | stack | feat/ms22-openclaw-skill | MS22-API-001,MS22-API-002 | MS22-VER-P0 | — | — | — | 15K | — | Skill in ~/.agents/skills/mosaic/ |
| MS22-INGEST-001 | done | p0-knowledge | Session log ingestion pipeline (OpenClaw logs → ConvArchive) | TASKS:P0 | stack | feat/ms22-ingest | MS22-API-004 | MS22-VER-P0 | — | — | — | 20K | — | Script to batch-ingest existing logs |
| MS22-VER-P0 | done | p0-knowledge | Phase 0 verification: all modules deployed + smoke tested | TASKS:P0 | stack | — | MS22-TEST-001,MS22-SKILL-001,MS22-INGEST-001,MS22-API-003 | — | — | — | — | 5K | — | |
## MS22 Phase 1: DB-Centric Agent Fleet (reworked)
Design doc: `docs/design/MS22-DB-CENTRIC-ARCHITECTURE.md`
| Task ID | Status | Phase | Description | Issue | Scope | Branch | Depends On | Blocks | Assigned Worker | Started | Completed | Est Tokens | Act Tokens | Notes |
| -------- | ------ | -------- | --------------------------------------------------------------------------------------------------------------------- | ----- | ------- | ---------------------------- | ---------- | --------------- | --------------- | ------- | --------- | ---------- | ---------- | ----- |
| MS22-P1a | done | phase-1a | Prisma schema: SystemConfig, BreakglassUser, LlmProvider, UserContainer, SystemContainer, UserAgentConfig + migration | — | api | feat/ms22-p1a-schema | — | P1b,P1c,P1d,P1e | — | — | — | 20K | — | |
| MS22-P1b | done | phase-1b | Encryption service (AES-256-GCM) for API keys and tokens | — | api | feat/ms22-p1b-crypto | — | P1c,P1e,P1g | — | — | — | 15K | — | |
| MS22-P1c | done | phase-1c | Internal config endpoint: assemble openclaw.json from DB | — | api | feat/ms22-p1c-config-api | P1a,P1b | P1i,P1j | — | — | — | 20K | — | |
| MS22-P1d | done | phase-1d | ContainerLifecycleService: Docker API (dockerode) start/stop/health/reap | — | api | feat/ms22-p1d-container-mgr | P1a | P1i,P1k | — | — | — | 25K | — | |
| MS22-P1e | done | phase-1e | Onboarding API: breakglass, OIDC, provider, agents, complete | — | api | feat/ms22-p1e-onboarding-api | P1a,P1b | P1f | — | — | — | 20K | — | |
| MS22-P1f | done | phase-1f | Onboarding wizard WebUI (multi-step form) | — | web | feat/ms22-p1f-onboarding-ui | P1e | — | — | — | — | 25K | — | |
| MS22-P1g | done | phase-1g | Settings API: CRUD providers, agent config, OIDC, breakglass | — | api | feat/ms22-p1g-settings-api | P1a,P1b | P1h | — | — | — | 20K | — | |
| MS22-P1h | done | phase-1h | Settings UI: Providers, Agent Config, Auth pages | — | web | feat/ms22-p1h-settings-ui | P1g | — | — | — | — | 25K | — | |
| MS22-P1i | done | phase-1i | Chat proxy: route WebUI chat to user's OpenClaw container (SSE) | — | api+web | feat/ms22-p1i-chat-proxy | P1c,P1d | — | — | — | — | 20K | — | |
| MS22-P1j | done | phase-1j | Docker entrypoint + health checks + core compose | — | docker | feat/ms22-p1j-docker | P1c | — | — | — | — | 10K | — | |
| MS22-P1k | done | phase-1k | Idle reaper cron: stop inactive user containers | — | api | feat/ms22-p1k-idle-reaper | P1d | — | — | — | — | 10K | — | |

View File

@@ -0,0 +1,157 @@
# MS22 Phase 1 Module Audit
Date: 2026-03-01
Branch: `fix/ms22-audit`
Scope:
- `apps/api/src/container-lifecycle/`
- `apps/api/src/crypto/`
- `apps/api/src/agent-config/`
- `apps/api/src/onboarding/`
- `apps/api/src/fleet-settings/`
- `apps/api/src/chat-proxy/`
## Summary
Audit completed for module wiring, security controls, input validation, and error handling.
Findings:
1. `chat-proxy`: raw internal/upstream error messages were returned to clients over SSE (fixed).
2. `chat-proxy`: proxy requests to OpenClaw did not forward the container bearer token returned by lifecycle startup (fixed).
3. `agent-config`: token validation returned early and used length-gated compare logic, creating avoidable timing side-channel behavior (hardened).
## Module Review Results
### 1) `container-lifecycle`
- NestJS module dependency audit:
- `ContainerLifecycleModule` imports `ConfigModule`, `PrismaModule`, and `CryptoModule` required by `ContainerLifecycleService`.
- Providers/exports are correct (`ContainerLifecycleService` provided and exported).
- Security review:
- Container operations are user-scoped by `userId` and do not expose cross-user selectors in this module.
- AES token generation/decryption delegated to `CryptoService`.
- Input validation:
- No controller endpoints in this module; no direct request DTO surface here.
- Error handling:
- No direct HTTP layer here; errors flow to callers/global filter.
- Finding status: **No issues found in this module**.
### 2) `crypto`
- NestJS module dependency audit:
- `CryptoModule` correctly imports `ConfigModule` for `ConfigService`.
- `CryptoService` is correctly provided/exported.
- Security review:
- AES-256-GCM is implemented correctly.
- 96-bit IV generated via `randomBytes(12)` per encryption.
- Auth tag captured and verified on decrypt (`setAuthTag` + `decipher.final()`).
- HKDF derives a fixed 32-byte key from `MOSAIC_SECRET_KEY`.
- Input validation:
- No DTO/request surface in this module.
- Error handling:
- Decrypt failures are normalized to `Failed to decrypt value`.
- Finding status: **No issues found in this module**.
### 3) `agent-config`
- NestJS module dependency audit:
- `AgentConfigModule` imports `PrismaModule` + `CryptoModule`; `AgentConfigService` and `AgentConfigGuard` are provided.
- Controller/guard/service wiring is correct.
- Security review:
- Bearer token comparisons used `timingSafeEqual`, but returned early on first match and performed length-gated comparison.
- Internal route (`/api/internal/agent-config/:id`) is access-controlled by bearer token guard and container-id match (`containerAuth.id === :id`).
- Input validation:
- Header token extraction and route param are manually handled (no DTO for `:id`, acceptable for current use but should remain constrained).
- Error handling:
- Service throws typed Nest exceptions for not-found paths.
- Finding status: **Issue found and fixed**.
### 4) `onboarding`
- NestJS module dependency audit:
- `OnboardingModule` imports required dependencies (`PrismaModule`, `CryptoModule`; `ConfigModule` currently unused but harmless).
- Providers/controllers are correctly declared.
- Security review:
- `OnboardingGuard` blocks all mutating onboarding routes once `onboarding.completed=true`.
- Onboarding cannot be re-run via guarded endpoints after completion.
- Input validation:
- DTOs use `class-validator` decorators for all request bodies.
- Error handling:
- Uses typed Nest exceptions (`ConflictException`, `BadRequestException`).
- Finding status: **No issues found in this module**.
### 5) `fleet-settings`
- NestJS module dependency audit:
- `FleetSettingsModule` imports `AuthModule`, `PrismaModule`, `CryptoModule` required by its controller/service.
- Provider/export wiring is correct for `FleetSettingsService`.
- Security review:
- Class-level `AuthGuard` protects all routes.
- Admin-only routes additionally use `AdminGuard` (`oidc` and `breakglass/reset-password`).
- Provider list/get responses do not expose `apiKey`.
- OIDC read response intentionally omits `clientSecret`.
- Input validation:
- DTOs are decorated with `class-validator`.
- Error handling:
- Ownership/not-found conditions use typed exceptions.
- Finding status: **No issues found in this module**.
### 6) `chat-proxy`
- NestJS module dependency audit:
- `ChatProxyModule` imports `AuthModule`, `PrismaModule`, `ContainerLifecycleModule` needed by controller/service.
- Provider/controller wiring is correct.
- Security review:
- User identity comes from `AuthGuard`; no user-provided container selector, so no cross-user container proxy path found.
- **Issue fixed:** gateway bearer token was not forwarded on proxied requests.
- **Issue fixed:** SSE error events exposed raw internal exception messages.
- Input validation:
- `ChatStreamDto` + nested `ChatMessageDto` use `class-validator` decorators.
- Error handling:
- **Issue fixed:** controller now emits safe client error messages and logs details server-side.
- Finding status: **Issues found and fixed**.
## Security Checklist Outcomes
- `fleet-settings`: admin-only routes are guarded; non-admin users cannot access OIDC or breakglass reset routes. Provider secrets are not returned in provider read endpoints.
- `agent-config`: token comparison hardened; route remains gated by bearer token + container id binding.
- `onboarding`: guarded mutating endpoints cannot run after completion.
- `crypto`: AES-256-GCM usage is correct (random IV, auth-tag verification, fixed 32-byte key derivation).
- `chat-proxy`: user cannot target another users container; proxy now authenticates to OpenClaw using per-container bearer token.
## Input Validation
- DTO coverage is present in onboarding, fleet-settings, and chat-proxy request bodies.
- No critical unvalidated body inputs found in scoped modules.
## Error Handling
- Global API layer has a sanitizing `GlobalExceptionFilter`.
- `chat-proxy` used manual response handling (`@Res`) and bypassed global filter; this was corrected by sending safe generic SSE errors.
- No additional critical sensitive-data leaks found in reviewed scope.
## Changes Made
1. Hardened token comparison behavior in:
- `apps/api/src/agent-config/agent-config.service.ts`
- Changes:
- Compare SHA-256 digests with `timingSafeEqual`.
- Avoid early return during scan to reduce timing signal differences.
2. Fixed OpenClaw auth forwarding and error leak risk in:
- `apps/api/src/chat-proxy/chat-proxy.service.ts`
- `apps/api/src/chat-proxy/chat-proxy.controller.ts`
- `apps/api/src/chat-proxy/chat-proxy.service.spec.ts`
- Changes:
- Forward `Authorization: Bearer <gatewayToken>` when proxying chat requests.
- Stop returning raw internal/upstream error text to clients over SSE.
- Log details server-side and return safe client-facing messages.
## Validation Commands
Required quality gate command run:
- `pnpm turbo lint typecheck --filter=@mosaic/api`
(Results captured in session logs.)

View File

@@ -0,0 +1,413 @@
# MS22 Phase 1: DB-Centric Multi-User Agent Architecture
## Design Principles
1. **2 env vars to bootstrap**`DATABASE_URL` + `MOSAIC_SECRET_KEY`
2. **DB-centric config** — All runtime config in Postgres, managed via WebUI
3. **Mosaic is the gatekeeper** — Users authenticate to Mosaic, never to OpenClaw directly
4. **Per-user agent isolation** — Each user gets their own OpenClaw container(s) with their own credentials
5. **Onboarding-first** — Breakglass user + wizard on first boot
6. **Generic product** — No hardcoded names, models, providers, or endpoints
## Architecture Overview
```
┌─────────────────────────────────────────────────────┐
│ MOSAIC WEBUI │
│ (Auth: breakglass local + OIDC via settings) │
└──────────────────────┬──────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ MOSAIC API │
│ │
│ ┌──────────────┐ ┌────────────────┐ ┌─────────┐ │
│ │ Onboarding │ │ Container │ │ Config │ │
│ │ Wizard │ │ Lifecycle Mgr │ │ Store │ │
│ └──────────────┘ └───────┬────────┘ └─────────┘ │
│ │ │
└────────────────────────────┼────────────────────────┘
│ Docker API
┌──────────────────┼──────────────────┐
│ │ │
▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ OpenClaw │ │ OpenClaw │ │ OpenClaw │
│ User A │ │ User B │ │ System │
│ │ │ │ │ (admin) │
│ Claude Max │ │ Z.ai key │ │ Shared key │
│ own memory │ │ own memory │ │ monitoring │
└─────────────┘ └─────────────┘ └─────────────┘
Scale to zero Scale to zero Always on
after idle after idle
```
## Container Lifecycle
### User containers (on-demand)
1. User logs in → Mosaic checks `UserContainer` table
2. No running container → Mosaic calls Docker API to create one
3. Injects user's encrypted API keys via config endpoint
4. Routes chat requests to user's container
5. Idle timeout (configurable, default 30min) → scale to zero
6. State volume persists (sessions, memory, auth tokens)
7. Next request → container restarts, picks up state from volume
### System containers (always-on, optional)
- Admin-provisioned for system tasks (monitoring, scheduled jobs)
- Use admin-configured shared API keys
- Not tied to any user
## Auth Layers
| Flow | Method |
| ------------------------------- | ---------------------------------------------------------------------- |
| User → Mosaic WebUI | Breakglass (local) or OIDC (configured in settings) |
| Mosaic API → OpenClaw container | Bearer token (auto-generated per container, stored encrypted in DB) |
| OpenClaw → LLM providers | User's own API keys (delivered via config endpoint, decrypted from DB) |
| Admin → System settings | RBAC (admin role required) |
| Internal config endpoint | Bearer token (container authenticates to fetch its config) |
## Database Schema
### System Tables
```prisma
model SystemConfig {
id String @id @default(cuid())
key String @unique // "oidc.issuerUrl", "oidc.clientId", "onboarding.completed"
value String // plaintext or encrypted (prefix: "enc:")
encrypted Boolean @default(false)
updatedAt DateTime @updatedAt
}
model BreakglassUser {
id String @id @default(cuid())
username String @unique
passwordHash String // bcrypt
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
### Provider Tables (per-user)
```prisma
model LlmProvider {
id String @id @default(cuid())
userId String // owner — each user manages their own providers
name String // "my-zai", "work-openai", "local-ollama"
displayName String // "Z.ai", "OpenAI (Work)", "Local Ollama"
type String // "zai" | "openai" | "anthropic" | "ollama" | "custom"
baseUrl String? // null for built-in, URL for custom/ollama
apiKey String? // encrypted
apiType String @default("openai-completions")
models Json @default("[]") // [{id, name, contextWindow, maxTokens}]
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@unique([userId, name])
}
```
### Container Tables
```prisma
model UserContainer {
id String @id @default(cuid())
userId String @unique
containerId String? // Docker container ID (null = not running)
containerName String // "mosaic-user-{userId}"
gatewayPort Int? // assigned port (null = not running)
gatewayToken String // encrypted — auto-generated
status String @default("stopped") // "running" | "stopped" | "starting" | "error"
lastActiveAt DateTime?
idleTimeoutMin Int @default(30)
config Json @default("{}") // cached openclaw.json for this user
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model SystemContainer {
id String @id @default(cuid())
name String @unique // "mosaic-system-ops", "mosaic-system-monitor"
role String // "operations" | "monitor" | "scheduler"
containerId String?
gatewayPort Int?
gatewayToken String // encrypted
status String @default("stopped")
providerId String? // references admin-level LlmProvider
primaryModel String // "zai/glm-5", etc.
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
### User Agent Preferences
```prisma
model UserAgentConfig {
id String @id @default(cuid())
userId String @unique
primaryModel String? // user's preferred model
fallbackModels Json @default("[]")
personality String? // custom SOUL.md content
providerId String? // default provider for this user
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
```
## Internal Config Endpoint
`GET /api/internal/agent-config/:containerType/:id`
- Auth: Bearer token (container's own gateway token)
- Returns: Complete `openclaw.json` generated from DB
- For user containers: includes user's providers, model prefs, personality
- For system containers: includes admin provider config
Response assembles openclaw.json dynamically:
```json
{
"gateway": { "mode": "local", "port": 18789, "bind": "lan", "auth": { "mode": "token" } ... },
"agents": { "defaults": { "model": { "primary": "<from UserAgentConfig>" } } },
"models": { "providers": { "<from LlmProvider rows>": { ... } } }
}
```
## Container Lifecycle Manager
NestJS service that manages Docker containers:
```typescript
class ContainerLifecycleService {
// Create and start a user's OpenClaw container
async ensureRunning(userId: string): Promise<{ url: string; token: string }>;
// Stop idle containers (called by cron/scheduler)
async reapIdle(): Promise<number>;
// Stop a specific user's container
async stop(userId: string): Promise<void>;
// Health check all running containers
async healthCheckAll(): Promise<HealthStatus[]>;
// Restart container with updated config
async restart(userId: string): Promise<void>;
}
```
Uses Docker Engine API (`/var/run/docker.sock` or TCP) via `dockerode` npm package.
## Onboarding Wizard
### First-Boot Detection
- API checks: `SystemConfig.get("onboarding.completed")` → null = first boot
- WebUI redirects to `/onboarding` if not completed
### Steps
**Step 1: Create Breakglass Admin**
- Username + password → bcrypt → `BreakglassUser` table
- This user always works, even if OIDC is misconfigured
**Step 2: Configure Authentication (optional)**
- OIDC: provider URL, client ID, client secret → encrypted in `SystemConfig`
- Skip = breakglass-only auth (can add OIDC later in settings)
**Step 3: Add Your First LLM Provider**
- Pick type → enter API key/endpoint → test connection → save to `LlmProvider`
- This becomes the admin's default provider
**Step 4: System Agents (optional)**
- Configure always-on system agents for monitoring/ops
- Or skip — users can just use their own personal agents
**Step 5: Complete**
- Sets `SystemConfig("onboarding.completed") = true`
- Redirects to dashboard
### Post-Onboarding: User Self-Service
- Each user adds their own LLM providers in profile settings
- Each user configures their preferred model, personality
- First chat request triggers container creation
## Docker Compose (final)
```yaml
services:
mosaic-api:
image: mosaic/api:latest
environment:
DATABASE_URL: ${DATABASE_URL}
MOSAIC_SECRET_KEY: ${MOSAIC_SECRET_KEY}
volumes:
- /var/run/docker.sock:/var/run/docker.sock # Docker API access
networks:
- internal
mosaic-web:
image: mosaic/web:latest
environment:
NEXT_PUBLIC_API_URL: http://mosaic-api:4000
networks:
- internal
postgres:
image: postgres:17
environment:
POSTGRES_DB: mosaic
POSTGRES_USER: mosaic
POSTGRES_PASSWORD: ${DATABASE_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
networks:
- internal
# System agent (optional, admin-provisioned)
# mosaic-system:
# image: alpine/openclaw:latest
# ... (managed by ContainerLifecycleService)
# User containers are NOT in this file —
# they are dynamically created by ContainerLifecycleService
# via the Docker API at runtime.
networks:
internal:
driver: overlay
volumes:
postgres-data:
```
Note: User OpenClaw containers are **not** defined in docker-compose. They are
created dynamically by the `ContainerLifecycleService` when users start chatting.
## Entrypoint (for dynamically created containers)
```sh
#!/bin/sh
set -e
: "${MOSAIC_API_URL:?required}"
: "${AGENT_TOKEN:?required}"
: "${AGENT_ID:?required}"
# Fetch config from Mosaic API
curl -sf "${MOSAIC_API_URL}/api/internal/agent-config/${AGENT_ID}" \
-H "Authorization: Bearer ${AGENT_TOKEN}" \
-o /tmp/openclaw.json
export OPENCLAW_CONFIG_PATH=/tmp/openclaw.json
exec openclaw gateway run --bind lan --auth token
```
Container env vars (injected by ContainerLifecycleService):
- `MOSAIC_API_URL` — internal API URL
- `AGENT_TOKEN` — this container's bearer token (from DB)
- `AGENT_ID` — container ID for config lookup
## Config Update Strategy
When a user changes settings (model, provider, personality):
1. Mosaic API updates DB
2. API calls `ContainerLifecycleService.restart(userId)`
3. Container restarts, fetches fresh config from API
4. OpenClaw gateway starts with new config
5. State volume preserves sessions/memory across restarts
## Task Breakdown
| Task | Phase | Scope | Dependencies |
| -------- | -------------- | --------------------------------------------------------------------------------------------------------------------- | ------------ |
| MS22-P1a | Schema | Prisma models: SystemConfig, BreakglassUser, LlmProvider, UserContainer, SystemContainer, UserAgentConfig. Migration. | — |
| MS22-P1b | Crypto | Encryption service for API keys/tokens (AES-256-GCM using MOSAIC_SECRET_KEY) | P1a |
| MS22-P1c | Config API | Internal config endpoint: assembles openclaw.json from DB | P1a, P1b |
| MS22-P1d | Container Mgr | ContainerLifecycleService: Docker API integration (dockerode), start/stop/health/reap | P1a |
| MS22-P1e | Onboarding API | Onboarding endpoints: breakglass, OIDC, provider, complete | P1a, P1b |
| MS22-P1f | Onboarding UI | Multi-step wizard in WebUI | P1e |
| MS22-P1g | Settings API | CRUD: providers, agent config, OIDC, breakglass | P1a, P1b |
| MS22-P1h | Settings UI | Settings pages: Providers, Agent Config, Auth | P1g |
| MS22-P1i | Chat Proxy | Route WebUI chat → user's OpenClaw container (SSE) | P1c, P1d |
| MS22-P1j | Docker | Entrypoint script, health checks, compose for core services | P1c |
| MS22-P1k | Idle Reaper | Cron service to stop idle user containers | P1d |
## Open Questions (Resolved)
1. ~~Config updates → restart?~~ **Yes.** Mosaic restarts the container, fresh config on boot.
2. ~~CLI alternative for breakglass?~~ **Yes.** Both WebUI wizard and CLI (`mosaic admin create-breakglass`).
3. ~~Config cache TTL?~~ **Yes.** Config fetched once at startup, changes trigger restart.
## Security Isolation Model
### Core Principle: ZERO cross-user access
Every user is fully sandboxed. No exceptions.
### Container Isolation
- Each user gets their **own** OpenClaw container (separate process, PID namespace)
- Each container has its **own** Docker volume (sessions, memory, workspace)
- Containers run on an **internal-only** Docker network — no external exposure
- Users NEVER talk to OpenClaw directly — Mosaic proxies all requests
- Container gateway tokens are unique per-user and single-purpose
### Data Isolation (enforced at API + DB level)
| Data | Isolation | Enforcement |
| ---------------- | ------------------------- | --------------------------------------------------------------------------------- |
| LLM API keys | Per-user, encrypted | `LlmProvider.userId` — all queries scoped by authenticated user |
| Chat history | Per-user container volume | Separate Docker volume per user, not shared |
| Agent memory | Per-user container volume | Separate Docker volume per user |
| Agent config | Per-user | `UserAgentConfig.userId` — scoped queries |
| Container access | Per-user | `UserContainer.userId` — Mosaic validates user owns the container before proxying |
### API Enforcement
- **All user-facing endpoints** include `WHERE userId = authenticatedUser.id`
- **No admin endpoint** exposes another user's API keys (even to admins)
- **Chat proxy** validates: authenticated user → owns target container → forwards request
- **Config endpoint** validates: container token matches the container requesting config
- **Provider CRUD** is fully user-scoped — User A cannot list, read, or modify User B's providers
### What admins CAN see
- Container status (running/stopped) — not contents
- User list and roles
- System-level config (OIDC, system agents)
- Aggregate usage metrics (not individual conversations)
### What admins CANNOT see
- Other users' API keys (encrypted, no decrypt endpoint)
- Other users' chat history (in container volumes, not in Mosaic DB)
- Other users' agent memory/workspace contents
### Future: Team Workspaces (NOT in scope)
Team/shared workspaces are a potential future feature where users opt-in to
shared agent contexts. This requires explicit consent, shared-key management,
and a different isolation model. **Not designed here. Not built now.**
### Attack Surface Notes
- Docker socket access (`/var/run/docker.sock`) is required by Mosaic API for container management. This is a privileged operation — the Mosaic API container must be trusted.
- `MOSAIC_SECRET_KEY` is the root of trust for encryption. Rotation requires re-encrypting all secrets in DB.
- Container-to-container communication is blocked by default (no shared network between user containers unless explicitly configured).

View File

@@ -35,7 +35,8 @@
"docker:ps": "docker compose ps", "docker:ps": "docker compose ps",
"docker:build": "docker compose build", "docker:build": "docker compose build",
"docker:restart": "docker compose restart", "docker:restart": "docker compose restart",
"prepare": "husky || true" "prepare": "husky || true",
"ingest:sessions": "tsx scripts/ingest-openclaw-sessions.ts"
}, },
"devDependencies": { "devDependencies": {
"@typescript-eslint/eslint-plugin": "^8.26.0", "@typescript-eslint/eslint-plugin": "^8.26.0",

36
pnpm-lock.yaml generated
View File

@@ -102,6 +102,9 @@ importers:
'@nestjs/platform-socket.io': '@nestjs/platform-socket.io':
specifier: ^11.1.12 specifier: ^11.1.12
version: 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.12)(rxjs@7.8.2) version: 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/websockets@11.1.12)(rxjs@7.8.2)
'@nestjs/schedule':
specifier: ^6.1.1
version: 6.1.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)
'@nestjs/throttler': '@nestjs/throttler':
specifier: ^6.5.0 specifier: ^6.5.0
version: 6.5.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(reflect-metadata@0.2.2) version: 6.5.0(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)(reflect-metadata@0.2.2)
@@ -171,6 +174,9 @@ importers:
discord.js: discord.js:
specifier: ^14.25.1 specifier: ^14.25.1
version: 14.25.1 version: 14.25.1
dockerode:
specifier: ^4.0.9
version: 4.0.9
gray-matter: gray-matter:
specifier: ^4.0.3 specifier: ^4.0.3
version: 4.0.3 version: 4.0.3
@@ -253,6 +259,9 @@ importers:
'@types/cookie-parser': '@types/cookie-parser':
specifier: ^1.4.10 specifier: ^1.4.10
version: 1.4.10(@types/express@5.0.6) version: 1.4.10(@types/express@5.0.6)
'@types/dockerode':
specifier: ^3.3.47
version: 3.3.47
'@types/express': '@types/express':
specifier: ^5.0.1 specifier: ^5.0.1
version: 5.0.6 version: 5.0.6
@@ -1604,7 +1613,6 @@ packages:
'@mosaicstack/telemetry-client@0.1.1': '@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} 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': '@mrleebo/prisma-ast@0.13.1':
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==} resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
@@ -1736,6 +1744,12 @@ packages:
'@nestjs/websockets': ^11.0.0 '@nestjs/websockets': ^11.0.0
rxjs: ^7.1.0 rxjs: ^7.1.0
'@nestjs/schedule@6.1.1':
resolution: {integrity: sha512-kQl1RRgi02GJ0uaUGCrXHCcwISsCsJDciCKe38ykJZgnAeeoeVWs8luWtBo4AqAAXm4nS5K8RlV0smHUJ4+2FA==}
peerDependencies:
'@nestjs/common': ^10.0.0 || ^11.0.0
'@nestjs/core': ^10.0.0 || ^11.0.0
'@nestjs/schematics@11.0.9': '@nestjs/schematics@11.0.9':
resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==} resolution: {integrity: sha512-0NfPbPlEaGwIT8/TCThxLzrlz3yzDNkfRNpbL7FiplKq3w4qXpJg0JYwqgMEJnLQZm3L/L/5XjoyfJHUO3qX9g==}
peerDependencies: peerDependencies:
@@ -3236,6 +3250,9 @@ packages:
'@types/linkify-it@5.0.0': '@types/linkify-it@5.0.0':
resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==}
'@types/luxon@3.7.1':
resolution: {integrity: sha512-H3iskjFIAn5SlJU7OuxUmTEpebK6TKB8rxZShDslBMZJ5u9S//KM1sbdAisiSrqwLQncVjnpi2OK2J51h+4lsg==}
'@types/markdown-it@13.0.9': '@types/markdown-it@13.0.9':
resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==} resolution: {integrity: sha512-1XPwR0+MgXLWfTn9gCsZ55AHOKW1WN+P9vr0PaQh5aerR9LLQXUbjfEAFhjmEmyoYFWAyuN2Mqkn40MZ4ukjBw==}
@@ -4246,6 +4263,10 @@ packages:
resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==} resolution: {integrity: sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==}
engines: {node: '>=12.0.0'} engines: {node: '>=12.0.0'}
cron@4.4.0:
resolution: {integrity: sha512-fkdfq+b+AHI4cKdhZlppHveI/mgz2qpiYxcm+t5E5TsxX7QrLS1VE0+7GENEk9z0EeGPcpSciGv6ez24duWhwQ==}
engines: {node: '>=18.x'}
cross-spawn@7.0.6: cross-spawn@7.0.6:
resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==}
engines: {node: '>= 8'} engines: {node: '>= 8'}
@@ -8858,6 +8879,12 @@ snapshots:
- supports-color - supports-color
- utf-8-validate - utf-8-validate
'@nestjs/schedule@6.1.1(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/core@11.1.12)':
dependencies:
'@nestjs/common': 11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2)
'@nestjs/core': 11.1.12(@nestjs/common@11.1.12(class-transformer@0.5.1)(class-validator@0.14.3)(reflect-metadata@0.2.2)(rxjs@7.8.2))(@nestjs/platform-express@11.1.12)(@nestjs/websockets@11.1.12)(reflect-metadata@0.2.2)(rxjs@7.8.2)
cron: 4.4.0
'@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)': '@nestjs/schematics@11.0.9(chokidar@4.0.3)(typescript@5.9.3)':
dependencies: dependencies:
'@angular-devkit/core': 19.2.17(chokidar@4.0.3) '@angular-devkit/core': 19.2.17(chokidar@4.0.3)
@@ -10588,6 +10615,8 @@ snapshots:
'@types/linkify-it@5.0.0': {} '@types/linkify-it@5.0.0': {}
'@types/luxon@3.7.1': {}
'@types/markdown-it@13.0.9': '@types/markdown-it@13.0.9':
dependencies: dependencies:
'@types/linkify-it': 3.0.5 '@types/linkify-it': 3.0.5
@@ -11782,6 +11811,11 @@ snapshots:
dependencies: dependencies:
luxon: 3.7.2 luxon: 3.7.2
cron@4.4.0:
dependencies:
'@types/luxon': 3.7.1
luxon: 3.7.2
cross-spawn@7.0.6: cross-spawn@7.0.6:
dependencies: dependencies:
path-key: 3.1.1 path-key: 3.1.1

View File

@@ -0,0 +1,621 @@
import { createReadStream, constants as fsConstants } from "node:fs";
import { access, readdir, stat } from "node:fs/promises";
import { homedir } from "node:os";
import * as path from "node:path";
import * as process from "node:process";
import { createInterface } from "node:readline";
const DEFAULT_ENDPOINT = "https://mosaic-api.woltje.com/conversation-archive/ingest";
type IngestRole = "user" | "assistant";
interface IngestMessage {
role: IngestRole;
content: string;
timestamp?: string;
}
interface IngestPayload {
sessionId: string;
workspaceId: string;
title: string;
messages: IngestMessage[];
agentId?: string;
}
interface CliOptions {
workspaceId: string;
agentId?: string;
since?: Date;
sessionsDir?: string;
endpoint: string;
}
interface ParsedSession {
sessionId: string;
title: string;
messages: IngestMessage[];
startedAt?: string;
endedAt?: string;
parseErrors: number;
inferredAgentId?: string;
}
interface SendResult {
ok: boolean;
status: number;
body: string;
}
interface IngestSummary {
discovered: number;
processed: number;
ingested: number;
skippedSince: number;
skippedEmpty: number;
skippedDuplicate: number;
failed: number;
}
function printUsage(): void {
console.log(
[
"Usage:",
" pnpm ingest:sessions --workspace-id <id> [--agent-id <id>] [--since <ISO date>] [--sessions-dir <path>] [--endpoint <url>]",
"",
"Required:",
" --workspace-id Target Mosaic workspace ID",
"",
"Optional:",
" --agent-id Agent ID to include in each ingest payload",
" --since Skip sessions before this date/time (ISO8601 or YYYY-MM-DD)",
" --sessions-dir Override session directory path",
` --endpoint Ingest endpoint (default: ${DEFAULT_ENDPOINT})`,
].join("\n")
);
}
function expandHomePath(inputPath: string): string {
if (inputPath === "~") {
return homedir();
}
if (inputPath.startsWith("~/")) {
return path.join(homedir(), inputPath.slice(2));
}
return inputPath;
}
function parseSinceDate(rawDate: string): Date {
const parsed = new Date(rawDate);
if (Number.isNaN(parsed.getTime())) {
throw new Error(`Invalid --since date: "${rawDate}". Use ISO8601 or YYYY-MM-DD.`);
}
return parsed;
}
function parseCliArgs(args: string[]): CliOptions {
let workspaceId: string | null = null;
let agentId: string | undefined;
let since: Date | undefined;
let sessionsDir: string | undefined;
let endpoint = DEFAULT_ENDPOINT;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
if (arg === "--help" || arg === "-h") {
printUsage();
process.exit(0);
}
if (arg.startsWith("--workspace-id=")) {
workspaceId = arg.slice("--workspace-id=".length);
continue;
}
if (arg === "--workspace-id") {
const value = args[index + 1];
if (!value) {
throw new Error("Missing value for --workspace-id");
}
workspaceId = value;
index += 1;
continue;
}
if (arg.startsWith("--agent-id=")) {
agentId = arg.slice("--agent-id=".length);
continue;
}
if (arg === "--agent-id") {
const value = args[index + 1];
if (!value) {
throw new Error("Missing value for --agent-id");
}
agentId = value;
index += 1;
continue;
}
if (arg.startsWith("--since=")) {
since = parseSinceDate(arg.slice("--since=".length));
continue;
}
if (arg === "--since") {
const value = args[index + 1];
if (!value) {
throw new Error("Missing value for --since");
}
since = parseSinceDate(value);
index += 1;
continue;
}
if (arg.startsWith("--sessions-dir=")) {
sessionsDir = arg.slice("--sessions-dir=".length);
continue;
}
if (arg === "--sessions-dir") {
const value = args[index + 1];
if (!value) {
throw new Error("Missing value for --sessions-dir");
}
sessionsDir = value;
index += 1;
continue;
}
if (arg.startsWith("--endpoint=")) {
endpoint = arg.slice("--endpoint=".length);
continue;
}
if (arg === "--endpoint") {
const value = args[index + 1];
if (!value) {
throw new Error("Missing value for --endpoint");
}
endpoint = value;
index += 1;
continue;
}
throw new Error(`Unknown flag: ${arg}`);
}
if (!workspaceId || workspaceId.trim().length === 0) {
throw new Error("--workspace-id is required");
}
return {
workspaceId: workspaceId.trim(),
agentId: agentId?.trim(),
since,
sessionsDir: sessionsDir ? path.resolve(expandHomePath(sessionsDir)) : undefined,
endpoint,
};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function asString(value: unknown): string | null {
return typeof value === "string" ? value : null;
}
function normalizeIsoTimestamp(value: unknown): string | null {
if (typeof value === "string") {
const parsed = new Date(value);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString();
}
return null;
}
if (typeof value === "number" && Number.isFinite(value)) {
const millis = value >= 1_000_000_000_000 ? value : value * 1000;
const parsed = new Date(millis);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toISOString();
}
}
return null;
}
function truncate(value: string, maxLength: number): string {
if (value.length <= maxLength) {
return value;
}
return `${value.slice(0, maxLength - 3)}...`;
}
function deriveTitle(content: string, fallbackSessionId: string): string {
const firstLine = content
.split(/\r?\n/u)
.map((line) => line.trim())
.find((line) => line.length > 0);
if (!firstLine) {
return `OpenClaw session ${fallbackSessionId}`;
}
const normalized = firstLine.replace(/\s+/gu, " ").trim();
return truncate(normalized, 140);
}
function extractTextContent(content: unknown): string {
if (typeof content === "string") {
return content.trim();
}
if (Array.isArray(content)) {
const parts: string[] = [];
for (const item of content) {
if (typeof item === "string") {
const trimmed = item.trim();
if (trimmed.length > 0) {
parts.push(trimmed);
}
continue;
}
if (!isRecord(item)) {
continue;
}
const itemType = asString(item.type);
if (itemType !== null && itemType !== "text") {
continue;
}
const textValue = asString(item.text);
if (textValue && textValue.trim().length > 0) {
parts.push(textValue.trim());
}
}
return parts.join("\n\n").trim();
}
if (isRecord(content)) {
const textValue = asString(content.text);
if (textValue) {
return textValue.trim();
}
}
return "";
}
function inferAgentIdFromPath(filePath: string): string | null {
const pathParts = filePath.split(path.sep);
const agentsIndex = pathParts.lastIndexOf("agents");
if (agentsIndex < 0) {
return null;
}
const candidate = pathParts[agentsIndex + 1];
return candidate && candidate.trim().length > 0 ? candidate : null;
}
async function parseSessionFile(filePath: string): Promise<ParsedSession> {
const fallbackSessionId = path.basename(filePath, path.extname(filePath));
const inferredAgentId = inferAgentIdFromPath(filePath) ?? undefined;
let sessionId = fallbackSessionId;
let title: string | null = null;
let startedAt: string | undefined;
let endedAt: string | undefined;
let parseErrors = 0;
const messages: IngestMessage[] = [];
const readStream = createReadStream(filePath, { encoding: "utf8" });
const lineReader = createInterface({
input: readStream,
crlfDelay: Number.POSITIVE_INFINITY,
});
for await (const rawLine of lineReader) {
const line = rawLine.trim();
if (line.length === 0) {
continue;
}
let parsedLine: unknown;
try {
parsedLine = JSON.parse(line) as unknown;
} catch {
parseErrors += 1;
continue;
}
if (!isRecord(parsedLine)) {
parseErrors += 1;
continue;
}
const eventType = asString(parsedLine.type);
if (eventType === "session") {
const rawSessionId = asString(parsedLine.id);
if (rawSessionId && rawSessionId.trim().length > 0) {
sessionId = rawSessionId;
}
const sessionTimestamp = normalizeIsoTimestamp(parsedLine.timestamp);
if (sessionTimestamp) {
startedAt ??= sessionTimestamp;
}
continue;
}
if (eventType !== "message") {
continue;
}
const messageRecord = parsedLine.message;
if (!isRecord(messageRecord)) {
continue;
}
const role = asString(messageRecord.role);
if (role !== "user" && role !== "assistant") {
continue;
}
const content = extractTextContent(messageRecord.content);
if (content.length === 0) {
continue;
}
const timestamp =
normalizeIsoTimestamp(messageRecord.timestamp) ?? normalizeIsoTimestamp(parsedLine.timestamp);
const message: IngestMessage = {
role,
content,
timestamp: timestamp ?? undefined,
};
messages.push(message);
if (!title && role === "user") {
title = deriveTitle(content, sessionId);
}
if (!startedAt && timestamp) {
startedAt = timestamp;
}
if (timestamp) {
endedAt = timestamp;
}
}
return {
sessionId,
title: title ?? `OpenClaw session ${sessionId}`,
messages,
startedAt,
endedAt,
parseErrors,
inferredAgentId,
};
}
async function pathExists(candidatePath: string): Promise<boolean> {
try {
await access(candidatePath, fsConstants.F_OK);
return true;
} catch {
return false;
}
}
async function discoverSessionDirectories(overrideDir?: string): Promise<string[]> {
if (overrideDir) {
if (!(await pathExists(overrideDir))) {
throw new Error(`Provided --sessions-dir does not exist: ${overrideDir}`);
}
return [overrideDir];
}
const defaultDir = path.join(homedir(), ".openclaw", "sessions");
if (await pathExists(defaultDir)) {
return [defaultDir];
}
const agentsRoot = path.join(homedir(), ".openclaw", "agents");
if (!(await pathExists(agentsRoot))) {
return [];
}
const agentEntries = await readdir(agentsRoot, { withFileTypes: true });
const directories: string[] = [];
for (const entry of agentEntries) {
if (!entry.isDirectory()) {
continue;
}
const sessionsDir = path.join(agentsRoot, entry.name, "sessions");
if (await pathExists(sessionsDir)) {
directories.push(sessionsDir);
}
}
return directories.sort((left, right) => left.localeCompare(right));
}
async function discoverSessionFiles(overrideDir?: string): Promise<string[]> {
const directories = await discoverSessionDirectories(overrideDir);
const files: string[] = [];
for (const directory of directories) {
const entries = await readdir(directory, { withFileTypes: true });
for (const entry of entries) {
if (!entry.isFile() || !entry.name.endsWith(".jsonl")) {
continue;
}
files.push(path.join(directory, entry.name));
}
}
return files.sort((left, right) => left.localeCompare(right));
}
async function resolveSessionTimestamp(session: ParsedSession, filePath: string): Promise<Date> {
const sessionTimestamp = session.startedAt ?? session.endedAt;
if (sessionTimestamp) {
const parsed = new Date(sessionTimestamp);
if (!Number.isNaN(parsed.getTime())) {
return parsed;
}
}
const fileStat = await stat(filePath);
return fileStat.mtime;
}
function buildPayload(
options: CliOptions,
session: ParsedSession,
fallbackAgentId: string | undefined
): IngestPayload {
const payload: IngestPayload = {
sessionId: session.sessionId,
workspaceId: options.workspaceId,
title: session.title,
messages: session.messages,
};
const selectedAgentId = options.agentId ?? fallbackAgentId;
if (selectedAgentId && selectedAgentId.trim().length > 0) {
payload.agentId = selectedAgentId.trim();
}
return payload;
}
async function sendIngestRequest(
endpoint: string,
token: string,
payload: IngestPayload
): Promise<SendResult> {
const response = await fetch(endpoint, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
});
const body = await response.text();
return {
ok: response.ok,
status: response.status,
body,
};
}
function summarizeFailureBody(body: string): string {
const compact = body.replace(/\s+/gu, " ").trim();
if (compact.length === 0) {
return "(empty response body)";
}
return truncate(compact, 220);
}
async function main(): Promise<void> {
const options = parseCliArgs(process.argv.slice(2));
const token = process.env.MOSAIC_API_TOKEN;
if (!token || token.trim().length === 0) {
throw new Error("MOSAIC_API_TOKEN environment variable is required.");
}
const sessionFiles = await discoverSessionFiles(options.sessionsDir);
if (sessionFiles.length === 0) {
console.log("No OpenClaw session files found.");
return;
}
console.log(`Discovered ${sessionFiles.length} session file(s).`);
if (options.since) {
console.log(`Applying --since filter at ${options.since.toISOString()}.`);
}
const summary: IngestSummary = {
discovered: sessionFiles.length,
processed: 0,
ingested: 0,
skippedSince: 0,
skippedEmpty: 0,
skippedDuplicate: 0,
failed: 0,
};
for (const [index, filePath] of sessionFiles.entries()) {
const position = `${index + 1}/${sessionFiles.length}`;
const parsedSession = await parseSessionFile(filePath);
summary.processed += 1;
if (parsedSession.messages.length === 0) {
summary.skippedEmpty += 1;
console.log(
`[${position}] Skipped ${parsedSession.sessionId}: no user/assistant text messages.`
);
continue;
}
const sessionDate = await resolveSessionTimestamp(parsedSession, filePath);
if (options.since && sessionDate.getTime() < options.since.getTime()) {
summary.skippedSince += 1;
console.log(
`[${position}] Skipped ${parsedSession.sessionId}: session is before --since (${sessionDate.toISOString()}).`
);
continue;
}
const payload = buildPayload(options, parsedSession, parsedSession.inferredAgentId);
let result: SendResult;
try {
result = await sendIngestRequest(options.endpoint, token, payload);
} catch (error) {
summary.failed += 1;
const message = error instanceof Error ? error.message : String(error);
console.error(`[${position}] Failed ${parsedSession.sessionId}: request error: ${message}`);
continue;
}
if (result.ok) {
summary.ingested += 1;
const note =
parsedSession.parseErrors > 0 ? ` (parse warnings: ${parsedSession.parseErrors})` : "";
console.log(
`[${position}] Ingested ${parsedSession.sessionId} (${parsedSession.messages.length} messages)${note}.`
);
continue;
}
if (result.status === 409) {
summary.skippedDuplicate += 1;
console.log(`[${position}] Skipped ${parsedSession.sessionId}: already exists (409).`);
continue;
}
summary.failed += 1;
console.error(
`[${position}] Failed ${parsedSession.sessionId}: HTTP ${result.status} ${summarizeFailureBody(result.body)}`
);
}
console.log("\nIngestion summary:");
console.log(` Discovered: ${summary.discovered}`);
console.log(` Processed: ${summary.processed}`);
console.log(` Ingested: ${summary.ingested}`);
console.log(` Skipped (--since): ${summary.skippedSince}`);
console.log(` Skipped (empty): ${summary.skippedEmpty}`);
console.log(` Skipped (duplicate): ${summary.skippedDuplicate}`);
console.log(` Failed: ${summary.failed}`);
if (summary.failed > 0) {
process.exit(1);
}
}
main().catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(`Fatal error: ${message}`);
process.exit(1);
});