/**
 * Config file loader / saver for mailpouch.
 *
 * Config is persisted to a single JSON file (default: ~/.mailpouch.json).
 * Override the path with the MAILPOUCH_CONFIG env var.
 *
 * On Unix systems the file is written with mode 0600 (owner-read/write only)
 * to reduce the risk of credential exposure.
 */
import { readFileSync, writeFileSync, existsSync, renameSync, statSync, openSync, closeSync, unlinkSync, chmodSync } from "fs";
import { homedir } from "os";
import { join, resolve, normalize } from "path";
import { randomBytes } from "crypto";
import { ALL_TOOLS, TOOL_CATEGORIES, CONFIG_VERSION, PERMISSION_PRESETS, DEFAULT_RESPONSE_LIMITS, } from "./schema.js";
import { loadCredentials as loadKeychainCredentials, saveCredentials as saveKeychainCredentials, loadAuxiliaryCredentials as loadKeychainAuxCredentials, saveAuxiliaryCredentials as saveKeychainAuxCredentials, migrateFromConfig, } from "../security/keychain.js";
import { CredentialEncryption } from "../crypto/credential-encryption.js";
import { tracer } from "../utils/tracer.js";
import { logger } from "../utils/logger.js";
/**
 * PERM-015: explicit set of bulk (mass-acting) action tools that get the
 * supervised "high cap" rate limit. Prefix-matching on `bulk_` silently missed
 * any future mass tool with a different prefix (e.g. `mark_all_read`); an
 * explicit list forces a deliberate addition when such a tool ships.
 */
const SUPERVISED_BULK_ACTION_TOOLS = [
    "bulk_mark_read",
    "bulk_star",
    "bulk_move_emails",
    "bulk_move_to_label",
    "bulk_remove_label",
];
/** Clamp a numeric value to [min, max], falling back to min for non-finite input. */
function clamp(value, min, max) {
    if (!Number.isFinite(value))
        return min;
    return Math.max(min, Math.min(max, Math.round(value)));
}
// ─── Config path ───────────────────────────────────────────────────────────────
export function getConfigPath() {
    const envPath = process.env.MAILPOUCH_CONFIG;
    if (envPath) {
        // Resolve to absolute path and ensure it stays within the user's home
        // directory — prevents path-traversal attacks (e.g. "../../etc/passwd").
        const resolved = resolve(normalize(envPath));
        const home = homedir();
        if (!resolved.startsWith(home + "/") && !resolved.startsWith(home + "\\") && resolved !== home) {
            throw new Error(`MAILPOUCH_CONFIG must point to a path within the home directory (${home}). Got: ${resolved}`);
        }
        return resolved;
    }
    return join(homedir(), ".mailpouch.json");
}
// ─── Default values ────────────────────────────────────────────────────────────
const DEFAULT_TOOL_PERM = { enabled: true, rateLimit: null };
/**
 * Build a full permissions object from a named preset.
 *
 * full       — all tools enabled, no limits
 * read_only  — reading/analytics/system enabled; all writes blocked
 * supervised — all tools enabled; reading unlimited; sending ≤200/hr,
 *              schedule ≤100/hr, bulk actions ≤100/hr, deletion ≤20/hr,
 *              folder delete ≤20/hr, server lifecycle ≤5/hr
 * send_only  — reading unlimited; send/forward/schedule ≤50/hr;
 *              actions, deletion, folder writes, and bulk ops disabled
 * custom     — same as full (caller modifies individual tools after)
 */
export function buildPermissions(preset) {
    const tools = {};
    for (const tool of ALL_TOOLS) {
        tools[tool] = { ...DEFAULT_TOOL_PERM };
    }
    if (preset === "read_only") {
        const allowed = new Set([
            ...TOOL_CATEGORIES.reading.tools,
            ...TOOL_CATEGORIES.analytics.tools,
            ...TOOL_CATEGORIES.system.tools,
            "get_folders",
            "start_bridge", // needed to bring Bridge up before reading
            // SimpleLogin read-only surface: list + activity logs only.
            "alias_list",
            "alias_get_activity",
        ]);
        for (const tool of ALL_TOOLS) {
            tools[tool].enabled = allowed.has(tool);
        }
    }
    else if (preset === "supervised") {
        // Reading tools are safe — no rate limits.
        // Sending: high cap.
        for (const tool of TOOL_CATEGORIES.sending.tools) {
            tools[tool].rateLimit = 200;
        }
        tools["schedule_email"].rateLimit = 100;
        tools["remind_if_no_reply"].rateLimit = 200;
        // Bulk non-delete actions: high cap. PERM-015: match an explicit allowlist
        // rather than the `bulk_` prefix so a future non-`bulk_` mass tool can't
        // slip through unthrottled.
        for (const tool of SUPERVISED_BULK_ACTION_TOOLS) {
            if (tool in tools)
                tools[tool].rateLimit = 100;
        }
        // Deletion: lower cap — irreversible.
        for (const tool of TOOL_CATEGORIES.deletion.tools) {
            tools[tool].rateLimit = 20;
        }
        // Folder writes: create/rename high, delete lower.
        tools["create_folder"].rateLimit = 100;
        tools["rename_folder"].rateLimit = 100;
        tools["delete_folder"].rateLimit = 20;
        // SimpleLogin: create/toggle high, delete lower.
        tools["alias_create_random"].rateLimit = 50;
        tools["alias_create_custom"].rateLimit = 50;
        tools["alias_toggle"].rateLimit = 100;
        tools["alias_delete"].rateLimit = 20;
        // Server lifecycle: allow a few per session.
        tools["shutdown_server"].rateLimit = 5;
        tools["restart_server"].rateLimit = 5;
    }
    else if (preset === "send_only") {
        const allowed = new Set([
            ...TOOL_CATEGORIES.sending.tools,
            ...TOOL_CATEGORIES.drafts.tools,
            ...TOOL_CATEGORIES.reading.tools,
            "get_folders",
            "get_connection_status",
            "sync_emails", // safe — reads from server, no email modified
            "get_contacts", // look up recipients when composing
            "get_logs",
            "start_bridge",
        ]);
        for (const tool of ALL_TOOLS) {
            tools[tool].enabled = allowed.has(tool);
        }
        // Outbound ops: rate-limited. Reads, sync, and draft management: unlimited.
        for (const tool of TOOL_CATEGORIES.sending.tools) {
            tools[tool].rateLimit = 50;
        }
        tools["schedule_email"].rateLimit = 50;
        tools["remind_if_no_reply"].rateLimit = 100;
    }
    // "full" and "custom" use the default (all enabled, no limits)
    return { preset, tools };
}
export function defaultConfig() {
    return {
        configVersion: CONFIG_VERSION,
        connection: {
            smtpHost: "localhost",
            smtpPort: 1025,
            imapHost: "localhost",
            imapPort: 1143,
            username: "",
            password: "",
            smtpToken: "",
            bridgeCertPath: "",
            allowInsecureBridge: false,
            bridgePath: "",
            debug: false,
        },
        // Safe default: read-only. Users must explicitly grant write/send/delete
        // access via the settings UI (npm run settings).
        permissions: buildPermissions("read_only"),
        responseLimits: { ...DEFAULT_RESPONSE_LIMITS },
        requireDestructiveConfirm: true,
    };
}
// ─── Load / Save ───────────────────────────────────────────────────────────────
const CONFIG_CACHE_TTL_MS = 15_000;
let _configCache = null;
/** Invalidate the in-process config cache (called after saveConfig). */
export function invalidateConfigCache() {
    _configCache = null;
}
export function configExists() {
    return existsSync(getConfigPath());
}
export function loadConfig() {
    const path = getConfigPath();
    // Serve from cache when it is fresh and the file hasn't been modified on disk.
    if (_configCache !== null) {
        const age = Date.now() - _configCache.loadedAt;
        if (age < CONFIG_CACHE_TTL_MS) {
            try {
                const mtimeMs = statSync(path).mtimeMs;
                if (mtimeMs === _configCache.mtimeMs)
                    return _configCache.config;
                // mtime changed — fall through to reload
            }
            catch {
                // statSync failed (file deleted, permission error, or test mock).
                // Invalidate and reload rather than returning a potentially stale null.
                _configCache = null;
            }
        }
    }
    const tags = {};
    const config = tracer.spanSync('config.load', tags, () => {
        const path = getConfigPath();
        if (!existsSync(path)) {
            tags.found = false;
            return null;
        }
        try {
            const raw = readFileSync(path, "utf-8");
            const parsed = JSON.parse(raw);
            // Deep-merge on top of defaults so new tools added to ALL_TOOLS are always present
            const base = defaultConfig();
            // Validate the preset value from disk against the known-good set.
            // An arbitrary string (e.g. "superuser") must not survive into the live
            // permission state; fall back to the safe "read_only" default.
            const VALID_PRESETS = new Set(PERMISSION_PRESETS);
            const rawPreset = parsed.permissions?.preset;
            const safePreset = VALID_PRESETS.has(rawPreset)
                ? rawPreset
                : "read_only";
            // Filter the tool map loaded from disk so that only canonical tool names
            // are merged.  An attacker who can write the config file must not be able
            // to inject arbitrary keys that confuse the permission-check logic or
            // accumulate unknown entries through repeated saves.
            const knownTools = new Set(ALL_TOOLS);
            const rawTools = parsed.permissions?.tools ?? {};
            const filteredTools = {};
            for (const [k, v] of Object.entries(rawTools)) {
                if (knownTools.has(k)) {
                    filteredTools[k] = v;
                }
            }
            // Merge and clamp response limits — prevents invalid values from disk.
            // base = defaultConfig() which always populates responseLimits; non-null is safe here.
            const mergedLimits = {
                ...base.responseLimits,
                ...(parsed.responseLimits ?? {}),
            };
            mergedLimits.maxResponseBytes = clamp(mergedLimits.maxResponseBytes, 100_000, 1_048_576);
            mergedLimits.maxEmailBodyChars = clamp(mergedLimits.maxEmailBodyChars, 1_000, 10_000_000);
            mergedLimits.maxEmailListResults = clamp(mergedLimits.maxEmailListResults, 1, 200);
            mergedLimits.maxAttachmentBytes = clamp(mergedLimits.maxAttachmentBytes, 0, 1_048_576);
            const loadedVersion = parsed.configVersion ?? 1;
            const mergedConnection = { ...base.connection, ...(parsed.connection ?? {}) };
            // v1 → v2 grandfather: legacy configs ran with TLS validation silently
            // disabled when no Bridge cert was set. Preserve that behavior (so existing
            // installs keep working) but make the opt-in explicit on the next save, and
            // leave a breadcrumb the services surface as a startup warning.
            if (loadedVersion < 2 &&
                !mergedConnection.bridgeCertPath &&
                parsed.connection?.allowInsecureBridge === undefined) {
                mergedConnection.allowInsecureBridge = true;
            }
            // Preserve settingsPort when it's a sane port number — without this, the
            // field round-trips to disk via saveConfig but is stripped on the way
            // back out, so GET /api/config returns no settingsPort → the UI defaults
            // the field to 8765 → the port-mismatch warning banner fires on every
            // reload even though the user already saved the correct value.
            //
            // Validation mirrors the POST /api/config merge path
            // (settings/server.ts): Math.round + range check [1, 65535]. Keeping
            // the two paths symmetric means a hand-edited `8765.5` on disk is
            // accepted with the same semantics a browser-sent 8765.5 would be,
            // rather than being silently dropped here and accepted on the next save.
            const parsedSettingsPort = parsed.settingsPort;
            let preservedSettingsPort = undefined;
            if (typeof parsedSettingsPort === "number" && Number.isFinite(parsedSettingsPort)) {
                const sp = Math.round(parsedSettingsPort);
                if (sp >= 1 && sp <= 65535)
                    preservedSettingsPort = sp;
            }
            // credentialStorage drives the settings UI's "where are my secrets
            // kept?" badge. Derive it from observed state rather than trusting the
            // persisted value — an attacker editing the config file could otherwise
            // set credentialStorage="keychain" while leaving plaintext passwords in
            // the file, hiding the fact that credentials live in cleartext.
            let preservedCredentialStorage;
            const hasEncryptedBlob = CredentialEncryption.isValidEncrypted(mergedConnection.passwordEncrypted) ||
                CredentialEncryption.isValidEncrypted(mergedConnection.smtpTokenEncrypted);
            const hasPlaintext = !!mergedConnection.password || !!mergedConnection.smtpToken;
            if (hasEncryptedBlob) {
                preservedCredentialStorage = "encrypted-file";
            }
            else if (hasPlaintext) {
                preservedCredentialStorage = "config";
            }
            else if (parsed.credentialStorage === "keychain" ||
                parsed.credentialStorage === "encrypted-file" ||
                parsed.credentialStorage === "config") {
                // No on-disk credentials at all → trust the saved hint (we expect this
                // to be "keychain" for any installation that's gone through migration).
                preservedCredentialStorage = parsed.credentialStorage;
            }
            const result = {
                configVersion: CONFIG_VERSION,
                connection: mergedConnection,
                permissions: {
                    // Default to "read_only" — not "full" — for pre-permissions config files.
                    // Silently upgrading old configs to full access would be a privilege-escalation risk.
                    preset: safePreset,
                    tools: { ...base.permissions.tools, ...filteredTools },
                },
                responseLimits: mergedLimits,
                // Destructive-tool confirmation defaults to TRUE; only an explicit false
                // opts out. This keeps the safe default for existing configs that never
                // set the field.
                requireDestructiveConfirm: parsed.requireDestructiveConfirm !== false,
                tosAcknowledged: parsed.tosAcknowledged,
                settingsPort: preservedSettingsPort,
                credentialStorage: preservedCredentialStorage,
                accounts: Array.isArray(parsed.accounts) ? parsed.accounts : undefined,
                activeAccountId: typeof parsed.activeAccountId === "string" ? parsed.activeAccountId : undefined,
                desktopNotificationsEnabled: typeof parsed.desktopNotificationsEnabled === "boolean"
                    ? parsed.desktopNotificationsEnabled
                    : undefined,
                webhooks: Array.isArray(parsed.webhooks) ? parsed.webhooks : undefined,
            };
            tags.found = true;
            return result;
        }
        catch {
            tags.found = false;
            return null;
        }
    }); // end tracer.spanSync('config.load')
    // Populate cache with the mtime at the point we read the file.
    let mtimeMs = 0;
    try {
        mtimeMs = statSync(path).mtimeMs;
    }
    catch { /* file gone */ }
    _configCache = { config, loadedAt: Date.now(), mtimeMs };
    return config;
}
// ─── Config file lock (CRED-008) ─────────────────────────────────────────────
//
// Read-modify-write callers (saveConfig, and writeRegistry's load→merge→save)
// race with each other and with the settings-UI POST handler. Without a lock,
// two near-simultaneous renames clobber one another (last-writer-wins) and a
// reader caught between them can observe a half-merged file. We serialize via
// an exclusive O_EXCL lock file next to the config — no new dependency.
/** Max attempts to acquire the lock before giving up. */
const LOCK_MAX_RETRIES = 50;
/** Delay between lock acquisition attempts (busy-wait; writes are sub-ms). */
const LOCK_RETRY_DELAY_MS = 20;
/** A lock file older than this is treated as abandoned by a crashed holder. */
const LOCK_STALE_MS = 10_000;
/**
 * Reentrancy depth. writeRegistry holds the lock across its whole
 * read-modify-write and calls saveConfig() inside it; the inner saveConfig
 * must reuse the held lock rather than deadlock on its own O_EXCL.
 */
let _lockDepth = 0;
/**
 * In-process async serialization. The on-disk O_EXCL lock guards against OTHER
 * processes (settings UI, a second MCP), but two concurrent async writers in
 * THIS process cannot busy-wait on it — a synchronous spin would block the
 * event loop and deadlock the holder mid-await. This promise chain queues
 * same-process async writers so they run one at a time.
 */
let _asyncLockChain = Promise.resolve();
function blockMs(ms) {
    // Synchronous sleep for the sync acquire path (saveConfig). Node lacks a sync
    // sleep; Atomics.wait on a throwaway buffer is the standard no-dep idiom.
    Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function sleepMs(ms) {
    return new Promise(resolve => setTimeout(resolve, ms));
}
/**
 * Run `fn` while holding an exclusive lock on `${dest}.lock`. Reentrant for the
 * same process (depth-counted). Reclaims a stale lock left by a crashed holder.
 * The lock is always released in the outermost frame via try/finally.
 */
function withConfigLock(dest, fn) {
    if (_lockDepth > 0) {
        // Already held by an outer frame in this process — reuse it.
        _lockDepth++;
        try {
            return fn();
        }
        finally {
            _lockDepth--;
        }
    }
    const lockPath = `${dest}.lock`;
    let fd = null;
    for (let attempt = 0; attempt < LOCK_MAX_RETRIES; attempt++) {
        try {
            fd = openSync(lockPath, "wx", 0o600); // O_CREAT | O_EXCL
            break;
        }
        catch (err) {
            if (err.code !== "EEXIST")
                throw err;
            // Lock held by someone else — reclaim it if it's stale (crashed holder),
            // otherwise back off and retry.
            try {
                const age = Date.now() - statSync(lockPath).mtimeMs;
                if (age > LOCK_STALE_MS) {
                    unlinkSync(lockPath);
                    continue; // retry immediately after clearing the stale lock
                }
            }
            catch { /* lock vanished between open and stat — just retry */ }
            blockMs(LOCK_RETRY_DELAY_MS);
        }
    }
    if (fd === null) {
        throw new Error(`Could not acquire config lock at ${lockPath} after ${LOCK_MAX_RETRIES} attempts`);
    }
    _lockDepth++;
    try {
        return fn();
    }
    finally {
        _lockDepth--;
        try {
            closeSync(fd);
        }
        catch { /* already closed */ }
        try {
            unlinkSync(lockPath);
        }
        catch { /* already removed */ }
    }
}
/**
 * Force the config file back to owner-only (0o600) if its mode drifted wider.
 * `writeFileSync({mode})` only applies at creation and is masked by umask, so
 * the destination can be group/world-readable. Best-effort — a chmod failure
 * must not break a config save. CRED-007.
 */
function reassertOwnerOnly(path) {
    try {
        const mode = statSync(path).mode & 0o777;
        if (mode & 0o077)
            chmodSync(path, 0o600);
    }
    catch { /* file vanished or chmod unsupported — best effort */ }
}
export function saveConfig(config) {
    tracer.spanSync('config.save', {}, () => {
        const dest = getConfigPath();
        withConfigLock(dest, () => {
            const payload = JSON.stringify(config, null, 2);
            // Atomic write: write to a temp file then rename into place.
            // rename(2) is atomic on POSIX only when both sides live on the same
            // filesystem. On Linux installs where /tmp is tmpfs and $HOME is on
            // separate storage, using os.tmpdir() produces EXDEV. Put the tmp next
            // to the destination so rename stays atomic regardless of mount layout.
            const tmp = `${dest}.${randomBytes(8).toString("hex")}.tmp`;
            writeFileSync(tmp, payload, { encoding: "utf-8", mode: 0o600 });
            renameSync(tmp, dest);
            // CRED-007: the `mode` arg above is masked by umask at creation, so the
            // file the config lands in may be wider than 0o600. Re-assert owner-only
            // on the destination — the config file carries plaintext credentials in
            // the legacy/encrypted-file storage modes.
            reassertOwnerOnly(dest);
            invalidateConfigCache();
        });
    }); // end tracer.spanSync('config.save')
}
/**
 * Run a read-modify-write of the config file under the exclusive lock so the
 * read (loadConfig) and the write (saveConfig) cannot interleave with a racing
 * writer. saveConfig() reuses the held lock reentrantly. CRED-008.
 */
export function withConfigWriteLock(fn) {
    return withConfigLock(getConfigPath(), fn);
}
/**
 * Async variant of withConfigWriteLock for callers whose read-modify-write
 * spans an await (e.g. writeRegistry, which routes secrets through the
 * keychain between load and save). The lock is held for the full duration and
 * released only after the promise settles. CRED-008.
 */
export async function withConfigWriteLockAsync(fn) {
    // Reentrant: an async writer already holding the lock (e.g. a future nested
    // call) reuses it rather than enqueuing behind itself.
    if (_lockDepth > 0) {
        _lockDepth++;
        try {
            return await fn();
        }
        finally {
            _lockDepth--;
        }
    }
    // Queue behind any in-flight same-process async writer. Chain on settle (not
    // resolve) so one writer's failure doesn't wedge the queue.
    const prior = _asyncLockChain;
    let release;
    _asyncLockChain = new Promise(r => { release = r; });
    await prior.catch(() => { });
    const dest = getConfigPath();
    const lockPath = `${dest}.lock`;
    let fd = null;
    try {
        for (let attempt = 0; attempt < LOCK_MAX_RETRIES; attempt++) {
            try {
                fd = openSync(lockPath, "wx", 0o600); // O_CREAT | O_EXCL
                break;
            }
            catch (err) {
                if (err.code !== "EEXIST")
                    throw err;
                try {
                    const age = Date.now() - statSync(lockPath).mtimeMs;
                    if (age > LOCK_STALE_MS) {
                        unlinkSync(lockPath);
                        continue;
                    }
                }
                catch { /* lock vanished — retry */ }
                await sleepMs(LOCK_RETRY_DELAY_MS); // async sleep — never blocks the loop
            }
        }
        if (fd === null) {
            throw new Error(`Could not acquire config lock at ${lockPath} after ${LOCK_MAX_RETRIES} attempts`);
        }
        _lockDepth++;
        try {
            return await fn();
        }
        finally {
            _lockDepth--;
            try {
                closeSync(fd);
            }
            catch { /* already closed */ }
            try {
                unlinkSync(lockPath);
            }
            catch { /* already removed */ }
        }
    }
    finally {
        release();
    }
}
// ─── Keychain-aware credential helpers ──────────────────────────────────────
/**
 * Load credentials with priority: keychain > encrypted-file > plaintext config.
 * Returns the credentials and the storage method used.
 */
export async function loadCredentialsFromKeychain() {
    const tags = {};
    return tracer.span('config.loadKeychain', tags, async () => {
        // 1. Try keychain first
        const keychainCreds = await loadKeychainCredentials();
        if (keychainCreds && (keychainCreds.password || keychainCreds.smtpToken)) {
            tags.hasPassword = !!keychainCreds.password;
            tags.hasSmtpToken = !!keychainCreds.smtpToken;
            tags.storage = "keychain";
            return { ...keychainCreds, storage: "keychain" };
        }
        const config = loadConfig();
        // 2. Try encrypted-file storage
        if (config) {
            const hasEncryptedPassword = CredentialEncryption.isValidEncrypted(config.connection.passwordEncrypted);
            const hasEncryptedToken = CredentialEncryption.isValidEncrypted(config.connection.smtpTokenEncrypted);
            if (hasEncryptedPassword || hasEncryptedToken) {
                let password = "";
                let smtpToken = "";
                // CRED-010: track GCM auth failures. A well-formed encrypted blob whose
                // decrypt() throws is an authenticated-decryption failure — the IV/tag/
                // ciphertext don't agree with the key (machine-id changed, downgrade, or
                // tampering). We must NOT silently fall through to a plaintext field in
                // the SAME file: plaintext coexisting with a failed-auth encrypted blob
                // is itself a tamper indicator, and serving it would hand the caller an
                // attacker-controllable value from a blob that just failed integrity.
                let decryptFailed = false;
                if (hasEncryptedPassword) {
                    try {
                        // isValidEncrypted confirmed algorithm === "aes-256-gcm"; cast is safe.
                        password = CredentialEncryption.decrypt(config.connection.passwordEncrypted);
                    }
                    catch (err) {
                        decryptFailed = true;
                        logger.error("Encrypted bridge password failed authenticated decryption — refusing to fall back to plaintext from the same config file (possible machine-id change, version downgrade, or tampering). Re-enter the credential to re-encrypt.", "Credentials", err);
                    }
                }
                if (hasEncryptedToken) {
                    try {
                        smtpToken = CredentialEncryption.decrypt(config.connection.smtpTokenEncrypted);
                    }
                    catch (err) {
                        decryptFailed = true;
                        logger.error("Encrypted SMTP token failed authenticated decryption — refusing to fall back to plaintext from the same config file (possible machine-id change, version downgrade, or tampering). Re-enter the credential to re-encrypt.", "Credentials", err);
                    }
                }
                if (password || smtpToken) {
                    tags.hasPassword = !!password;
                    tags.hasSmtpToken = !!smtpToken;
                    tags.storage = "encrypted-file";
                    return { password, smtpToken, storage: "encrypted-file" };
                }
                // Fail closed: a valid-shaped encrypted blob that failed to decrypt must
                // not degrade to plaintext from this same file. Return a DISTINCT
                // "decrypt-failed" sentinel (not null, which callers can't tell apart from
                // "no credentials" and would answer by reading the plaintext field
                // themselves) so the caller can refuse the plaintext fallback explicitly.
                if (decryptFailed) {
                    tags.hasPassword = false;
                    tags.hasSmtpToken = false;
                    tags.storage = "decrypt-failed";
                    return { password: "", smtpToken: "", storage: "decrypt-failed" };
                }
            }
        }
        // 3. Fall back to plaintext config (legacy / migration path)
        if (config && (config.connection.password || config.connection.smtpToken)) {
            tags.hasPassword = !!config.connection.password;
            tags.hasSmtpToken = !!config.connection.smtpToken;
            tags.storage = "config";
            return {
                password: config.connection.password,
                smtpToken: config.connection.smtpToken,
                storage: "config",
            };
        }
        tags.hasPassword = false;
        tags.hasSmtpToken = false;
        return null;
    }); // end tracer.span('config.loadKeychain')
}
/**
 * Save config with credentials routed to the most secure available store.
 * Priority: keychain > encrypted-file (AES-256-GCM) > plaintext config (legacy).
 * Mutates `config` — blanks plaintext fields when storing elsewhere.
 */
export async function saveConfigWithCredentials(config) {
    const password = config.connection.password;
    const smtpToken = config.connection.smtpToken;
    const passPat = config.connection.passAccessToken;
    const simpleloginKey = config.connection.simpleloginApiKey;
    // 1. Try keychain
    const keychainOk = await saveKeychainCredentials(password, smtpToken);
    if (keychainOk) {
        config.connection.password = "";
        config.connection.smtpToken = "";
        delete config.connection.passwordEncrypted;
        delete config.connection.smtpTokenEncrypted;
        // CRED-001: route Pass PAT + SimpleLogin API key to keychain too.
        // Best-effort: if the aux save fails, leave the fields on disk — the
        // settings save reporting "keychain" stays honest for the bridge creds.
        if (passPat || simpleloginKey) {
            const auxOk = await saveKeychainAuxCredentials(passPat ?? "", simpleloginKey ?? "");
            if (auxOk) {
                config.connection.passAccessToken = "";
                config.connection.simpleloginApiKey = "";
            }
        }
        config.credentialStorage = "keychain";
        saveConfig(config);
        return "keychain";
    }
    // 2. Encrypt and store in config file (keychain unavailable)
    if (password) {
        config.connection.passwordEncrypted = CredentialEncryption.encrypt(password);
        config.connection.password = "";
    }
    if (smtpToken) {
        config.connection.smtpTokenEncrypted = CredentialEncryption.encrypt(smtpToken);
        config.connection.smtpToken = "";
    }
    config.credentialStorage = "encrypted-file";
    saveConfig(config);
    return "encrypted-file";
}
/**
 * Load passAccessToken + simpleloginApiKey from the keychain, falling back
 * to the config-file plaintext if the keychain has neither. Used at startup
 * to rehydrate the in-process clients after migrateCredentials() has blanked
 * the disk fields. Returns null if neither secret is configured anywhere.
 */
export async function loadAuxiliaryCredentialsFromKeychain() {
    const fromKeychain = await loadKeychainAuxCredentials();
    if (fromKeychain) {
        return { ...fromKeychain, storage: "keychain" };
    }
    const config = loadConfig();
    if (config && (config.connection.passAccessToken || config.connection.simpleloginApiKey)) {
        return {
            passAccessToken: config.connection.passAccessToken ?? "",
            simpleloginApiKey: config.connection.simpleloginApiKey ?? "",
            storage: "config",
        };
    }
    return null;
}
/**
 * One-time migration: move plaintext credentials to the best available store.
 * Priority: keychain > encrypted-file. Idempotent — safe to call on every startup.
 */
export async function migrateCredentials() {
    const tags = {};
    return tracer.span('config.migrateCredentials', tags, async () => {
        // CRED-015: take the config write lock for the whole read-modify-write so a
        // concurrent loadCredentialsFromKeychain() can't observe the in-flight,
        // partially-blanked config object (loadConfig returns the cached reference).
        return withConfigWriteLockAsync(async () => {
            const loaded = loadConfig();
            if (!loaded) {
                tags.migrated = false;
                return false;
            }
            // Operate on a deep clone, then save once. The cache keeps the un-mutated
            // reference until saveConfig() invalidates it, so racing readers within the
            // lock window never see `password=""` + a missing encrypted blob.
            const config = structuredClone(loaded);
            // Plaintext-creds path: hoist to keychain (preferred) or encrypted-file.
            const hasPlaintext = !!(config.connection.password || config.connection.smtpToken);
            const alreadyEncrypted = !!(config.connection.passwordEncrypted || config.connection.smtpTokenEncrypted);
            // Re-encryption path: existing v1 blobs upgraded to v2 (per-system entropy).
            // Decrypt with the matching old key, re-encrypt with the new key, save.
            const passwordEncryptedField = config.connection.passwordEncrypted;
            const smtpEncryptedField = config.connection.smtpTokenEncrypted;
            const pwNeedsReencrypt = CredentialEncryption.isValidEncrypted(passwordEncryptedField)
                && CredentialEncryption.needsReencrypt(passwordEncryptedField);
            const smtpNeedsReencrypt = CredentialEncryption.isValidEncrypted(smtpEncryptedField)
                && CredentialEncryption.needsReencrypt(smtpEncryptedField);
            if (!hasPlaintext && (pwNeedsReencrypt || smtpNeedsReencrypt)) {
                try {
                    // CRED-011: stage BOTH re-encrypted blobs in locals first. If the second
                    // encrypt throws, we must not have already mutated the first field — that
                    // would leave a config with one v2 blob and one v1 blob, and the catch
                    // below would swallow it. Assign + save only once both succeed.
                    let newPasswordEncrypted;
                    let newSmtpEncrypted;
                    if (pwNeedsReencrypt && CredentialEncryption.isValidEncrypted(passwordEncryptedField)) {
                        newPasswordEncrypted = CredentialEncryption.encrypt(CredentialEncryption.decrypt(passwordEncryptedField));
                    }
                    if (smtpNeedsReencrypt && CredentialEncryption.isValidEncrypted(smtpEncryptedField)) {
                        newSmtpEncrypted = CredentialEncryption.encrypt(CredentialEncryption.decrypt(smtpEncryptedField));
                    }
                    if (newPasswordEncrypted !== undefined)
                        config.connection.passwordEncrypted = newPasswordEncrypted;
                    if (newSmtpEncrypted !== undefined)
                        config.connection.smtpTokenEncrypted = newSmtpEncrypted;
                    saveConfig(config);
                    tags.migrated = true;
                    return true;
                }
                catch {
                    // Decryption failed (e.g. host moved without preserving v1 key inputs).
                    // Don't crash; leave the v1 blob as-is so the next save path can rotate.
                    tags.migrated = false;
                    return false;
                }
            }
            if (!hasPlaintext || alreadyEncrypted) {
                tags.migrated = false;
                return false;
            }
            // Try keychain first
            const migratedToKeychain = await migrateFromConfig(config, saveConfig);
            if (migratedToKeychain) {
                tags.migrated = true;
                return true;
            }
            // Fall back to encrypted-file (always writes the current version)
            config.connection.passwordEncrypted = config.connection.password
                ? CredentialEncryption.encrypt(config.connection.password)
                : undefined;
            config.connection.smtpTokenEncrypted = config.connection.smtpToken
                ? CredentialEncryption.encrypt(config.connection.smtpToken)
                : undefined;
            config.connection.password = "";
            config.connection.smtpToken = "";
            config.credentialStorage = "encrypted-file";
            saveConfig(config);
            tags.migrated = true;
            return true;
        });
    }); // end tracer.span('config.migrateCredentials')
}
//# sourceMappingURL=loader.js.map