import { registerMigration, runMigrations as runMigrationsService, } from "../services/migrationService"; import { DEFAULT_ENDORSER_API_SERVER } from "@/constants/app"; import { arrayBufferToBase64 } from "@/libs/crypto"; import { logger } from "@/utils/logger"; // Database result interface for SQLite queries interface DatabaseResult { values?: unknown[][]; [key: string]: unknown; } // Generate a random secret for the secret table // It's not really secure to maintain the secret next to the user's data. // However, until we have better hooks into a real wallet or reliable secure // storage, we'll do this for user convenience. As they sign more records // and integrate with more people, they'll value it more and want to be more // secure, so we'll prompt them to take steps to back it up, properly encrypt, // etc. At the beginning, we'll prompt for a password, then we'll prompt for a // PWA so it's not in a browser... and then we hope to be integrated with a // real wallet or something else more secure. // One might ask: why encrypt at all? We figure a basic encryption is better // than none. Plus, we expect to support their own password or keystore or // external wallet as better signing options in the future, so it's gonna be // important to have the structure where each account access might require // user action. // (Once upon a time we stored the secret in localStorage, but it frequently // got erased, even though the IndexedDB still had the identity data. This // ended up throwing lots of errors to the user... and they'd end up in a state // where they couldn't take action because they couldn't unlock that identity.) const randomBytes = crypto.getRandomValues(new Uint8Array(32)); const secretBase64 = arrayBufferToBase64(randomBytes.buffer); // Each migration can include multiple SQL statements (with semicolons) const MIGRATIONS = [ { name: "001_initial", sql: ` CREATE TABLE IF NOT EXISTS accounts ( id INTEGER PRIMARY KEY AUTOINCREMENT, dateCreated TEXT NOT NULL, derivationPath TEXT, did TEXT NOT NULL, identityEncrBase64 TEXT, -- encrypted & base64-encoded mnemonicEncrBase64 TEXT, -- encrypted & base64-encoded passkeyCredIdHex TEXT, publicKeyHex TEXT NOT NULL ); CREATE INDEX IF NOT EXISTS idx_accounts_did ON accounts(did); CREATE TABLE IF NOT EXISTS secret ( id INTEGER PRIMARY KEY AUTOINCREMENT, secretBase64 TEXT NOT NULL ); INSERT INTO secret (id, secretBase64) VALUES (1, '${secretBase64}'); CREATE TABLE IF NOT EXISTS settings ( id INTEGER PRIMARY KEY AUTOINCREMENT, accountDid TEXT, activeDid TEXT, apiServer TEXT, filterFeedByNearby BOOLEAN, filterFeedByVisible BOOLEAN, finishedOnboarding BOOLEAN, firstName TEXT, hideRegisterPromptOnNewContact BOOLEAN, isRegistered BOOLEAN, lastName TEXT, lastAckedOfferToUserJwtId TEXT, lastAckedOfferToUserProjectsJwtId TEXT, lastNotifiedClaimId TEXT, lastViewedClaimId TEXT, notifyingNewActivityTime TEXT, notifyingReminderMessage TEXT, notifyingReminderTime TEXT, partnerApiServer TEXT, passkeyExpirationMinutes INTEGER, profileImageUrl TEXT, searchBoxes TEXT, -- Stored as JSON string showContactGivesInline BOOLEAN, showGeneralAdvanced BOOLEAN, showShortcutBvc BOOLEAN, vapid TEXT, warnIfProdServer BOOLEAN, warnIfTestServer BOOLEAN, webPushServer TEXT ); CREATE INDEX IF NOT EXISTS idx_settings_accountDid ON settings(accountDid); INSERT INTO settings (id, apiServer) VALUES (1, '${DEFAULT_ENDORSER_API_SERVER}'); CREATE TABLE IF NOT EXISTS contacts ( id INTEGER PRIMARY KEY AUTOINCREMENT, did TEXT NOT NULL, name TEXT, contactMethods TEXT, -- Stored as JSON string nextPubKeyHashB64 TEXT, notes TEXT, profileImageUrl TEXT, publicKeyBase64 TEXT, seesMe BOOLEAN, registered BOOLEAN ); CREATE INDEX IF NOT EXISTS idx_contacts_did ON contacts(did); CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name); CREATE TABLE IF NOT EXISTS logs ( date TEXT NOT NULL, message TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS temp ( id TEXT PRIMARY KEY, blobB64 TEXT ); `, }, { name: "002_add_iViewContent_to_contacts", sql: ` ALTER TABLE contacts ADD COLUMN iViewContent BOOLEAN DEFAULT TRUE; `, }, { name: "003_add_hasBackedUpSeed_to_settings", sql: ` -- Add hasBackedUpSeed field to settings -- This migration assumes master code has been deployed -- The error handling will catch this if column already exists and mark migration as applied ALTER TABLE settings ADD COLUMN hasBackedUpSeed BOOLEAN DEFAULT FALSE; `, }, { name: "004_active_identity_and_seed_backup", sql: ` -- Migration 004: active_identity_and_seed_backup -- Assumes master code deployed with migration 003 (hasBackedUpSeed) -- Enable foreign key constraints for data integrity PRAGMA foreign_keys = ON; -- Add UNIQUE constraint to accounts.did for foreign key support CREATE UNIQUE INDEX IF NOT EXISTS idx_accounts_did_unique ON accounts(did); -- Create active_identity table with foreign key constraint CREATE TABLE IF NOT EXISTS active_identity ( id INTEGER PRIMARY KEY CHECK (id = 1), activeDid TEXT DEFAULT NULL, -- NULL instead of empty string lastUpdated TEXT NOT NULL DEFAULT (datetime('now')), FOREIGN KEY (activeDid) REFERENCES accounts(did) ON DELETE SET NULL ); -- Add performance indexes CREATE UNIQUE INDEX IF NOT EXISTS idx_active_identity_single_record ON active_identity(id); -- Seed singleton row (only if not already exists) INSERT INTO active_identity (id, activeDid, lastUpdated) SELECT 1, NULL, datetime('now') WHERE NOT EXISTS (SELECT 1 FROM active_identity WHERE id = 1); -- MIGRATE EXISTING DATA: Copy activeDid from settings to active_identity -- This prevents data loss when migration runs on existing databases UPDATE active_identity SET activeDid = (SELECT activeDid FROM settings WHERE id = 1), lastUpdated = datetime('now') WHERE id = 1 AND EXISTS (SELECT 1 FROM settings WHERE id = 1 AND activeDid IS NOT NULL AND activeDid != ''); `, }, ]; /** * @param sqlExec - A function that executes a SQL statement and returns the result * @param extractMigrationNames - A function that extracts the names (string array) from "select name from migrations" */ export async function runMigrations( sqlExec: (sql: string, params?: unknown[]) => Promise, sqlQuery: (sql: string, params?: unknown[]) => Promise, extractMigrationNames: (result: T) => Set, ): Promise { logger.debug("[Migration] Starting database migrations"); for (const migration of MIGRATIONS) { logger.debug("[Migration] Registering migration:", migration.name); registerMigration(migration); } logger.debug("[Migration] Running migration service"); await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames); logger.debug("[Migration] Database migrations completed"); // Bootstrapping: Ensure active account is selected after migrations logger.debug("[Migration] Running bootstrapping hooks"); try { // Check if we have accounts but no active selection const accountsResult = await sqlQuery("SELECT COUNT(*) FROM accounts"); const accountsCount = accountsResult && (accountsResult as DatabaseResult).values ? ((accountsResult as DatabaseResult).values?.[0]?.[0] as number) : 0; const activeResult = await sqlQuery( "SELECT activeDid FROM active_identity WHERE id = 1", ); const activeDid = activeResult && (activeResult as DatabaseResult).values ? ((activeResult as DatabaseResult).values?.[0]?.[0] as string) : null; if (accountsCount > 0 && (!activeDid || activeDid === "")) { logger.debug("[Migration] Auto-selecting first account as active"); const firstAccountResult = await sqlQuery( "SELECT did FROM accounts ORDER BY dateCreated, did LIMIT 1", ); const firstAccountDid = firstAccountResult && (firstAccountResult as DatabaseResult).values ? ((firstAccountResult as DatabaseResult).values?.[0]?.[0] as string) : null; if (firstAccountDid) { await sqlExec( "UPDATE active_identity SET activeDid = ?, lastUpdated = datetime('now') WHERE id = 1", [firstAccountDid], ); logger.info(`[Migration] Set active account to: ${firstAccountDid}`); } } } catch (error) { logger.warn("[Migration] Bootstrapping hook failed (non-critical):", error); } }