Compare commits

..

1 Commits

Author SHA1 Message Date
b174ba4f14 feat(web): RBAC access guard on users settings page (MS21-RBAC-002/003/004)
All checks were successful
ci/woodpecker/push/web Pipeline was successful
2026-02-28 17:19:57 -06:00
20 changed files with 37 additions and 750 deletions

View File

@@ -34,9 +34,3 @@ 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

@@ -1,24 +0,0 @@
-- 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,7 +298,6 @@ model Workspace {
agents Agent[]
agentSessions AgentSession[]
agentTasks AgentTask[]
agentMemories AgentMemory[]
userLayouts UserLayout[]
knowledgeEntries KnowledgeEntry[]
knowledgeTags KnowledgeTag[]
@@ -736,23 +735,6 @@ 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,15 +24,7 @@ describe("AdminService", () => {
workspaceMember: {
create: vi.fn(),
},
session: {
deleteMany: vi.fn(),
},
$transaction: vi.fn(async (ops) => {
if (typeof ops === "function") {
return ops(mockPrismaService);
}
return Promise.all(ops);
}),
$transaction: vi.fn(),
};
const mockAdminId = "550e8400-e29b-41d4-a716-446655440001";
@@ -90,6 +82,10 @@ describe("AdminService", () => {
service = module.get<AdminService>(AdminService);
vi.clearAllMocks();
mockPrismaService.$transaction.mockImplementation(async (fn: (tx: unknown) => unknown) => {
return fn(mockPrismaService);
});
});
it("should be defined", () => {
@@ -329,13 +325,12 @@ describe("AdminService", () => {
});
describe("deactivateUser", () => {
it("should set deactivatedAt and invalidate sessions", async () => {
it("should set deactivatedAt on the user", 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);
@@ -346,7 +341,6 @@ 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,22 +192,19 @@ export class AdminService {
throw new BadRequestException(`User ${id} is already deactivated`);
}
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 } },
},
const user = await 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 and sessions invalidated: ${id}`);
this.logger.log(`User deactivated: ${id}`);
return {
id: user.id,

View File

@@ -1,102 +0,0 @@
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

@@ -1,89 +0,0 @@
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

@@ -1,13 +0,0 @@
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

@@ -1,126 +0,0 @@
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

@@ -1,79 +0,0 @@
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

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

View File

@@ -1,10 +0,0 @@
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,7 +27,6 @@ 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";
@@ -101,7 +100,6 @@ import { RlsContextInterceptor } from "./common/interceptors/rls-context.interce
BrainModule,
CronModule,
AgentTasksModule,
AgentMemoryModule,
RunnerJobsModule,
JobEventsModule,
JobStepsModule,

View File

@@ -1,60 +0,0 @@
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

@@ -1,53 +0,0 @@
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

@@ -1,65 +0,0 @@
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 | 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 | — | |
| 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 | — | |
## Budget Summary

View File

@@ -1,64 +0,0 @@
# 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,8 +74,7 @@
"tough-cookie": ">=4.1.3",
"undici": ">=6.23.0",
"rollup": ">=4.59.0",
"serialize-javascript": ">=7.0.3",
"multer": ">=2.1.0"
"serialize-javascript": ">=7.0.3"
}
}
}

21
pnpm-lock.yaml generated
View File

@@ -17,7 +17,6 @@ overrides:
undici: '>=6.23.0'
rollup: '>=4.59.0'
serialize-javascript: '>=7.0.3'
multer: '>=2.1.0'
importers:
@@ -1604,7 +1603,6 @@ 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==}
@@ -5807,6 +5805,10 @@ 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'}
@@ -5835,8 +5837,8 @@ packages:
msgpackr@1.11.5:
resolution: {integrity: sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA==}
multer@2.1.0:
resolution: {integrity: sha512-TBm6j41rxNohqawsxlsWsNNh/VdV4QFXcBvRcPhXaA05EZ79z0qJ2bQFpync6JBoHTeNY5Q1JpG7AlTjdlfAEA==}
multer@2.0.2:
resolution: {integrity: sha512-u7f2xaZ/UG8oLXHvtF/oWTRvT44p9ecwBBqTwgJVq0+4BW1g8OW01TyMEGWBHbyMOYVHXslaut7qEQ1meATXgw==}
engines: {node: '>= 10.16.0'}
mute-stream@2.0.0:
@@ -8840,7 +8842,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.1.0
multer: 2.0.2
path-to-regexp: 8.3.0
tslib: 2.8.1
transitivePeerDependencies:
@@ -13389,6 +13391,10 @@ snapshots:
mkdirp-classic@0.5.3: {}
mkdirp@0.5.6:
dependencies:
minimist: 1.2.8
mkdirp@3.0.1: {}
mlly@1.8.0:
@@ -13430,12 +13436,15 @@ snapshots:
optionalDependencies:
msgpackr-extract: 3.0.3
multer@2.1.0:
multer@2.0.2:
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: {}