Add file handoff audit records
This commit is contained in:
1 parent
babbf07cb2
commit
4dbd8997c0
10 files changed
+298
-100
No files matched your search
@@ -0,0 +1,65 @@
|
|||||||
|
export const fileHandoffRecordsSchema = `
|
||||||
|
create table if not exists file_handoff_records (
|
||||||
|
id text primary key,
|
||||||
|
org_id text not null references organizations(id) on delete cascade,
|
||||||
|
message_id text not null references chat_messages(id) on delete cascade,
|
||||||
|
file_id text not null references file_objects(id) on delete cascade,
|
||||||
|
source_principal_type text not null,
|
||||||
|
source_principal_id text,
|
||||||
|
target_principal_type text not null,
|
||||||
|
target_principal_id text not null,
|
||||||
|
transfer_type text not null,
|
||||||
|
checksum text not null,
|
||||||
|
scan_state text not null,
|
||||||
|
scan_labels_json text not null,
|
||||||
|
policy_decision text not null,
|
||||||
|
object_ref text not null,
|
||||||
|
created_at text not null,
|
||||||
|
unique(message_id, file_id, target_principal_type, target_principal_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
insert into file_handoff_records(
|
||||||
|
id, org_id, message_id, file_id, source_principal_type, source_principal_id,
|
||||||
|
target_principal_type, target_principal_id, transfer_type, checksum, scan_state,
|
||||||
|
scan_labels_json, policy_decision, object_ref, created_at
|
||||||
|
)
|
||||||
|
select
|
||||||
|
'fhr_' || lower(hex(randomblob(16))),
|
||||||
|
messages.org_id,
|
||||||
|
attachments.message_id,
|
||||||
|
attachments.file_id,
|
||||||
|
messages.sender_type,
|
||||||
|
messages.sender_id,
|
||||||
|
coalesce(grants.granted_to_type, 'org_group'),
|
||||||
|
coalesce(grants.granted_to_id, messages.org_id || ':workspace'),
|
||||||
|
attachments.transfer_type,
|
||||||
|
attachments.checksum,
|
||||||
|
files.scan_state,
|
||||||
|
files.scan_labels_json,
|
||||||
|
case
|
||||||
|
when files.scan_state = 'approved' then 'approved_for_handoff'
|
||||||
|
else 'blocked_by_scan'
|
||||||
|
end,
|
||||||
|
files.storage_path,
|
||||||
|
messages.created_at
|
||||||
|
from chat_attachments attachments
|
||||||
|
join chat_messages messages on messages.id = attachments.message_id
|
||||||
|
join file_objects files on files.id = attachments.file_id
|
||||||
|
left join file_access_grants grants
|
||||||
|
on grants.file_id = attachments.file_id
|
||||||
|
and grants.source_message_id = attachments.message_id
|
||||||
|
where not exists (
|
||||||
|
select 1 from file_handoff_records records
|
||||||
|
where records.message_id = attachments.message_id
|
||||||
|
and records.file_id = attachments.file_id
|
||||||
|
and records.target_principal_type = coalesce(grants.granted_to_type, 'org_group')
|
||||||
|
and records.target_principal_id = coalesce(grants.granted_to_id, messages.org_id || ':workspace')
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_file_handoff_org_created on file_handoff_records(org_id, created_at);
|
||||||
|
create index if not exists idx_file_handoff_file on file_handoff_records(org_id, file_id);
|
||||||
|
create index if not exists idx_file_handoff_target on file_handoff_records(org_id, target_principal_type, target_principal_id);
|
||||||
|
|
||||||
|
insert into schema_migrations(version, name, applied_at)
|
||||||
|
values (29, 'file_handoff_records', datetime('now'));
|
||||||
|
`;
|
||||||
@@ -27,6 +27,7 @@ import { chatMessageDeliverySchema } from "./migrations/025-chat-message-deliver
|
|||||||
import { browserPlatformGovernanceSchema } from "./migrations/026-browser-platform-governance";
|
import { browserPlatformGovernanceSchema } from "./migrations/026-browser-platform-governance";
|
||||||
import { socialAuditRecordsSchema } from "./migrations/027-social-audit-records";
|
import { socialAuditRecordsSchema } from "./migrations/027-social-audit-records";
|
||||||
import { migrationCutoverJournalSchema } from "./migrations/028-migration-cutover-journal";
|
import { migrationCutoverJournalSchema } from "./migrations/028-migration-cutover-journal";
|
||||||
|
import { fileHandoffRecordsSchema } from "./migrations/029-file-handoff-records";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -63,6 +64,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ version: 26, name: "browser_platform_governance", sql: browserPlatformGovernanceSchema },
|
{ version: 26, name: "browser_platform_governance", sql: browserPlatformGovernanceSchema },
|
||||||
{ version: 27, name: "social_audit_records", sql: socialAuditRecordsSchema },
|
{ version: 27, name: "social_audit_records", sql: socialAuditRecordsSchema },
|
||||||
{ version: 28, name: "migration_cutover_journal", sql: migrationCutoverJournalSchema },
|
{ version: 28, name: "migration_cutover_journal", sql: migrationCutoverJournalSchema },
|
||||||
|
{ version: 29, name: "file_handoff_records", sql: fileHandoffRecordsSchema },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||||
|
|||||||
@@ -4,27 +4,26 @@ import {
|
|||||||
CHAT_THREAD_BOARD_STATE,
|
CHAT_THREAD_BOARD_STATE,
|
||||||
FILE_SCAN_STATE,
|
FILE_SCAN_STATE,
|
||||||
RISK_LEVEL,
|
RISK_LEVEL,
|
||||||
|
type FileScanState,
|
||||||
} from "../../shared/domain";
|
} from "../../shared/domain";
|
||||||
import { createId } from "../../shared/ids";
|
import { createId } from "../../shared/ids";
|
||||||
import { ApprovalService } from "./approval-service";
|
import { ApprovalService } from "./approval-service";
|
||||||
import { AuditService } from "./audit-service";
|
import { AuditService } from "./audit-service";
|
||||||
import { BoardInterventionService, parseBoardCommand } from "./board-intervention-service";
|
import { BoardInterventionService, parseBoardCommand } from "./board-intervention-service";
|
||||||
import { buildChatMessageStructure } from "./chat-message-structure";
|
import { buildChatMessageStructure } from "./chat-message-structure";
|
||||||
import { recordChatMentions, resolveChatMentions, type MentionTarget, type ResolvedMention } from "./chat-mentions";
|
import { recordChatMentions, resolveChatMentions, type ResolvedMention } from "./chat-mentions";
|
||||||
|
import { FileHandoffService, type FileHandoffAttachment } from "./file-handoff-service";
|
||||||
|
|
||||||
const SPACE_TYPES = new Set<CreateChatSpaceInput["spaceType"]>(["company", "department", "project", "direct"]);
|
const SPACE_TYPES = new Set<CreateChatSpaceInput["spaceType"]>(["company", "department", "project", "direct"]);
|
||||||
const SPACE_VISIBILITIES = new Set<CreateChatSpaceInput["visibility"]>(["workspace", "restricted"]);
|
const SPACE_VISIBILITIES = new Set<CreateChatSpaceInput["visibility"]>(["workspace", "restricted"]);
|
||||||
const RETENTION_POLICIES = new Set(["standard_365d", "restricted_365d"]);
|
const RETENTION_POLICIES = new Set(["standard_365d", "restricted_365d"]);
|
||||||
|
|
||||||
interface AttachmentCandidate {
|
|
||||||
fileId: string;
|
|
||||||
checksum: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface FileAttachmentRow {
|
interface FileAttachmentRow {
|
||||||
id: string;
|
id: string;
|
||||||
sha256: string;
|
sha256: string;
|
||||||
scan_state: string;
|
scan_state: FileScanState;
|
||||||
|
scan_labels_json: string;
|
||||||
|
storage_path: string;
|
||||||
owner_type: string;
|
owner_type: string;
|
||||||
owner_id: string | null;
|
owner_id: string | null;
|
||||||
}
|
}
|
||||||
@@ -52,11 +51,13 @@ export class ChatService {
|
|||||||
private readonly audit: AuditService;
|
private readonly audit: AuditService;
|
||||||
private readonly approvals: ApprovalService;
|
private readonly approvals: ApprovalService;
|
||||||
private readonly boardInterventions: BoardInterventionService;
|
private readonly boardInterventions: BoardInterventionService;
|
||||||
|
private readonly handoffs: FileHandoffService;
|
||||||
|
|
||||||
constructor(private readonly db: DatabaseSync) {
|
constructor(private readonly db: DatabaseSync) {
|
||||||
this.audit = new AuditService(db);
|
this.audit = new AuditService(db);
|
||||||
this.approvals = new ApprovalService(db);
|
this.approvals = new ApprovalService(db);
|
||||||
this.boardInterventions = new BoardInterventionService(db);
|
this.boardInterventions = new BoardInterventionService(db);
|
||||||
|
this.handoffs = new FileHandoffService(db);
|
||||||
}
|
}
|
||||||
|
|
||||||
createSpace(input: CreateChatSpaceInput): string {
|
createSpace(input: CreateChatSpaceInput): string {
|
||||||
@@ -141,8 +142,17 @@ export class ChatService {
|
|||||||
recordChatMentions(this.db, id, mentions);
|
recordChatMentions(this.db, id, mentions);
|
||||||
const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, mentions, slash, id, threadId, now) : null;
|
const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, mentions, slash, id, threadId, now) : null;
|
||||||
if (slash) this.recordSlashInvocation(id, slash, slashResultRef, now);
|
if (slash) this.recordSlashInvocation(id, slash, slashResultRef, now);
|
||||||
this.recordAttachments(id, attachments);
|
this.handoffs.record({
|
||||||
this.recordAttachmentGrants(input, id, threadId, attachments, mentions, now);
|
orgId: input.orgId,
|
||||||
|
spaceId: input.spaceId,
|
||||||
|
messageId: id,
|
||||||
|
threadId,
|
||||||
|
senderType: input.senderType,
|
||||||
|
senderId: input.senderId,
|
||||||
|
attachments,
|
||||||
|
mentions,
|
||||||
|
now,
|
||||||
|
});
|
||||||
if (input.senderId) {
|
if (input.senderId) {
|
||||||
const presenceStatus = slash?.command === "escalate" ? "waiting_approval" : "active";
|
const presenceStatus = slash?.command === "escalate" ? "waiting_approval" : "active";
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
@@ -229,62 +239,6 @@ export class ChatService {
|
|||||||
`).run(createId("slash"), messageId, slash.command, JSON.stringify({ raw: slash.args }), resultRef, now);
|
`).run(createId("slash"), messageId, slash.command, JSON.stringify({ raw: slash.args }), resultRef, now);
|
||||||
}
|
}
|
||||||
|
|
||||||
private recordAttachments(messageId: string, attachments: AttachmentCandidate[]): void {
|
|
||||||
for (const file of attachments) {
|
|
||||||
this.db.prepare(`
|
|
||||||
insert into chat_attachments(message_id, file_id, transfer_type, checksum, preview_key)
|
|
||||||
values (?, ?, ?, ?, ?)
|
|
||||||
`).run(messageId, file.fileId, "handoff", file.checksum, null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private recordAttachmentGrants(
|
|
||||||
input: SendMessageInput,
|
|
||||||
messageId: string,
|
|
||||||
threadId: string,
|
|
||||||
attachments: AttachmentCandidate[],
|
|
||||||
mentions: ResolvedMention[],
|
|
||||||
now: string,
|
|
||||||
): void {
|
|
||||||
if (attachments.length === 0) return;
|
|
||||||
const recipients = this.attachmentGrantRecipients(input.orgId, input.spaceId, mentions);
|
|
||||||
const statement = this.db.prepare(`
|
|
||||||
insert or ignore into file_access_grants(
|
|
||||||
id, org_id, file_id, granted_to_type, granted_to_id, granted_by_type, granted_by_id,
|
|
||||||
source_message_id, source_thread_id, access_level, created_at
|
|
||||||
)
|
|
||||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
||||||
`);
|
|
||||||
for (const attachment of attachments) {
|
|
||||||
for (const recipient of recipients) {
|
|
||||||
const result = statement.run(
|
|
||||||
createId("fgr"),
|
|
||||||
input.orgId,
|
|
||||||
attachment.fileId,
|
|
||||||
recipient.type,
|
|
||||||
recipient.id,
|
|
||||||
input.senderType,
|
|
||||||
input.senderId,
|
|
||||||
messageId,
|
|
||||||
threadId,
|
|
||||||
"read",
|
|
||||||
now,
|
|
||||||
);
|
|
||||||
if (result.changes > 0) {
|
|
||||||
this.audit.record({
|
|
||||||
orgId: input.orgId,
|
|
||||||
entityType: "file_object",
|
|
||||||
entityId: attachment.fileId,
|
|
||||||
actorType: input.senderType,
|
|
||||||
actorId: input.senderId,
|
|
||||||
action: "access_granted",
|
|
||||||
payload: { messageId, threadId, grantedToType: recipient.type, grantedToId: recipient.id },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireSpace(orgId: string, spaceId: string): SpaceRow {
|
private requireSpace(orgId: string, spaceId: string): SpaceRow {
|
||||||
const space = this.db.prepare("select id, org_id, visibility, space_type from chat_spaces where id = ? and org_id = ?")
|
const space = this.db.prepare("select id, org_id, visibility, space_type from chat_spaces where id = ? and org_id = ?")
|
||||||
.get(spaceId, orgId) as SpaceRow | undefined;
|
.get(spaceId, orgId) as SpaceRow | undefined;
|
||||||
@@ -331,18 +285,24 @@ export class ChatService {
|
|||||||
return thread;
|
return thread;
|
||||||
}
|
}
|
||||||
|
|
||||||
private requireAttachments(orgId: string, fileIds: string[], sender: PrincipalRef | null): AttachmentCandidate[] {
|
private requireAttachments(orgId: string, fileIds: string[], sender: PrincipalRef | null): FileHandoffAttachment[] {
|
||||||
if (new Set(fileIds).size !== fileIds.length) throw new Error("Attachment file ids must be unique.");
|
if (new Set(fileIds).size !== fileIds.length) throw new Error("Attachment file ids must be unique.");
|
||||||
return fileIds.map((fileId) => {
|
return fileIds.map((fileId) => {
|
||||||
const file = this.db.prepare(`
|
const file = this.db.prepare(`
|
||||||
select id, sha256, scan_state, owner_type, owner_id
|
select id, sha256, scan_state, scan_labels_json, storage_path, owner_type, owner_id
|
||||||
from file_objects
|
from file_objects
|
||||||
where id = ? and org_id = ?
|
where id = ? and org_id = ?
|
||||||
`).get(fileId, orgId) as FileAttachmentRow | undefined;
|
`).get(fileId, orgId) as FileAttachmentRow | undefined;
|
||||||
if (!file) throw new Error("Attachment file was not found.");
|
if (!file) throw new Error("Attachment file was not found.");
|
||||||
if (file.scan_state !== FILE_SCAN_STATE.APPROVED) throw new Error("Attachment file is not approved for handoff.");
|
if (file.scan_state !== FILE_SCAN_STATE.APPROVED) throw new Error("Attachment file is not approved for handoff.");
|
||||||
if (!this.senderCanReadFile(orgId, file, sender)) throw new Error("Attachment file access is required.");
|
if (!this.senderCanReadFile(orgId, file, sender)) throw new Error("Attachment file access is required.");
|
||||||
return { fileId, checksum: file.sha256 };
|
return {
|
||||||
|
fileId,
|
||||||
|
checksum: file.sha256,
|
||||||
|
scanState: file.scan_state,
|
||||||
|
scanLabelsJson: file.scan_labels_json,
|
||||||
|
objectRef: file.storage_path,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -364,35 +324,6 @@ export class ChatService {
|
|||||||
return Boolean(grant);
|
return Boolean(grant);
|
||||||
}
|
}
|
||||||
|
|
||||||
private attachmentGrantRecipients(orgId: string, spaceId: string, mentions: ResolvedMention[]): MentionTarget[] {
|
|
||||||
const source = mentions.length > 0 ? mentions.map((mention) => mention.target) : this.spaceRecipients(spaceId);
|
|
||||||
const seen = new Set<string>();
|
|
||||||
const recipients: MentionTarget[] = [];
|
|
||||||
for (const target of source) {
|
|
||||||
const key = `${target.type}:${target.id}`;
|
|
||||||
if (seen.has(key)) continue;
|
|
||||||
seen.add(key);
|
|
||||||
recipients.push(target);
|
|
||||||
}
|
|
||||||
if (recipients.length === 0) throw new Error("Attachment grant recipient is required.");
|
|
||||||
return recipients;
|
|
||||||
}
|
|
||||||
|
|
||||||
private spaceRecipients(spaceId: string): MentionTarget[] {
|
|
||||||
const rows = this.db.prepare(`
|
|
||||||
select principal_type, principal_id
|
|
||||||
from chat_memberships
|
|
||||||
where space_id = ?
|
|
||||||
order by principal_type asc, principal_id asc
|
|
||||||
`).all(spaceId) as Array<{ principal_type: string; principal_id: string }>;
|
|
||||||
return rows.map((row) => {
|
|
||||||
if (row.principal_type === "employee" || row.principal_type === "agent") {
|
|
||||||
return { type: row.principal_type, id: row.principal_id };
|
|
||||||
}
|
|
||||||
throw new Error("Chat membership principal type is unsupported.");
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireMembership(spaceId: string, principal: PrincipalRef): void {
|
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 = ?")
|
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;
|
.get(spaceId, principal.type, principal.id) as { space_id: string } | undefined;
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import type { SendMessageInput } from "../../shared/commands";
|
||||||
|
import type { FileScanState } from "../../shared/domain";
|
||||||
|
import { createId } from "../../shared/ids";
|
||||||
|
import { AuditService } from "./audit-service";
|
||||||
|
import type { MentionTarget, ResolvedMention } from "./chat-mentions";
|
||||||
|
|
||||||
|
export interface FileHandoffAttachment {
|
||||||
|
fileId: string;
|
||||||
|
checksum: string;
|
||||||
|
scanState: FileScanState;
|
||||||
|
scanLabelsJson: string;
|
||||||
|
objectRef: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RecordFileHandoffsInput {
|
||||||
|
orgId: string;
|
||||||
|
spaceId: string;
|
||||||
|
messageId: string;
|
||||||
|
threadId: string;
|
||||||
|
senderType: SendMessageInput["senderType"];
|
||||||
|
senderId: string | null;
|
||||||
|
attachments: FileHandoffAttachment[];
|
||||||
|
mentions: ResolvedMention[];
|
||||||
|
now: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class FileHandoffService {
|
||||||
|
private readonly audit: AuditService;
|
||||||
|
|
||||||
|
constructor(private readonly db: DatabaseSync) {
|
||||||
|
this.audit = new AuditService(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
record(input: RecordFileHandoffsInput): void {
|
||||||
|
if (input.attachments.length === 0) return;
|
||||||
|
const recipients = this.attachmentGrantRecipients(input.orgId, input.spaceId, input.mentions);
|
||||||
|
const attachmentStatement = this.db.prepare(`
|
||||||
|
insert into chat_attachments(message_id, file_id, transfer_type, checksum, preview_key)
|
||||||
|
values (?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const grantStatement = this.db.prepare(`
|
||||||
|
insert or ignore into file_access_grants(
|
||||||
|
id, org_id, file_id, granted_to_type, granted_to_id, granted_by_type, granted_by_id,
|
||||||
|
source_message_id, source_thread_id, access_level, created_at
|
||||||
|
)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
const handoffStatement = this.db.prepare(`
|
||||||
|
insert or ignore into file_handoff_records(
|
||||||
|
id, org_id, message_id, file_id, source_principal_type, source_principal_id,
|
||||||
|
target_principal_type, target_principal_id, transfer_type, checksum, scan_state,
|
||||||
|
scan_labels_json, policy_decision, object_ref, created_at
|
||||||
|
)
|
||||||
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`);
|
||||||
|
for (const attachment of input.attachments) {
|
||||||
|
attachmentStatement.run(input.messageId, attachment.fileId, "handoff", attachment.checksum, null);
|
||||||
|
for (const recipient of recipients) {
|
||||||
|
const grant = grantStatement.run(
|
||||||
|
createId("fgr"),
|
||||||
|
input.orgId,
|
||||||
|
attachment.fileId,
|
||||||
|
recipient.type,
|
||||||
|
recipient.id,
|
||||||
|
input.senderType,
|
||||||
|
input.senderId,
|
||||||
|
input.messageId,
|
||||||
|
input.threadId,
|
||||||
|
"read",
|
||||||
|
input.now,
|
||||||
|
);
|
||||||
|
handoffStatement.run(
|
||||||
|
createId("fhr"),
|
||||||
|
input.orgId,
|
||||||
|
input.messageId,
|
||||||
|
attachment.fileId,
|
||||||
|
input.senderType,
|
||||||
|
input.senderId,
|
||||||
|
recipient.type,
|
||||||
|
recipient.id,
|
||||||
|
"handoff",
|
||||||
|
attachment.checksum,
|
||||||
|
attachment.scanState,
|
||||||
|
attachment.scanLabelsJson,
|
||||||
|
"approved_for_handoff",
|
||||||
|
attachment.objectRef,
|
||||||
|
input.now,
|
||||||
|
);
|
||||||
|
if (grant.changes > 0) this.recordGrantAudit(input, attachment.fileId, recipient);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private recordGrantAudit(input: RecordFileHandoffsInput, fileId: string, recipient: MentionTarget): void {
|
||||||
|
this.audit.record({
|
||||||
|
orgId: input.orgId,
|
||||||
|
entityType: "file_object",
|
||||||
|
entityId: fileId,
|
||||||
|
actorType: input.senderType,
|
||||||
|
actorId: input.senderId,
|
||||||
|
action: "access_granted",
|
||||||
|
payload: {
|
||||||
|
messageId: input.messageId,
|
||||||
|
threadId: input.threadId,
|
||||||
|
grantedToType: recipient.type,
|
||||||
|
grantedToId: recipient.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private attachmentGrantRecipients(orgId: string, spaceId: string, mentions: ResolvedMention[]): MentionTarget[] {
|
||||||
|
const source = mentions.length > 0 ? mentions.map((mention) => mention.target) : this.spaceRecipients(spaceId);
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const recipients: MentionTarget[] = [];
|
||||||
|
for (const target of source) {
|
||||||
|
const key = `${target.type}:${target.id}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
recipients.push(target);
|
||||||
|
}
|
||||||
|
if (recipients.length === 0) throw new Error("Attachment grant recipient is required.");
|
||||||
|
return recipients.map((recipient) => recipient.type === "org_group" ? recipient : this.requireRecipient(orgId, recipient));
|
||||||
|
}
|
||||||
|
|
||||||
|
private requireRecipient(orgId: string, recipient: MentionTarget): MentionTarget {
|
||||||
|
if (recipient.type === "board") return recipient;
|
||||||
|
const table = recipient.type === "employee" ? "employees" : "agents";
|
||||||
|
const row = this.db.prepare(`select id from ${table} where id = ? and org_id = ?`)
|
||||||
|
.get(recipient.id, orgId) as { id: string } | undefined;
|
||||||
|
if (!row) throw new Error("Attachment grant recipient was not found.");
|
||||||
|
return recipient;
|
||||||
|
}
|
||||||
|
|
||||||
|
private spaceRecipients(spaceId: string): MentionTarget[] {
|
||||||
|
const rows = this.db.prepare(`
|
||||||
|
select principal_type, principal_id
|
||||||
|
from chat_memberships
|
||||||
|
where space_id = ?
|
||||||
|
order by principal_type asc, principal_id asc
|
||||||
|
`).all(spaceId) as Array<{ principal_type: string; principal_id: string }>;
|
||||||
|
return rows.map((row) => {
|
||||||
|
if (row.principal_type === "employee" || row.principal_type === "agent") {
|
||||||
|
return { type: row.principal_type, id: row.principal_id };
|
||||||
|
}
|
||||||
|
throw new Error("Chat membership principal type is unsupported.");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@ const LEGACY_TABLES = [
|
|||||||
"skill_catalog_tags",
|
"skill_catalog_tags",
|
||||||
"file_objects",
|
"file_objects",
|
||||||
"file_access_grants",
|
"file_access_grants",
|
||||||
|
"file_handoff_records",
|
||||||
"social_accounts",
|
"social_accounts",
|
||||||
"social_account_grants",
|
"social_account_grants",
|
||||||
"browser_session_containers",
|
"browser_session_containers",
|
||||||
@@ -217,6 +218,9 @@ const RELATION_CHECKS: RelationCheck[] = [
|
|||||||
{ table: "file_access_grants", column: "file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_file_id" },
|
{ table: "file_access_grants", column: "file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_file_id" },
|
||||||
{ table: "file_access_grants", column: "source_message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_source_message_id" },
|
{ table: "file_access_grants", column: "source_message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_source_message_id" },
|
||||||
{ table: "file_access_grants", column: "source_thread_id", referencedTable: "chat_threads", referencedColumn: "id", reason: "dangling_source_thread_id" },
|
{ table: "file_access_grants", column: "source_thread_id", referencedTable: "chat_threads", referencedColumn: "id", reason: "dangling_source_thread_id" },
|
||||||
|
{ table: "file_handoff_records", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||||
|
{ table: "file_handoff_records", column: "message_id", referencedTable: "chat_messages", referencedColumn: "id", reason: "dangling_message_id" },
|
||||||
|
{ table: "file_handoff_records", column: "file_id", referencedTable: "file_objects", referencedColumn: "id", reason: "dangling_file_id" },
|
||||||
{ table: "social_accounts", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
{ table: "social_accounts", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||||
{ table: "social_accounts", column: "authorized_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_authorized_employee_id" },
|
{ table: "social_accounts", column: "authorized_employee_id", referencedTable: "employees", referencedColumn: "id", reason: "dangling_authorized_employee_id" },
|
||||||
{ table: "social_account_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
{ table: "social_account_grants", column: "org_id", referencedTable: "organizations", referencedColumn: "id", reason: "dangling_org_id" },
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ export class SdlcService {
|
|||||||
{ key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` },
|
{ key: "provider_adapter_coverage", passed: missing.length === 0, value: `${adapterKeys.length}/${catalogKeys.length}` },
|
||||||
{ key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry },
|
{ key: "provider_rate_limit_registry", passed: rateLimitRegistry === 1, value: rateLimitRegistry },
|
||||||
{ key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady },
|
{ key: "provider_runtime_contract", passed: runtimeContractReady, value: runtimeContractReady },
|
||||||
{ key: "schema_version", passed: schemaVersion >= 28, value: schemaVersion },
|
{ key: "schema_version", passed: schemaVersion >= 29, value: schemaVersion },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -103,6 +103,7 @@ export class SnapshotService {
|
|||||||
chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"),
|
chatReadStates: list(this.db, "chat_read_state", "last_read_at desc"),
|
||||||
files,
|
files,
|
||||||
fileAccessGrants: list(this.db, "file_access_grants", "created_at desc"),
|
fileAccessGrants: list(this.db, "file_access_grants", "created_at desc"),
|
||||||
|
fileHandoffRecords: list(this.db, "file_handoff_records", "created_at desc"),
|
||||||
scheduledPosts: list(this.db, "scheduled_posts", "scheduled_for asc"),
|
scheduledPosts: list(this.db, "scheduled_posts", "scheduled_for asc"),
|
||||||
providers,
|
providers,
|
||||||
providerAuthProfiles,
|
providerAuthProfiles,
|
||||||
|
|||||||
@@ -287,6 +287,24 @@ export interface FileAccessGrantRecord {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FileHandoffRecord {
|
||||||
|
id: string;
|
||||||
|
orgId: string;
|
||||||
|
messageId: string;
|
||||||
|
fileId: string;
|
||||||
|
sourcePrincipalType: "employee" | "agent" | "board" | "system";
|
||||||
|
sourcePrincipalId: string | null;
|
||||||
|
targetPrincipalType: "employee" | "agent" | "board" | "org_group";
|
||||||
|
targetPrincipalId: string;
|
||||||
|
transferType: string;
|
||||||
|
checksum: string;
|
||||||
|
scanState: FileScanState;
|
||||||
|
scanLabelsJson: string;
|
||||||
|
policyDecision: "approved_for_handoff" | "blocked_by_scan";
|
||||||
|
objectRef: string;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ScheduledPostRecord {
|
export interface ScheduledPostRecord {
|
||||||
id: string;
|
id: string;
|
||||||
orgId: string;
|
orgId: string;
|
||||||
@@ -432,6 +450,7 @@ export interface AppSnapshot {
|
|||||||
chatReadStates: ChatReadStateRecord[];
|
chatReadStates: ChatReadStateRecord[];
|
||||||
files: FileObjectRecord[];
|
files: FileObjectRecord[];
|
||||||
fileAccessGrants: FileAccessGrantRecord[];
|
fileAccessGrants: FileAccessGrantRecord[];
|
||||||
|
fileHandoffRecords: FileHandoffRecord[];
|
||||||
scheduledPosts: ScheduledPostRecord[];
|
scheduledPosts: ScheduledPostRecord[];
|
||||||
providers: ProviderRecord[];
|
providers: ProviderRecord[];
|
||||||
providerAuthProfiles: ProviderAuthProfileRecord[];
|
providerAuthProfiles: ProviderAuthProfileRecord[];
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ describe("domain services", () => {
|
|||||||
const connection = createConnection();
|
const connection = createConnection();
|
||||||
|
|
||||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28]);
|
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29]);
|
||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -208,6 +208,7 @@ describe("domain services", () => {
|
|||||||
const chat = new ChatService(connection.db);
|
const chat = new ChatService(connection.db);
|
||||||
const snapshot = new SnapshotService(connection.db);
|
const snapshot = new SnapshotService(connection.db);
|
||||||
let orgId = "";
|
let orgId = "";
|
||||||
|
let fileId = "";
|
||||||
|
|
||||||
connection.transaction(() => {
|
connection.transaction(() => {
|
||||||
orgId = organization.createOrganization({
|
orgId = organization.createOrganization({
|
||||||
@@ -225,7 +226,7 @@ describe("domain services", () => {
|
|||||||
});
|
});
|
||||||
const employee = connection.db.prepare("select id from employees where org_id = ?")
|
const employee = connection.db.prepare("select id from employees where org_id = ?")
|
||||||
.get(orgId) as { id: string };
|
.get(orgId) as { id: string };
|
||||||
const fileId = createId("file");
|
fileId = createId("file");
|
||||||
connection.db.prepare(`
|
connection.db.prepare(`
|
||||||
insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at)
|
insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at)
|
||||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
@@ -249,6 +250,15 @@ describe("domain services", () => {
|
|||||||
expect(state.chatAttachments).toHaveLength(1);
|
expect(state.chatAttachments).toHaveLength(1);
|
||||||
expect(state.files[0]?.scanState).toBe("approved");
|
expect(state.files[0]?.scanState).toBe("approved");
|
||||||
expect(state.fileAccessGrants[0]).toMatchObject({ grantedToType: "board", grantedToId: orgId, accessLevel: "read" });
|
expect(state.fileAccessGrants[0]).toMatchObject({ grantedToType: "board", grantedToId: orgId, accessLevel: "read" });
|
||||||
|
expect(state.fileHandoffRecords[0]).toMatchObject({
|
||||||
|
fileId,
|
||||||
|
targetPrincipalType: "board",
|
||||||
|
targetPrincipalId: orgId,
|
||||||
|
checksum: "8f3a",
|
||||||
|
scanState: "approved",
|
||||||
|
policyDecision: "approved_for_handoff",
|
||||||
|
objectRef: "artifacts/runbook.md",
|
||||||
|
});
|
||||||
expect(state.chatMentions[0]).toMatchObject({ targetType: "board", targetId: orgId });
|
expect(state.chatMentions[0]).toMatchObject({ targetType: "board", targetId: orgId });
|
||||||
expect(state.chatSlashInvocations[0]).toMatchObject({ command: "handoff" });
|
expect(state.chatSlashInvocations[0]).toMatchObject({ command: "handoff" });
|
||||||
expect(state.chatPresence).toHaveLength(1);
|
expect(state.chatPresence).toHaveLength(1);
|
||||||
@@ -362,7 +372,20 @@ describe("domain services", () => {
|
|||||||
const state = snapshot.load();
|
const state = snapshot.load();
|
||||||
const benGrant = state.fileAccessGrants.find((grant) => grant.fileId === approvedFileId && grant.grantedToId === benId);
|
const benGrant = state.fileAccessGrants.find((grant) => grant.fileId === approvedFileId && grant.grantedToId === benId);
|
||||||
if (!benGrant) throw new Error("Agent handoff grant was not created.");
|
if (!benGrant) throw new Error("Agent handoff grant was not created.");
|
||||||
|
const benHandoff = state.fileHandoffRecords.find((record) => (
|
||||||
|
record.fileId === approvedFileId && record.sourcePrincipalId === adaId && record.targetPrincipalId === benId
|
||||||
|
));
|
||||||
|
if (!benHandoff) throw new Error("Agent handoff record was not created.");
|
||||||
expect(benGrant).toMatchObject({ grantedToType: "agent", accessLevel: "read" });
|
expect(benGrant).toMatchObject({ grantedToType: "agent", accessLevel: "read" });
|
||||||
|
expect(benHandoff).toMatchObject({
|
||||||
|
sourcePrincipalType: "agent",
|
||||||
|
sourcePrincipalId: adaId,
|
||||||
|
targetPrincipalType: "agent",
|
||||||
|
checksum: "sha-approved",
|
||||||
|
objectRef: "artifacts/release-notes.md",
|
||||||
|
scanState: "approved",
|
||||||
|
policyDecision: "approved_for_handoff",
|
||||||
|
});
|
||||||
expect(state.chatMessages).toHaveLength(2);
|
expect(state.chatMessages).toHaveLength(2);
|
||||||
expect(state.auditEvents.some((event) => event.entityType === "file_object" && event.action === "access_granted")).toBe(true);
|
expect(state.auditEvents.some((event) => event.entityType === "file_object" && event.action === "access_granted")).toBe(true);
|
||||||
connection.close();
|
connection.close();
|
||||||
|
|||||||
@@ -163,6 +163,7 @@ describe("migration service", () => {
|
|||||||
create table skill_catalog_tags(skill_id text, tag text, tag_type text);
|
create table skill_catalog_tags(skill_id text, tag text, tag_type text);
|
||||||
create table file_objects(id text primary key, org_id text, space_id text);
|
create table file_objects(id text primary key, org_id text, space_id text);
|
||||||
create table file_access_grants(id text primary key, org_id text, file_id text, source_message_id text, source_thread_id text);
|
create table file_access_grants(id text primary key, org_id text, file_id text, source_message_id text, source_thread_id text);
|
||||||
|
create table file_handoff_records(id text primary key, org_id text, message_id text, file_id text);
|
||||||
create table social_accounts(id text primary key, org_id text, authorized_employee_id text);
|
create table social_accounts(id text primary key, org_id text, authorized_employee_id text);
|
||||||
create table social_account_grants(id text primary key, org_id text, social_account_id text, employee_id text);
|
create table social_account_grants(id text primary key, org_id text, social_account_id text, employee_id text);
|
||||||
create table browser_session_containers(id text primary key, org_id text, social_account_id text);
|
create table browser_session_containers(id text primary key, org_id text, social_account_id text);
|
||||||
@@ -218,6 +219,7 @@ describe("migration service", () => {
|
|||||||
insert into chat_memberships(space_id, principal_type, principal_id) values ('space-1', 'employee', 'emp-1');
|
insert into chat_memberships(space_id, principal_type, principal_id) values ('space-1', 'employee', 'emp-1');
|
||||||
insert into chat_attachments(message_id, file_id) values ('msg-1', 'file-1');
|
insert into chat_attachments(message_id, file_id) values ('msg-1', 'file-1');
|
||||||
insert into file_access_grants(id, org_id, file_id, source_message_id, source_thread_id) values ('file-grant-1', 'org-1', 'file-1', 'msg-1', 'thread-1');
|
insert into file_access_grants(id, org_id, file_id, source_message_id, source_thread_id) values ('file-grant-1', 'org-1', 'file-1', 'msg-1', 'thread-1');
|
||||||
|
insert into file_handoff_records(id, org_id, message_id, file_id) values ('handoff-1', 'org-1', 'msg-1', 'file-1');
|
||||||
insert into chat_mentions(message_id, target_type, target_id) values ('msg-1', 'agent', 'agent-1');
|
insert into chat_mentions(message_id, target_type, target_id) values ('msg-1', 'agent', 'agent-1');
|
||||||
insert into chat_slash_invocations(id, message_id) values ('slash-1', 'msg-1');
|
insert into chat_slash_invocations(id, message_id) values ('slash-1', 'msg-1');
|
||||||
insert into board_interventions(id, org_id, thread_id, message_id, approval_id) values ('bdi-1', 'org-1', 'thread-1', 'msg-1', 'approval-1');
|
insert into board_interventions(id, org_id, thread_id, message_id, approval_id) values ('bdi-1', 'org-1', 'thread-1', 'msg-1', 'approval-1');
|
||||||
@@ -283,6 +285,7 @@ describe("migration service", () => {
|
|||||||
expect(body.tableCounts.chat_read_state).toBe(1);
|
expect(body.tableCounts.chat_read_state).toBe(1);
|
||||||
expect(body.tableCounts.board_interventions).toBe(1);
|
expect(body.tableCounts.board_interventions).toBe(1);
|
||||||
expect(body.tableCounts.file_access_grants).toBe(1);
|
expect(body.tableCounts.file_access_grants).toBe(1);
|
||||||
|
expect(body.tableCounts.file_handoff_records).toBe(1);
|
||||||
expect(body.tableCounts.provider_requests).toBe(1);
|
expect(body.tableCounts.provider_requests).toBe(1);
|
||||||
expect(body.tableCounts.provider_usage_windows).toBe(1);
|
expect(body.tableCounts.provider_usage_windows).toBe(1);
|
||||||
expect(body.tableCounts.provider_rate_limit_observations).toBe(1);
|
expect(body.tableCounts.provider_rate_limit_observations).toBe(1);
|
||||||
@@ -306,6 +309,7 @@ describe("migration service", () => {
|
|||||||
expect(body.idMaps.sdlc_review_runs).toEqual(["review-run-1"]);
|
expect(body.idMaps.sdlc_review_runs).toEqual(["review-run-1"]);
|
||||||
expect(body.idMaps.eval_runs).toEqual(["eval-run-1"]);
|
expect(body.idMaps.eval_runs).toEqual(["eval-run-1"]);
|
||||||
expect(body.idMaps.board_interventions).toEqual(["bdi-1"]);
|
expect(body.idMaps.board_interventions).toEqual(["bdi-1"]);
|
||||||
|
expect(body.idMaps.file_handoff_records).toEqual(["handoff-1"]);
|
||||||
expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]);
|
expect(body.idMaps.retention_freezes).toEqual(["freeze-1"]);
|
||||||
expect(body.idMaps.audit_replay_runs).toEqual(["replay-1"]);
|
expect(body.idMaps.audit_replay_runs).toEqual(["replay-1"]);
|
||||||
expect(body.idMaps.rollback_snapshots).toEqual(["rollback-1"]);
|
expect(body.idMaps.rollback_snapshots).toEqual(["rollback-1"]);
|
||||||
|
|||||||
Reference in new issue
Block a user