operation-logger.tsā¢13 kB
import { randomBytes, createCipheriv, createDecipheriv } from 'crypto';
import { DatabaseManager } from './database.js';
export interface CopyOperation {
sourceFile: string;
startLine: number;
endLine: number;
content: string;
}
export interface PasteTarget {
filePath: string;
targetLine: number;
originalContent: string;
}
export interface PasteOperation {
targets: PasteTarget[];
content: string;
cutSourceFile?: string;
cutSourceContent?: string;
}
export interface OperationRecord {
operationId: number;
operationType: 'copy' | 'cut' | 'paste' | 'undo';
timestamp: number;
sourceFile?: string;
startLine?: number;
endLine?: number;
targetFile?: string;
targetLine?: number;
}
export interface PasteRecord {
operationId: number;
targets: Array<{
filePath: string;
targetLine: number;
originalContent: string;
modifiedContent: string;
}>;
timestamp: number;
cutSourceFile?: string;
cutSourceContent?: string;
}
/**
* Logs all clipboard operations for audit trail and undo support
*/
export class OperationLogger {
private dbManager: DatabaseManager;
private readonly ENCRYPTION_VERSION = 1;
constructor(dbManager: DatabaseManager) {
this.dbManager = dbManager;
}
/**
* Log a copy operation
* @param sessionId - The session ID
* @param operation - Copy operation details
* @returns Operation ID
*/
logCopy(sessionId: string, operation: CopyOperation): number {
const db = this.dbManager.getConnection();
const timestamp = Date.now();
// Store only metadata, not actual content (content is encrypted in clipboard_buffer)
const contentSummary = JSON.stringify({
lines: operation.endLine - operation.startLine + 1,
bytes: Buffer.byteLength(operation.content, 'utf-8'),
});
const result = db
.prepare(
`INSERT INTO operations_log
(session_id, operation_type, timestamp, source_file, source_start_line, source_end_line, content_snapshot)
VALUES (?, ?, ?, ?, ?, ?, ?)`
)
.run(
sessionId,
'copy',
timestamp,
operation.sourceFile,
operation.startLine,
operation.endLine,
contentSummary
);
return result.lastInsertRowid as number;
}
/**
* Log a cut operation
* @param sessionId - The session ID
* @param operation - Cut operation details
* @returns Operation ID
*/
logCut(sessionId: string, operation: CopyOperation): number {
const db = this.dbManager.getConnection();
const timestamp = Date.now();
// Store only metadata, not actual content (content is encrypted in clipboard_buffer)
const contentSummary = JSON.stringify({
lines: operation.endLine - operation.startLine + 1,
bytes: Buffer.byteLength(operation.content, 'utf-8'),
});
const result = db
.prepare(
`INSERT INTO operations_log
(session_id, operation_type, timestamp, source_file, source_start_line, source_end_line, content_snapshot, undoable)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`
)
.run(
sessionId,
'cut',
timestamp,
operation.sourceFile,
operation.startLine,
operation.endLine,
contentSummary,
1 // Cut operations are undoable
);
return result.lastInsertRowid as number;
}
/**
* Log a paste operation with its history for undo support
* @param sessionId - The session ID
* @param operation - Paste operation details
* @returns Operation ID
*/
logPaste(sessionId: string, operation: PasteOperation): number {
const db = this.dbManager.getConnection();
return this.dbManager.transaction(() => {
const timestamp = Date.now();
// Store only metadata in operations_log, not actual content
// Actual content is stored in paste_history for undo support
const contentSummary = JSON.stringify({
targets: operation.targets.length,
bytes: Buffer.byteLength(operation.content, 'utf-8'),
hasCutSource: !!operation.cutSourceFile,
});
// Log the paste operation
const result = db
.prepare(
`INSERT INTO operations_log
(session_id, operation_type, timestamp, content_snapshot, undoable)
VALUES (?, ?, ?, ?, ?)`
)
.run(sessionId, 'paste', timestamp, contentSummary, 1);
const operationId = result.lastInsertRowid as number;
// Create paste history entries for each target
// Note: All sensitive content is now encrypted at rest
const historyStmt = db.prepare(
`INSERT INTO paste_history
(session_id, operation_log_id, file_path, original_content, modified_content, paste_line, timestamp, undone, cut_source_file, cut_source_content, encrypted_payload, encryption_iv, encryption_tag, encryption_version)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
for (const target of operation.targets) {
// Encrypt the sensitive content
const { ciphertext, iv, authTag } = this.encryptPastePayload({
originalContent: target.originalContent,
modifiedContent: operation.content,
cutSourceContent: operation.cutSourceContent || null,
});
historyStmt.run(
sessionId,
operationId,
target.filePath,
'[encrypted]', // original_content - redacted
'[encrypted]', // modified_content - redacted
target.targetLine,
timestamp,
0, // Not undone yet
operation.cutSourceFile || null,
null, // cut_source_content - encrypted in payload
ciphertext,
iv,
authTag,
this.ENCRYPTION_VERSION
);
}
return operationId;
});
}
/**
* Log an undo operation and mark the original paste as undone
* @param sessionId - The session ID
* @param originalPasteOpId - The operation ID of the paste being undone
* @returns Operation ID
*/
logUndo(sessionId: string, originalPasteOpId: number): number {
const db = this.dbManager.getConnection();
return this.dbManager.transaction(() => {
const timestamp = Date.now();
// Log the undo operation
const result = db
.prepare(
`INSERT INTO operations_log
(session_id, operation_type, timestamp, content_snapshot)
VALUES (?, ?, ?, ?)`
)
.run(
sessionId,
'undo',
timestamp,
JSON.stringify({ undoneOperationId: originalPasteOpId })
);
// Mark paste history as undone
db.prepare(
`UPDATE paste_history
SET undone = 1
WHERE operation_log_id = ? AND session_id = ?`
).run(originalPasteOpId, sessionId);
return result.lastInsertRowid as number;
});
}
/**
* Get operation history for a session
* @param sessionId - The session ID
* @param limit - Maximum number of operations to return (default: 10)
* @returns Array of operation records
*/
getHistory(sessionId: string, limit: number = 10): OperationRecord[] {
const db = this.dbManager.getConnection();
const rows = db
.prepare(
`SELECT id, operation_type, timestamp, source_file, source_start_line, source_end_line, target_file, target_line
FROM operations_log
WHERE session_id = ?
ORDER BY timestamp DESC
LIMIT ?`
)
.all(sessionId, limit) as Array<{
id: number;
operation_type: 'copy' | 'cut' | 'paste' | 'undo';
timestamp: number;
source_file: string | null;
source_start_line: number | null;
source_end_line: number | null;
target_file: string | null;
target_line: number | null;
}>;
return rows.map((row) => ({
operationId: row.id,
operationType: row.operation_type,
timestamp: row.timestamp,
sourceFile: row.source_file || undefined,
startLine: row.source_start_line || undefined,
endLine: row.source_end_line || undefined,
targetFile: row.target_file || undefined,
targetLine: row.target_line || undefined,
}));
}
/**
* Get the last paste operation that hasn't been undone
* @param sessionId - The session ID
* @returns Paste record or null if none found
*/
getLastPaste(sessionId: string): PasteRecord | null {
const db = this.dbManager.getConnection();
// Find the most recent paste operation that hasn't been undone
const pasteOp = db
.prepare(
`SELECT ol.id, ol.timestamp
FROM operations_log ol
WHERE ol.session_id = ?
AND ol.operation_type = 'paste'
AND EXISTS (
SELECT 1 FROM paste_history ph
WHERE ph.operation_log_id = ol.id
AND ph.undone = 0
)
ORDER BY ol.timestamp DESC
LIMIT 1`
)
.get(sessionId) as { id: number; timestamp: number } | undefined;
if (!pasteOp) {
return null;
}
// Get all paste history entries for this operation
const historyRows = db
.prepare(
`SELECT file_path, original_content, modified_content, paste_line, cut_source_file, cut_source_content,
encrypted_payload, encryption_iv, encryption_tag, encryption_version
FROM paste_history
WHERE operation_log_id = ? AND undone = 0`
)
.all(pasteOp.id) as Array<{
file_path: string;
original_content: string;
modified_content: string;
paste_line: number;
cut_source_file: string | null;
cut_source_content: string | null;
encrypted_payload: string | null;
encryption_iv: string | null;
encryption_tag: string | null;
encryption_version: number | null;
}>;
// Decrypt and extract content from rows
const targets = historyRows.map((row) => {
// Decrypt if encrypted payload exists
if (row.encrypted_payload && row.encryption_iv && row.encryption_tag) {
const decrypted = this.decryptPastePayload({
ciphertext: row.encrypted_payload,
iv: row.encryption_iv,
authTag: row.encryption_tag,
version: row.encryption_version ?? this.ENCRYPTION_VERSION,
});
return {
filePath: row.file_path,
targetLine: row.paste_line,
originalContent: decrypted.originalContent,
modifiedContent: decrypted.modifiedContent,
cutSourceContent: decrypted.cutSourceContent || undefined,
};
}
// Fallback for legacy plaintext rows
return {
filePath: row.file_path,
targetLine: row.paste_line,
originalContent: row.original_content,
modifiedContent: row.modified_content,
cutSourceContent: row.cut_source_content || undefined,
};
});
// Extract cut source info from first row (all rows should have same cut source)
const cutSourceFile = historyRows[0]?.cut_source_file || undefined;
const cutSourceContent = targets[0]?.cutSourceContent;
return {
operationId: pasteOp.id,
targets: targets.map((t) => ({
filePath: t.filePath,
targetLine: t.targetLine,
originalContent: t.originalContent,
modifiedContent: t.modifiedContent,
})),
timestamp: pasteOp.timestamp,
cutSourceFile,
cutSourceContent,
};
}
/**
* Encrypt paste history payload
*/
private encryptPastePayload(payload: {
originalContent: string;
modifiedContent: string;
cutSourceContent?: string | null;
}): { ciphertext: string; iv: string; authTag: string } {
const key = this.dbManager.getEncryptionKey();
const iv = randomBytes(12);
const cipher = createCipheriv('aes-256-gcm', key, iv);
const plaintext = JSON.stringify(payload);
const encrypted = Buffer.concat([cipher.update(plaintext, 'utf8'), cipher.final()]);
const authTag = cipher.getAuthTag();
return {
ciphertext: encrypted.toString('base64'),
iv: iv.toString('base64'),
authTag: authTag.toString('base64'),
};
}
/**
* Decrypt paste history payload
*/
private decryptPastePayload(params: {
ciphertext: string;
iv: string;
authTag: string;
version: number;
}): {
originalContent: string;
modifiedContent: string;
cutSourceContent?: string | null;
} {
if (params.version !== this.ENCRYPTION_VERSION) {
throw new Error(`Unsupported encryption version: ${params.version}`);
}
const key = this.dbManager.getEncryptionKey();
const iv = Buffer.from(params.iv, 'base64');
const authTag = Buffer.from(params.authTag, 'base64');
const encrypted = Buffer.from(params.ciphertext, 'base64');
const decipher = createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);
const decrypted = Buffer.concat([decipher.update(encrypted), decipher.final()]);
return JSON.parse(decrypted.toString('utf8'));
}
}