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"; // 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); // Single source of truth for migration 004 SQL const MIG_004_SQL = ` -- Migration 004: active_identity_management (CONSOLIDATED) -- Combines original migrations 004, 005, and 006 into single atomic operation -- CRITICAL SECURITY: Uses ON DELETE RESTRICT constraint from the start -- 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 SECURE constraint (ON DELETE RESTRICT) -- This prevents accidental account deletion - critical security feature CREATE TABLE IF NOT EXISTS active_identity ( id INTEGER PRIMARY KEY CHECK (id = 1), activeDid TEXT REFERENCES accounts(did) ON DELETE RESTRICT, lastUpdated TEXT NOT NULL DEFAULT (datetime('now')) ); -- 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 != ''); -- Copy important settings that were set in the MASTER_SETTINGS_KEY to the main identity. -- (We're not doing them all because some were already identity-specific and others aren't as critical.) UPDATE settings SET lastViewedClaimId = (SELECT lastViewedClaimId FROM settings WHERE id = 1), profileImageUrl = (SELECT profileImageUrl FROM settings WHERE id = 1), showShortcutBvc = (SELECT showShortcutBvc FROM settings WHERE id = 1), warnIfProdServer = (SELECT warnIfProdServer FROM settings WHERE id = 1), warnIfTestServer = (SELECT warnIfTestServer FROM settings WHERE id = 1) WHERE id = 2; -- CLEANUP: Remove orphaned settings records and clear legacy activeDid values -- which usually simply deletes the MASTER_SETTINGS_KEY record. -- This completes the migration from settings-based to table-based active identity DELETE FROM settings WHERE accountDid IS NULL; UPDATE settings SET activeDid = NULL; `; // 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_management", sql: MIG_004_SQL, }, { name: "005_add_starredPlanHandleIds_to_settings", sql: ` ALTER TABLE settings ADD COLUMN starredPlanHandleIds TEXT DEFAULT '[]'; -- JSON string ALTER TABLE settings ADD COLUMN lastAckedStarredPlanChangesJwtId TEXT; `, }, ]; /** * Extract single value from database query result * Works with different database service result formats */ function extractSingleValue(result: T): string | number | null { if (!result) return null; // Handle AbsurdSQL format: QueryExecResult[] if (Array.isArray(result) && result.length > 0 && result[0]?.values) { const values = result[0].values; return values.length > 0 ? values[0][0] : null; } // Handle Capacitor SQLite format: { values: unknown[][] } if (typeof result === "object" && result !== null && "values" in result) { const values = (result as { values: unknown[][] }).values; return values && values.length > 0 ? (values[0][0] as string | number) : null; } return null; } /** * @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 { // Only log migration start in development const isDevelopment = process.env.VITE_PLATFORM === "development"; if (isDevelopment) { logger.debug("[Migration] Starting database migrations"); } for (const migration of MIGRATIONS) { if (isDevelopment) { logger.debug("[Migration] Registering migration:", migration.name); } registerMigration(migration); } if (isDevelopment) { logger.debug("[Migration] Running migration service"); } await runMigrationsService(sqlExec, sqlQuery, extractMigrationNames); if (isDevelopment) { logger.debug("[Migration] Database migrations completed"); } // Bootstrapping: Ensure active account is selected after migrations if (isDevelopment) { 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 = (extractSingleValue(accountsResult) as number) || 0; // Check if active_identity table exists, and if not, try to recover let activeDid: string | null = null; try { const activeResult = await sqlQuery( "SELECT activeDid FROM active_identity WHERE id = 1", ); activeDid = (extractSingleValue(activeResult) as string) || null; } catch (error) { // Table doesn't exist - migration 004 may not have run yet if (isDevelopment) { logger.debug( "[Migration] active_identity table not found - migration may not have run", ); } activeDid = null; } if (accountsCount > 0 && (!activeDid || activeDid === "")) { if (isDevelopment) { 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 = (extractSingleValue(firstAccountResult) 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); } }