Audit chat message mutations
This commit is contained in:
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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>>;
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user