/**
 * SqliteEncryptedStore — concrete SecretStore + SettingsStore on
 * better-sqlite3.
 *
 * Lives at /workspace/secrets.db on Railway, ~/.studiograph/secrets.db
 * locally. File mode 0600 enforced after open. The audit table lives in
 * the same DB so cross-table operations stay atomic.
 *
 * After the scope-collapse refactor, the schema has no `scope` /
 * `scope_id` columns — Studiograph is single-workspace-per-process, so
 * each key has at most one row. Existing populated databases (with the
 * old `(scope, scope_id, key)` shape) are migrated transparently on
 * first construction via `migrateScopeCollapse()`.
 *
 * AAD binding for encrypted rows keeps the four-tuple `AADInput` shape so
 * the shared encryption module continues to serve auth.db (which uses
 * different `scope: 'user'` AAD per user). Inside this store every row
 * binds AAD with the constant `scope: 'workspace'`, `scopeId: ''` —
 * cosmetic-only after the migration since each row is also uniquely
 * keyed by `(key)`.
 */
import Database from 'better-sqlite3';
import { chmodSync, closeSync, copyFileSync, openSync } from 'fs';
import { encrypt, decrypt } from './encryption.js';
import { getKeyDef } from './registry.js';
import { AuditLogger } from './audit.js';
const SCHEMA_VERSION = '2';
const DEFAULT_KEY_VERSION = 'mk_v1';
/**
 * Constant AAD components written for every row in this store. Kept as a
 * literal so the AADInput type doesn't need a different shape from
 * auth-db-migration's `'user'`-scoped calls.
 */
const STORE_AAD_SCOPE = 'workspace';
const STORE_AAD_SCOPE_ID = '';
export class SqliteEncryptedStore {
    db;
    masterKey;
    keyVersion;
    previousMasterKey;
    previousKeyVersion;
    audit;
    constructor(opts) {
        // L1: pre-create the DB file with restrictive mode BEFORE handing the
        // path to better-sqlite3. The previous flow was
        //   new Database(opts.dbPath)        // creates with default umask (0644)
        //   chmodSync(opts.dbPath, 0o600)    // tightens after the fact
        // which left a window in which a co-located process could open the
        // file with relaxed permissions. `openSync(..., 'wx', 0o600)` is the
        // primary protection — fails if the file already exists, in which
        // case we leave it alone (it already exists from a previous start;
        // the chmod below is defense-in-depth in case its mode drifted).
        if (process.platform !== 'win32') {
            try {
                const fd = openSync(opts.dbPath, 'wx', 0o600);
                closeSync(fd);
            }
            catch (err) {
                // ENOENT (parent dir missing) is a real error; rethrow.
                // EEXIST means the file is already there — fall through to the
                // defense-in-depth chmod below.
                if (err.code !== 'EEXIST')
                    throw err;
            }
        }
        this.db = new Database(opts.dbPath);
        if (process.platform !== 'win32') {
            try {
                chmodSync(opts.dbPath, 0o600);
            }
            catch { /* non-fatal */ }
        }
        this.db.pragma('journal_mode = WAL');
        this.db.pragma('foreign_keys = ON');
        this.masterKey = opts.masterKey;
        this.keyVersion = opts.keyVersion ?? DEFAULT_KEY_VERSION;
        this.previousMasterKey = opts.previousMasterKey;
        this.previousKeyVersion = opts.previousKeyVersion;
        this.initSchema();
        this.audit = new AuditLogger(this.db);
        // One-shot migration: collapse legacy (scope, scope_id, key)-keyed
        // tables into the single-tier shape. Idempotent — re-running after
        // success is a no-op via meta.scope_collapse_migrated_at.
        this.migrateScopeCollapse(opts.dbPath);
    }
    initSchema() {
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS settings (
        key TEXT NOT NULL PRIMARY KEY,
        value_json TEXT NOT NULL,
        updated_at INTEGER NOT NULL,
        updated_by TEXT NOT NULL
      );

      CREATE TABLE IF NOT EXISTS secrets (
        key TEXT NOT NULL PRIMARY KEY,
        ciphertext TEXT NOT NULL,
        key_version TEXT NOT NULL,
        label TEXT,
        provider TEXT,
        last_tested_at INTEGER,
        last_test_result TEXT,
        updated_at INTEGER NOT NULL,
        updated_by TEXT NOT NULL
      );

      CREATE TABLE IF NOT EXISTS meta (
        key TEXT PRIMARY KEY,
        value TEXT NOT NULL
      );
    `);
        // Defensive cleanup of the legacy `last_rotated_at` column. The rotate
        // feature was removed; existing DBs created before the removal still
        // carry the column even though no code references it. SQLite 3.35+
        // supports ALTER TABLE DROP COLUMN; the try/catch handles fresh DBs
        // (column never existed) and any future re-runs.
        try {
            this.db.exec('ALTER TABLE secrets DROP COLUMN last_rotated_at');
        }
        catch { /* already absent */ }
        // Schema version metadata. Idempotent insert.
        this.db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)').run('schema_version', SCHEMA_VERSION);
        this.db.prepare('INSERT OR IGNORE INTO meta (key, value) VALUES (?, ?)').run('master_key_version', this.keyVersion);
    }
    /**
     * One-shot migration: collapse the legacy `(scope, scope_id, key)` tables
     * into the single-tier shape. Steps:
     *
     *   1. Detect legacy shape by querying `PRAGMA table_info` for the
     *      `scope` column on settings or secrets.
     *   2. Back up secrets.db → secrets.db.backup.<ts> with mode 0o600.
     *   3. For each legacy row in `secrets`, decrypt with its original AAD
     *      (4-tuple including the row's scope+scope_id), re-encrypt under
     *      the new constant AAD, INSERT into the new table. Drop the old.
     *   4. For `settings`, INSERT...SELECT keeping just `key`/`value_json`/
     *      timestamps. Duplicate keys across scopes would collide; assert
     *      none before commit.
     *   5. For `audit_log`, ALTER TABLE DROP COLUMN scope/scope_id. SQLite
     *      3.35+ supports this directly; PK is auto-increment so no rebuild.
     *   6. Mark `meta.scope_collapse_migrated_at`. Re-running is a no-op.
     *
     * Failure modes: per-row decrypt failures during step 3 are logged as
     * decrypt_fail audit rows and the row is skipped. The transaction
     * still commits — the alternative is refusing to start when one
     * historical row got out of sync, which is worse than dropping a key
     * that already couldn't decrypt.
     */
    migrateScopeCollapse(dbPath) {
        const already = this.db
            .prepare("SELECT value FROM meta WHERE key='scope_collapse_migrated_at'")
            .get();
        if (already?.value)
            return;
        // Detect legacy shape — old table has a `scope` column.
        const settingsCols = this.db.prepare('PRAGMA table_info(settings)').all();
        const secretsCols = this.db.prepare('PRAGMA table_info(secrets)').all();
        const auditCols = this.db.prepare('PRAGMA table_info(audit_log)').all();
        const settingsLegacy = settingsCols.some((c) => c.name === 'scope');
        const secretsLegacy = secretsCols.some((c) => c.name === 'scope');
        const auditLegacy = auditCols.some((c) => c.name === 'scope') || auditCols.some((c) => c.name === 'scope_id');
        if (!settingsLegacy && !secretsLegacy && !auditLegacy) {
            // Fresh DB created with the post-collapse schema — nothing to do.
            this.db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('scope_collapse_migrated_at', ?)").run(String(Date.now()));
            return;
        }
        // Backup before destructive rebuild. We deliberately do this OUTSIDE
        // any transaction — copy-on-write semantics on Node's copyFileSync
        // give us a snapshot of the on-disk pages.
        //
        // Checkpoint the WAL into the .db BEFORE the copy so the backup is a
        // complete restorable snapshot. Without this, rows still in the .wal
        // sidecar would be missing from the backup (the .wal is intentionally
        // NOT copied alongside — its existence with a stale .db would confuse
        // a restore). Operators restore by copying the backup over the live
        // file with the server shut down.
        try {
            this.db.pragma('wal_checkpoint(FULL)');
        }
        catch { /* non-fatal — best-effort flush */ }
        const backupPath = `${dbPath}.backup.${Date.now()}`;
        try {
            copyFileSync(dbPath, backupPath);
            if (process.platform !== 'win32') {
                try {
                    chmodSync(backupPath, 0o600);
                }
                catch { /* non-fatal */ }
            }
        }
        catch (err) {
            // If we can't take a backup, refuse to migrate — the operator
            // gets a clear error instead of a clobbered DB. The flag stays
            // unset so the next boot retries.
            throw new Error(`[secrets] scope-collapse migration: failed to take backup at ${backupPath}: ${err instanceof Error ? err.message : String(err)}`);
        }
        // Read legacy rows BEFORE rebuilding so we can re-encrypt secrets.
        // The legacy `last_rotated_at` column (if present) is read but
        // discarded — the rotate feature was removed.
        const legacySecrets = secretsLegacy
            ? this.db.prepare('SELECT * FROM secrets').all()
            : [];
        const legacySettings = settingsLegacy
            ? this.db.prepare('SELECT * FROM settings').all()
            : [];
        // Pre-flight: assert no duplicate keys across scopes (would collide
        // on the new PK). All known data lived at scope='instance'|'workspace'
        // with scopeId='', so the same key in both scopes should be
        // vanishingly rare — but assert to fail loud rather than silently
        // drop one row.
        const dupeSecrets = new Set();
        const seenSecrets = new Set();
        for (const r of legacySecrets) {
            if (seenSecrets.has(r.key))
                dupeSecrets.add(r.key);
            seenSecrets.add(r.key);
        }
        const dupeSettings = new Set();
        const seenSettings = new Set();
        for (const r of legacySettings) {
            if (seenSettings.has(r.key))
                dupeSettings.add(r.key);
            seenSettings.add(r.key);
        }
        if (dupeSecrets.size > 0 || dupeSettings.size > 0) {
            throw new Error(`[secrets] scope-collapse migration: duplicate keys across scopes detected (secrets: [${[...dupeSecrets].join(', ')}], settings: [${[...dupeSettings].join(', ')}]). Backup at ${backupPath}. Resolve by manual SQL before retrying.`);
        }
        // Decrypt every legacy secret under its original AAD up front. If
        // any fails, log decrypt_fail and drop — but keep going. After all
        // rows are decrypted (or dropped), rebuild the tables in one
        // transaction.
        const reencrypted = [];
        for (const r of legacySecrets) {
            const oldCtx = {
                scope: r.scope,
                scopeId: r.scope_id,
                key: r.key,
                keyVersion: r.key_version,
            };
            const encRow = { ciphertext: r.ciphertext, keyVersion: r.key_version };
            let plaintext;
            try {
                if (r.key_version === this.keyVersion) {
                    plaintext = decrypt(this.masterKey, encRow, oldCtx);
                }
                else if (this.previousMasterKey && r.key_version === this.previousKeyVersion) {
                    plaintext = decrypt(this.previousMasterKey, encRow, oldCtx);
                }
                else {
                    plaintext = decrypt(this.masterKey, encRow, oldCtx);
                }
            }
            catch (err) {
                // Log + skip — the row was already undecryptable before the
                // migration, and we shouldn't fail the whole boot for one bad
                // row. The audit logger uses the legacy 4-arg signature (scope
                // + scope_id columns exist on audit_log right now).
                this.audit.write({
                    action: 'decrypt_fail',
                    actor: { type: 'system', systemId: 'scope-collapse-migration' },
                    scope: r.scope,
                    scope_id: r.scope_id,
                    key: r.key,
                    success: false,
                    detail: { keyVersion: r.key_version, error: err instanceof Error ? err.message : String(err) },
                });
                continue;
            }
            // Re-encrypt under the new constant AAD.
            const newCtx = {
                scope: STORE_AAD_SCOPE,
                scopeId: STORE_AAD_SCOPE_ID,
                key: r.key,
                keyVersion: this.keyVersion,
            };
            const fresh = encrypt(this.masterKey, plaintext, newCtx);
            reencrypted.push({ key: r.key, ciphertext: fresh.ciphertext, key_version: fresh.keyVersion, row: r });
        }
        const tx = this.db.transaction(() => {
            if (secretsLegacy) {
                this.db.exec('DROP TABLE secrets');
                this.db.exec(`
          CREATE TABLE secrets (
            key TEXT NOT NULL PRIMARY KEY,
            ciphertext TEXT NOT NULL,
            key_version TEXT NOT NULL,
            label TEXT,
            provider TEXT,
            last_tested_at INTEGER,
            last_test_result TEXT,
            updated_at INTEGER NOT NULL,
            updated_by TEXT NOT NULL
          );
        `);
                const insert = this.db.prepare(`
          INSERT INTO secrets (key, ciphertext, key_version, label, provider, last_tested_at, last_test_result, updated_at, updated_by)
          VALUES (@key, @ciphertext, @key_version, @label, @provider, @last_tested_at, @last_test_result, @updated_at, @updated_by)
        `);
                for (const r of reencrypted) {
                    insert.run({
                        key: r.key,
                        ciphertext: r.ciphertext,
                        key_version: r.key_version,
                        label: r.row.label,
                        provider: r.row.provider,
                        last_tested_at: r.row.last_tested_at,
                        last_test_result: r.row.last_test_result,
                        updated_at: r.row.updated_at,
                        updated_by: r.row.updated_by,
                    });
                }
            }
            if (settingsLegacy) {
                this.db.exec('DROP TABLE settings');
                this.db.exec(`
          CREATE TABLE settings (
            key TEXT NOT NULL PRIMARY KEY,
            value_json TEXT NOT NULL,
            updated_at INTEGER NOT NULL,
            updated_by TEXT NOT NULL
          );
        `);
                const insertS = this.db.prepare(`
          INSERT INTO settings (key, value_json, updated_at, updated_by)
          VALUES (@key, @value_json, @updated_at, @updated_by)
        `);
                for (const r of legacySettings) {
                    insertS.run({
                        key: r.key,
                        value_json: r.value_json,
                        updated_at: r.updated_at,
                        updated_by: r.updated_by,
                    });
                }
            }
            if (auditLegacy) {
                // SQLite 3.35+ supports ALTER TABLE DROP COLUMN. The PK is
                // auto-increment id, so no rebuild needed for audit_log — we
                // can drop the now-redundant scope / scope_id columns in place.
                try {
                    this.db.exec('ALTER TABLE audit_log DROP COLUMN scope');
                }
                catch { /* may not exist */ }
                try {
                    this.db.exec('ALTER TABLE audit_log DROP COLUMN scope_id');
                }
                catch { /* may not exist */ }
            }
            this.db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('scope_collapse_migrated_at', ?)").run(String(Date.now()));
            this.db.prepare("INSERT OR REPLACE INTO meta (key, value) VALUES ('schema_version', ?)").run(SCHEMA_VERSION);
        });
        tx();
    }
    actorString(actor) {
        if (actor.type === 'user')
            return `user:${actor.userId ?? 'unknown'}`;
        return `system:${actor.systemId ?? 'unknown'}`;
    }
    // ── SettingsStore impl ─────────────────────────────────────────────────
    async set(input, actor) {
        // Discriminate by whether the registry classifies the key as a secret.
        const def = getKeyDef(input.key);
        const isSecret = !!def && def.classification === 'workspace-secret';
        if (isSecret)
            return this.setSecret(input, actor);
        return this.setSetting(input, actor);
    }
    async setSetting(input, actor) {
        const updatedAt = Date.now();
        const updatedBy = this.actorString(actor);
        const valueJson = JSON.stringify(input.value);
        // M1: settings write + audit insert run inside one transaction so a
        // disk-full / table-locked / write-failure on either step rolls the
        // whole pair back. Otherwise we'd persist mutations without their
        // audit row, breaking the "every write is logged" property.
        const tx = this.db.transaction(() => {
            this.db.prepare(`
        INSERT INTO settings (key, value_json, updated_at, updated_by)
        VALUES (@key, @value_json, @updated_at, @updated_by)
        ON CONFLICT(key) DO UPDATE SET
          value_json = excluded.value_json,
          updated_at = excluded.updated_at,
          updated_by = excluded.updated_by
      `).run({
                key: input.key,
                value_json: valueJson,
                updated_at: updatedAt,
                updated_by: updatedBy,
            });
            this.audit.write({
                action: 'set',
                actor,
                key: input.key,
                success: true,
            });
        });
        tx();
        return {
            key: input.key,
            valueJson,
            updatedAt,
            updatedBy,
        };
    }
    get(key) {
        const row = this.db.prepare('SELECT value_json FROM settings WHERE key=?').get(key);
        if (!row)
            return undefined;
        try {
            return JSON.parse(row.value_json);
        }
        catch {
            return undefined;
        }
    }
    list() {
        const rows = this.db.prepare('SELECT * FROM settings').all();
        return rows.map((r) => ({
            key: r.key,
            valueJson: r.value_json,
            updatedAt: r.updated_at,
            updatedBy: r.updated_by,
        }));
    }
    async delete(key, actor) {
        // M1: delete + audit insert run inside one transaction. If audit
        // insert fails, the deletes roll back so we never silently drop a
        // row without its audit trail.
        let removed = false;
        const tx = this.db.transaction(() => {
            // Delete from both tables — same primary-key shape, only one will match.
            const settingsRes = this.db.prepare('DELETE FROM settings WHERE key=?').run(key);
            const secretsRes = this.db.prepare('DELETE FROM secrets WHERE key=?').run(key);
            removed = settingsRes.changes + secretsRes.changes > 0;
            this.audit.write({
                action: 'delete',
                actor,
                key,
                success: removed,
            });
        });
        tx();
        return removed;
    }
    // ── SecretStore impl ───────────────────────────────────────────────────
    async setSecret(input, actor) {
        const def = getKeyDef(input.key);
        if (!def || def.classification !== 'workspace-secret') {
            throw new Error(`SqliteEncryptedStore: cannot set secret for key "${input.key}" — not a registered workspace-secret`);
        }
        const updatedAt = Date.now();
        const updatedBy = this.actorString(actor);
        const ctx = {
            scope: STORE_AAD_SCOPE,
            scopeId: STORE_AAD_SCOPE_ID,
            key: input.key,
            keyVersion: this.keyVersion,
        };
        const enc = encrypt(this.masterKey, input.value, ctx);
        // M1: encrypted-row insert + audit insert run inside one transaction
        // so any failure (disk full, table locked, audit insert error) rolls
        // both writes back. Encryption itself runs outside the txn — if
        // encrypt() throws, we never opened the txn.
        const tx = this.db.transaction(() => {
            this.db.prepare(`
        INSERT INTO secrets (key, ciphertext, key_version, label, provider, last_tested_at, last_test_result, updated_at, updated_by)
        VALUES (@key, @ciphertext, @key_version, @label, @provider, @last_tested_at, @last_test_result, @updated_at, @updated_by)
        ON CONFLICT(key) DO UPDATE SET
          ciphertext = excluded.ciphertext,
          key_version = excluded.key_version,
          label = COALESCE(excluded.label, secrets.label),
          provider = COALESCE(excluded.provider, secrets.provider),
          last_tested_at = excluded.last_tested_at,
          last_test_result = excluded.last_test_result,
          updated_at = excluded.updated_at,
          updated_by = excluded.updated_by
      `).run({
                key: input.key,
                ciphertext: enc.ciphertext,
                key_version: enc.keyVersion,
                label: input.label ?? null,
                provider: input.provider ?? null,
                last_tested_at: null,
                last_test_result: null,
                updated_at: updatedAt,
                updated_by: updatedBy,
            });
            this.audit.write({
                action: 'set',
                actor,
                key: input.key,
                success: true,
            });
        });
        tx();
        return this.describe(input.key);
    }
    describe(key) {
        const row = this.db.prepare('SELECT * FROM secrets WHERE key=?').get(key);
        if (!row)
            return undefined;
        const def = getKeyDef(row.key);
        return {
            key: row.key,
            classification: def?.classification ?? 'workspace-secret',
            label: row.label ?? undefined,
            provider: row.provider ?? undefined,
            lastTestedAt: row.last_tested_at ?? undefined,
            lastTestResult: row.last_test_result ?? undefined,
            updatedAt: row.updated_at,
            updatedBy: row.updated_by,
            keyVersion: row.key_version,
        };
    }
    listSecrets(actor) {
        const rows = this.db.prepare('SELECT * FROM secrets').all();
        if (actor) {
            this.audit.write({
                action: 'describe',
                actor,
                key: '*',
                success: true,
            });
        }
        return rows.map((r) => {
            const def = getKeyDef(r.key);
            return {
                key: r.key,
                classification: def?.classification ?? 'workspace-secret',
                label: r.label ?? undefined,
                provider: r.provider ?? undefined,
                lastTestedAt: r.last_tested_at ?? undefined,
                lastTestResult: r.last_test_result ?? undefined,
                updatedAt: r.updated_at,
                updatedBy: r.updated_by,
                keyVersion: r.key_version,
            };
        });
    }
    unsafeGetForResolver(key) {
        const row = this.db.prepare('SELECT ciphertext, key_version FROM secrets WHERE key=?').get(key);
        if (!row)
            return undefined;
        const ctx = {
            scope: STORE_AAD_SCOPE,
            scopeId: STORE_AAD_SCOPE_ID,
            key,
            keyVersion: row.key_version,
        };
        const encRow = { ciphertext: row.ciphertext, keyVersion: row.key_version };
        try {
            // Try current master key first (steady state).
            if (row.key_version === this.keyVersion) {
                return decrypt(this.masterKey, encRow, ctx);
            }
            // Transitional: row was encrypted under a previous key version.
            if (this.previousMasterKey &&
                row.key_version === this.previousKeyVersion) {
                return decrypt(this.previousMasterKey, encRow, ctx);
            }
            // Fall through — try current key against whatever AAD we have.
            // (Misconfigured rotation; better to error noisily.)
            return decrypt(this.masterKey, encRow, ctx);
        }
        catch (err) {
            // Audit decrypt failures as a security signal but don't throw out
            // through the resolver hot path. Caller treats undefined as "unset".
            this.audit.write({
                action: 'decrypt_fail',
                actor: { type: 'system', systemId: 'resolver' },
                key,
                success: false,
                detail: { keyVersion: row.key_version, error: err instanceof Error ? err.message : String(err) },
            });
            return undefined;
        }
    }
    /**
     * Read a value from the `meta` key/value table — used by one-shot
     * migrations to mark completion (e.g., scope-collapse, R2 structural
     * import). Returns undefined when the row is absent. Not audited:
     * meta rows are operational state, not configuration.
     */
    getMeta(key) {
        const row = this.db.prepare('SELECT value FROM meta WHERE key=?').get(key);
        return row?.value;
    }
    /**
     * Write a value to the `meta` key/value table. Upserts. Paired with
     * `getMeta` for migration completion markers.
     */
    setMeta(key, value) {
        this.db.prepare('INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)').run(key, value);
    }
    close() {
        this.db.close();
    }
}
//# sourceMappingURL=SqliteEncryptedStore.js.map