refactor: remove backup, storage, and plugin types from Electron app
- Delete src/main/backup/ (backup-manager, e2e-crypto, sync-queue) - Remove backup lifecycle from index.ts and router - Remove syncQueue table from db/schema.ts - Remove backupEnabled/backupIntervalHours/lastBackupAt from store - Remove uploadBackup/downloadBackup from backend-client - Update embed URL to /api/v1/chat/embed - Remove PluginListing, InstalledPlugin from batch-types - Remove PermissionGrant, BackupMetadata from api-types
This commit is contained in:
@@ -11,7 +11,7 @@
|
||||
* `final` / `ping` frames → Client handles `tool_call` via DrizzleExecutor
|
||||
* and sends back `tool_result`.
|
||||
*
|
||||
* - Embeddings: POST `/api/v1/storage/vectors/embed`
|
||||
* - Embeddings: POST `/api/v1/chat/embed`
|
||||
* - Health: GET `/api/v1/health`
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 1, Step 1.3
|
||||
@@ -198,9 +198,6 @@ export class BackendClient {
|
||||
private shouldReconnect = false;
|
||||
private reconnectAttempt = 0;
|
||||
|
||||
/** Optional callback fired when the persistent WS successfully connects. */
|
||||
private onConnectedCallback: (() => void) | null = null;
|
||||
|
||||
/** V3 stream listeners keyed by requestId. */
|
||||
private streamListeners: Map<string, StreamListener> = new Map();
|
||||
|
||||
@@ -258,8 +255,8 @@ export class BackendClient {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
logHttp('POST', `${this.baseUrl}/api/v1/storage/vectors/embed`, { text: text.slice(0, 80) + '…' });
|
||||
const res = await fetch(`${this.baseUrl}/api/v1/storage/vectors/embed`, {
|
||||
logHttp('POST', `${this.baseUrl}/api/v1/chat/embed`, { text: text.slice(0, 80) + '…' });
|
||||
const res = await fetch(`${this.baseUrl}/api/v1/chat/embed`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -269,7 +266,7 @@ export class BackendClient {
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
});
|
||||
|
||||
logHttpResponse('POST', `${this.baseUrl}/api/v1/storage/vectors/embed`, res.status);
|
||||
logHttpResponse('POST', `${this.baseUrl}/api/v1/chat/embed`, res.status);
|
||||
await this.assertHttpOk(res);
|
||||
const data = toCamelCase<{ vector: number[] }>(await res.json());
|
||||
return data.vector;
|
||||
@@ -555,104 +552,6 @@ export class BackendClient {
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// E2E Backup (Phase 4, Step 4.1)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Uploads an E2E-encrypted backup blob to the backend.
|
||||
* The body is raw bytes; metadata is passed via custom headers.
|
||||
*
|
||||
* @param blob - The packed ADV1 backup blob.
|
||||
* @param version - Monotonically increasing version number (unix seconds).
|
||||
* @param timestamp - Unix epoch milliseconds of the backup.
|
||||
* @param checksum - SHA-256 hex digest of the blob.
|
||||
*/
|
||||
async uploadBackup(
|
||||
blob: Buffer,
|
||||
version: number,
|
||||
timestamp: number,
|
||||
checksum: string,
|
||||
): Promise<{ ok: boolean }> {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/api/v1/backup`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/octet-stream',
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'X-Backup-Version': String(version),
|
||||
'X-Backup-Timestamp': String(timestamp),
|
||||
'X-Backup-Checksum': checksum,
|
||||
'Content-Length': String(blob.length),
|
||||
},
|
||||
body: new Uint8Array(blob),
|
||||
signal: AbortSignal.timeout(60_000), // large file may take time
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
if (res.status === 401) throw new AuthExpiredError();
|
||||
if (res.status === 402) throw new Error('Backup quota exceeded for your current plan.');
|
||||
if (res.status >= 500) throw new ServerError(text || res.statusText, res.status);
|
||||
throw new Error(`Backup upload failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the latest backup blob from the backend.
|
||||
* Returns null if no backup exists (404) or content is unchanged (304).
|
||||
*
|
||||
* @param ifModifiedSince - Optional: only download if newer than this Unix ms.
|
||||
*/
|
||||
async downloadBackup(
|
||||
ifModifiedSince?: number,
|
||||
): Promise<{ blob: Buffer; version: number; timestamp: number; checksum: string } | null> {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
Authorization: `Bearer ${token}`,
|
||||
};
|
||||
if (ifModifiedSince !== undefined) {
|
||||
headers['If-Modified-Since'] = new Date(ifModifiedSince).toUTCString();
|
||||
}
|
||||
|
||||
const res = await fetch(`${this.baseUrl}/api/v1/backup`, {
|
||||
method: 'GET',
|
||||
headers,
|
||||
signal: AbortSignal.timeout(60_000),
|
||||
});
|
||||
|
||||
if (res.status === 304 || res.status === 404) return null;
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
if (res.status === 401) throw new AuthExpiredError();
|
||||
throw new Error(`Backup download failed: ${res.status} ${text}`);
|
||||
}
|
||||
|
||||
const arrayBuffer = await res.arrayBuffer();
|
||||
const blob = Buffer.from(arrayBuffer);
|
||||
|
||||
const version = Number(res.headers.get('X-Backup-Version') ?? '0');
|
||||
const timestamp = Number(res.headers.get('X-Backup-Timestamp') ?? '0');
|
||||
const checksum = res.headers.get('X-Backup-Checksum') ?? '';
|
||||
|
||||
return { blob, version, timestamp, checksum };
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a callback to be invoked each time the persistent device WS
|
||||
* successfully connects (including reconnects). Used by the sync queue
|
||||
* to replay queued operations when connectivity is restored.
|
||||
*/
|
||||
onConnected(callback: () => void): void {
|
||||
this.onConnectedCallback = callback;
|
||||
}
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -719,11 +618,6 @@ export class BackendClient {
|
||||
this.reconnectAttempt = 0;
|
||||
console.log('[DeviceWS] Connected.');
|
||||
|
||||
// Notify connectivity listeners (e.g. sync queue)
|
||||
if (this.onConnectedCallback) {
|
||||
try { this.onConnectedCallback(); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// Read enabled local agent IDs from local storage
|
||||
const deviceId = getDeviceId();
|
||||
const agentIds = getLocalAgents()
|
||||
|
||||
@@ -1,253 +0,0 @@
|
||||
/**
|
||||
* BackupManager — orchestrates E2E encrypted database backup and restore.
|
||||
*
|
||||
* Flow (create):
|
||||
* 1. Use better-sqlite3's `.backup()` to snapshot the live DB to a temp file
|
||||
* (WAL-safe, consistent snapshot).
|
||||
* 2. Read the snapshot into a Buffer; delete the temp file.
|
||||
* 3. Retrieve the user's cached password (set at login; never persisted).
|
||||
* 4. Generate a random 16-byte salt; derive a 256-bit AES key via Argon2id.
|
||||
* 5. Encrypt with AES-256-GCM; pack into the ADV1 blob format.
|
||||
* 6. Compute SHA-256 checksum of the packed blob.
|
||||
* 7. Upload to the backend. On offline failure: enqueue in sync_queue.
|
||||
* 8. Update `lastBackupAt` in electron-store.
|
||||
*
|
||||
* Flow (restore):
|
||||
* 1. Download the latest backup blob from the backend.
|
||||
* 2. Verify the SHA-256 checksum.
|
||||
* 3. Unpack → extract salt, IV, authTag, ciphertext.
|
||||
* 4. Derive key from cached password + embedded salt.
|
||||
* 5. Decrypt and authenticate.
|
||||
* 6. Verify the decrypted bytes start with the SQLite magic string.
|
||||
* 7. Close the current DB; atomically replace the file; reopen.
|
||||
* 8. Send `backup:restored` IPC event so the renderer refreshes all data.
|
||||
*
|
||||
* Security notes:
|
||||
* - The user's password is held in memory only (never written to disk).
|
||||
* - A fresh random 16-byte salt is generated per backup so the same
|
||||
* password always produces a different ciphertext.
|
||||
* - The backend treats the blob as opaque bytes and only verifies the
|
||||
* SHA-256 checksum before accepting the upload.
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 4, Step 4.1
|
||||
*/
|
||||
|
||||
import fs from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import os from 'node:os';
|
||||
import crypto from 'node:crypto';
|
||||
import { BrowserWindow } from 'electron';
|
||||
import { getStore } from '../store';
|
||||
import { getRawSqlite, closeDb, getDbPath, initDb } from '../db';
|
||||
import { getAuthManager } from '../auth/auth-manager';
|
||||
import {
|
||||
deriveKey,
|
||||
encrypt,
|
||||
decrypt,
|
||||
computeChecksum,
|
||||
packBackup,
|
||||
unpackBackup,
|
||||
} from './e2e-crypto';
|
||||
import type { BackupMetadata } from '../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface BackupResult {
|
||||
success: boolean;
|
||||
timestamp: number;
|
||||
checksum: string;
|
||||
sizeBytes: number;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SQLite magic bytes (first 16 bytes of every valid .db file)
|
||||
// ---------------------------------------------------------------------------
|
||||
const SQLITE_MAGIC = Buffer.from('SQLite format 3\x00');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BackupManager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export class BackupManager {
|
||||
private static instance: BackupManager | null = null;
|
||||
|
||||
private _periodicTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): BackupManager {
|
||||
if (!this.instance) this.instance = new BackupManager();
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Creates an E2E-encrypted backup of the local SQLite database and uploads
|
||||
* it to the backend.
|
||||
*
|
||||
* @throws if not authenticated, or if no password has been cached.
|
||||
*/
|
||||
async createBackup(): Promise<BackupResult> {
|
||||
const password = this._requirePassword();
|
||||
|
||||
// 1. Consistent snapshot via better-sqlite3 .backup()
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'adiuva-bk-'));
|
||||
const tempPath = path.join(tempDir, 'snapshot.db');
|
||||
|
||||
try {
|
||||
await getRawSqlite().backup(tempPath);
|
||||
|
||||
// 2. Read snapshot into memory
|
||||
const dbBuffer = await fs.readFile(tempPath);
|
||||
|
||||
// 3. Derive key with a fresh random salt
|
||||
const salt = crypto.randomBytes(16);
|
||||
const key = await deriveKey(password, salt);
|
||||
|
||||
// 4. Encrypt
|
||||
const { ciphertext, iv, authTag } = encrypt(dbBuffer, key);
|
||||
|
||||
// 5. Pack
|
||||
const blob = packBackup(ciphertext, iv, authTag, salt);
|
||||
|
||||
// 6. Checksum
|
||||
const checksum = computeChecksum(blob);
|
||||
|
||||
// 7. Upload
|
||||
const timestamp = Date.now();
|
||||
const version = Math.floor(timestamp / 1000); // incrementing unix seconds
|
||||
|
||||
const { getBackendClient } = await import('../api/backend-client');
|
||||
await getBackendClient().uploadBackup(blob, version, timestamp, checksum);
|
||||
|
||||
// 8. Persist last-backup timestamp
|
||||
getStore().set('lastBackupAt', timestamp);
|
||||
|
||||
return { success: true, timestamp, checksum, sizeBytes: blob.length };
|
||||
} catch (err) {
|
||||
// Enqueue for retry if offline
|
||||
const { OfflineError } = await import('../api/backend-client');
|
||||
if (err instanceof OfflineError) {
|
||||
const { getSyncQueue } = await import('./sync-queue');
|
||||
getSyncQueue().enqueue('backup', {});
|
||||
}
|
||||
throw err;
|
||||
} finally {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the latest backup from the backend, decrypts it, and replaces
|
||||
* the local database file.
|
||||
*
|
||||
* After a successful restore the renderer is notified via the
|
||||
* `backup:restored` IPC event so it can reload all query caches.
|
||||
*/
|
||||
async restoreBackup(): Promise<void> {
|
||||
const password = this._requirePassword();
|
||||
|
||||
const { getBackendClient } = await import('../api/backend-client');
|
||||
const result = await getBackendClient().downloadBackup();
|
||||
if (!result) throw new Error('No backup found on the server.');
|
||||
|
||||
const { blob, checksum } = result;
|
||||
|
||||
// Verify integrity
|
||||
const actualChecksum = computeChecksum(blob);
|
||||
if (actualChecksum !== checksum) {
|
||||
throw new Error('Backup integrity check failed: checksum mismatch.');
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
const { salt, iv, authTag, ciphertext } = unpackBackup(blob);
|
||||
const key = await deriveKey(password, salt);
|
||||
const plaintext = decrypt(ciphertext, key, iv, authTag);
|
||||
|
||||
// Validate SQLite magic
|
||||
if (!plaintext.subarray(0, 16).equals(SQLITE_MAGIC)) {
|
||||
throw new Error('Restore failed: decrypted data is not a valid SQLite database.');
|
||||
}
|
||||
|
||||
// Atomic DB replacement
|
||||
const dbPath = getDbPath();
|
||||
const backupPath = dbPath + '.restore-tmp';
|
||||
await fs.writeFile(backupPath, plaintext);
|
||||
|
||||
closeDb();
|
||||
await fs.rename(backupPath, dbPath);
|
||||
initDb();
|
||||
|
||||
// Notify renderer to reload
|
||||
BrowserWindow.getAllWindows().forEach((w) => {
|
||||
w.webContents.send('backup:restored');
|
||||
});
|
||||
}
|
||||
|
||||
/** Returns backup history metadata from the backend. */
|
||||
async getHistory(): Promise<BackupMetadata[]> {
|
||||
const { getBackendClient } = await import('../api/backend-client');
|
||||
return getBackendClient().proxyGet<BackupMetadata[]>('/api/v1/backup/history');
|
||||
}
|
||||
|
||||
/** Deletes a specific backup by ID. */
|
||||
async deleteBackup(id: string): Promise<void> {
|
||||
const { getBackendClient } = await import('../api/backend-client');
|
||||
await getBackendClient().proxyDelete(`/api/v1/backup/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Periodic backup scheduling
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Starts a periodic backup timer based on the current store settings.
|
||||
* Safe to call multiple times — stops any existing timer first.
|
||||
*/
|
||||
schedulePeriodicBackup(): void {
|
||||
this.stopPeriodicBackup();
|
||||
const hours = getStore().get('backupIntervalHours') ?? 24;
|
||||
const ms = hours * 60 * 60 * 1000;
|
||||
this._periodicTimer = setInterval(() => {
|
||||
this.createBackup().catch((err) =>
|
||||
console.error('[Backup] Periodic backup failed:', err),
|
||||
);
|
||||
}, ms);
|
||||
console.log(`[Backup] Periodic backup scheduled every ${hours}h.`);
|
||||
}
|
||||
|
||||
stopPeriodicBackup(): void {
|
||||
if (this._periodicTimer !== null) {
|
||||
clearInterval(this._periodicTimer);
|
||||
this._periodicTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private _requirePassword(): string {
|
||||
const pw = getAuthManager().getCachedPassword();
|
||||
if (!pw) {
|
||||
throw new Error(
|
||||
'Backup encryption key unavailable. Please log in first.',
|
||||
);
|
||||
}
|
||||
return pw;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getBackupManager(): BackupManager {
|
||||
return BackupManager.getInstance();
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
/**
|
||||
* E2E encryption primitives for local database backup.
|
||||
*
|
||||
* Encryption scheme:
|
||||
* - Key derivation : scrypt (N=2^17, r=8, p=1, 256-bit output) via Node.js built-in crypto
|
||||
* - Cipher : AES-256-GCM with a random 12-byte IV per backup
|
||||
* - Integrity : SHA-256 checksum of the packed blob (matches backend's verify_checksum)
|
||||
*
|
||||
* Packed blob layout (binary, big-endian where applicable):
|
||||
* [4 bytes] magic "ADV1"
|
||||
* [16 bytes] scrypt salt (random per backup)
|
||||
* [12 bytes] AES-GCM IV (random per backup)
|
||||
* [16 bytes] AES-GCM authTag
|
||||
* [N bytes] ciphertext
|
||||
*
|
||||
* The salt being embedded in the blob means a fresh random salt (and therefore
|
||||
* a fresh derived key) is used for every backup, even with the same password.
|
||||
*
|
||||
* The backend receives and stores the blob as opaque bytes — it never decrypts
|
||||
* and only verifies the SHA-256 checksum before accepting the upload.
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 4, Step 4.1
|
||||
*/
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** ASCII magic bytes that identify a valid Adiuva backup blob. */
|
||||
const MAGIC = Buffer.from('ADV1');
|
||||
|
||||
const SALT_LEN = 16;
|
||||
const IV_LEN = 12;
|
||||
const AUTH_TAG_LEN = 16;
|
||||
const KEY_LEN = 32; // 256-bit AES key
|
||||
|
||||
// scrypt parameters — follows OWASP recommended minimums.
|
||||
// N=2^17 (131072), r=8, p=1 — ~64 MB memory, strong against GPU attacks.
|
||||
const SCRYPT_N = 131_072;
|
||||
const SCRYPT_R = 8;
|
||||
const SCRYPT_P = 1;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Derives a 256-bit AES key from `password` and `salt` using scrypt.
|
||||
*
|
||||
* @param password - The user's account password (never persisted; cleared after use).
|
||||
* @param salt - 16-byte random salt (stored in the backup blob header).
|
||||
*/
|
||||
export function deriveKey(password: string, salt: Buffer): Promise<Buffer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
crypto.scrypt(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }, (err, key) => {
|
||||
if (err) reject(err);
|
||||
else resolve(key);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export interface EncryptResult {
|
||||
ciphertext: Buffer;
|
||||
iv: Buffer;
|
||||
authTag: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypts `plaintext` with AES-256-GCM using the supplied `key`.
|
||||
* A cryptographically random 12-byte IV is generated for each call.
|
||||
*/
|
||||
export function encrypt(plaintext: Buffer, key: Buffer): EncryptResult {
|
||||
const iv = crypto.randomBytes(IV_LEN);
|
||||
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
|
||||
const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]);
|
||||
const authTag = cipher.getAuthTag();
|
||||
return { ciphertext, iv, authTag };
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypts and authenticates `ciphertext`.
|
||||
* Throws if the authTag is wrong (tampered data).
|
||||
*/
|
||||
export function decrypt(
|
||||
ciphertext: Buffer,
|
||||
key: Buffer,
|
||||
iv: Buffer,
|
||||
authTag: Buffer,
|
||||
): Buffer {
|
||||
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
|
||||
decipher.setAuthTag(authTag);
|
||||
return Buffer.concat([decipher.update(ciphertext), decipher.final()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the SHA-256 hex digest of `data`.
|
||||
* Matches the checksum algorithm used by the backend (`hashlib.sha256`).
|
||||
*/
|
||||
export function computeChecksum(data: Buffer): string {
|
||||
return crypto.createHash('sha256').update(data).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* Packs all encrypted components into a single blob for upload.
|
||||
*
|
||||
* Layout: [magic][salt][iv][authTag][ciphertext]
|
||||
*/
|
||||
export function packBackup(
|
||||
ciphertext: Buffer,
|
||||
iv: Buffer,
|
||||
authTag: Buffer,
|
||||
salt: Buffer,
|
||||
): Buffer {
|
||||
return Buffer.concat([MAGIC, salt, iv, authTag, ciphertext]);
|
||||
}
|
||||
|
||||
export interface UnpackedBackup {
|
||||
salt: Buffer;
|
||||
iv: Buffer;
|
||||
authTag: Buffer;
|
||||
ciphertext: Buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a packed backup blob back into its components.
|
||||
* Throws if the magic bytes are missing or the blob is too short.
|
||||
*/
|
||||
export function unpackBackup(blob: Buffer): UnpackedBackup {
|
||||
const headerLen = MAGIC.length + SALT_LEN + IV_LEN + AUTH_TAG_LEN;
|
||||
if (blob.length < headerLen + 1) {
|
||||
throw new Error('Invalid backup blob: too short');
|
||||
}
|
||||
|
||||
let offset = 0;
|
||||
|
||||
const magic = blob.subarray(offset, offset + MAGIC.length);
|
||||
if (!magic.equals(MAGIC)) {
|
||||
throw new Error('Invalid backup blob: unrecognised format (bad magic bytes)');
|
||||
}
|
||||
offset += MAGIC.length;
|
||||
|
||||
const salt = Buffer.from(blob.subarray(offset, offset + SALT_LEN));
|
||||
offset += SALT_LEN;
|
||||
|
||||
const iv = Buffer.from(blob.subarray(offset, offset + IV_LEN));
|
||||
offset += IV_LEN;
|
||||
|
||||
const authTag = Buffer.from(blob.subarray(offset, offset + AUTH_TAG_LEN));
|
||||
offset += AUTH_TAG_LEN;
|
||||
|
||||
const ciphertext = Buffer.from(blob.subarray(offset));
|
||||
|
||||
return { salt, iv, authTag, ciphertext };
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
/**
|
||||
* SyncQueue — persists failed backup attempts in SQLite and retries them
|
||||
* automatically when connectivity is restored.
|
||||
*
|
||||
* Current actions supported:
|
||||
* - 'backup': re-runs BackupManager.createBackup() on next online opportunity
|
||||
*
|
||||
* The queue stores *intent* (not data). When a backup is retried, a fresh
|
||||
* DB snapshot is taken so the latest data is always included.
|
||||
*
|
||||
* Retry policy: max 5 attempts per item; after that the item is marked
|
||||
* 'failed' and left for manual inspection.
|
||||
*
|
||||
* @see AI_REFACTOR_PLAN.md — Phase 4, Step 4.2
|
||||
*/
|
||||
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { syncQueue } from '../db/schema';
|
||||
import type { SyncQueueItem } from '../db/schema';
|
||||
|
||||
const MAX_RETRIES = 5;
|
||||
|
||||
export class SyncQueue {
|
||||
private static instance: SyncQueue | null = null;
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
static getInstance(): SyncQueue {
|
||||
if (!this.instance) this.instance = new SyncQueue();
|
||||
return this.instance;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public API
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/** Enqueue a failed action for retry when the device comes back online. */
|
||||
enqueue(action: string, payload: Record<string, unknown> = {}): void {
|
||||
try {
|
||||
getDb()
|
||||
.insert(syncQueue)
|
||||
.values({
|
||||
id: crypto.randomUUID(),
|
||||
action,
|
||||
payload: JSON.stringify(payload),
|
||||
status: 'pending',
|
||||
retries: 0,
|
||||
createdAt: Date.now(),
|
||||
lastAttemptAt: null,
|
||||
})
|
||||
.run();
|
||||
console.log(`[SyncQueue] Enqueued action: ${action}`);
|
||||
} catch (err) {
|
||||
console.error('[SyncQueue] Failed to enqueue action:', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Process all pending queue items in order.
|
||||
* Called automatically when the persistent WebSocket (re)connects.
|
||||
*/
|
||||
async processQueue(): Promise<void> {
|
||||
let items: SyncQueueItem[];
|
||||
try {
|
||||
items = getDb()
|
||||
.select()
|
||||
.from(syncQueue)
|
||||
.where(eq(syncQueue.status, 'pending'))
|
||||
.all();
|
||||
} catch {
|
||||
// DB may not be initialized yet during early startup
|
||||
return;
|
||||
}
|
||||
|
||||
if (items.length === 0) return;
|
||||
console.log(`[SyncQueue] Processing ${items.length} pending item(s)…`);
|
||||
|
||||
for (const item of items) {
|
||||
await this._processItem(item);
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns all items currently in the queue (for diagnostics). */
|
||||
getAll(): SyncQueueItem[] {
|
||||
try {
|
||||
return getDb().select().from(syncQueue).all();
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Internals
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private async _processItem(item: SyncQueueItem): Promise<void> {
|
||||
const db = getDb();
|
||||
const now = Date.now();
|
||||
|
||||
// Bump attempt count immediately
|
||||
db.update(syncQueue)
|
||||
.set({ retries: item.retries + 1, lastAttemptAt: now })
|
||||
.where(eq(syncQueue.id, item.id))
|
||||
.run();
|
||||
|
||||
try {
|
||||
await this._dispatch(item.action, JSON.parse(item.payload) as Record<string, unknown>);
|
||||
|
||||
// Success — remove from queue
|
||||
db.delete(syncQueue).where(eq(syncQueue.id, item.id)).run();
|
||||
console.log(`[SyncQueue] Action '${item.action}' succeeded.`);
|
||||
} catch (err) {
|
||||
const newRetries = item.retries + 1;
|
||||
if (newRetries >= MAX_RETRIES) {
|
||||
db.update(syncQueue)
|
||||
.set({ status: 'failed', lastAttemptAt: now })
|
||||
.where(eq(syncQueue.id, item.id))
|
||||
.run();
|
||||
console.error(
|
||||
`[SyncQueue] Action '${item.action}' permanently failed after ${MAX_RETRIES} attempts:`,
|
||||
err,
|
||||
);
|
||||
} else {
|
||||
console.warn(
|
||||
`[SyncQueue] Action '${item.action}' failed (attempt ${newRetries}/${MAX_RETRIES}):`,
|
||||
err,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async _dispatch(
|
||||
action: string,
|
||||
payload: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
switch (action) {
|
||||
case 'backup': {
|
||||
void payload; // payload reserved for future per-action metadata
|
||||
const { getBackupManager } = await import('./backup-manager');
|
||||
await getBackupManager().createBackup();
|
||||
break;
|
||||
}
|
||||
default:
|
||||
throw new Error(`Unknown sync queue action: ${action}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Singleton accessor
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function getSyncQueue(): SyncQueue {
|
||||
return SyncQueue.getInstance();
|
||||
}
|
||||
@@ -103,19 +103,3 @@ export type AgentRun = InferSelectModel<typeof agentRuns>;
|
||||
export type NewAgentRun = InferInsertModel<typeof agentRuns>;
|
||||
export type AgentRunAction = InferSelectModel<typeof agentRunActions>;
|
||||
export type NewAgentRunAction = InferInsertModel<typeof agentRunActions>;
|
||||
|
||||
export const syncQueue = sqliteTable('sync_queue', {
|
||||
id: text('id').primaryKey(),
|
||||
/** Action to retry (currently only 'backup'). */
|
||||
action: text('action').notNull(),
|
||||
/** JSON-serialised metadata for the queued action. */
|
||||
payload: text('payload').notNull().default('{}'),
|
||||
/** 'pending' while waiting to run; 'failed' after max retries exhausted. */
|
||||
status: text('status', { enum: ['pending', 'failed'] }).notNull().default('pending'),
|
||||
retries: integer('retries', { mode: 'number' }).notNull().default(0),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
lastAttemptAt: integer('last_attempt_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export type SyncQueueItem = InferSelectModel<typeof syncQueue>;
|
||||
export type NewSyncQueueItem = InferInsertModel<typeof syncQueue>;
|
||||
|
||||
@@ -7,8 +7,6 @@ import { createIPCHandler } from './ipc';
|
||||
import { initVectorDb, migrateNotesIfNeeded } from './db/vectordb';
|
||||
import { getAuthManager } from './auth/auth-manager';
|
||||
import { getBackendClient } from './api/backend-client';
|
||||
import { getBackupManager } from './backup/backup-manager';
|
||||
import { getSyncQueue } from './backup/sync-queue';
|
||||
import { getStore } from './store';
|
||||
import { startBriefScheduler, stopBriefScheduler } from './ai/orchestrator';
|
||||
import { startAgentScheduler, stopAgentScheduler } from './agents/agent-scheduler';
|
||||
@@ -69,23 +67,12 @@ app.on('ready', () => {
|
||||
.then(() => migrateNotesIfNeeded())
|
||||
.catch((err) => console.error('[VectorDB] Init or migration failed:', err));
|
||||
|
||||
// Register sync-queue callback: process queued backup ops on reconnect
|
||||
getBackendClient().onConnected(() => {
|
||||
getSyncQueue().processQueue().catch((err) =>
|
||||
console.error('[SyncQueue] processQueue error:', err),
|
||||
);
|
||||
});
|
||||
|
||||
// Persistent device WebSocket for agent triggers — best-effort on startup
|
||||
getAuthManager()
|
||||
.isAuthenticated()
|
||||
.then((authenticated) => {
|
||||
if (authenticated) {
|
||||
void getBackendClient().connectPersistent();
|
||||
// Start periodic backup if enabled
|
||||
if (getStore().get('backupEnabled')) {
|
||||
getBackupManager().schedulePeriodicBackup();
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err) => console.error('[DeviceWS] Startup connect failed:', err));
|
||||
@@ -98,7 +85,6 @@ app.on('ready', () => {
|
||||
app.on('will-quit', () => {
|
||||
stopBriefScheduler();
|
||||
stopAgentScheduler();
|
||||
getBackupManager().stopPeriodicBackup();
|
||||
getBackendClient().disconnectPersistent();
|
||||
});
|
||||
|
||||
|
||||
@@ -7,11 +7,10 @@ import { clients, projects, tasks, timelineEvents, notes, taskComments, agentRun
|
||||
import { getStore, getDeviceId, getLocalAgents, getLocalAgent, saveLocalAgent, deleteLocalAgent } from '../store';
|
||||
import type { LocalAgentLocalConfig } from '../store';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
import type { AgentCatalogItem, CloudAgentConfig, AgentRunLog, BackupMetadata } from '../../shared/api-types';
|
||||
import type { AgentCatalogItem, CloudAgentConfig, AgentRunLog } from '../../shared/api-types';
|
||||
import { orchestrate, orchestrateFloating, dailyBrief, getCachedBrief, invalidateBriefCache } from '../ai/orchestrator';
|
||||
import { upsertNoteEmbedding } from '../db/vectordb';
|
||||
import { getAuthManager, AuthError } from '../auth/auth-manager';
|
||||
import { getBackupManager } from '../backup/backup-manager';
|
||||
import type { TRPCContext } from '../ipc';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
@@ -1038,8 +1037,6 @@ const authRouter = router({
|
||||
await auth.login(input.email, input.password);
|
||||
// Connect persistent device WS now that we have a valid token
|
||||
void getBackendClient().connectPersistent();
|
||||
// Start periodic backup if the user has it enabled
|
||||
if (getStore().get('backupEnabled')) getBackupManager().schedulePeriodicBackup();
|
||||
return { success: true as const, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Login failed';
|
||||
@@ -1050,8 +1047,7 @@ const authRouter = router({
|
||||
logout: publicProcedure.mutation(async () => {
|
||||
const auth = getAuthManager();
|
||||
await auth.logout();
|
||||
// Stop backup scheduler and disconnect WS
|
||||
getBackupManager().stopPeriodicBackup();
|
||||
// Disconnect persistent WS
|
||||
getBackendClient().disconnectPersistent();
|
||||
return { success: true as const };
|
||||
}),
|
||||
@@ -1070,9 +1066,8 @@ const authRouter = router({
|
||||
// Token stored but backend unreachable or token invalid
|
||||
if (err instanceof AuthError && err.statusCode === 401) {
|
||||
await auth.logout();
|
||||
// Also tear down persistent WS and backup scheduler
|
||||
// Also tear down persistent WS
|
||||
getBackendClient().disconnectPersistent();
|
||||
getBackupManager().stopPeriodicBackup();
|
||||
return { authenticated: false as const, profile: null };
|
||||
}
|
||||
return { authenticated: true as const, profile: null };
|
||||
@@ -1099,88 +1094,6 @@ const authRouter = router({
|
||||
}),
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backup router — E2E encrypted database backup (Phase 4, Step 4.1)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const backupRouter = router({
|
||||
/** Trigger a manual backup immediately. */
|
||||
create: publicProcedure.mutation(async () => {
|
||||
try {
|
||||
const result = await getBackupManager().createBackup();
|
||||
return { success: true as const, error: null, timestamp: result.timestamp, checksum: result.checksum, sizeBytes: result.sizeBytes };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Backup failed';
|
||||
return { success: false as const, error: msg, timestamp: 0, checksum: '', sizeBytes: 0 };
|
||||
}
|
||||
}),
|
||||
|
||||
/** Restore the latest backup from the backend (destructive — replaces local DB). */
|
||||
restore: publicProcedure.mutation(async () => {
|
||||
try {
|
||||
await getBackupManager().restoreBackup();
|
||||
return { success: true as const, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Restore failed';
|
||||
return { success: false as const, error: msg };
|
||||
}
|
||||
}),
|
||||
|
||||
/** List backup history metadata (no blob bytes). */
|
||||
history: publicProcedure.query(async () => {
|
||||
try {
|
||||
return await getBackupManager().getHistory() as BackupMetadata[];
|
||||
} catch {
|
||||
return [] as BackupMetadata[];
|
||||
}
|
||||
}),
|
||||
|
||||
/** Delete a specific backup by ID. */
|
||||
delete: publicProcedure
|
||||
.input(z.object({ id: z.string().min(1) }))
|
||||
.mutation(async ({ input }) => {
|
||||
try {
|
||||
await getBackupManager().deleteBackup(input.id);
|
||||
return { success: true as const, error: null };
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Delete failed';
|
||||
return { success: false as const, error: msg };
|
||||
}
|
||||
}),
|
||||
|
||||
/** Returns current backup settings from the store. */
|
||||
settings: publicProcedure.query(() => {
|
||||
const store = getStore();
|
||||
return {
|
||||
backupEnabled: store.get('backupEnabled'),
|
||||
backupIntervalHours: store.get('backupIntervalHours'),
|
||||
lastBackupAt: store.get('lastBackupAt'),
|
||||
};
|
||||
}),
|
||||
|
||||
/** Updates backup settings and restarts the periodic backup timer. */
|
||||
updateSettings: publicProcedure
|
||||
.input(
|
||||
z.object({
|
||||
backupEnabled: z.boolean().optional(),
|
||||
backupIntervalHours: z.number().int().min(1).max(168).optional(),
|
||||
}),
|
||||
)
|
||||
.mutation(({ input }) => {
|
||||
const store = getStore();
|
||||
const bm = getBackupManager();
|
||||
if (input.backupEnabled !== undefined) store.set('backupEnabled', input.backupEnabled);
|
||||
if (input.backupIntervalHours !== undefined)
|
||||
store.set('backupIntervalHours', input.backupIntervalHours);
|
||||
|
||||
// Restart timer with updated settings
|
||||
bm.stopPeriodicBackup();
|
||||
if (store.get('backupEnabled')) bm.schedulePeriodicBackup();
|
||||
|
||||
return { success: true as const };
|
||||
}),
|
||||
});
|
||||
|
||||
export const appRouter = router({
|
||||
health: healthRouter,
|
||||
settings: settingsRouter,
|
||||
@@ -1193,7 +1106,6 @@ export const appRouter = router({
|
||||
ai: aiRouter,
|
||||
auth: authRouter,
|
||||
agent: agentRouter,
|
||||
backup: backupRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
@@ -30,12 +30,6 @@ interface AppSettings {
|
||||
* configured on.
|
||||
*/
|
||||
deviceId: string;
|
||||
/** Whether automatic periodic backup is enabled. */
|
||||
backupEnabled: boolean;
|
||||
/** How often to run an automatic backup (hours). Default 24. */
|
||||
backupIntervalHours: number;
|
||||
/** Unix epoch ms of the last successful backup upload. Null if none. */
|
||||
lastBackupAt: number | null;
|
||||
/** Cached daily brief — regenerated once per day or when relevant data changes. */
|
||||
dailyBriefCache: { content: string; date: string } | null;
|
||||
/** Locally-managed agent configurations. */
|
||||
@@ -52,9 +46,6 @@ export function getStore(): Store<AppSettings> {
|
||||
encryptedTokens: {},
|
||||
backendUrl: 'http://localhost:8000',
|
||||
deviceId: '',
|
||||
backupEnabled: false,
|
||||
backupIntervalHours: 24,
|
||||
lastBackupAt: null,
|
||||
dailyBriefCache: null,
|
||||
localAgents: [],
|
||||
},
|
||||
|
||||
@@ -275,14 +275,6 @@ export const AgentManifestSchema = z.object({
|
||||
});
|
||||
export type AgentManifest = z.infer<typeof AgentManifestSchema>;
|
||||
|
||||
export const PermissionGrantSchema = z.object({
|
||||
plugin: z.string(),
|
||||
permissionType: z.string(),
|
||||
resourcePath: z.string(),
|
||||
grantedAt: z.string().datetime(),
|
||||
});
|
||||
export type PermissionGrant = z.infer<typeof PermissionGrantSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Agent REST API — response types (Phase 3, Step 3.4)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -357,16 +349,3 @@ export const JourneyMessageSchema = z.object({
|
||||
promptTemplate: z.string().optional(),
|
||||
});
|
||||
export type JourneyMessage = z.infer<typeof JourneyMessageSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Backup
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const BackupMetadataSchema = z.object({
|
||||
version: z.number().int(),
|
||||
/** Unix epoch seconds (matches backend `timestamp: int`). */
|
||||
timestamp: z.number().int(),
|
||||
checksum: z.string(),
|
||||
chunkCount: z.number().int(),
|
||||
});
|
||||
export type BackupMetadata = z.infer<typeof BackupMetadataSchema>;
|
||||
|
||||
@@ -123,33 +123,6 @@ export const BatchRunResultSchema = z.object({
|
||||
});
|
||||
export type BatchRunResult = z.infer<typeof BatchRunResultSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Plugin Marketplace
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const PluginListingSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
author: z.string(),
|
||||
version: z.string(),
|
||||
rating: z.number().min(0).max(5),
|
||||
installs: z.number().int().min(0),
|
||||
category: z.string(),
|
||||
permissions: z.array(z.string()),
|
||||
/** Price in cents. `0` means free. */
|
||||
price: z.number().int().min(0),
|
||||
});
|
||||
export type PluginListing = z.infer<typeof PluginListingSchema>;
|
||||
|
||||
export const InstalledPluginSchema = z.object({
|
||||
listing: PluginListingSchema,
|
||||
installedAt: z.string().datetime(),
|
||||
enabled: z.boolean(),
|
||||
storageConfig: BatchStorageSchema,
|
||||
});
|
||||
export type InstalledPlugin = z.infer<typeof InstalledPluginSchema>;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Data Manager
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user