Audit chat message mutations

This commit is contained in:
ZacharyZhang-NY committed 2026-05-23 21:59:16 -04:00
1 parent c59be97b79
commit e78e9a8aa0
8 files changed
+232 -1

No files matched your search

@@ -104,6 +104,8 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.grantRole, (_event, input) => mutate(() => identityAccess.grantRole(input)));
ipcMain.handle(IPC_CHANNELS.createChatSpace, (_event, input) => mutate(() => chat.createSpace(input)));
ipcMain.handle(IPC_CHANNELS.sendMessage, (_event, input) => mutate(() => chat.sendMessage(input)));
ipcMain.handle(IPC_CHANNELS.editMessage, (_event, input) => mutate(() => chat.editMessage(input)));
ipcMain.handle(IPC_CHANNELS.deleteMessage, (_event, input) => mutate(() => chat.deleteMessage(input)));
ipcMain.handle(IPC_CHANNELS.markChatSpaceRead, (_event, input) => mutate(() => chat.markSpaceRead(input)));
ipcMain.handle(IPC_CHANNELS.saveProviderCredential, (_event, input) => mutate(() => providers.saveCredential(input)));
ipcMain.handle(IPC_CHANNELS.revokeProviderCredential, (_event, input) => mutate(() => providers.revokeCredential(input)));
@@ -0,0 +1,133 @@
import { createHash } from "node:crypto";
import type { DatabaseSync } from "node:sqlite";
import type { DeleteMessageInput, EditMessageInput, SendMessageInput } from "../../shared/commands";
import { buildChatMessageStructure } from "./chat-message-structure";
import { recordChatMentions, resolveChatMentions } from "./chat-mentions";
import { AuditService } from "./audit-service";
interface MessageMutationRow {
id: string;
org_id: string;
space_id: string;
sender_type: SendMessageInput["senderType"];
sender_id: string | null;
body_md: string;
deleted_at: string | null;
}
interface PrincipalRef {
type: "employee" | "agent";
id: string;
}
export class ChatMessageMutationService {
private readonly audit: AuditService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
}
editMessage(input: EditMessageInput): void {
const body = input.bodyMd.trim();
if (!body) throw new Error("Message body is required.");
if (parseSlashInvocation(body)) throw new Error("Slash command messages are immutable.");
const message = this.requireMutableMessage(input.orgId, input.messageId);
if (parseSlashInvocation(message.body_md)) throw new Error("Slash command messages are immutable.");
this.assertMessageMutationActor(input, message);
const attachmentFileIds = this.messageAttachmentFileIds(message.id);
const mentions = resolveChatMentions(this.db, input.orgId, message.space_id, body);
const structuredBody = buildChatMessageStructure({ slash: null, mentions, attachmentFileIds });
const now = new Date().toISOString();
this.db.prepare(`
update chat_messages
set body_md = ?, body_structured_json = ?, edited_at = ?
where id = ? and org_id = ? and deleted_at is null
`).run(body, structuredBody, now, message.id, input.orgId);
this.db.prepare("delete from chat_mentions where message_id = ?").run(message.id);
recordChatMentions(this.db, message.id, mentions);
this.audit.record({
orgId: input.orgId,
entityType: "chat_message",
entityId: message.id,
actorType: input.actorType,
actorId: input.actorId,
action: "edited",
payload: { editedAt: now, previousBodySha256: sha256(message.body_md), bodySha256: sha256(body) },
});
}
deleteMessage(input: DeleteMessageInput): void {
const message = this.requireMutableMessage(input.orgId, input.messageId);
this.assertMessageMutationActor(input, message);
const now = new Date().toISOString();
this.db.prepare("update chat_messages set deleted_at = ? where id = ? and org_id = ? and deleted_at is null")
.run(now, message.id, input.orgId);
this.audit.record({
orgId: input.orgId,
entityType: "chat_message",
entityId: message.id,
actorType: input.actorType,
actorId: input.actorId,
action: "deleted",
payload: { deletedAt: now, bodySha256: sha256(message.body_md) },
});
}
private requireMutableMessage(orgId: string, messageId: string): MessageMutationRow {
const message = this.db.prepare(`
select id, org_id, space_id, sender_type, sender_id, body_md, deleted_at
from chat_messages
where id = ? and org_id = ?
`).get(messageId, orgId) as MessageMutationRow | undefined;
if (!message || message.deleted_at) throw new Error("Mutable chat message was not found.");
return message;
}
private assertMessageMutationActor(input: EditMessageInput | DeleteMessageInput, message: MessageMutationRow): void {
const actor = this.requireSender(input.orgId, input.actorType, input.actorId);
if (actor) this.requireMembership(message.space_id, actor);
if (input.actorType === "system") {
if (message.sender_type === "system") return;
throw new Error("Message actor is not allowed to mutate this message.");
}
if (input.actorType === "board") return;
if (message.sender_type === input.actorType && message.sender_id === input.actorId) return;
throw new Error("Message actor is not allowed to mutate this message.");
}
private requireSender(orgId: string, senderType: SendMessageInput["senderType"], senderId: string | null): PrincipalRef | null {
if (senderType === "system") return null;
if (senderType === "employee") return this.requirePrincipal("employees", orgId, senderId, "Employee actor");
if (senderType === "agent") return this.requirePrincipal("agents", orgId, senderId, "Agent actor");
return this.requirePrincipal("employees", orgId, senderId, "Board actor");
}
private requirePrincipal(table: "employees" | "agents", orgId: string, id: string | null, label: string): PrincipalRef {
if (!id) throw new Error(`${label} is required.`);
const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`).get(id, orgId) as { id: string } | undefined;
if (!row) throw new Error(`${label} was not found.`);
return { type: table === "employees" ? "employee" : "agent", id };
}
private requireMembership(spaceId: string, principal: PrincipalRef): void {
const row = this.db.prepare("select space_id from chat_memberships where space_id = ? and principal_type = ? and principal_id = ?")
.get(spaceId, principal.type, principal.id) as { space_id: string } | undefined;
if (!row) throw new Error("Chat membership is required.");
}
private messageAttachmentFileIds(messageId: string): string[] {
const rows = this.db.prepare("select file_id from chat_attachments where message_id = ? order by file_id asc")
.all(messageId) as Array<{ file_id: string }>;
return rows.map((row) => row.file_id);
}
}
function parseSlashInvocation(body: string): { command: string; args: string } | null {
const match = body.match(/^\/([a-z][a-z0-9_-]*)(?:\s+([\s\S]*))?$/i);
if (!match) return null;
return { command: match[1].toLowerCase(), args: match[2] ?? "" };
}
function sha256(value: string): string {
return createHash("sha256").update(value).digest("hex");
}
@@ -1,5 +1,5 @@
import type { DatabaseSync } from "node:sqlite";
import type { CreateChatSpaceInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
import type { CreateChatSpaceInput, DeleteMessageInput, EditMessageInput, MarkChatSpaceReadInput, SendMessageInput } from "../../shared/commands";
import {
CHAT_THREAD_BOARD_STATE,
FILE_SCAN_STATE,
@@ -11,6 +11,7 @@ import { ApprovalService } from "./approval-service";
import { AuditService } from "./audit-service";
import { BoardInterventionService, parseBoardCommand } from "./board-intervention-service";
import { buildChatMessageStructure } from "./chat-message-structure";
import { ChatMessageMutationService } from "./chat-message-mutation-service";
import { recordChatMentions, resolveChatMentions, type ResolvedMention } from "./chat-mentions";
import { FileHandoffService, type FileHandoffAttachment } from "./file-handoff-service";
@@ -52,12 +53,14 @@ export class ChatService {
private readonly approvals: ApprovalService;
private readonly boardInterventions: BoardInterventionService;
private readonly handoffs: FileHandoffService;
private readonly messageMutations: ChatMessageMutationService;
constructor(private readonly db: DatabaseSync) {
this.audit = new AuditService(db);
this.approvals = new ApprovalService(db);
this.boardInterventions = new BoardInterventionService(db);
this.handoffs = new FileHandoffService(db);
this.messageMutations = new ChatMessageMutationService(db);
}
createSpace(input: CreateChatSpaceInput): string {
@@ -197,6 +200,14 @@ export class ChatService {
`).run(input.orgId, input.employeeId, input.spaceId, latest?.id ?? null, now);
}
editMessage(input: EditMessageInput): void {
this.messageMutations.editMessage(input);
}
deleteMessage(input: DeleteMessageInput): void {
this.messageMutations.deleteMessage(input);
}
private executeSlashCommand(
input: SendMessageInput,
space: SpaceRow,
+2
View File
@@ -22,6 +22,8 @@ const api: DesktopApi = {
grantRole: (input) => invoke(IPC_CHANNELS.grantRole, input),
createChatSpace: (input) => invoke(IPC_CHANNELS.createChatSpace, input),
sendMessage: (input) => invoke(IPC_CHANNELS.sendMessage, input),
editMessage: (input) => invoke(IPC_CHANNELS.editMessage, input),
deleteMessage: (input) => invoke(IPC_CHANNELS.deleteMessage, input),
markChatSpaceRead: (input) => invoke(IPC_CHANNELS.markChatSpaceRead, input),
saveProviderCredential: (input) => invoke(IPC_CHANNELS.saveProviderCredential, input),
revokeProviderCredential: (input) => invoke(IPC_CHANNELS.revokeProviderCredential, input),
+15
View File
@@ -123,6 +123,21 @@ export interface SendMessageInput {
attachmentFileIds?: string[];
}
export interface EditMessageInput {
orgId: string;
messageId: string;
actorType: SendMessageInput["senderType"];
actorId: string | null;
bodyMd: string;
}
export interface DeleteMessageInput {
orgId: string;
messageId: string;
actorType: SendMessageInput["senderType"];
actorId: string | null;
}
export interface CreateChatSpaceInput {
orgId: string;
spaceType: "company" | "department" | "project" | "direct";
@@ -22,7 +22,9 @@ import type {
CreateSocialAccountInput,
CreateTaskInput,
DecideApprovalInput,
DeleteMessageInput,
DeleteOrganizationInput,
EditMessageInput,
ImportFileInput,
ImportSkillDirectoryInput,
GrantRoleInput,
@@ -72,6 +74,8 @@ export interface DesktopApi {
grantRole(input: GrantRoleInput): Promise<DesktopResult<AppSnapshot>>;
createChatSpace(input: CreateChatSpaceInput): Promise<DesktopResult<AppSnapshot>>;
sendMessage(input: SendMessageInput): Promise<DesktopResult<AppSnapshot>>;
editMessage(input: EditMessageInput): Promise<DesktopResult<AppSnapshot>>;
deleteMessage(input: DeleteMessageInput): Promise<DesktopResult<AppSnapshot>>;
markChatSpaceRead(input: MarkChatSpaceReadInput): Promise<DesktopResult<AppSnapshot>>;
saveProviderCredential(input: SaveProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
revokeProviderCredential(input: RevokeProviderCredentialInput): Promise<DesktopResult<AppSnapshot>>;
+2
View File
@@ -14,6 +14,8 @@ export const IPC_CHANNELS = {
grantRole: "agentcompany:grant-role",
createChatSpace: "agentcompany:create-chat-space",
sendMessage: "agentcompany:send-message",
editMessage: "agentcompany:edit-message",
deleteMessage: "agentcompany:delete-message",
markChatSpaceRead: "agentcompany:mark-chat-space-read",
saveProviderCredential: "agentcompany:save-provider-credential",
revokeProviderCredential: "agentcompany:revoke-provider-credential",
@@ -78,6 +78,68 @@ describe("chat governance", () => {
connection.close();
});
test("audits chat message edits and deletes", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);
const chat = new ChatService(connection.db);
const snapshot = new SnapshotService(connection.db);
let orgId = "";
let employeeId = "";
let agentId = "";
let spaceId = "";
let messageId = "";
connection.transaction(() => {
orgId = organization.createOrganization({
organizationName: "Acme Operations",
employeeName: "Dana Lee",
mission: "Coordinate agents with auditable approvals.",
goal: "Ship a verified control plane.",
});
employeeId = (connection.db.prepare("select id from employees where org_id = ?").get(orgId) as { id: string }).id;
agentId = organization.createAgent({
orgId,
name: "Ada Lovelace",
role: "Reviewer",
department: "Engineering",
modelProviderId: null,
modelId: null,
budgetCents: 10000,
managerAgentId: null,
});
spaceId = chat.createSpace({ orgId, spaceType: "project", name: "release", topic: "Release coordination", visibility: "workspace" });
messageId = chat.sendMessage({
orgId,
spaceId,
threadId: null,
senderType: "employee",
senderId: employeeId,
bodyMd: "Initial launch note @Ada-Lovelace",
attachmentFileIds: [],
});
chat.editMessage({
orgId,
messageId,
actorType: "employee",
actorId: employeeId,
bodyMd: `Updated launch note @${agentId}`,
});
chat.deleteMessage({ orgId, messageId, actorType: "employee", actorId: employeeId });
});
const state = snapshot.load();
const message = state.chatMessages.find((item) => item.id === messageId);
expect(message).toMatchObject({ bodyMd: `Updated launch note @${agentId}` });
expect(message?.editedAt).toBeTruthy();
expect(message?.deletedAt).toBeTruthy();
expect(state.chatMentions).toEqual([expect.objectContaining({ messageId, targetType: "agent", targetId: agentId })]);
expect(state.auditEvents).toEqual(expect.arrayContaining([
expect.objectContaining({ entityType: "chat_message", entityId: messageId, action: "edited" }),
expect.objectContaining({ entityType: "chat_message", entityId: messageId, action: "deleted" }),
]));
connection.close();
});
test("records board interventions and enforces thread control", () => {
const connection = createConnection();
const organization = new OrganizationService(connection.db);