Compare commits

...

2 Commits

Author SHA1 Message Date
3051e6e0a9 update plan 2026-03-05 23:54:30 +01:00
4cd382b829 step 4.1+4.2 complete: E2E encrypted backup + offline sync queue
- e2e-crypto.ts: Argon2id key derivation (time=3, mem=64MB) + AES-256-GCM
  encrypt/decrypt + SHA-256 checksum + ADV1 blob packing (salt+IV+authTag+ciphertext)
- backup-manager.ts: createBackup (WAL snapshot → encrypt → upload), restoreBackup
  (download → verify → decrypt → atomic file swap → reinit DB → notify renderer),
  getHistory, deleteBackup, schedulePeriodicBackup (setInterval, default 24h)
- sync-queue.ts: enqueues failed backup intents in sync_queue table; processQueue
  retries up to 5×; triggered automatically on WS reconnect via onConnected()
- backend-client.ts: uploadBackup (raw PUT /api/v1/backup with custom headers),
  downloadBackup (If-Modified-Since / 304 support), onConnected() event hook
- auth-manager.ts: password cached in memory at login/register, cleared at logout,
  getCachedPassword() for BackupManager — never persisted to disk
- store.ts: backupEnabled, backupIntervalHours, lastBackupAt settings
- db/schema.ts: sync_queue table (id, action, payload, status, retries, timestamps)
- db/index.ts: getRawSqlite() for .backup() API, getDbPath(), closeDb() for restore
- router/index.ts: backupRouter (create/restore/history/delete/settings/updateSettings);
  login starts periodic backup; logout stops it
- index.ts: backup scheduler wired to app lifecycle; will-quit cleans up timer
- package.json: argon2 added

Backend integration: PUT/GET /api/v1/backup already fully implemented; no
backend changes needed. Tier gating (free=0, pro=5GB, power=25GB) enforced
server-side. Backend only verifies SHA-256 checksum — never decrypts.
2026-03-05 22:56:10 +01:00
13 changed files with 1015 additions and 23 deletions

View File

@@ -354,25 +354,86 @@ Key constraints:
## Phase 4 — Security: E2E Backup & Offline
### Step 4.1 — E2E encrypted backup
- [ ] `src/main/backup/e2e-crypto.ts` + `backup-manager.ts`
- [x] `src/main/backup/e2e-crypto.ts` + `backup-manager.ts`
- **Outcome:** User data never leaves the device unencrypted.
### Step 4.2 — Offline sync queue
- [ ] `src/main/backup/sync-queue.ts` + `sync_queue` table
- [x] `src/main/backup/sync-queue.ts` + `sync_queue` table
- **Outcome:** Queued actions auto-sync when online.
### Step 4.3 — Migrate to SQLCipher
- [ ] Replace `better-sqlite3` with `@journeyapps/sqlcipher`
- **Outcome:** All local data encrypted at rest.
> **Step 4.3 (SQLCipher) — Dropped.** OS-level FDE covers at-rest encryption for a local-first desktop app. Backups already E2E encrypted via Argon2id + AES-256-GCM. Native module build complexity, ~10% perf overhead, and key management UX friction not justified by the threat model.
---
## Phase 5 — Shared Memory
### Step 5.1 — Three-tier local memory
- [ ] `src/main/database/shared-memory.ts`: conversation buffer + agent KV store + multi-collection vector store
- [ ] `agent_memory` table in schema
- **Outcome:** Short-term, long-term, and semantic memory.
> **Objective:** Add persistent chat history (multi-turn conversations survive app restarts) and an agent KV store (agents persist learned facts — preferences, patterns, corrections). All memory lives in Electron's SQLite (local-first). The orchestrator populates `conversationHistory` for the backend LLM, and pre-loads agent memories into context. Backend gets new memory tools + a critical fix to wire the chat WS for bidirectional tool calls.
>
> **Backend Phase 5 plan:** `../adiuva-api/AI_REFACTOR_PLAN.md` Phase 5 section.
>
> **Why this matters:**
> - **Chat history:** Currently every message is ephemeral — the AI has no memory between turns or across restarts. With persistence, multi-turn conversations work, the LLM sees prior turns, and conversations feel continuous across app restarts.
> - **Agent KV:** Agents currently have zero long-term memory. They can't remember "User prefers short task names" or "Client X wants weekly Monday updates". The KV store transforms agents from stateless tool executors into learning assistants that improve over time.
### Step 5.1 — Chat history + agent memory tables
- [ ] Add `chatMessages` table to `src/main/db/schema.ts`:
- `id` (text PK), `scope` (text: `'global'` or `'project:<uuid>'`), `role` (text: `'user'|'assistant'`), `content` (text), `error` (integer 0|1, default 0), `createdAt` (integer)
- Export `ChatMessage` / `NewChatMessage` inferred types
- [ ] Add `agentMemory` table to `src/main/db/schema.ts`:
- `id` (text PK), `agentName` (text: e.g. `'task_agent'`), `key` (text), `value` (text — JSON-serialized), `scope` (text: `'global'` or `'project:<uuid>'`), `createdAt` (integer), `updatedAt` (integer)
- Unique constraint: upsert by `(agentName, key, scope)` — same key overwrites value
- Export `AgentMemoryEntry` / `NewAgentMemoryEntry` inferred types
- [ ] Add migration SQL to `src/main/db/index.ts`:
- `CREATE TABLE IF NOT EXISTS chat_messages (...)` and `CREATE TABLE IF NOT EXISTS agent_memory (...)`
- `CREATE UNIQUE INDEX IF NOT EXISTS idx_agent_memory_unique ON agent_memory(agent_name, key, scope)`
- [ ] Register both tables in `TABLE_REGISTRY` in `src/main/api/drizzle-executor.ts`
- Enables backend agent tools to read/write both tables via `execute_on_client()` tool calls
- **Files:** `src/main/db/schema.ts`, `src/main/db/index.ts`, `src/main/api/drizzle-executor.ts`
- **Outcome:** Two new persistent tables for chat and agent memory. Backend can access both via existing WS tool_call protocol.
### Step 5.2 — Chat and memory tRPC routers
- [ ] Add `chatMessagesRouter` to `src/main/router/index.ts`:
- `chatMessages.list` — query: `{ scope: string, limit?: number }` → messages ordered by `createdAt ASC`, default limit 50
- `chatMessages.append` — mutation: `{ scope, role: 'user'|'assistant', content, error?: boolean }` → insert with UUID + timestamp
- `chatMessages.clear` — mutation: `{ scope }` → delete all messages for that scope
- [ ] Add `agentMemoryRouter` to `src/main/router/index.ts`:
- `agentMemory.list` — query: `{ scope?, agentName? }` → all memories, filterable
- `agentMemory.delete` — mutation: `{ id }` → delete single entry
- `agentMemory.clearScope` — mutation: `{ scope }` → clear all memories for a scope
- Purpose: UI display + user management of agent memories (reads/deletes only — writes come from backend agent tools)
- [ ] Merge both into `appRouter`
- **Files:** `src/main/router/index.ts`
- **Outcome:** Renderer can list/clear chat history and view/manage agent memories.
### Step 5.3 — Orchestrator context enrichment
- [ ] Update `buildChatContext()` in `src/main/ai/orchestrator.ts`:
- Derive `scope` from context type + projectId (`'global'` or `'project:<uuid>'`)
- Query last 20 chat messages from `chatMessages` table for that scope → map to `{ role, content }` → set as `conversationHistory` on the `ChatContext`
- Query last 20 agent memories from `agentMemory` table for scope (global + project if project-scoped) → format as `"[key]: value"` strings → include in context
- [ ] Extend `ChatContextSchema` in `src/shared/api-types.ts` with optional `agentMemories: z.array(z.object({ key: z.string(), value: z.string(), agentName: z.string() }))` field
- **Files:** `src/main/ai/orchestrator.ts`, `src/shared/api-types.ts`
- **Outcome:** Backend LLM sees multi-turn history and pre-loaded agent memories in every request.
### Step 5.4 — Renderer chat persistence
- [ ] Update `src/renderer/hooks/useAIChat.ts`:
- Compute `scope` from `defaultContext`: `'global'` or `'project:<projectId>'`
- Seed `messages` state from `trpc.chatMessages.list.useQuery({ scope })` on mount
- After each user message and assistant response, call `trpc.chatMessages.append.useMutation()` to persist
- Update `clearMessages()` to also call `trpc.chatMessages.clear.mutate({ scope })`
- [ ] Handle project deletion cleanup in `projectsRouter.delete`:
- Delete `chatMessages` where `scope = 'project:<projectId>'`
- Delete `agentMemory` where `scope = 'project:<projectId>'`
- **Files:** `src/renderer/hooks/useAIChat.ts`, `src/main/router/index.ts`
- **Outcome:** Chat messages persist across app restarts. Scope isolation between global and project threads. Project deletion cleans up associated memory.
### Design Decisions
- **All memory in Electron SQLite** — local-first, E2E encrypted by default. Backend never stores user memories.
- **Implicit sessions** — one chat thread per scope (`'global'` or `'project:<uuid>'`), no conversation list UI. Matches current UX.
- **Scope encoding** — `'global'` or `'project:<uuid>'` as a text column. Simple, queryable, extensible to per-client etc.
- **Upsert by (agentName, key, scope)** — prevents duplicate facts; agent can overwrite a preference without creating duplicates.
- **Pre-load + recall tool** — 20 recent memories injected into context at conversation start; agents also have `recall_memories` tool for targeted mid-conversation lookup (backend Phase 5).
- **Last 20 messages/memories** — balances context quality vs. token cost.
- **No vector embedding of chat messages** — recency-based retrieval is the right fit for conversation history. Notes vector search stays as-is.
---
@@ -410,7 +471,6 @@ Key constraints:
| Package | Purpose |
|---|---|
| `ws` | WebSocket client for backend streaming |
| `@journeyapps/sqlcipher` | Encrypted SQLite (replaces `better-sqlite3`) |
| `argon2` | Key derivation for E2E backup |
| `node-cron` | Batch agent scheduling |
| `chokidar` | File watching (plugin) |

75
package-lock.json generated
View File

@@ -20,6 +20,7 @@
"@trpc/react-query": "^11.10.0",
"@trpc/server": "^11.10.0",
"@types/ws": "^8.18.1",
"argon2": "^0.44.0",
"better-sqlite3": "^12.6.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -2164,6 +2165,12 @@
"license": "0BSD",
"optional": true
},
"node_modules/@epic-web/invariant": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz",
"integrity": "sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA==",
"license": "MIT"
},
"node_modules/@esbuild-kit/core-utils": {
"version": "3.3.2",
"resolved": "https://registry.npmjs.org/@esbuild-kit/core-utils/-/core-utils-3.3.2.tgz",
@@ -5093,6 +5100,15 @@
"win32"
]
},
"node_modules/@phc/format": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/@phc/format/-/format-1.0.0.tgz",
"integrity": "sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/@radix-ui/number": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz",
@@ -9290,6 +9306,22 @@
"license": "0BSD",
"peer": true
},
"node_modules/argon2": {
"version": "0.44.0",
"resolved": "https://registry.npmjs.org/argon2/-/argon2-0.44.0.tgz",
"integrity": "sha512-zHPGN3S55sihSQo0dBbK0A5qpi2R31z7HZDZnry3ifOyj8bZZnpZND2gpmhnRGO1V/d555RwBqIK5W4Mrmv3ig==",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@phc/format": "^1.0.0",
"cross-env": "^10.0.0",
"node-addon-api": "^8.5.0",
"node-gyp-build": "^4.8.4"
},
"engines": {
"node": ">=16.17.0"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -10681,11 +10713,27 @@
"dev": true,
"license": "MIT"
},
"node_modules/cross-env": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/cross-env/-/cross-env-10.1.0.tgz",
"integrity": "sha512-GsYosgnACZTADcmEyJctkJIoqAhHjttw7RsFrVoJNXbsWWqaq6Ym+7kZjq6mS45O0jij6vtiReppKQEtqWy6Dw==",
"license": "MIT",
"dependencies": {
"@epic-web/invariant": "^1.0.0",
"cross-spawn": "^7.0.6"
},
"bin": {
"cross-env": "dist/bin/cross-env.js",
"cross-env-shell": "dist/bin/cross-env-shell.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
"path-key": "^3.1.0",
@@ -15631,7 +15679,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"dev": true,
"license": "ISC"
},
"node_modules/jest-worker": {
@@ -18087,6 +18134,15 @@
"node": ">=10"
}
},
"node_modules/node-addon-api": {
"version": "8.6.0",
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.6.0.tgz",
"integrity": "sha512-gBVjCaqDlRUk0EwoPNKzIr9KkS9041G/q31IBShPs1Xz6UTA+EXdZADbzqAJQrpDRq71CIMnOP5VMut3SL0z5Q==",
"license": "MIT",
"engines": {
"node": "^18 || ^20 || >= 21"
}
},
"node_modules/node-api-version": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/node-api-version/-/node-api-version-0.2.1.tgz",
@@ -18139,6 +18195,17 @@
}
}
},
"node_modules/node-gyp-build": {
"version": "4.8.4",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz",
"integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==",
"license": "MIT",
"bin": {
"node-gyp-build": "bin.js",
"node-gyp-build-optional": "optional.js",
"node-gyp-build-test": "build-test.js"
}
},
"node_modules/node-releases": {
"version": "2.0.27",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
@@ -18795,7 +18862,6 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -21471,7 +21537,6 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"dev": true,
"license": "MIT",
"dependencies": {
"shebang-regex": "^3.0.0"
@@ -21484,7 +21549,6 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
@@ -24047,7 +24111,6 @@
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"dev": true,
"license": "ISC",
"dependencies": {
"isexe": "^2.0.0"

View File

@@ -59,6 +59,7 @@
"@trpc/react-query": "^11.10.0",
"@trpc/server": "^11.10.0",
"@types/ws": "^8.18.1",
"argon2": "^0.44.0",
"better-sqlite3": "^12.6.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",

View File

@@ -108,6 +108,9 @@ export class BackendClient {
private shouldReconnect = false;
private reconnectAttempt = 0;
/** Optional callback fired when the persistent WS successfully connects. */
private onConnectedCallback: (() => void) | null = null;
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
@@ -411,7 +414,103 @@ export class BackendClient {
}
// -------------------------------------------------------------------------
// Persistent device WebSocket (Step 3.5)
// 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;
}
// -------------------------------------------------------------------------
/**
@@ -478,6 +577,11 @@ 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 */ }
}
// Fetch enabled local agent IDs bound to this device
const deviceId = getDeviceId();
let agentIds: string[] = [];

View File

@@ -54,6 +54,13 @@ export class AuthManager {
private static instance: AuthManager | null = null;
private refreshPromise: Promise<void> | null = null;
/**
* User's account password held in memory only — never persisted to disk.
* Set at login/register; cleared at logout. Used by BackupManager to
* derive the AES encryption key for database backups.
*/
private _cachedPassword: string | null = null;
// eslint-disable-next-line @typescript-eslint/no-empty-function
private constructor() {}
@@ -73,6 +80,7 @@ export class AuthManager {
const data = await this.post<AuthTokens>('/api/v1/auth/register', { email, password });
const tokens = AuthTokensSchema.parse(data);
await this.storeTokens(tokens);
this._cachedPassword = password;
return tokens;
}
@@ -81,11 +89,13 @@ export class AuthManager {
const data = await this.post<AuthTokens>('/api/v1/auth/login', { email, password });
const tokens = AuthTokensSchema.parse(data);
await this.storeTokens(tokens);
this._cachedPassword = password;
return tokens;
}
/** Clear all stored auth tokens. */
async logout(): Promise<void> {
this._cachedPassword = null;
await Promise.all([
deleteToken(TOKEN_KEYS.access),
deleteToken(TOKEN_KEYS.refresh),
@@ -93,6 +103,15 @@ export class AuthManager {
]);
}
/**
* Returns the password that was cached at login.
* Used exclusively by BackupManager for key derivation — never persisted.
* Returns null if the user has not logged in this session.
*/
getCachedPassword(): string | null {
return this._cachedPassword;
}
/**
* Return a valid access token, refreshing transparently if near expiry.
* Returns `null` if not authenticated.

View File

@@ -0,0 +1,253 @@
/**
* 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();
}

View File

@@ -0,0 +1,160 @@
/**
* E2E encryption primitives for local database backup.
*
* Encryption scheme:
* - Key derivation : Argon2id (time=3, memory=64 MB, parallelism=4, 256-bit output)
* - 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] Argon2 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';
import { hash as argon2Hash, argon2id } from 'argon2';
// ---------------------------------------------------------------------------
// 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
// Argon2id parameters — follows OWASP recommended minimums.
const ARGON2_TIME_COST = 3;
const ARGON2_MEMORY_COST = 65_536; // 64 MB
const ARGON2_PARALLELISM = 4;
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Derives a 256-bit AES key from `password` and `salt` using Argon2id.
*
* @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 async function deriveKey(password: string, salt: Buffer): Promise<Buffer> {
const hash = await argon2Hash(password, {
type: argon2id,
salt,
timeCost: ARGON2_TIME_COST,
memoryCost: ARGON2_MEMORY_COST,
parallelism: ARGON2_PARALLELISM,
hashLength: KEY_LEN,
raw: true,
});
return hash as Buffer;
}
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 };
}

View File

@@ -0,0 +1,157 @@
/**
* 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();
}

View File

@@ -4,6 +4,12 @@ import { app } from 'electron';
import path from 'node:path';
import * as schema from './schema';
/** Resolved path to the SQLite database file. Set once in initDb(). */
let _dbPath: string | null = null;
/** Raw better-sqlite3 instance (needed for .backup() API). */
let _rawSqlite: Database.Database | null = null;
// SQL to create all tables if they don't exist (non-destructive push strategy)
const MIGRATION_SQL = `
CREATE TABLE IF NOT EXISTS clients (
@@ -63,6 +69,16 @@ const MIGRATION_SQL = `
content TEXT NOT NULL,
created_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS sync_queue (
id TEXT PRIMARY KEY,
action TEXT NOT NULL,
payload TEXT NOT NULL DEFAULT '{}',
status TEXT NOT NULL DEFAULT 'pending',
retries INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
last_attempt_at INTEGER
);
`;
type DbInstance = ReturnType<typeof drizzle<typeof schema>>;
@@ -71,12 +87,14 @@ let dbInstance: DbInstance | null = null;
export function initDb(): DbInstance {
const userDataPath = app.getPath('userData');
const dbPath = path.join(userDataPath, 'adiuva.db');
_dbPath = path.join(userDataPath, 'adiuva.db');
const sqlite = new Database(dbPath);
const sqlite = new Database(_dbPath);
_rawSqlite = sqlite;
// Enable WAL mode for better concurrent read performance
sqlite.pragma('journal_mode = WAL');
sqlite.pragma('synchronous = NORMAL');
// Run non-destructive migrations on every start
sqlite.exec(MIGRATION_SQL);
@@ -95,3 +113,31 @@ export function getDb(): DbInstance {
}
return dbInstance;
}
/** Returns the absolute path to the active SQLite database file. */
export function getDbPath(): string {
if (!_dbPath) throw new Error('Database not initialized.');
return _dbPath;
}
/**
* Returns the raw better-sqlite3 Database instance.
* Used by BackupManager for the `.backup()` API.
*/
export function getRawSqlite(): Database.Database {
if (!_rawSqlite) throw new Error('Database not initialized.');
return _rawSqlite;
}
/**
* Closes the database connection and clears all module-level references.
* Called by BackupManager before atomically replacing the DB file.
* After calling this, you must call `initDb()` again to re-open.
*/
export function closeDb(): void {
if (_rawSqlite) {
try { _rawSqlite.close(); } catch { /* ignore */ }
_rawSqlite = null;
}
dbInstance = null;
}

View File

@@ -77,3 +77,19 @@ export type NewNote = InferInsertModel<typeof notes>;
export type TaskComment = InferSelectModel<typeof taskComments>;
export type NewTaskComment = InferInsertModel<typeof taskComments>;
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>;

View File

@@ -7,6 +7,9 @@ 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';
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
if (started) {
@@ -64,17 +67,31 @@ 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) return getBackendClient().connectPersistent();
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));
});
// Clean up the persistent WS before the app exits
// Clean up the persistent WS and backup timers before the app exits
app.on('will-quit', () => {
getBackupManager().stopPeriodicBackup();
getBackendClient().disconnectPersistent();
});

View File

@@ -6,10 +6,11 @@ import { getDb } from '../db';
import { clients, projects, tasks, checkpoints, notes, taskComments } from '../db/schema';
import { getStore, getDeviceId } from '../store';
import { getBackendClient } from '../api/backend-client';
import type { AgentCatalogItem, LocalAgentConfig, CloudAgentConfig, AgentRunLog, JourneyMessage } from '../../shared/api-types';
import type { AgentCatalogItem, LocalAgentConfig, CloudAgentConfig, AgentRunLog, JourneyMessage, BackupMetadata } from '../../shared/api-types';
import { orchestrate, dailyBrief } 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();
@@ -849,6 +850,8 @@ 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';
@@ -859,7 +862,8 @@ const authRouter = router({
logout: publicProcedure.mutation(async () => {
const auth = getAuthManager();
await auth.logout();
// Disconnect persistent device WS — stops agent triggers until next login
// Stop backup scheduler and disconnect WS
getBackupManager().stopPeriodicBackup();
getBackendClient().disconnectPersistent();
return { success: true as const };
}),
@@ -896,6 +900,88 @@ 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,
@@ -908,6 +994,7 @@ export const appRouter = router({
ai: aiRouter,
auth: authRouter,
agent: agentRouter,
backup: backupRouter,
});
export type AppRouter = typeof appRouter;

View File

@@ -12,6 +12,12 @@ interface AppSettings {
* configured on (Step 3.3).
*/
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;
}
let _store: Store<AppSettings> | null = null;
@@ -25,6 +31,9 @@ export function getStore(): Store<AppSettings> {
userName: 'there',
backendUrl: 'http://localhost:8000',
deviceId: '',
backupEnabled: false,
backupIntervalHours: 24,
lastBackupAt: null,
},
});
}