You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
268 lines
9.9 KiB
268 lines
9.9 KiB
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 != '');
|
|
`,
|
|
},
|
|
{
|
|
name: "005_active_identity_constraint_fix",
|
|
sql: `
|
|
-- Migration 005: Fix foreign key constraint to ON DELETE RESTRICT
|
|
-- CRITICAL SECURITY FIX: Prevents accidental account deletion
|
|
|
|
PRAGMA foreign_keys = ON;
|
|
|
|
-- Recreate table with ON DELETE RESTRICT constraint (SECURITY FIX)
|
|
CREATE TABLE active_identity_new (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
activeDid TEXT REFERENCES accounts(did) ON DELETE RESTRICT,
|
|
lastUpdated TEXT NOT NULL DEFAULT (datetime('now'))
|
|
);
|
|
|
|
-- Copy existing data
|
|
INSERT INTO active_identity_new (id, activeDid, lastUpdated)
|
|
SELECT id, activeDid, lastUpdated FROM active_identity;
|
|
|
|
-- Replace old table
|
|
DROP TABLE active_identity;
|
|
ALTER TABLE active_identity_new RENAME TO active_identity;
|
|
|
|
-- Recreate indexes
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_active_identity_single_record ON active_identity(id);
|
|
`,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* @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<T>(
|
|
sqlExec: (sql: string, params?: unknown[]) => Promise<void>,
|
|
sqlQuery: (sql: string, params?: unknown[]) => Promise<T>,
|
|
extractMigrationNames: (result: T) => Set<string>,
|
|
): Promise<void> {
|
|
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);
|
|
}
|
|
}
|
|
|