/**
 * Phase 8 — auth.db column upgrades for per-user secret encryption.
 *
 * Two tables in `auth.db` carry per-user secrets:
 *
 *   1. `oauth_tokens.data_json` (TEXT NOT NULL) holds the full MCP OAuth
 *      payload (tokens + client info + code verifier) for a user+connector.
 *      Its value is a JSON blob containing the access/refresh tokens.
 *   2. `user_preferences.settings_json` (TEXT NOT NULL DEFAULT '{}') holds
 *      a free-form per-user key/value map. Today the only secret it
 *      carries is the Deepgram key (meeting capture); the rest is
 *      non-secret prefs (default folder, calendar config). Discrimination
 *      is registry-driven — any key whose registry entry is
 *      `user-secret` moves to the new ciphertext column.
 *
 * Strategy:
 *
 *   - `oauth_tokens`: SQLite cannot ALTER a TEXT NOT NULL column to be
 *     nullable. The standard pattern is a transactional table rebuild
 *     (CREATE NEW + INSERT...SELECT + DROP + RENAME). We do that, with
 *     the per-row encryption running in the Node loop between SELECT and
 *     INSERT.
 *   - `user_preferences`: column additions are easy (no constraints
 *     block us), so we just ALTER to add `settings_ciphertext` and
 *     `key_version`, then walk each row and partition keys by
 *     classification.
 *
 * Safety:
 *
 *   - Before any DDL, `auth.db` is copied to
 *     `auth.db.backup.<unix-ts-ms>`. If the backup write fails, the
 *     migration aborts before touching the source DB.
 *   - The rebuild runs inside a single transaction. SQLite atomic
 *     semantics give us rollback on any per-row encryption failure.
 *   - A row-count integrity check fires before COMMIT; mismatch
 *     triggers ROLLBACK and a thrown error.
 *   - Idempotent: completion is recorded in a new `meta` table.
 *     Re-running is a fast no-op.
 *   - Half-state guard: at boot we refuse to start if we find
 *     `data_json IS NOT NULL AND data_ciphertext IS NOT NULL` on any
 *     row — that means a previous run committed partial state.
 *
 * AAD binding for every encrypted blob:
 *
 *   `{ scope: 'user', scopeId: <user_id>, key: <column>, keyVersion: 'mk_v1' }`
 *
 * Swapping a ciphertext between users invalidates the AAD; tampering
 * with the row triggers AEAD failure on decrypt.
 */
import Database from 'better-sqlite3';
import { chmodSync, copyFileSync, existsSync } from 'fs';
import { encrypt, decrypt } from './encryption.js';
import { getKeyDef } from './registry.js';
import { log } from '../../utils/log.js';
const DEFAULT_KEY_VERSION = 'mk_v1';
const MIGRATION_META_KEY = 'auth_db_phase8_migrated_at';
const PREFS_SECRET_BLOB_KEY = 'user_preferences.settings';
const OAUTH_TOKENS_KEY = 'oauth_token';
/**
 * Run the Phase 8 auth.db migration. Idempotent; returns immediately
 * with `skipped: true` once the meta row is present.
 *
 * Caller MUST hold the only open handle to auth.db while this runs —
 * the migration opens its own Database connection internally so that
 * AuthService's WAL/foreign-keys configuration doesn't bleed in.
 * In `serve.ts` this runs before AuthService is constructed.
 */
export function migrateAuthDbPhase8(opts) {
    if (!existsSync(opts.authDbPath)) {
        // Fresh install — no DB to migrate. We still want the meta marker so
        // a later AuthService construction doesn't redo migration on a DB
        // that was created with the new schema from scratch. But at this
        // point the DB doesn't exist, so we have nothing to write to.
        // Defer: AuthService.constructor will create the DB with the new
        // shape and write the meta row itself.
        return { skipped: true, oauthRowsMigrated: 0, prefsRowsMigrated: 0 };
    }
    const keyVersion = opts.keyVersion ?? DEFAULT_KEY_VERSION;
    // Open the DB. Brief connection lifecycle — we close before returning so
    // the caller can open it fresh through AuthService.
    const db = new Database(opts.authDbPath);
    try {
        ensureMetaTable(db);
        if (alreadyMigrated(db)) {
            return { skipped: true, oauthRowsMigrated: 0, prefsRowsMigrated: 0 };
        }
        // Belt-and-suspenders: a half-state from a previous crashed run should
        // never reach this point (the migration runs inside a transaction), but
        // if it somehow did, refuse to continue silently.
        detectHalfState(db);
        // Backup before any DDL. If this throws, the source DB is untouched.
        const backupPath = backupDb(opts.authDbPath);
        let oauthRowsMigrated = 0;
        let prefsRowsMigrated = 0;
        try {
            // The whole migration runs in one transaction. If any step
            // throws (encryption failure, row-count mismatch, anything),
            // better-sqlite3 rolls back automatically.
            db.transaction(() => {
                oauthRowsMigrated = migrateOAuthTokens(db, opts.masterKey, keyVersion);
                prefsRowsMigrated = migrateUserPreferences(db, opts.masterKey, keyVersion);
                // Mark completion inside the same transaction so the meta row
                // and the new schema commit atomically.
                db.prepare(`INSERT INTO meta (key, value) VALUES (?, ?)
             ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(MIGRATION_META_KEY, String(Date.now()));
            })();
        }
        catch (err) {
            // Re-throw after logging. The caller (serve.ts) refuses to start
            // on migration error; the backup file is the recovery path.
            log.error(`[secrets] auth.db Phase 8 migration FAILED: ${err instanceof Error ? err.message : String(err)}. ` +
                `Restore from backup at ${backupPath} and investigate before retrying.`);
            throw err;
        }
        return {
            skipped: false,
            oauthRowsMigrated,
            prefsRowsMigrated,
            backupPath,
        };
    }
    finally {
        db.close();
    }
}
/**
 * Public helper for AuthService to invoke at construction time once the
 * migration is known to be complete (or didn't need to run on a fresh
 * DB). On a freshly-created DB this writes the meta row so future
 * `migrateAuthDbPhase8` calls short-circuit.
 */
export function markAuthDbPhase8Migrated(db) {
    ensureMetaTable(db);
    if (alreadyMigrated(db))
        return;
    db.prepare(`INSERT INTO meta (key, value) VALUES (?, ?)
       ON CONFLICT(key) DO UPDATE SET value = excluded.value`).run(MIGRATION_META_KEY, String(Date.now()));
}
export function isAuthDbPhase8Migrated(db) {
    ensureMetaTable(db);
    return alreadyMigrated(db);
}
/**
 * Half-state detector for the live AuthService boot. Caller-friendly:
 * returns true when migration appears complete-and-consistent. Throws
 * on the corrupted shape (data_json non-null on a row that also has
 * data_ciphertext) — that's evidence a previous run committed partial
 * state and we'd rather refuse to start than serve corrupt OAuth data.
 */
export function assertAuthDbConsistent(db) {
    if (!tableHasColumn(db, 'oauth_tokens', 'data_ciphertext'))
        return;
    detectHalfState(db);
}
// ─────────────────────────────────────────────────────────────────────
// Internals
// ─────────────────────────────────────────────────────────────────────
function ensureMetaTable(db) {
    db.exec(`
    CREATE TABLE IF NOT EXISTS meta (
      key TEXT PRIMARY KEY,
      value TEXT NOT NULL
    )
  `);
}
function alreadyMigrated(db) {
    const row = db
        .prepare('SELECT value FROM meta WHERE key = ?')
        .get(MIGRATION_META_KEY);
    return !!row;
}
function tableHasColumn(db, table, column) {
    const cols = db
        .prepare(`PRAGMA table_info(${table})`)
        .all();
    return cols.some((c) => c.name === column);
}
function detectHalfState(db) {
    // Only meaningful when the new column already exists.
    if (!tableHasColumn(db, 'oauth_tokens', 'data_ciphertext'))
        return;
    const row = db
        .prepare(`SELECT COUNT(*) AS n FROM oauth_tokens
         WHERE data_json IS NOT NULL AND data_ciphertext IS NOT NULL`)
        .get();
    if (row.n > 0) {
        throw new Error(`auth.db Phase 8 half-state detected: ${row.n} row(s) in oauth_tokens have both ` +
            `data_json and data_ciphertext populated. Restore from backup and investigate.`);
    }
}
function backupDb(authDbPath) {
    const backupPath = `${authDbPath}.backup.${Date.now()}`;
    // Codex M2: tighten the source file mode BEFORE copy so the backup
    // can't inherit a wider 0644 than the running server would have set.
    // The AuthService constructor chmods 0o600 on open, but the migration
    // runs before AuthService is constructed — and a fresh-from-tarball
    // or first-deploy auth.db can land as 0644 from `umask 022`. Setting
    // both source and backup to 0o600 is defensive and idempotent.
    // POSIX only; Windows ACLs differ and best-effort.
    if (process.platform !== 'win32') {
        try {
            chmodSync(authDbPath, 0o600);
        }
        catch { /* non-fatal */ }
    }
    // copyFileSync copies the main DB file. WAL contents are flushed on the
    // close that happens before we open here (auth.db is opened with WAL by
    // AuthService), but we are running before AuthService starts. The DB
    // file we copy here is the post-checkpoint snapshot from the previous
    // session. Any not-yet-checkpointed WAL would belong to a write that
    // happened in this process — and we haven't done any writes yet.
    copyFileSync(authDbPath, backupPath);
    // Backup carries credential-equivalent material (bcrypt hashes, OAuth
    // payloads). Lock it down to the running user — copyFileSync mirrors
    // the umask, not the source mode.
    if (process.platform !== 'win32') {
        try {
            chmodSync(backupPath, 0o600);
        }
        catch { /* non-fatal */ }
    }
    return backupPath;
}
function migrateOAuthTokens(db, masterKey, keyVersion) {
    // Snapshot the original table_info so we can preserve every existing
    // column (the schema may have evolved beyond what we expect). The new
    // table mirrors the original, except:
    //   - `data_json` drops the NOT NULL constraint (we'll write NULL).
    //   - Two new columns: `data_ciphertext` TEXT, `key_version` TEXT.
    const origCols = db
        .prepare('PRAGMA table_info(oauth_tokens)')
        .all();
    // Track the original primary-key columns so the rebuilt table keeps
    // the same composite PK. Today this is (user_id, connector_name) but
    // we mirror what PRAGMA reports rather than hardcode.
    const pkCols = origCols
        .filter((c) => c.pk > 0)
        .sort((a, b) => a.pk - b.pk)
        .map((c) => c.name);
    // Tolerate pre-existing ciphertext columns. Non-serve construction paths
    // (CLI `user`/`mcp`/`clone`/`sync` — anything that builds AuthService
    // before the boot-time migration runs) add `data_ciphertext` and
    // `key_version` idempotently in the constructor (auth-service.ts:378-387).
    // If that happened first, the source table already has the new columns
    // and pushing them again here would create a duplicate-column error in
    // the rebuilt table. Detect and skip the re-add — the migration still
    // needs to run to encrypt any rows that were left with `data_json`
    // populated and `data_ciphertext` NULL (partial-state recovery).
    const hasCiphertextCol = origCols.some((c) => c.name === 'data_ciphertext');
    const hasKeyVersionCol = origCols.some((c) => c.name === 'key_version');
    // Render each column in the same order/type as the original, except
    // drop NOT NULL from `data_json` so we can write NULLs after encrypt.
    // SQLite's PRAGMA returns `dflt_value` without surrounding parens for
    // function expressions (e.g. `datetime('now')`), but the SQL grammar
    // requires the parens — bare `DEFAULT datetime('now')` fails to parse.
    // We wrap any non-literal default in parens.
    const columnDefs = origCols.map((c) => {
        const name = c.name;
        const type = c.type || 'TEXT';
        const notNull = c.notnull && name !== 'data_json' ? ' NOT NULL' : '';
        const dflt = c.dflt_value !== null && c.dflt_value !== undefined
            ? ` DEFAULT ${renderDefault(c.dflt_value)}`
            : '';
        return `${name} ${type}${notNull}${dflt}`;
    });
    // Add the new columns ONLY if they're not already part of the source
    // schema (see hasCiphertextCol / hasKeyVersionCol above).
    if (!hasCiphertextCol)
        columnDefs.push('data_ciphertext TEXT');
    if (!hasKeyVersionCol)
        columnDefs.push('key_version TEXT');
    // Preserve foreign-key constraints from the original table.
    // `PRAGMA foreign_key_list` reports each FK with its source column,
    // target table+column, and ON UPDATE/DELETE actions. Multi-column FKs
    // share an `id`; we group by id and emit one composite clause each.
    const fkRows = db
        .prepare('PRAGMA foreign_key_list(oauth_tokens)')
        .all();
    const fkGroups = new Map();
    for (const fk of fkRows) {
        if (!fkGroups.has(fk.id))
            fkGroups.set(fk.id, []);
        fkGroups.get(fk.id).push(fk);
    }
    const fkClauses = [];
    for (const group of fkGroups.values()) {
        group.sort((a, b) => a.seq - b.seq);
        const cols = group.map((g) => g.from).join(', ');
        const refCols = group.map((g) => g.to).join(', ');
        const target = group[0].table;
        const onDelete = group[0].on_delete && group[0].on_delete !== 'NO ACTION'
            ? ` ON DELETE ${group[0].on_delete}`
            : '';
        const onUpdate = group[0].on_update && group[0].on_update !== 'NO ACTION'
            ? ` ON UPDATE ${group[0].on_update}`
            : '';
        fkClauses.push(`FOREIGN KEY (${cols}) REFERENCES ${target}(${refCols})${onDelete}${onUpdate}`);
    }
    const pkClause = pkCols.length > 0 ? `, PRIMARY KEY (${pkCols.join(', ')})` : '';
    const fkClause = fkClauses.length > 0 ? `, ${fkClauses.join(', ')}` : '';
    // Drop any pre-existing v2 from a crashed prior attempt before we use the name.
    db.exec('DROP TABLE IF EXISTS oauth_tokens_v2');
    db.exec(`CREATE TABLE oauth_tokens_v2 (
      ${columnDefs.join(',\n      ')}${pkClause}${fkClause}
    )`);
    // Snapshot any indexes/triggers on the original so we can recreate
    // them on the rebuilt table. SQLite's sqlite_master lets us read the
    // CREATE SQL verbatim; we'll have to rewrite "oauth_tokens" → "oauth_tokens"
    // (the same name after rename) so the indexes apply correctly. Simplest
    // path: capture them, drop them, recreate after rename. They reference
    // the table by name, so they survive the rename automatically once we
    // re-exec their original CREATE SQL.
    const auxObjects = db
        .prepare(`SELECT type, name, sql FROM sqlite_master
         WHERE tbl_name = 'oauth_tokens' AND type IN ('index', 'trigger')
           AND sql IS NOT NULL
           AND name NOT LIKE 'sqlite_autoindex_%'`)
        .all();
    // Pre-count rows so we can verify integrity before committing.
    const preCount = db.prepare('SELECT COUNT(*) AS n FROM oauth_tokens').get().n;
    // Per-row encrypt + insert. We can't do this in a pure SQL
    // INSERT...SELECT because encryption needs the Node-side cipher.
    // The original columns SELECT is built from PRAGMA, so any future
    // column additions flow through transparently.
    const selectCols = origCols.map((c) => c.name);
    const selectStmt = db.prepare(`SELECT ${selectCols.join(', ')} FROM oauth_tokens`);
    // The INSERT shape depends on whether ciphertext columns pre-existed
    // on the source table. When they did, they're already part of `selectCols`
    // and we just rewrite the per-row values (encrypt rows where data_json
    // is populated and ciphertext is missing — partial-state recovery).
    // When they didn't, we append them after the original column list.
    const insertCols = hasCiphertextCol && hasKeyVersionCol
        ? selectCols
        : [...selectCols,
            ...(hasCiphertextCol ? [] : ['data_ciphertext']),
            ...(hasKeyVersionCol ? [] : ['key_version'])];
    const insertPlaceholders = insertCols.map(() => '?').join(', ');
    const insertStmt = db.prepare(`INSERT INTO oauth_tokens_v2 (${insertCols.join(', ')})
     VALUES (${insertPlaceholders})`);
    // Materialize rows before iterating + inserting — better-sqlite3 refuses
    // to run another statement (the INSERT below) while a SELECT iterator is
    // open. The oauth_tokens table is small (one row per user+connector), so
    // the memory cost is trivial.
    const rows = selectStmt.all();
    let rowsMigrated = 0;
    for (const row of rows) {
        const userId = String(row['user_id'] ?? '');
        const dataJson = row['data_json'];
        const existingCiphertext = hasCiphertextCol ? row['data_ciphertext'] : null;
        const existingKeyVersion = hasKeyVersionCol ? row['key_version'] : null;
        // Decide the row's target state.
        let nextDataJson;
        let nextCiphertext;
        let nextKeyVersion;
        if (typeof dataJson === 'string') {
            // data_json is populated → row needs encryption. detectHalfState()
            // (run before this transaction) refuses to proceed when both
            // data_json and data_ciphertext are populated, so reaching here
            // with `existingCiphertext` non-null is impossible. Asserting
            // defensively for future safety.
            if (typeof existingCiphertext === 'string' && existingCiphertext.length > 0) {
                throw new Error(`oauth_tokens row for user_id=${userId} has both data_json and ` +
                    `data_ciphertext populated; refusing to overwrite ciphertext`);
            }
            const enc = encrypt(masterKey, dataJson, {
                scope: 'user',
                scopeId: userId,
                key: OAUTH_TOKENS_KEY,
                keyVersion,
            });
            nextDataJson = null;
            nextCiphertext = enc.ciphertext;
            nextKeyVersion = enc.keyVersion;
        }
        else if (dataJson === null && typeof existingCiphertext === 'string' && existingCiphertext.length > 0) {
            // Already encrypted (e.g. a previous Phase 8 pass on this DB whose
            // meta marker got rolled back, or an out-of-band write). Pass
            // through unchanged.
            nextDataJson = null;
            nextCiphertext = existingCiphertext;
            nextKeyVersion = (typeof existingKeyVersion === 'string' && existingKeyVersion.length > 0)
                ? existingKeyVersion
                : keyVersion;
        }
        else if (dataJson === null && (existingCiphertext === null || existingCiphertext === undefined)) {
            // Neither column has data. Legal when the row got partway through
            // some other path that nulled data_json but didn't write ciphertext.
            // Migration can't recover a value that's gone — keep the row blank.
            nextDataJson = null;
            nextCiphertext = null;
            nextKeyVersion = null;
        }
        else {
            throw new Error(`oauth_tokens row for user_id=${userId} has unexpected data_json type ` +
                `(${typeof dataJson}); aborting migration`);
        }
        // Build the insert payload in `insertCols` order. For pre-existing
        // ciphertext columns, the new computed value replaces the source.
        const values = insertCols.map((c) => {
            if (c === 'data_json')
                return nextDataJson;
            if (c === 'data_ciphertext')
                return nextCiphertext;
            if (c === 'key_version')
                return nextKeyVersion;
            return row[c];
        });
        insertStmt.run(...values);
        rowsMigrated++;
    }
    if (rowsMigrated !== preCount) {
        throw new Error(`oauth_tokens migration row-count mismatch: pre=${preCount}, post=${rowsMigrated}`);
    }
    db.exec('DROP TABLE oauth_tokens');
    db.exec('ALTER TABLE oauth_tokens_v2 RENAME TO oauth_tokens');
    // Verify post-rename row count again, defensively.
    const postCount = db.prepare('SELECT COUNT(*) AS n FROM oauth_tokens').get().n;
    if (postCount !== preCount) {
        throw new Error(`oauth_tokens post-rename row-count mismatch: pre=${preCount}, post=${postCount}`);
    }
    // Re-create any indexes/triggers. Their CREATE SQL referenced the
    // original table by name; the rename means the same name now applies
    // to the new table, so the original SQL works as-is.
    for (const obj of auxObjects) {
        // The old objects are dropped by `DROP TABLE oauth_tokens` (SQLite
        // drops dependent indexes/triggers automatically). Re-create now.
        try {
            db.exec(obj.sql);
        }
        catch (err) {
            throw new Error(`failed to recreate ${obj.type} ${obj.name} on rebuilt oauth_tokens: ` +
                `${err instanceof Error ? err.message : String(err)}`);
        }
    }
    return rowsMigrated;
}
function migrateUserPreferences(db, masterKey, keyVersion) {
    // Add columns idempotently. Re-running after a partial crash should not
    // fail on "duplicate column"; ALTER TABLE ADD COLUMN doesn't support
    // IF NOT EXISTS in SQLite, so we check via PRAGMA.
    if (!tableHasColumn(db, 'user_preferences', 'settings_ciphertext')) {
        db.exec('ALTER TABLE user_preferences ADD COLUMN settings_ciphertext TEXT');
    }
    if (!tableHasColumn(db, 'user_preferences', 'key_version')) {
        db.exec('ALTER TABLE user_preferences ADD COLUMN key_version TEXT');
    }
    // Walk every row that has a non-empty settings_json. Partition keys by
    // registry classification: any key whose `user.*` registry entry is
    // `user-secret` moves to ciphertext; the rest stay in settings_json.
    // Each row gets ONE ciphertext blob covering the subset of secret keys.
    const rows = db
        .prepare(`SELECT user_id, settings_json, settings_ciphertext
         FROM user_preferences
         WHERE settings_json IS NOT NULL AND settings_json != ''`)
        .all();
    const updateStmt = db.prepare(`UPDATE user_preferences
        SET settings_json = ?, settings_ciphertext = ?, key_version = ?, updated_at = datetime('now')
      WHERE user_id = ?`);
    let rowsMigrated = 0;
    for (const row of rows) {
        let parsed;
        try {
            parsed = JSON.parse(row.settings_json);
        }
        catch {
            // Malformed JSON — leave it alone, don't write garbage to ciphertext.
            // The read side already tolerates this (returns {}).
            continue;
        }
        if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
            continue;
        const secretEntries = {};
        const plainEntries = {};
        for (const [k, v] of Object.entries(parsed)) {
            if (isUserSecretSettingsKey(k)) {
                secretEntries[k] = v;
            }
            else {
                plainEntries[k] = v;
            }
        }
        // If there are no secret entries, leave the row alone — nothing to
        // encrypt. The new columns stay NULL.
        if (Object.keys(secretEntries).length === 0)
            continue;
        const enc = encrypt(masterKey, JSON.stringify(secretEntries), {
            scope: 'user',
            scopeId: String(row.user_id),
            key: PREFS_SECRET_BLOB_KEY,
            keyVersion,
        });
        updateStmt.run(JSON.stringify(plainEntries), enc.ciphertext, enc.keyVersion, row.user_id);
        rowsMigrated++;
    }
    return rowsMigrated;
}
/**
 * Map a key from `user_preferences.settings_json` to its registry entry,
 * if any, and decide whether it's `user-secret`. The registry stores
 * canonical names like `user.deepgram.api_key`; the raw JSON key in
 * settings today is `deepgram_key`. We accept both forms (canonical
 * registry name OR the legacy JSON key suffix) so the migration works
 * against rows produced by the current code.
 */
function isUserSecretSettingsKey(rawKey) {
    const direct = getKeyDef(rawKey);
    if (direct?.classification === 'user-secret')
        return true;
    // Legacy short-form fallback: `deepgram_key` → `user.deepgram.api_key`.
    // This mapping is a small lookup table; we only need it for keys that
    // actually appear in pre-migration data.
    const canonical = LEGACY_PREFS_KEY_TO_REGISTRY[rawKey];
    if (canonical) {
        const def = getKeyDef(canonical);
        if (def?.classification === 'user-secret')
            return true;
    }
    return false;
}
/**
 * Map legacy `user_preferences.settings_json` keys to canonical registry
 * names. The migration uses this to classify pre-existing rows; new
 * writes through the API will use canonical names directly (Phase 8
 * API update lands alongside the migration).
 */
export const LEGACY_PREFS_KEY_TO_REGISTRY = {
    deepgram_key: 'user.deepgram.api_key',
};
/**
 * Helper used by AuthService when reading user_preferences. Given a
 * row's plaintext `settings_json` and optional `settings_ciphertext`,
 * return the merged object. Decryption errors throw — the caller
 * should let them propagate (corrupt or wrong-key data is a hard
 * fail, not a silent empty object).
 */
export function decryptUserPreferencesSettings(masterKey, userId, settingsJson, settingsCiphertext, keyVersion) {
    const plain = settingsJson && settingsJson.trim()
        ? safeJsonParseObject(settingsJson)
        : {};
    if (!settingsCiphertext)
        return plain;
    const decrypted = decrypt(masterKey, {
        ciphertext: settingsCiphertext,
        keyVersion: keyVersion ?? DEFAULT_KEY_VERSION,
    }, {
        scope: 'user',
        scopeId: String(userId),
        key: PREFS_SECRET_BLOB_KEY,
        keyVersion: keyVersion ?? DEFAULT_KEY_VERSION,
    });
    const secret = safeJsonParseObject(decrypted);
    // Plain and secret keys are partitioned at write time, so they should
    // not collide. If they do (shouldn't happen), the secret wins — it
    // was the value originally stored in the ciphertext-bound slot.
    return { ...plain, ...secret };
}
/**
 * Encrypt the secret subset of a user_preferences object for storage.
 * Returns the pair (settings_json plaintext, settings_ciphertext) to
 * write into the row. The caller (AuthService.setUserSettings) is
 * responsible for the full merged write.
 */
export function encryptUserPreferencesSettings(masterKey, userId, merged, keyVersion = DEFAULT_KEY_VERSION) {
    const secretEntries = {};
    const plainEntries = {};
    for (const [k, v] of Object.entries(merged)) {
        if (isUserSecretSettingsKey(k)) {
            secretEntries[k] = v;
        }
        else {
            plainEntries[k] = v;
        }
    }
    if (Object.keys(secretEntries).length === 0) {
        return {
            settingsJson: JSON.stringify(plainEntries),
            settingsCiphertext: null,
            keyVersion: null,
        };
    }
    const enc = encrypt(masterKey, JSON.stringify(secretEntries), {
        scope: 'user',
        scopeId: String(userId),
        key: PREFS_SECRET_BLOB_KEY,
        keyVersion,
    });
    return {
        settingsJson: JSON.stringify(plainEntries),
        settingsCiphertext: enc.ciphertext,
        keyVersion: enc.keyVersion,
    };
}
/**
 * Encrypt an oauth_tokens row's data payload for storage. The AAD is
 * bound to the owning user_id so a row swap is detected on decrypt.
 */
export function encryptOAuthTokenData(masterKey, userId, dataJson, keyVersion = DEFAULT_KEY_VERSION) {
    return encrypt(masterKey, dataJson, {
        scope: 'user',
        scopeId: String(userId),
        key: OAUTH_TOKENS_KEY,
        keyVersion,
    });
}
/**
 * Decrypt an oauth_tokens row's ciphertext. Throws on AAD mismatch,
 * tampering, or wrong key — callers should let the error propagate.
 */
export function decryptOAuthTokenData(masterKey, userId, ciphertext, keyVersion) {
    return decrypt(masterKey, { ciphertext, keyVersion: keyVersion ?? DEFAULT_KEY_VERSION }, {
        scope: 'user',
        scopeId: String(userId),
        key: OAUTH_TOKENS_KEY,
        keyVersion: keyVersion ?? DEFAULT_KEY_VERSION,
    });
}
/**
 * SQLite's PRAGMA emits column defaults verbatim from the source CREATE
 * TABLE — but strips surrounding parens on function expressions. To round-
 * trip safely we need to re-add parens for anything that doesn't look like
 * a SQL literal (NULL, numeric, quoted string). Literals can pass through
 * unchanged; the test for a literal is conservative — when in doubt, wrap.
 */
function renderDefault(dflt) {
    const trimmed = dflt.trim();
    // Quoted string literal — leave alone.
    if (/^'(?:[^']|'')*'$/.test(trimmed))
        return trimmed;
    // NULL/TRUE/FALSE keywords.
    if (/^(NULL|TRUE|FALSE)$/i.test(trimmed))
        return trimmed;
    // Numeric literal (integer or float, optionally signed).
    if (/^-?\d+(?:\.\d+)?$/.test(trimmed))
        return trimmed;
    // Already parenthesised.
    if (/^\(.*\)$/.test(trimmed))
        return trimmed;
    // Everything else (function calls, expressions) gets wrapped.
    return `(${trimmed})`;
}
function safeJsonParseObject(text) {
    try {
        const v = JSON.parse(text);
        if (v && typeof v === 'object' && !Array.isArray(v)) {
            return v;
        }
    }
    catch {
        /* fall through */
    }
    return {};
}
//# sourceMappingURL=auth-db-migration.js.map