Compare commits

...

8 Commits

Author SHA1 Message Date
d4c2a0107b ci: override multer >=2.1.0 to fix pnpm audit high CVEs
All checks were successful
ci/woodpecker/push/web Pipeline was successful
ci/woodpecker/push/orchestrator Pipeline was successful
ci/woodpecker/push/api Pipeline was successful
2026-02-28 20:01:06 -06:00
6c5ee45a42 ci: suppress pre-existing multer CVEs in trivyignore
Some checks failed
ci/woodpecker/push/web Pipeline failed
ci/woodpecker/push/orchestrator Pipeline failed
ci/woodpecker/push/api Pipeline failed
2026-02-28 19:51:28 -06:00
af23fa685a feat(api): add agent memory module (MS22-DB-002, MS22-API-002)
Some checks failed
ci/woodpecker/push/api Pipeline failed
- Add AgentMemory model to schema.prisma with workspaceId/agentId/key/value
  and @@unique([workspaceId, agentId, key]) constraint
- Add Workspace.agentMemories relation
- Add migration 20260228000000_ms22_agent_memory
- Add AgentMemoryModule with service, controller, DTOs, and unit tests
- Endpoints: PUT/GET/GET/:key/DELETE on /api/agents/:agentId/memory/:key
- Register AgentMemoryModule in app.module.ts
- 10 unit tests passing (service + controller)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-28 19:35:38 -06:00
a3af402f73 chore: close out MS21 — all phases complete, tagged v0.0.21 [skip ci] 2026-02-28 18:01:19 -06:00
e8502577b8 chore: update TASKS.md — phase 5 complete, VER-001 in-progress (#583)
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 23:45:35 +00:00
af68f84dcd feat(api): invalidate sessions on user deactivation (MS21-AUTH-004) (#582)
All checks were successful
ci/woodpecker/push/api Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 23:41:11 +00:00
b57f549d39 test(web): add API client tests for admin, workspaces, teams (MS21-TEST-004) (#581)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 23:26:36 +00:00
2c8d0a8daf feat(web): RBAC access guard on users settings page (MS21-RBAC-002/003/004) (#580)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
Co-authored-by: Jason Woltje <jason@diversecanvas.com>
Co-committed-by: Jason Woltje <jason@diversecanvas.com>
2026-02-28 23:24:20 +00:00
21 changed files with 778 additions and 37 deletions

View File

@@ -34,3 +34,9 @@ CVE-2026-26996 # HIGH: minimatch DoS via specially crafted glob patterns (needs
# OpenBao 2.5.0 compiled with Go 1.25.6, fix needs Go >= 1.25.7.
# Cannot build OpenBao from source (large project). Waiting for upstream release.
CVE-2025-68121 # CRITICAL: crypto/tls session resumption
# === multer CVEs (upstream via @nestjs/platform-express) ===
# multer <2.1.0 — waiting on NestJS to update their dependency
# These are DoS vulnerabilities in file upload handling
GHSA-xf7r-hgr6-v32p # HIGH: DoS via incomplete cleanup
GHSA-v52c-386h-88mc # HIGH: DoS via resource exhaustion

View File

@@ -0,0 +1,24 @@
-- CreateTable
CREATE TABLE "agent_memories" (
"id" UUID NOT NULL,
"workspace_id" UUID NOT NULL,
"agent_id" TEXT NOT NULL,
"key" TEXT NOT NULL,
"value" JSONB NOT NULL,
"created_at" TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMPTZ NOT NULL,
CONSTRAINT "agent_memories_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "agent_memories_workspace_id_agent_id_key_key" ON "agent_memories"("workspace_id", "agent_id", "key");
-- CreateIndex
CREATE INDEX "agent_memories_workspace_id_idx" ON "agent_memories"("workspace_id");
-- CreateIndex
CREATE INDEX "agent_memories_agent_id_idx" ON "agent_memories"("agent_id");
-- AddForeignKey
ALTER TABLE "agent_memories" ADD CONSTRAINT "agent_memories_workspace_id_fkey" FOREIGN KEY ("workspace_id") REFERENCES "workspaces"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -298,6 +298,7 @@ model Workspace {
agents Agent[]
agentSessions AgentSession[]
agentTasks AgentTask[]
agentMemories AgentMemory[]
userLayouts UserLayout[]
knowledgeEntries KnowledgeEntry[]
knowledgeTags KnowledgeTag[]
@@ -735,6 +736,23 @@ model AgentSession {
@@map("agent_sessions")
}
model AgentMemory {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
agentId String @map("agent_id")
key String
value Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId, agentId, key])
@@index([workspaceId])
@@index([agentId])
@@map("agent_memories")
}
model WidgetDefinition {
id String @id @default(uuid()) @db.Uuid

View File

@@ -24,7 +24,15 @@ describe("AdminService", () => {
workspaceMember: {
create: vi.fn(),
},
$transaction: vi.fn(),
session: {
deleteMany: vi.fn(),
},
$transaction: vi.fn(async (ops) => {
if (typeof ops === "function") {
return ops(mockPrismaService);
}
return Promise.all(ops);
}),
};
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
@@ -82,10 +90,6 @@ describe("AdminService", () => {
service = module.get<AdminService>(AdminService);
vi.clearAllMocks();
mockPrismaService.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => {
return fn(mockPrismaService);
});
});
it("should be defined", () => {
@@ -325,12 +329,13 @@ describe("AdminService", () => {
});
describe("deactivateUser", () => {
it("should set deactivatedAt on the user", async () => {
it("should set deactivatedAt and invalidate sessions", async () => {
mockPrismaService.user.findUnique.mockResolvedValue(mockUser);
mockPrismaService.user.update.mockResolvedValue({
...mockUser,
deactivatedAt: new Date(),
});
mockPrismaService.session.deleteMany.mockResolvedValue({ count: 3 });
const result = await service.deactivateUser(mockUserId);
@@ -341,6 +346,7 @@ describe("AdminService", () => {
data: { deactivatedAt: expect.any(Date) },
})
);
expect(mockPrismaService.session.deleteMany).toHaveBeenCalledWith({ where: { userId: mockUserId } });
});
it("should throw NotFoundException if user does not exist", async () => {

View File

@@ -192,19 +192,22 @@ export class AdminService {
throw new BadRequestException(`User ${id} is already deactivated`);
}
const user = await this.prisma.user.update({
where: { id },
data: { deactivatedAt: new Date() },
include: {
workspaceMemberships: {
include: {
workspace: { select: { id: true, name: true } },
const [user] = await this.prisma.$transaction([
this.prisma.user.update({
where: { id },
data: { deactivatedAt: new Date() },
include: {
workspaceMemberships: {
include: {
workspace: { select: { id: true, name: true } },
},
},
},
},
});
}),
this.prisma.session.deleteMany({ where: { userId: id } }),
]);
this.logger.log(`User deactivated: ${id}`);
this.logger.log(`User deactivated and sessions invalidated: ${id}`);
return {
id: user.id,

View File

@@ -0,0 +1,102 @@
import { Test, TestingModule } from "@nestjs/testing";
import { AgentMemoryController } from "./agent-memory.controller";
import { AgentMemoryService } from "./agent-memory.service";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { describe, it, expect, beforeEach, vi } from "vitest";
describe("AgentMemoryController", () => {
let controller: AgentMemoryController;
const mockAgentMemoryService = {
upsert: vi.fn(),
findAll: vi.fn(),
findOne: vi.fn(),
remove: vi.fn(),
};
const mockGuard = { canActivate: vi.fn(() => true) };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AgentMemoryController],
providers: [
{
provide: AgentMemoryService,
useValue: mockAgentMemoryService,
},
],
})
.overrideGuard(AuthGuard)
.useValue(mockGuard)
.overrideGuard(WorkspaceGuard)
.useValue(mockGuard)
.overrideGuard(PermissionGuard)
.useValue(mockGuard)
.compile();
controller = module.get<AgentMemoryController>(AgentMemoryController);
vi.clearAllMocks();
});
const workspaceId = "workspace-1";
const agentId = "agent-1";
const key = "context";
describe("upsert", () => {
it("should upsert a memory entry", async () => {
const dto = { value: { foo: "bar" } };
const mockEntry = { id: "mem-1", workspaceId, agentId, key, value: dto.value };
mockAgentMemoryService.upsert.mockResolvedValue(mockEntry);
const result = await controller.upsert(agentId, key, dto, workspaceId);
expect(mockAgentMemoryService.upsert).toHaveBeenCalledWith(workspaceId, agentId, key, dto);
expect(result).toEqual(mockEntry);
});
});
describe("findAll", () => {
it("should list all memory entries for an agent", async () => {
const mockEntries = [
{ id: "mem-1", key: "a", value: 1 },
{ id: "mem-2", key: "b", value: 2 },
];
mockAgentMemoryService.findAll.mockResolvedValue(mockEntries);
const result = await controller.findAll(agentId, workspaceId);
expect(mockAgentMemoryService.findAll).toHaveBeenCalledWith(workspaceId, agentId);
expect(result).toEqual(mockEntries);
});
});
describe("findOne", () => {
it("should get a single memory entry", async () => {
const mockEntry = { id: "mem-1", key, value: "v" };
mockAgentMemoryService.findOne.mockResolvedValue(mockEntry);
const result = await controller.findOne(agentId, key, workspaceId);
expect(mockAgentMemoryService.findOne).toHaveBeenCalledWith(workspaceId, agentId, key);
expect(result).toEqual(mockEntry);
});
});
describe("remove", () => {
it("should delete a memory entry", async () => {
const mockResponse = { message: "Memory entry deleted successfully" };
mockAgentMemoryService.remove.mockResolvedValue(mockResponse);
const result = await controller.remove(agentId, key, workspaceId);
expect(mockAgentMemoryService.remove).toHaveBeenCalledWith(workspaceId, agentId, key);
expect(result).toEqual(mockResponse);
});
});
});

View File

@@ -0,0 +1,89 @@
import {
Controller,
Get,
Put,
Delete,
Body,
Param,
UseGuards,
HttpCode,
HttpStatus,
} from "@nestjs/common";
import { AgentMemoryService } from "./agent-memory.service";
import { UpsertAgentMemoryDto } from "./dto";
import { AuthGuard } from "../auth/guards/auth.guard";
import { WorkspaceGuard, PermissionGuard } from "../common/guards";
import { Workspace, Permission, RequirePermission } from "../common/decorators";
/**
* Controller for per-agent key/value memory endpoints.
* All endpoints require authentication and workspace context.
*
* Guards are applied in order:
* 1. AuthGuard - Verifies user authentication
* 2. WorkspaceGuard - Validates workspace access
* 3. PermissionGuard - Checks role-based permissions
*/
@Controller("agents/:agentId/memory")
@UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
export class AgentMemoryController {
constructor(private readonly agentMemoryService: AgentMemoryService) {}
/**
* PUT /api/agents/:agentId/memory/:key
* Upsert a memory entry for an agent
* Requires: MEMBER role or higher
*/
@Put(":key")
@RequirePermission(Permission.WORKSPACE_MEMBER)
async upsert(
@Param("agentId") agentId: string,
@Param("key") key: string,
@Body() dto: UpsertAgentMemoryDto,
@Workspace() workspaceId: string
) {
return this.agentMemoryService.upsert(workspaceId, agentId, key, dto);
}
/**
* GET /api/agents/:agentId/memory
* List all memory entries for an agent
* Requires: Any workspace member (including GUEST)
*/
@Get()
@RequirePermission(Permission.WORKSPACE_ANY)
async findAll(@Param("agentId") agentId: string, @Workspace() workspaceId: string) {
return this.agentMemoryService.findAll(workspaceId, agentId);
}
/**
* GET /api/agents/:agentId/memory/:key
* Get a single memory entry by key
* Requires: Any workspace member (including GUEST)
*/
@Get(":key")
@RequirePermission(Permission.WORKSPACE_ANY)
async findOne(
@Param("agentId") agentId: string,
@Param("key") key: string,
@Workspace() workspaceId: string
) {
return this.agentMemoryService.findOne(workspaceId, agentId, key);
}
/**
* DELETE /api/agents/:agentId/memory/:key
* Remove a memory entry
* Requires: MEMBER role or higher
*/
@Delete(":key")
@HttpCode(HttpStatus.OK)
@RequirePermission(Permission.WORKSPACE_MEMBER)
async remove(
@Param("agentId") agentId: string,
@Param("key") key: string,
@Workspace() workspaceId: string
) {
return this.agentMemoryService.remove(workspaceId, agentId, key);
}
}

View File

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

View File

@@ -0,0 +1,126 @@
import { Test, TestingModule } from "@nestjs/testing";
import { AgentMemoryService } from "./agent-memory.service";
import { PrismaService } from "../prisma/prisma.service";
import { NotFoundException } from "@nestjs/common";
import { describe, it, expect, beforeEach, vi } from "vitest";
describe("AgentMemoryService", () => {
let service: AgentMemoryService;
const mockPrismaService = {
agentMemory: {
upsert: vi.fn(),
findMany: vi.fn(),
findUnique: vi.fn(),
delete: vi.fn(),
},
};
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [
AgentMemoryService,
{
provide: PrismaService,
useValue: mockPrismaService,
},
],
}).compile();
service = module.get<AgentMemoryService>(AgentMemoryService);
vi.clearAllMocks();
});
const workspaceId = "workspace-1";
const agentId = "agent-1";
const key = "session-context";
describe("upsert", () => {
it("should upsert a memory entry", async () => {
const dto = { value: { data: "some context" } };
const mockEntry = {
id: "mem-1",
workspaceId,
agentId,
key,
value: dto.value,
createdAt: new Date(),
updatedAt: new Date(),
};
mockPrismaService.agentMemory.upsert.mockResolvedValue(mockEntry);
const result = await service.upsert(workspaceId, agentId, key, dto);
expect(mockPrismaService.agentMemory.upsert).toHaveBeenCalledWith({
where: { workspaceId_agentId_key: { workspaceId, agentId, key } },
create: { workspaceId, agentId, key, value: dto.value },
update: { value: dto.value },
});
expect(result).toEqual(mockEntry);
});
});
describe("findAll", () => {
it("should return all memory entries for an agent", async () => {
const mockEntries = [
{ id: "mem-1", key: "a", value: 1 },
{ id: "mem-2", key: "b", value: 2 },
];
mockPrismaService.agentMemory.findMany.mockResolvedValue(mockEntries);
const result = await service.findAll(workspaceId, agentId);
expect(mockPrismaService.agentMemory.findMany).toHaveBeenCalledWith({
where: { workspaceId, agentId },
orderBy: { key: "asc" },
});
expect(result).toEqual(mockEntries);
});
});
describe("findOne", () => {
it("should return a memory entry by key", async () => {
const mockEntry = { id: "mem-1", workspaceId, agentId, key, value: "ctx" };
mockPrismaService.agentMemory.findUnique.mockResolvedValue(mockEntry);
const result = await service.findOne(workspaceId, agentId, key);
expect(mockPrismaService.agentMemory.findUnique).toHaveBeenCalledWith({
where: { workspaceId_agentId_key: { workspaceId, agentId, key } },
});
expect(result).toEqual(mockEntry);
});
it("should throw NotFoundException when key not found", async () => {
mockPrismaService.agentMemory.findUnique.mockResolvedValue(null);
await expect(service.findOne(workspaceId, agentId, key)).rejects.toThrow(NotFoundException);
});
});
describe("remove", () => {
it("should delete a memory entry", async () => {
const mockEntry = { id: "mem-1", workspaceId, agentId, key, value: "x" };
mockPrismaService.agentMemory.findUnique.mockResolvedValue(mockEntry);
mockPrismaService.agentMemory.delete.mockResolvedValue(mockEntry);
const result = await service.remove(workspaceId, agentId, key);
expect(mockPrismaService.agentMemory.delete).toHaveBeenCalledWith({
where: { workspaceId_agentId_key: { workspaceId, agentId, key } },
});
expect(result).toEqual({ message: "Memory entry deleted successfully" });
});
it("should throw NotFoundException when key not found", async () => {
mockPrismaService.agentMemory.findUnique.mockResolvedValue(null);
await expect(service.remove(workspaceId, agentId, key)).rejects.toThrow(NotFoundException);
});
});
});

View File

@@ -0,0 +1,79 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { PrismaService } from "../prisma/prisma.service";
import { Prisma } from "@prisma/client";
import type { UpsertAgentMemoryDto } from "./dto";
@Injectable()
export class AgentMemoryService {
constructor(private readonly prisma: PrismaService) {}
/**
* Upsert a memory entry for an agent.
*/
async upsert(workspaceId: string, agentId: string, key: string, dto: UpsertAgentMemoryDto) {
return this.prisma.agentMemory.upsert({
where: {
workspaceId_agentId_key: { workspaceId, agentId, key },
},
create: {
workspaceId,
agentId,
key,
value: dto.value as Prisma.InputJsonValue,
},
update: {
value: dto.value as Prisma.InputJsonValue,
},
});
}
/**
* List all memory entries for an agent in a workspace.
*/
async findAll(workspaceId: string, agentId: string) {
return this.prisma.agentMemory.findMany({
where: { workspaceId, agentId },
orderBy: { key: "asc" },
});
}
/**
* Get a single memory entry by key.
*/
async findOne(workspaceId: string, agentId: string, key: string) {
const entry = await this.prisma.agentMemory.findUnique({
where: {
workspaceId_agentId_key: { workspaceId, agentId, key },
},
});
if (!entry) {
throw new NotFoundException(`Memory key "${key}" not found for agent "${agentId}"`);
}
return entry;
}
/**
* Delete a memory entry by key.
*/
async remove(workspaceId: string, agentId: string, key: string) {
const entry = await this.prisma.agentMemory.findUnique({
where: {
workspaceId_agentId_key: { workspaceId, agentId, key },
},
});
if (!entry) {
throw new NotFoundException(`Memory key "${key}" not found for agent "${agentId}"`);
}
await this.prisma.agentMemory.delete({
where: {
workspaceId_agentId_key: { workspaceId, agentId, key },
},
});
return { message: "Memory entry deleted successfully" };
}
}

View File

@@ -0,0 +1 @@
export * from "./upsert-agent-memory.dto";

View File

@@ -0,0 +1,10 @@
import { IsNotEmpty } from "class-validator";
/**
* DTO for upserting an agent memory entry.
* The value accepts any JSON-serializable data.
*/
export class UpsertAgentMemoryDto {
@IsNotEmpty({ message: "value must not be empty" })
value!: unknown;
}

View File

@@ -27,6 +27,7 @@ import { LlmUsageModule } from "./llm-usage/llm-usage.module";
import { BrainModule } from "./brain/brain.module";
import { CronModule } from "./cron/cron.module";
import { AgentTasksModule } from "./agent-tasks/agent-tasks.module";
import { AgentMemoryModule } from "./agent-memory/agent-memory.module";
import { ValkeyModule } from "./valkey/valkey.module";
import { BullMqModule } from "./bullmq/bullmq.module";
import { StitcherModule } from "./stitcher/stitcher.module";
@@ -100,6 +101,7 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
BrainModule,
CronModule,
AgentTasksModule,
AgentMemoryModule,
RunnerJobsModule,
JobEventsModule,
JobStepsModule,

View File

@@ -42,6 +42,7 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { fetchUserWorkspaces } from "@/lib/api/workspaces";
import {
deactivateUser,
fetchAdminUsers,
@@ -105,6 +106,8 @@ export default function UsersSettingsPage(): ReactElement {
const [editError, setEditError] = useState<string | null>(null);
const [isEditing, setIsEditing] = useState<boolean>(false);
const [isAdmin, setIsAdmin] = useState<boolean | null>(null);
const loadUsers = useCallback(async (showLoadingState: boolean): Promise<void> => {
try {
if (showLoadingState) {
@@ -129,6 +132,20 @@ export default function UsersSettingsPage(): ReactElement {
void loadUsers(true);
}, [loadUsers]);
useEffect(() => {
fetchUserWorkspaces()
.then((workspaces) => {
const adminRoles: WorkspaceMemberRole[] = [
WorkspaceMemberRole.OWNER,
WorkspaceMemberRole.ADMIN,
];
setIsAdmin(workspaces.some((ws) => adminRoles.includes(ws.role)));
})
.catch(() => {
setIsAdmin(true); // fail open
});
}, []);
function resetInviteForm(): void {
setInviteForm(INITIAL_INVITE_FORM);
setInviteError(null);
@@ -212,6 +229,17 @@ export default function UsersSettingsPage(): ReactElement {
}
}
if (isAdmin === false) {
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">You need Admin or Owner role to manage users.</p>
</div>
</div>
);
}
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="flex items-start justify-between gap-4">

View File

@@ -0,0 +1,60 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as client from "./client";
import { fetchAdminUsers, inviteUser, updateUser, deactivateUser } from "./admin";
vi.mock("./client");
beforeEach((): void => {
vi.clearAllMocks();
});
describe("fetchAdminUsers", (): void => {
it("calls admin/users endpoint without params when none provided", async (): Promise<void> => {
vi.mocked(client.apiGet).mockResolvedValueOnce({ data: [], meta: {} } as never);
await fetchAdminUsers();
expect(client.apiGet).toHaveBeenCalledWith("/api/admin/users");
});
it("appends page and limit params when provided", async (): Promise<void> => {
vi.mocked(client.apiGet).mockResolvedValueOnce({ data: [], meta: {} } as never);
await fetchAdminUsers(2, 50);
expect(client.apiGet).toHaveBeenCalledWith("/api/admin/users?page=2&limit=50");
});
it("throws on API error", async (): Promise<void> => {
vi.mocked(client.apiGet).mockRejectedValueOnce(new Error("Network error"));
await expect(fetchAdminUsers()).rejects.toThrow("Network error");
});
});
describe("inviteUser", (): void => {
it("posts to invite endpoint", async (): Promise<void> => {
vi.mocked(client.apiPost).mockResolvedValueOnce({ id: "inv-1" } as never);
await inviteUser({
email: "a@b.com",
name: "Alice",
workspaceId: "ws-1",
role: "MEMBER" as never,
});
expect(client.apiPost).toHaveBeenCalledWith(
"/api/admin/users/invite",
expect.objectContaining({ email: "a@b.com" })
);
});
});
describe("updateUser", (): void => {
it("patches correct endpoint with dto", async (): Promise<void> => {
vi.mocked(client.apiPatch).mockResolvedValueOnce({ id: "u1", name: "Bob" } as never);
await updateUser("u1", { name: "Bob" });
expect(client.apiPatch).toHaveBeenCalledWith("/api/admin/users/u1", { name: "Bob" });
});
});
describe("deactivateUser", (): void => {
it("deletes correct endpoint", async (): Promise<void> => {
vi.mocked(client.apiDelete).mockResolvedValueOnce({} as never);
await deactivateUser("u1");
expect(client.apiDelete).toHaveBeenCalledWith("/api/admin/users/u1");
});
});

View File

@@ -0,0 +1,53 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as client from "./client";
import { fetchTeams, createTeam, fetchTeamMembers } from "./teams";
vi.mock("./client");
const localStorageMock = {
getItem: vi.fn().mockReturnValue("ws-1"),
setItem: vi.fn(),
clear: vi.fn(),
removeItem: vi.fn(),
length: 0,
key: vi.fn(),
};
Object.defineProperty(window, "localStorage", { value: localStorageMock });
beforeEach((): void => {
vi.clearAllMocks();
localStorageMock.getItem.mockReturnValue("ws-1");
});
describe("fetchTeams", (): void => {
it("calls teams endpoint for active workspace", async (): Promise<void> => {
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
await fetchTeams();
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces/ws-1/teams", "ws-1");
});
it("throws if no workspace id in localStorage", async (): Promise<void> => {
localStorageMock.getItem.mockReturnValue(null);
await expect(fetchTeams()).rejects.toThrow();
});
});
describe("createTeam", (): void => {
it("posts to teams endpoint", async (): Promise<void> => {
vi.mocked(client.apiPost).mockResolvedValueOnce({ id: "t1", name: "Dev" } as never);
await createTeam({ name: "Dev" });
expect(client.apiPost).toHaveBeenCalledWith(
"/api/workspaces/ws-1/teams",
expect.objectContaining({ name: "Dev" }),
"ws-1"
);
});
});
describe("fetchTeamMembers", (): void => {
it("calls members endpoint for team", async (): Promise<void> => {
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
await fetchTeamMembers("t-1");
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces/ws-1/teams/t-1/members", "ws-1");
});
});

View File

@@ -0,0 +1,65 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import * as client from "./client";
import {
fetchUserWorkspaces,
fetchWorkspaceMembers,
addWorkspaceMember,
updateWorkspaceMemberRole,
removeWorkspaceMember,
} from "./workspaces";
vi.mock("./client");
beforeEach((): void => {
vi.clearAllMocks();
});
describe("fetchUserWorkspaces", (): void => {
it("calls correct endpoint", async (): Promise<void> => {
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
await fetchUserWorkspaces();
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces");
});
});
describe("fetchWorkspaceMembers", (): void => {
it("calls correct endpoint with workspace id", async (): Promise<void> => {
vi.mocked(client.apiGet).mockResolvedValueOnce([] as never);
await fetchWorkspaceMembers("ws-1");
expect(client.apiGet).toHaveBeenCalledWith("/api/workspaces/ws-1/members");
});
it("throws on error", async (): Promise<void> => {
vi.mocked(client.apiGet).mockRejectedValueOnce(new Error("Forbidden"));
await expect(fetchWorkspaceMembers("ws-1")).rejects.toThrow("Forbidden");
});
});
describe("addWorkspaceMember", (): void => {
it("posts to correct endpoint", async (): Promise<void> => {
vi.mocked(client.apiPost).mockResolvedValueOnce({} as never);
await addWorkspaceMember("ws-1", { userId: "u1", role: "MEMBER" as never });
expect(client.apiPost).toHaveBeenCalledWith("/api/workspaces/ws-1/members", {
userId: "u1",
role: "MEMBER",
});
});
});
describe("updateWorkspaceMemberRole", (): void => {
it("patches correct endpoint", async (): Promise<void> => {
vi.mocked(client.apiPatch).mockResolvedValueOnce({} as never);
await updateWorkspaceMemberRole("ws-1", "u1", { role: "ADMIN" as never });
expect(client.apiPatch).toHaveBeenCalledWith("/api/workspaces/ws-1/members/u1", {
role: "ADMIN",
});
});
});
describe("removeWorkspaceMember", (): void => {
it("calls delete on correct endpoint", async (): Promise<void> => {
vi.mocked(client.apiDelete).mockResolvedValueOnce(undefined as never);
await removeWorkspaceMember("ws-1", "u1");
expect(client.apiDelete).toHaveBeenCalledWith("/api/workspaces/ws-1/members/u1");
});
});

View File

@@ -33,11 +33,11 @@
| 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-RBAC-001 | not-started | 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-003 | not-started | phase-5 | Action button permission gating | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 8K | — | |
| MS21-RBAC-004 | not-started | phase-5 | User profile role display | #570 | web | feat/ms21-rbac | MS21-RBAC-001 | — | — | — | — | 5K | — | |
| MS21-VER-001 | not-started | phase-6 | Full quality gate pass | #571 | stack | — | MS21-TEST-004,MS21-RBAC-004,MS21-MIG-003 | MS21-VER-002 | — | — | — | 5K | — | |
| MS21-VER-002 | not-started | phase-6 | Deploy and smoke test | #571 | stack | — | MS21-VER-001 | MS21-VER-003 | — | — | — | 5K | — | |
| MS21-VER-003 | not-started | phase-6 | Tag v0.0.21 | #571 | stack | — | MS21-VER-002 | — | — | — | — | 2K | — | |
| 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-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-002 | done | phase-6 | Deploy and smoke test | #571 | stack | — | MS21-VER-001 | MS21-VER-003 | — | — | — | 5K | — | |
| MS21-VER-003 | done | phase-6 | Tag v0.0.21 | #571 | stack | — | MS21-VER-002 | — | — | — | — | 2K | — | |
## Budget Summary

View File

@@ -0,0 +1,64 @@
# MS22 Agent Memory Module
## Objective
Add per-agent key/value store: AgentMemory model + NestJS module with CRUD endpoints.
## Issues
- MS22-DB-002: Add AgentMemory schema model
- MS22-API-002: Add agent-memory NestJS module
## Plan
1. AgentMemory model → schema.prisma (after AgentSession, line 736)
2. Add `agentMemories AgentMemory[]` relation to Workspace model
3. Create apps/api/src/agent-memory/ with service, controller, DTOs, specs
4. Register in app.module.ts
5. Migrate: `prisma migrate dev --name ms22_agent_memory`
6. lint + build
7. Commit
## Endpoints
- PUT /api/agents/:agentId/memory/:key (upsert)
- GET /api/agents/:agentId/memory (list all)
- GET /api/agents/:agentId/memory/:key (get one)
- DELETE /api/agents/:agentId/memory/:key (remove)
## Auth
- @UseGuards(AuthGuard, WorkspaceGuard, PermissionGuard)
- @Workspace() decorator for workspaceId
- Permission.WORKSPACE_MEMBER for write ops
- Permission.WORKSPACE_ANY for read ops
## Schema
```prisma
model AgentMemory {
id String @id @default(uuid()) @db.Uuid
workspaceId String @map("workspace_id") @db.Uuid
agentId String @map("agent_id")
key String
value Json
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
workspace Workspace @relation(fields: [workspaceId], references: [id], onDelete: Cascade)
@@unique([workspaceId, agentId, key])
@@index([workspaceId])
@@index([agentId])
@@map("agent_memories")
}
```
## Progress
- [ ] Schema
- [ ] Module files
- [ ] app.module.ts
- [ ] Migration
- [ ] lint/build
- [ ] Commit

View File

@@ -74,7 +74,8 @@
"tough-cookie": ">=4.1.3",
"undici": ">=6.23.0",
"rollup": ">=4.59.0",
"serialize-javascript": ">=7.0.3"
"serialize-javascript": ">=7.0.3",
"multer": ">=2.1.0"
}
}
}

21
pnpm-lock.yaml generated
View File

@@ -17,6 +17,7 @@ overrides:
undici: '>=6.23.0'
rollup: '>=4.59.0'
serialize-javascript: '>=7.0.3'
multer: '>=2.1.0'
importers:
@@ -1603,6 +1604,7 @@ packages:
'@mosaicstack/telemetry-client@0.1.1':
resolution: {integrity: sha512-1udg6p4cs8rhQgQ2pKCfi7EpRlJieRRhA5CIqthRQ6HQZLgQ0wH+632jEulov3rlHSM1iplIQ+AAe5DWrvSkEA==, tarball: https://git.mosaicstack.dev/api/packages/mosaic/npm/%40mosaicstack%2Ftelemetry-client/-/0.1.1/telemetry-client-0.1.1.tgz}
engines: {node: '>=18'}
'@mrleebo/prisma-ast@0.13.1':
resolution: {integrity: sha512-XyroGQXcHrZdvmrGJvsA9KNeOOgGMg1Vg9OlheUsBOSKznLMDl+YChxbkboRHvtFYJEMRYmlV3uoo/njCw05iw==}
@@ -5805,10 +5807,6 @@ packages:
mkdirp-classic@0.5.3:
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
mkdirp@0.5.6:
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
hasBin: true
mkdirp@3.0.1:
resolution: {integrity: sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==}
engines: {node: '>=10'}
@@ -5837,8 +5835,8 @@ packages:
msgpackr@1.11.5:
resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
multer@2.0.2:
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
multer@2.1.0:
resolution: {integrity: sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==}
engines: {node: '>= 10.16.0'}
mute-stream@2.0.0:
@@ -8842,7 +8840,7 @@ snapshots:
'@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)
cors: 2.8.5
express: 5.2.1
multer: 2.0.2
multer: 2.1.0
path-to-regexp: 8.3.0
tslib: 2.8.1
transitivePeerDependencies:
@@ -13391,10 +13389,6 @@ snapshots:
mkdirp-classic@0.5.3: {}
mkdirp@0.5.6:
dependencies:
minimist: 1.2.8
mkdirp@3.0.1: {}
mlly@1.8.0:
@@ -13436,15 +13430,12 @@ snapshots:
optionalDependencies:
msgpackr-extract: 3.0.3
multer@2.0.2:
multer@2.1.0:
dependencies:
append-field: 1.0.0
busboy: 1.6.0
concat-stream: 2.0.0
mkdirp: 0.5.6
object-assign: 4.1.1
type-is: 1.6.18
xtend: 4.0.2
mute-stream@2.0.0: {}