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 { socialAuditRecordsSchema } from "./migrations/027-social-audit-records";
|
||||
import { migrationCutoverJournalSchema } from "./migrations/028-migration-cutover-journal";
|
||||
import { fileHandoffRecordsSchema } from "./migrations/029-file-handoff-records";
|
||||
|
||||
interface Migration {
|
||||
version: number;
|
||||
@@ -63,6 +64,7 @@ const MIGRATIONS: Migration[] = [
|
||||
{ version: 26, name: "browser_platform_governance", sql: browserPlatformGovernanceSchema },
|
||||
{ version: 27, name: "social_audit_records", sql: socialAuditRecordsSchema },
|
||||
{ version: 28, name: "migration_cutover_journal", sql: migrationCutoverJournalSchema },
|
||||
{ version: 29, name: "file_handoff_records", sql: fileHandoffRecordsSchema },
|
||||
];
|
||||
|
||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||
|
||||
@@ -4,27 +4,26 @@ import {
|
||||
CHAT_THREAD_BOARD_STATE,
|
||||
FILE_SCAN_STATE,
|
||||
RISK_LEVEL,
|
||||
type FileScanState,
|
||||
} from "../../shared/domain";
|
||||
import { createId } from "../../shared/ids";
|
||||
import { ApprovalService } from "./approval-service";
|
||||
import { AuditService } from "./audit-service";
|
||||
import { BoardInterventionService, parseBoardCommand } from "./board-intervention-service";
|
||||
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_VISIBILITIES = new Set<CreateChatSpaceInput["visibility"]>(["workspace", "restricted"]);
|
||||
const RETENTION_POLICIES = new Set(["standard_365d", "restricted_365d"]);
|
||||
|
||||
interface AttachmentCandidate {
|
||||
fileId: string;
|
||||
checksum: string;
|
||||
}
|
||||
|
||||
interface FileAttachmentRow {
|
||||
id: string;
|
||||
sha256: string;
|
||||
scan_state: string;
|
||||
scan_state: FileScanState;
|
||||
scan_labels_json: string;
|
||||
storage_path: string;
|
||||
owner_type: string;
|
||||
owner_id: string | null;
|
||||
}
|
||||
@@ -52,11 +51,13 @@ export class ChatService {
|
||||
private readonly audit: AuditService;
|
||||
private readonly approvals: ApprovalService;
|
||||
private readonly boardInterventions: BoardInterventionService;
|
||||
private readonly handoffs: FileHandoffService;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
createSpace(input: CreateChatSpaceInput): string {
|
||||
@@ -141,8 +142,17 @@ export class ChatService {
|
||||
recordChatMentions(this.db, id, mentions);
|
||||
const slashResultRef = slash ? this.executeSlashCommand(input, space, sender, mentions, slash, id, threadId, now) : null;
|
||||
if (slash) this.recordSlashInvocation(id, slash, slashResultRef, now);
|
||||
this.recordAttachments(id, attachments);
|
||||
this.recordAttachmentGrants(input, id, threadId, attachments, mentions, now);
|
||||
this.handoffs.record({
|
||||
orgId: input.orgId,
|
||||
spaceId: input.spaceId,
|
||||
messageId: id,
|
||||
threadId,
|
||||
senderType: input.senderType,
|
||||
senderId: input.senderId,
|
||||
attachments,
|
||||
mentions,
|
||||
now,
|
||||
});
|
||||
if (input.senderId) {
|
||||
const presenceStatus = slash?.command === "escalate" ? "waiting_approval" : "active";
|
||||
this.db.prepare(`
|
||||
@@ -229,62 +239,6 @@ export class ChatService {
|
||||
`).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 {
|
||||
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;
|
||||
@@ -331,18 +285,24 @@ export class ChatService {
|
||||
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.");
|
||||
return fileIds.map((fileId) => {
|
||||
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
|
||||
where id = ? and org_id = ?
|
||||
`).get(fileId, orgId) as FileAttachmentRow | undefined;
|
||||
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 (!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);
|
||||
}
|
||||
|
||||
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 {
|
||||
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;
|
||||
|
||||
@@ -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",
|
||||
"file_objects",
|
||||
"file_access_grants",
|
||||
"file_handoff_records",
|
||||
"social_accounts",
|
||||
"social_account_grants",
|
||||
"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: "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_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: "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" },
|
||||
|
||||
@@ -262,7 +262,7 @@ export class SdlcService {
|
||||
{ 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_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"),
|
||||
files,
|
||||
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"),
|
||||
providers,
|
||||
providerAuthProfiles,
|
||||
|
||||
@@ -287,6 +287,24 @@ export interface FileAccessGrantRecord {
|
||||
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 {
|
||||
id: string;
|
||||
orgId: string;
|
||||
@@ -432,6 +450,7 @@ export interface AppSnapshot {
|
||||
chatReadStates: ChatReadStateRecord[];
|
||||
files: FileObjectRecord[];
|
||||
fileAccessGrants: FileAccessGrantRecord[];
|
||||
fileHandoffRecords: FileHandoffRecord[];
|
||||
scheduledPosts: ScheduledPostRecord[];
|
||||
providers: ProviderRecord[];
|
||||
providerAuthProfiles: ProviderAuthProfileRecord[];
|
||||
|
||||
@@ -23,7 +23,7 @@ describe("domain services", () => {
|
||||
const connection = createConnection();
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
@@ -208,6 +208,7 @@ describe("domain services", () => {
|
||||
const chat = new ChatService(connection.db);
|
||||
const snapshot = new SnapshotService(connection.db);
|
||||
let orgId = "";
|
||||
let fileId = "";
|
||||
|
||||
connection.transaction(() => {
|
||||
orgId = organization.createOrganization({
|
||||
@@ -225,7 +226,7 @@ describe("domain services", () => {
|
||||
});
|
||||
const employee = connection.db.prepare("select id from employees where org_id = ?")
|
||||
.get(orgId) as { id: string };
|
||||
const fileId = createId("file");
|
||||
fileId = createId("file");
|
||||
connection.db.prepare(`
|
||||
insert into file_objects(id, org_id, name, kind, size_bytes, sha256, storage_path, owner_type, owner_id, created_at)
|
||||
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
@@ -249,6 +250,15 @@ describe("domain services", () => {
|
||||
expect(state.chatAttachments).toHaveLength(1);
|
||||
expect(state.files[0]?.scanState).toBe("approved");
|
||||
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.chatSlashInvocations[0]).toMatchObject({ command: "handoff" });
|
||||
expect(state.chatPresence).toHaveLength(1);
|
||||
@@ -362,7 +372,20 @@ describe("domain services", () => {
|
||||
const state = snapshot.load();
|
||||
const benGrant = state.fileAccessGrants.find((grant) => grant.fileId === approvedFileId && grant.grantedToId === benId);
|
||||
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(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.auditEvents.some((event) => event.entityType === "file_object" && event.action === "access_granted")).toBe(true);
|
||||
connection.close();
|
||||
|
||||
@@ -163,6 +163,7 @@ describe("migration service", () => {
|
||||
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_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_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);
|
||||
@@ -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_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_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_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');
|
||||
@@ -283,6 +285,7 @@ describe("migration service", () => {
|
||||
expect(body.tableCounts.chat_read_state).toBe(1);
|
||||
expect(body.tableCounts.board_interventions).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_usage_windows).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.eval_runs).toEqual(["eval-run-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.audit_replay_runs).toEqual(["replay-1"]);
|
||||
expect(body.idMaps.rollback_snapshots).toEqual(["rollback-1"]);
|
||||
|
||||
Reference in new issue
Block a user