/**
 * Authentication service
 *
 * SQLite-backed user store with bcrypt password hashing and JWT sessions.
 * Database lives at .studiograph/auth.db inside the workspace.
 *
 * Auth state is portable via .studiograph/auth-seed.json — a JSON export of
 * all users (with bcrypt hashes) and the JWT signing secret. The seed file
 * is committed to the config repo so it syncs between local and Railway:
 *   local setup → commit → push → Railway redeploy → users restored from seed
 *
 * ─────────────────────────────────────────────────────────────────────────
 * SECURITY — config repo is a secret store
 * ─────────────────────────────────────────────────────────────────────────
 * `auth-seed.json` carries:
 *   • the JWT signing secret (anyone holding this can mint sessions for any
 *     user in the workspace, bypassing password auth entirely)
 *   • bcrypt password hashes for every user (offline-crackable; bcrypt cost
 *     12 buys time but not invulnerability against weak passwords)
 *
 * Treat the .studiograph/ config repo with the same operational care as any
 * other secret store: private remote, restricted contributors, never made
 * public. The audit (2026-05) flagged this surface specifically — `getSeedData`
 * returns these fields by design (they're how the seed-restore flow works on
 * Railway redeploys) but every caller needs to understand they are exporting
 * raw secrets.
 *
 * To override the file-based JWT secret in production (e.g. Railway), set
 * the STUDIOGRAPH_JWT_SECRET environment variable. The env var wins over the
 * `auth-secret` file and the seed file: a leaked seed file alone won't let
 * an attacker mint tokens for an environment with a different in-process
 * secret. Rotation: change the env var and restart — all extant tokens
 * invalidate together.
 */
import Database from 'better-sqlite3';
import bcrypt from 'bcryptjs';
import jwt from 'jsonwebtoken';
import { randomBytes, createHash } from 'crypto';
import { spawnSync } from 'child_process';
import { existsSync, readFileSync, writeFileSync, mkdirSync, statSync, chmodSync } from 'fs';
import { join } from 'path';
import { pickDefaultAccentColor } from '../core/accent-palette.js';
import { sanitizeAuthSeed } from '../core/secrets/bundle.js';
import { assertAuthDbConsistent, decryptOAuthTokenData, decryptUserPreferencesSettings, encryptOAuthTokenData, encryptUserPreferencesSettings, isAuthDbPhase8Migrated, markAuthDbPhase8Migrated, } from '../core/secrets/auth-db-migration.js';
import { sanitize } from '../server/middleware/sanitize.js';
import { createOAuthStateSigner, deriveStateSigningKey, } from '../mcp/state-signer.js';
import { OAuthServerStore } from './oauth-server-store.js';
import { log } from '../utils/log.js';
/**
 * Resolve the JWT signing secret. Priority order:
 *   1. STUDIOGRAPH_JWT_SECRET env var — production override; not committed,
 *      not on disk, can rotate by restarting the process.
 *   2. .studiograph/auth-secret file — local-dev / single-host stable secret.
 *   3. auth-seed.json's `jwtSecret` field — restored from a portable seed.
 *   4. Freshly generated 32-byte random — first-run fallback; written to
 *      auth-secret so subsequent restarts pick path 2.
 *
 * Phase 4: production (RAILWAY_ENVIRONMENT set) MUST hit path 1. The
 * file/seed/generated fallbacks are local-dev only — a misconfigured
 * Railway service should fail loud rather than silently mint a fresh
 * secret per restart (which would invalidate every active session and
 * mask the misconfiguration).
 *
 * Returns the secret AND the source so the caller can log/operator-visible
 * which path won (without leaking the secret itself).
 */
function resolveJwtSecret(sgDir) {
    // 1. Env var wins. Trim — accidental newline at the end of `secret > file`
    // happens often enough that we shouldn't punish operators for it.
    const envSecret = process.env.STUDIOGRAPH_JWT_SECRET?.trim();
    if (envSecret) {
        if (envSecret.length < 16) {
            // Refuse comically weak secrets so an operator who set
            // STUDIOGRAPH_JWT_SECRET=hunter2 doesn't accidentally weaken the
            // workspace versus the auto-generated 64-hex-char fallback.
            throw new Error('STUDIOGRAPH_JWT_SECRET is set but too short (need ≥16 chars). ' +
                'Generate one with: openssl rand -hex 32');
        }
        return { secret: envSecret, source: 'env' };
    }
    // Phase 4 production gate. File/seed/generated paths below are local-dev
    // only; a Railway deploy reaching them indicates the backfill missed this
    // service (or someone unset the var). Hard-fail so an operator notices
    // immediately instead of silently rotating the JWT on every restart.
    if (process.env.RAILWAY_ENVIRONMENT) {
        throw new Error('auth: STUDIOGRAPH_JWT_SECRET is required in production (RAILWAY_ENVIRONMENT is set). ' +
            'Generate one with: openssl rand -hex 32 — then set on the Railway service. ' +
            'Use scripts/backfill-railway-secrets.ts to provision across all services.');
    }
    // 2. On-disk secret file (local-dev path).
    const secretPath = join(sgDir, 'auth-secret');
    if (existsSync(secretPath)) {
        return { secret: readFileSync(secretPath, 'utf-8').trim(), source: 'file' };
    }
    // 3. Seed file may have been imported just before us (applySeed path).
    const seedPath = join(sgDir, 'auth-seed.json');
    if (existsSync(seedPath)) {
        try {
            const seed = JSON.parse(readFileSync(seedPath, 'utf-8'));
            if (typeof seed.jwtSecret === 'string' && seed.jwtSecret.length >= 16) {
                // Persist to auth-secret so subsequent boots pick the file path
                // without re-parsing the seed JSON.
                writeFileSync(secretPath, seed.jwtSecret, { mode: 0o600 });
                return { secret: seed.jwtSecret, source: 'seed' };
            }
        }
        catch { /* fall through to generation */ }
    }
    // 4. First-run: generate and persist. 32 bytes of entropy = 64-char hex.
    const generated = randomBytes(32).toString('hex');
    writeFileSync(secretPath, generated, { mode: 0o600 });
    return { secret: generated, source: 'generated' };
}
/**
 * Warn loudly if `auth-seed.json` is world-readable. Anyone with read access
 * to the file holds the workspace's JWT secret + bcrypt hashes; on a shared
 * host that's an instant credential-store leak. We can't `chmod` it ourselves
 * because the user may want their checkout group-readable for collaborators —
 * but we WILL tell them the risk.
 *
 * Idempotent: the warning prints at boot once. Posix only — Windows mode bits
 * don't carry the same meaning, so we skip there.
 */
function warnIfSeedWorldReadable(sgDir) {
    if (process.platform === 'win32')
        return;
    const seedPath = join(sgDir, 'auth-seed.json');
    if (!existsSync(seedPath))
        return;
    try {
        const mode = statSync(seedPath).mode;
        // Other-readable bit (S_IROTH = 0o004).
        if ((mode & 0o004) !== 0) {
            log.warn(`[auth] WARNING: ${seedPath} is world-readable (mode ${(mode & 0o777).toString(8)}). ` +
                'It contains the JWT signing secret and bcrypt password hashes — anyone with ' +
                'read access to this file can impersonate workspace users. Attempting chmod 600...');
            // Best-effort tightening. The seed lives inside the .studiograph/ git
            // repo; a fresh `git checkout` restores files at 0644 by default. Trying
            // to fix it on every boot is harmless (already-0600 is a no-op) and
            // defends against the most common leak path. NEVER throw — this is a
            // boot-time hygiene step, not a hard gate.
            try {
                chmodSync(seedPath, 0o600);
            }
            catch { /* non-fatal */ }
        }
    }
    catch { /* stat failure is non-fatal */ }
}
/** Check if a user has workspace-owner privileges. */
export function isWorkspaceOwner(role) {
    return role === 'owner';
}
// NOTE: the WorkspaceView / WorkspaceViewRow types + their methods were removed
// in Saved-Views Phase 1 (the workspace_views multi-view-checkoff system). The
// inert `workspace_views` table is retained in the schema (no destructive
// migration); saved views build on UserView / user_views.
const BCRYPT_ROUNDS = 12;
const JWT_EXPIRY = '7d';
function hashToken(token) {
    return createHash('sha256').update(token).digest('hex');
}
/**
 * Slug-ify a name into a handle candidate. Lowercases, strips combining
 * diacritics, replaces non-alphanumerics with hyphens, trims edge hyphens.
 * Falls back to 'user' if the input has nothing usable (emoji-only, etc.) —
 * the caller is responsible for ensuring uniqueness via collision suffix.
 */
function slugifyHandle(name) {
    const cleaned = name
        .normalize('NFKD')
        .replace(/[̀-ͯ]/g, '')
        .toLowerCase()
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-+|-+$/g, '');
    return cleaned || 'user';
}
export class AuthService {
    db;
    jwtSecret;
    sgDir;
    crypto;
    /**
     * True once we've confirmed `data_ciphertext` exists on `oauth_tokens`
     * (post-migration shape). The encrypted code path keys off this rather
     * than re-running PRAGMA on every call.
     */
    oauthTokensEncrypted = false;
    /** True once `settings_ciphertext` is available on `user_preferences`. */
    userPrefsEncrypted = false;
    /**
     * Listeners for OAuth-token mutations. Fires after `saveOAuthData` or
     * `clearOAuthData` lands a row. Used by the agent orchestrator to evict
     * the user's pooled agent so the next chat builds a fresh tool list
     * that reflects the new per-user connector state (otherwise the agent
     * keeps the toolset it had at first session creation, missing any
     * connector the user just OAuthed). Mirrors the `onSecretMutation`
     * pattern in `src/core/secrets/index.ts`.
     *
     * Listener errors are swallowed — never let a stray subscriber take
     * down the OAuth write path.
     */
    oauthMutationListeners = new Set();
    /**
     * OAuth state signer. Initialized lazily from the master key (HKDF-derived,
     * domain-separated from the AEAD key). When `cryptoOpts` is missing — e.g.
     * tests that don't exercise the OAuth init/callback paths — sign/verify
     * throw on use rather than silently falling back to unsigned state.
     */
    oauthStateSigner = null;
    /** OAuth 2.0 authorization-server store. Initialized at the end of the constructor. */
    oauthServerStore;
    constructor(workspacePath, cryptoOpts) {
        this.sgDir = join(workspacePath, '.studiograph');
        this.crypto = cryptoOpts;
        if (cryptoOpts) {
            const key = deriveStateSigningKey(cryptoOpts.masterKey);
            this.oauthStateSigner = createOAuthStateSigner(key);
        }
        if (!existsSync(this.sgDir)) {
            mkdirSync(this.sgDir, { recursive: true });
        }
        // Open/create SQLite database
        const dbPath = join(this.sgDir, 'auth.db');
        this.db = new Database(dbPath);
        // Tighten file mode immediately after open. The DB embeds the JWT signing
        // secret-equivalent (bcrypt hashes + per-user OAuth tokens via
        // oauth_tokens.data_json) — leaving it 0644 on a shared host is an
        // instant-credential-leak vector. Posix only; Windows ACLs differ.
        if (process.platform !== 'win32') {
            try {
                chmodSync(dbPath, 0o600);
            }
            catch { /* non-fatal */ }
        }
        this.db.pragma('journal_mode = WAL');
        // Create users table. CHECK tolerates legacy 'admin' rows so existing
        // databases don't error on load; app code only ever writes 'owner' or
        // 'member' going forward.
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS users (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        email TEXT UNIQUE NOT NULL,
        handle TEXT UNIQUE NOT NULL,
        password_hash TEXT NOT NULL,
        display_name TEXT NOT NULL,
        role TEXT NOT NULL DEFAULT 'member' CHECK(role IN ('owner', 'admin', 'member')),
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        updated_at TEXT NOT NULL DEFAULT (datetime('now'))
      )
    `);
        // Migration: backfill `handle` on databases created before commit 26d3d6f9
        // (Apr 25 2026). The original commit said "wipe manually" — fine for dev,
        // but Railway tenants can't be wiped without losing data. Idempotent:
        // ALTER fails harmlessly when the column already exists, and the
        // backfill loop only touches NULL rows.
        const userCols = this.db.prepare("PRAGMA table_info(users)").all();
        if (!userCols.some(c => c.name === 'handle')) {
            this.db.exec("ALTER TABLE users ADD COLUMN handle TEXT");
            const needHandle = this.db.prepare("SELECT id, display_name, email FROM users WHERE handle IS NULL").all();
            const update = this.db.prepare("UPDATE users SET handle = ? WHERE id = ?");
            const seen = new Set();
            for (const row of needHandle) {
                const baseSource = row.display_name?.trim() || row.email.split('@')[0];
                let candidate = slugifyHandle(baseSource);
                if (seen.has(candidate)) {
                    for (let n = 2; n < 1000; n++) {
                        const next = `${candidate}-${n}`;
                        if (!seen.has(next)) {
                            candidate = next;
                            break;
                        }
                    }
                }
                seen.add(candidate);
                update.run(candidate, row.id);
            }
            // App code expects UNIQUE; a unique index gives us that without
            // recreating the table.
            try {
                this.db.exec("CREATE UNIQUE INDEX IF NOT EXISTS idx_users_handle ON users(handle)");
            }
            catch { /* ignore */ }
        }
        // Create folder_access table for per-folder membership + role (admin | editor).
        // `folder_name` stores the repo slug — the underlying git-backed container
        // is still called a repo internally; the column value equals the repo name.
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS folder_access (
        user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        folder_name TEXT NOT NULL,
        role TEXT NOT NULL DEFAULT 'editor',
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        PRIMARY KEY (user_id, folder_name)
      )
    `);
        // Per-user OAuth tokens for MCP connectors.
        //
        // Phase 8: `data_json` is nullable on freshly-provisioned DBs because
        // encrypted values land in `data_ciphertext` instead. Pre-existing
        // databases were created with `data_json TEXT NOT NULL`; the Phase 8
        // migration (`auth-db-migration.ts`) rebuilds the table to relax that
        // constraint. CREATE TABLE IF NOT EXISTS is a no-op on those — the
        // migrate path handles them.
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS oauth_tokens (
        user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        connector_name TEXT NOT NULL,
        data_json TEXT,
        data_ciphertext TEXT,
        key_version TEXT,
        updated_at TEXT NOT NULL DEFAULT (datetime('now')),
        PRIMARY KEY (user_id, connector_name)
      )
    `);
        // Defensive: on a pre-migration DB the table was created without the
        // ciphertext columns, but the migration may not yet have run (tests
        // construct AuthService directly without the boot path). Add them
        // idempotently so the read/write paths can branch on column presence.
        {
            const oauthCols = this.db.prepare("PRAGMA table_info(oauth_tokens)").all();
            if (!oauthCols.some(c => c.name === 'data_ciphertext')) {
                try {
                    this.db.exec("ALTER TABLE oauth_tokens ADD COLUMN data_ciphertext TEXT");
                }
                catch { /* idempotent */ }
            }
            if (!oauthCols.some(c => c.name === 'key_version')) {
                try {
                    this.db.exec("ALTER TABLE oauth_tokens ADD COLUMN key_version TEXT");
                }
                catch { /* idempotent */ }
            }
            this.oauthTokensEncrypted = true;
        }
        // Per-user API tokens. Used by MCP clients (HTTP and stdio) so every
        // call carries a real user identity and access control can be enforced.
        // Only the sha256 hash is stored — the raw token is shown once at mint.
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS api_tokens (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
        token_hash TEXT NOT NULL UNIQUE,
        name TEXT NOT NULL,
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        last_used_at TEXT
      )
    `);
        // Per-user saved views (Saved-Views Phase 2). A view = a durable named
        // lens: visualization + filters + sort/group (in `config_json`) pinned to a
        // `scope` (recent / type:<t> / folder:<id>). `view_type` mirrors the
        // visualization for a cheap sidebar-icon read without parsing the config.
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS user_views (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        user_id INTEGER NOT NULL DEFAULT 0,
        name TEXT NOT NULL,
        view_type TEXT NOT NULL DEFAULT 'list',
        config_json TEXT NOT NULL DEFAULT '{}',
        scope TEXT NOT NULL DEFAULT 'recent',
        sort_order INTEGER NOT NULL DEFAULT 0,
        visibility TEXT NOT NULL DEFAULT 'private',
        created_by_name TEXT,
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        updated_at TEXT NOT NULL DEFAULT (datetime('now'))
      )
    `);
        // Migration: add columns if missing (existing databases). Additive,
        // IF-NOT-EXISTS pattern — each ALTER throws-and-ignores when the column is
        // already present.
        try {
            this.db.exec(`ALTER TABLE user_views ADD COLUMN visibility TEXT NOT NULL DEFAULT 'private'`);
        }
        catch { }
        try {
            this.db.exec(`ALTER TABLE user_views ADD COLUMN created_by_name TEXT`);
        }
        catch { }
        try {
            this.db.exec(`ALTER TABLE user_views ADD COLUMN scope TEXT NOT NULL DEFAULT 'recent'`);
        }
        catch { }
        // Per-user adoption of workspace-shared views (D10). Empty in Phase 2 —
        // built now so `getUserViews` ships its final `own ∪ adopted` shape in one
        // migration; Phase 4's share-to-workspace populates it. A shared view
        // surfaces in another user's sidebar only once they adopt it here.
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS user_view_adoptions (
        user_id INTEGER NOT NULL,
        view_id INTEGER NOT NULL,
        sort_order INTEGER NOT NULL DEFAULT 0,
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        PRIMARY KEY (user_id, view_id)
      )
    `);
        // NOTE: legacy `user_views` rows from pre-Phase-2 versions (the old
        // ensureDefaultViews seeds + per-viewmode auto-saves — identifiable by a
        // null or 'Studiograph' created_by_name, never set by the real save flow)
        // are cleaned up as a one-time per-workspace ops sweep, NOT boot-time code:
        //   DELETE FROM user_views WHERE created_by_name IS NULL OR created_by_name = 'Studiograph';
        // A standing boot-time purge was removed (2026-06-09) — a one-time data fix
        // shouldn't run forever, and the predicate would silently nuke any future
        // legitimately 'Studiograph'-authored workspace view.
        // Workspace-shared views (folder-views redesign). Scope-keyed, one per
        // (scope, view_type), no per-user column — everyone sees the same set.
        // Fresh table: born here via CREATE TABLE IF NOT EXISTS, no migration.
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS workspace_views (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        scope TEXT NOT NULL,
        view_type TEXT NOT NULL,
        config_json TEXT NOT NULL DEFAULT '{}',
        position INTEGER NOT NULL DEFAULT 0,
        name TEXT,
        created_by TEXT,
        created_at TEXT NOT NULL DEFAULT (datetime('now')),
        updated_at TEXT NOT NULL DEFAULT (datetime('now')),
        UNIQUE(scope, view_type)
      )
    `);
        // Per-user preferences (accent color, onboarding flag, meeting-capture settings)
        this.db.exec(`
      CREATE TABLE IF NOT EXISTS user_preferences (
        user_id INTEGER PRIMARY KEY DEFAULT 0,
        accent_color TEXT,
        updated_at TEXT NOT NULL DEFAULT (datetime('now'))
      )
    `);
        // Migration: add columns if missing (existing installs)
        const cols = this.db.prepare("PRAGMA table_info(user_preferences)").all();
        if (!cols.some(c => c.name === 'accent_color')) {
            this.db.exec("ALTER TABLE user_preferences ADD COLUMN accent_color TEXT");
        }
        if (!cols.some(c => c.name === 'onboarded')) {
            this.db.exec("ALTER TABLE user_preferences ADD COLUMN onboarded INTEGER NOT NULL DEFAULT 0");
        }
        // Onboarding "deferred" (skipped for now) — distinct from `onboarded`
        // (interview completed). Deferring silences the auto-greet + pill without
        // marking the user done, so they can return on their own terms.
        if (!cols.some(c => c.name === 'onboarding_deferred')) {
            this.db.exec("ALTER TABLE user_preferences ADD COLUMN onboarding_deferred INTEGER NOT NULL DEFAULT 0");
        }
        // Meeting capture: per-user settings stored as JSON (deepgram key, calendar tokens, default folder)
        if (!cols.some(c => c.name === 'settings_json')) {
            this.db.exec("ALTER TABLE user_preferences ADD COLUMN settings_json TEXT NOT NULL DEFAULT '{}'");
        }
        // Phase 8: split user_preferences.settings_json by classification —
        // `user-secret` keys land in the encrypted column. Migration handles
        // pre-existing rows; here we just make sure the columns exist so
        // fresh DBs can use the new code path immediately.
        if (!cols.some(c => c.name === 'settings_ciphertext')) {
            try {
                this.db.exec("ALTER TABLE user_preferences ADD COLUMN settings_ciphertext TEXT");
            }
            catch { /* idempotent */ }
        }
        if (!cols.some(c => c.name === 'key_version')) {
            try {
                this.db.exec("ALTER TABLE user_preferences ADD COLUMN key_version TEXT");
            }
            catch { /* idempotent */ }
        }
        this.userPrefsEncrypted = true;
        this.db.pragma('foreign_keys = ON');
        // Half-state guard. If a previous run committed partial migration
        // state (rows with both data_json AND data_ciphertext populated),
        // refuse to start. Better to fail-fast than silently serve corrupt
        // OAuth data. See auth-db-migration.ts for the migration's own
        // transactional safety net — this is the last-resort detector.
        assertAuthDbConsistent(this.db);
        // On a fresh DB (no pre-existing rows in either secret-carrying
        // table) we can mark Phase 8 as already migrated, so a later
        // `migrateAuthDbPhase8` invocation short-circuits. This matters for
        // the test path and for first-run installs where no legacy data
        // ever existed.
        if (this.crypto && !isAuthDbPhase8Migrated(this.db)) {
            const hasOauthRows = this.db.prepare('SELECT COUNT(*) AS n FROM oauth_tokens').get().n > 0;
            const hasSecretPrefRows = this.db.prepare(`SELECT COUNT(*) AS n FROM user_preferences WHERE settings_json IS NOT NULL AND settings_json != '' AND settings_json != '{}'`).get().n > 0;
            if (!hasOauthRows && !hasSecretPrefRows) {
                markAuthDbPhase8Migrated(this.db);
            }
        }
        // Ensure auth.db and auth-secret are gitignored (only seed file should be committed)
        this.ensureGitignore();
        // If DB is empty, try restoring from seed file (synced via config repo).
        // Best-effort during boot — a corrupt or partial seed shouldn't prevent
        // the server from starting. Explicit pull/import callers still get the
        // throw and can surface the error to the user.
        if (!this.hasUsers()) {
            try {
                this.applySeed();
            }
            catch { /* already logged inside applySeed */ }
        }
        // Load JWT secret. Priority: env var > auth-secret file > seed file >
        // freshly generated. The env-var path lets production deployments
        // (Railway, etc.) hold the signing secret outside the config repo, so a
        // leaked auth-seed.json is not by itself a session-forgery risk for
        // that environment. See resolveJwtSecret for the full ordering.
        const resolved = resolveJwtSecret(this.sgDir);
        this.jwtSecret = resolved.secret;
        if (resolved.source === 'env') {
            log.info('[auth] JWT secret loaded from STUDIOGRAPH_JWT_SECRET env var');
        }
        else if (resolved.source === 'generated') {
            log.info('[auth] JWT secret generated (first run)');
        }
        // One-shot operator warning if the seed file is world-readable. Loud
        // because the file embeds the signing secret + bcrypt hashes.
        warnIfSeedWorldReadable(this.sgDir);
        // OAuth 2.0 authorization-server store (clients, codes, refresh tokens).
        // Decoupled from this class — handed only the db, the JWT secret (which it
        // uses to mint access tokens + derive the consent signing key), and the
        // shared user lookup, so the AS surface stays separately auditable.
        this.oauthServerStore = new OAuthServerStore({
            db: this.db,
            jwtSecret: this.jwtSecret,
            resolveUser: (userId) => this.findUserById(userId),
        });
    }
    /** The OAuth authorization-server store (clients/codes/refresh + minting). */
    get oauthServer() {
        return this.oauthServerStore;
    }
    createUser(email, password, displayName, role = 'member') {
        const normalized = email.toLowerCase().trim();
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
            throw new Error('Invalid email address');
        }
        if (!password || password.length < 8) {
            throw new Error('Password must be at least 8 characters');
        }
        const hash = bcrypt.hashSync(password, BCRYPT_ROUNDS);
        const handle = this.allocateHandle(displayName);
        const stmt = this.db.prepare('INSERT INTO users (email, handle, password_hash, display_name, role) VALUES (?, ?, ?, ?, ?)');
        const result = stmt.run(normalized, handle, hash, displayName.trim(), role);
        const userId = result.lastInsertRowid;
        // Assign a default avatar accent color so the user shows up distinct
        // across presence / member lists immediately, without having to open
        // their profile.
        this.setAccentColor(userId, pickDefaultAccentColor(userId));
        this.exportSeed();
        return {
            id: userId,
            email: normalized,
            handle,
            displayName: displayName.trim(),
            role,
            createdAt: new Date().toISOString(),
        };
    }
    upsertCloudManagedUser(input) {
        const normalized = input.email.toLowerCase().trim();
        if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalized)) {
            throw new Error('Invalid email address');
        }
        const displayName = input.displayName.trim() || normalized.split('@')[0];
        const existing = this.findUserByEmail(normalized);
        if (!existing) {
            const unguessablePassword = randomBytes(32).toString('base64url');
            return this.createUser(normalized, unguessablePassword, displayName, input.role);
        }
        const nextRole = existing.role === 'owner' ? 'owner' : input.role;
        this.db.prepare(`
      UPDATE users
      SET display_name = ?,
          role = ?,
          updated_at = datetime('now')
      WHERE id = ?
    `).run(displayName, nextRole, existing.id);
        this.exportSeed();
        return this.findUserById(existing.id);
    }
    /**
     * Pick a unique handle by slugifying the display name (or email local-part
     * fallback) and appending `-2`, `-3`, … on collision. Handles are immutable
     * once assigned, so we don't have to worry about two parallel inserts
     * racing — SQLite's UNIQUE constraint will reject the loser and the caller
     * can retry.
     */
    allocateHandle(displayName) {
        const base = slugifyHandle(displayName.trim());
        const exists = this.db.prepare('SELECT 1 FROM users WHERE handle = ? LIMIT 1');
        if (!exists.get(base))
            return base;
        for (let suffix = 2; suffix < 1000; suffix++) {
            const candidate = `${base}-${suffix}`;
            if (!exists.get(candidate))
                return candidate;
        }
        // Pathological: 1000 collisions on the same slug. Fall back to a random
        // tail so we don't block account creation.
        return `${base}-${randomBytes(3).toString('hex')}`;
    }
    authenticate(email, password) {
        const normalized = email.toLowerCase().trim();
        const row = this.db.prepare('SELECT * FROM users WHERE email = ?').get(normalized);
        if (!row || !bcrypt.compareSync(password, row.password_hash)) {
            return null;
        }
        const user = this.toAuthUser(row);
        const token = this.createSessionToken(user);
        return { user, token };
    }
    createSessionToken(user) {
        return jwt.sign({ userId: user.id, email: user.email, role: user.role }, this.jwtSecret, { expiresIn: JWT_EXPIRY });
    }
    /** Expose the resolved JWT secret to sibling services that mint their own
     *  signed tokens against the same key (e.g. `asset-upload-token` for the
     *  MCP presign flow). Reusing one secret keeps "rotate the JWT" a single
     *  operational lever instead of N parallel ones. The secret never leaves
     *  the server process. */
    getJwtSecret() {
        return this.jwtSecret;
    }
    /**
     * Resolve a session JWT to its user. **Rejects OAuth access tokens**
     * (`oauth:true`) — those are authorization-server-issued and confined to the
     * MCP surface, so they must never satisfy a session check. This is the
     * choke point: every hook-bypassing surface that authenticates a JWT
     * (`requireUser` for `/api/auth/*`, Git Basic-auth, the WebSocket route, the
     * cookie branch of the global hook) calls this, so rejecting OAuth tokens
     * here closes all of them at once. The single place that intentionally
     * accepts an OAuth token — the global hook's Bearer→`/mcp` branch — uses
     * `verifyTokenWithClaims` directly and enforces audience scoping there.
     */
    verifyToken(token) {
        const claims = this.verifyTokenWithClaims(token);
        if (!claims || claims.oauth)
            return null;
        return claims.user;
    }
    /**
     * Verify a session/OAuth JWT in a single decode and surface the OAuth-specific
     * claims the auth hook needs: `oauth` marks an authorization-server-issued
     * token (which the hook scopes to `/mcp` + `/oauth/*`), `cid` is the issuing
     * client_id (used as the integration label), and `aud` is the bound resource.
     * Returns null for any invalid/expired token or unknown user.
     */
    verifyTokenWithClaims(token) {
        try {
            const payload = jwt.verify(token, this.jwtSecret);
            const row = this.db.prepare('SELECT * FROM users WHERE id = ?').get(payload.userId);
            if (!row)
                return null;
            return {
                user: this.toAuthUser(row),
                oauth: payload.oauth === true,
                cid: typeof payload.cid === 'string' ? payload.cid : null,
                aud: typeof payload.aud === 'string' ? payload.aud : null,
            };
        }
        catch {
            return null;
        }
    }
    findUserById(userId) {
        const row = this.db.prepare('SELECT * FROM users WHERE id = ?').get(userId);
        return row ? this.toAuthUser(row) : null;
    }
    /** Look up a user by their stable handle (e.g. `christian-marc-schmidt`). */
    findUserByHandle(handle) {
        const row = this.db.prepare('SELECT * FROM users WHERE handle = ?').get(handle);
        return row ? this.toAuthUser(row) : null;
    }
    /** Look up a user by email (case-insensitive). */
    findUserByEmail(email) {
        const normalized = email.toLowerCase().trim();
        const row = this.db.prepare('SELECT * FROM users WHERE email = ?').get(normalized);
        return row ? this.toAuthUser(row) : null;
    }
    listUsers() {
        const rows = this.db.prepare('SELECT * FROM users ORDER BY created_at ASC').all();
        return rows.map(r => this.toAuthUser(r));
    }
    deleteUser(email) {
        const normalized = email.toLowerCase().trim();
        const result = this.db.prepare('DELETE FROM users WHERE email = ?').run(normalized);
        if (result.changes > 0)
            this.exportSeed();
        return result.changes > 0;
    }
    /**
     * Before deleting `targetUserId`, transfer folder-admin rights to
     * `actorUserId` for every folder where the target is the sole admin.
     * Inserts an admin folder_access row for the actor (or upgrades an
     * existing editor row). Returns the list of folder names that were
     * transferred so the caller can surface them in UX.
     *
     * This prevents folders from becoming orphaned (no admins) after the
     * target user's folder_access rows cascade-delete. Folders with other
     * admins are left alone — no redundant membership added.
     */
    transferFoldersOnDelete(targetUserId, actorUserId) {
        if (targetUserId === actorUserId)
            return [];
        const targetAdminFolders = this.db.prepare(`SELECT folder_name FROM folder_access WHERE user_id = ? AND role = 'admin'`).all(targetUserId);
        const transferred = [];
        const countAdminsStmt = this.db.prepare(`SELECT COUNT(*) as count FROM folder_access WHERE folder_name = ? AND role = 'admin'`);
        const upsertStmt = this.db.prepare(`
      INSERT INTO folder_access (user_id, folder_name, role)
      VALUES (?, ?, 'admin')
      ON CONFLICT(user_id, folder_name) DO UPDATE SET role = 'admin'
    `);
        for (const { folder_name } of targetAdminFolders) {
            const { count } = countAdminsStmt.get(folder_name);
            if (count > 1)
                continue; // Other admins remain — nothing to transfer.
            upsertStmt.run(actorUserId, folder_name);
            transferred.push(folder_name);
        }
        if (transferred.length > 0)
            this.exportSeed();
        return transferred;
    }
    updateProfile(userId, fields) {
        const updates = [];
        const params = [];
        if (fields.displayName !== undefined) {
            updates.push('display_name = ?');
            params.push(fields.displayName);
        }
        if (fields.email !== undefined) {
            updates.push('email = ?');
            params.push(fields.email.toLowerCase().trim());
        }
        if (updates.length === 0)
            return this.findUserById(userId);
        updates.push("updated_at = datetime('now')");
        params.push(userId);
        this.db.prepare(`UPDATE users SET ${updates.join(', ')} WHERE id = ?`).run(...params);
        this.exportSeed();
        return this.findUserById(userId);
    }
    updatePassword(email, newPassword) {
        if (!newPassword || newPassword.length < 8) {
            throw new Error('Password must be at least 8 characters');
        }
        const normalized = email.toLowerCase().trim();
        const hash = bcrypt.hashSync(newPassword, BCRYPT_ROUNDS);
        const result = this.db.prepare("UPDATE users SET password_hash = ?, updated_at = datetime('now') WHERE email = ?").run(hash, normalized);
        if (result.changes > 0)
            this.exportSeed();
        return result.changes > 0;
    }
    /**
     * Get the workspace owner (the single user with role 'owner').
     * Returns null if no owner exists (e.g., legacy workspace not yet migrated).
     */
    getOwner() {
        const row = this.db.prepare("SELECT * FROM users WHERE role = 'owner' LIMIT 1").get();
        return row ? this.toAuthUser(row) : null;
    }
    /**
     * Transfer ownership to another user. The current owner becomes a regular member.
     * Both changes happen in a single transaction.
     */
    transferOwnership(currentOwnerId, newOwnerId) {
        const target = this.db.prepare('SELECT * FROM users WHERE id = ?').get(newOwnerId);
        if (!target)
            throw new Error('Target user not found');
        const current = this.db.prepare('SELECT * FROM users WHERE id = ?').get(currentOwnerId);
        if (!current || current.role !== 'owner')
            throw new Error('Current user is not the owner');
        const transfer = this.db.transaction(() => {
            this.db.prepare("UPDATE users SET role = 'member', updated_at = datetime('now') WHERE id = ?").run(currentOwnerId);
            this.db.prepare("UPDATE users SET role = 'owner', updated_at = datetime('now') WHERE id = ?").run(newOwnerId);
        });
        transfer();
        this.exportSeed();
    }
    // ── Folder access management ──
    /** Grant folder membership. `role` defaults to 'editor'. Upserts the role if the row exists. */
    grantFolderAccess(userId, folder, role = 'editor') {
        this.db.prepare(`
      INSERT INTO folder_access (user_id, folder_name, role) VALUES (?, ?, ?)
      ON CONFLICT (user_id, folder_name) DO UPDATE SET role = excluded.role
    `).run(userId, folder, role);
        this.exportSeed();
    }
    revokeFolderAccess(userId, folder) {
        this.db.prepare('DELETE FROM folder_access WHERE user_id = ? AND folder_name = ?').run(userId, folder);
        this.exportSeed();
    }
    /** Folder names the user is a member of. */
    getUserFolders(userId) {
        const rows = this.db.prepare('SELECT folder_name FROM folder_access WHERE user_id = ? ORDER BY folder_name').all(userId);
        return rows.map(r => r.folder_name);
    }
    /** All users with any role on a folder. */
    getFolderUsers(folder) {
        const rows = this.db.prepare(`
      SELECT u.* FROM users u
      JOIN folder_access fa ON fa.user_id = u.id
      WHERE fa.folder_name = ?
      ORDER BY u.display_name
    `).all(folder);
        return rows.map(r => this.toAuthUser(r));
    }
    /** (user, role) pairs for every member of a folder. */
    getFolderMembers(folder) {
        const rows = this.db.prepare(`
      SELECT u.*, fa.role AS folder_role FROM users u
      JOIN folder_access fa ON fa.user_id = u.id
      WHERE fa.folder_name = ?
      ORDER BY u.display_name
    `).all(folder);
        return rows.map(r => ({ user: this.toAuthUser(r), role: r.folder_role }));
    }
    /** Folder role for a specific user, or null if they aren't a member. */
    getFolderRole(userId, folder) {
        const row = this.db.prepare('SELECT role FROM folder_access WHERE user_id = ? AND folder_name = ?').get(userId, folder);
        return row?.role ?? null;
    }
    /** True if the user is a folder admin on that folder. */
    isFolderAdmin(userId, folder) {
        return this.getFolderRole(userId, folder) === 'admin';
    }
    /** All users with folder-admin role on a folder. */
    getFolderAdmins(folder) {
        const rows = this.db.prepare(`
      SELECT u.* FROM users u
      JOIN folder_access fa ON fa.user_id = u.id
      WHERE fa.folder_name = ? AND fa.role = 'admin'
      ORDER BY u.display_name
    `).all(folder);
        return rows.map(r => this.toAuthUser(r));
    }
    /**
     * Transfer folder admin role from one user to another. Used during member
     * removal to pass orphaned folders to the workspace owner.
     */
    transferFolderAdmin(folder, fromUserId, toUserId) {
        const transfer = this.db.transaction(() => {
            this.db.prepare('DELETE FROM folder_access WHERE user_id = ? AND folder_name = ?')
                .run(fromUserId, folder);
            this.db.prepare(`
        INSERT INTO folder_access (user_id, folder_name, role) VALUES (?, ?, 'admin')
        ON CONFLICT (user_id, folder_name) DO UPDATE SET role = 'admin'
      `).run(toUserId, folder);
        });
        transfer();
        this.exportSeed();
    }
    /** Rename the folder slug across all membership rows. */
    renameFolder(oldName, newName) {
        this.db.prepare('UPDATE folder_access SET folder_name = ? WHERE folder_name = ?').run(newName, oldName);
        this.exportSeed();
    }
    // ── OAuth token management ──
    /** Get stored OAuth data (tokens, clientInfo, codeVerifier) for a user+connector. */
    getOAuthData(userId, connectorName) {
        const row = this.db.prepare('SELECT data_json, data_ciphertext, key_version FROM oauth_tokens WHERE user_id = ? AND connector_name = ?').get(userId, connectorName);
        if (!row)
            return null;
        // Encrypted-path: ciphertext present. Decryption errors throw — a row
        // bound to user A cannot be decrypted with user B's AAD, and tampering
        // is detected. Callers should let the error propagate.
        if (row.data_ciphertext) {
            if (!this.crypto) {
                throw new Error(`oauth_tokens row for user_id=${userId} connector=${connectorName} is encrypted but ` +
                    `AuthService was constructed without a master key. Wire the crypto options in serve.ts.`);
            }
            try {
                const plaintext = decryptOAuthTokenData(this.crypto.masterKey, userId, row.data_ciphertext, row.key_version);
                return JSON.parse(plaintext);
            }
            catch (err) {
                log.error(`[auth] OAuth token decrypt failed for user_id=${userId} connector=${connectorName}: ${err instanceof Error ? err.message : String(err)}`);
                throw err;
            }
        }
        // Pre-migration fallback: legacy plaintext column.
        if (!row.data_json)
            return null;
        try {
            return JSON.parse(row.data_json);
        }
        catch {
            return null;
        }
    }
    /**
     * Sign an OAuth state parameter for outbound flows. Throws when the service
     * was constructed without crypto options (the signing key is HKDF-derived
     * from the master key). Callers in OAuth init paths must ensure the
     * AuthService is constructed with crypto options — production always is;
     * tests that exercise OAuth flows should provide a master key.
     */
    signOAuthState(claims) {
        if (!this.oauthStateSigner) {
            throw new Error('AuthService: cannot sign OAuth state — master key not configured. ' +
                'OAuth flows require AuthService to be constructed with cryptoOpts.');
        }
        return this.oauthStateSigner.sign(claims);
    }
    /**
     * Verify a signed OAuth state. Returns null on:
     *   - missing/invalid signing key (defensive — should not happen in
     *     production where serve.ts wires the master key)
     *   - tampered / forged / unsigned state
     *   - state older than 10 minutes (replay window)
     *   - state with future-skewed `t` beyond a small tolerance
     * Callers should treat null as "invalid/expired state" and surface a
     * generic error to the browser without echoing details.
     */
    verifyOAuthState(state) {
        if (!this.oauthStateSigner)
            return null;
        return this.oauthStateSigner.verify(state);
    }
    /**
     * Subscribe to OAuth-token mutations. Returns an unsubscribe fn.
     * Listeners fire AFTER the row write commits. Both connect and disconnect
     * paths emit; subscribers that only care about one should filter
     * themselves.
     */
    onOAuthMutation(fn) {
        this.oauthMutationListeners.add(fn);
        return () => { this.oauthMutationListeners.delete(fn); };
    }
    notifyOAuthMutation(userId, connectorName) {
        for (const fn of this.oauthMutationListeners) {
            try {
                fn(userId, connectorName);
            }
            catch { /* listener bugs must not break the write path */ }
        }
    }
    /**
     * Save OAuth data (upsert).
     *
     * `opts.silent` suppresses the OAuth-mutation event. Pass it for writes
     * that do NOT change the user's connected-connector set — chiefly a token
     * REFRESH and the SDK's pre-token machinery (PKCE verifier, DCR client
     * info). Those writes fire while ConnectorManager.buildTools constructs the
     * pooled agent (inside withAgent's `await toolsLoaded`); notifying there
     * makes the orchestrator evict the in-flight agent, after which
     * findSessionForAgent() returns null for the rest of the turn and every
     * session-scoped tool loses context (create_artifact then fails with
     * "No artifact canvas is available in this context"). An INITIAL token
     * grant (none → tokens) and a disconnect DO change the tool list and must
     * still notify so the agent rebuilds.
     */
    saveOAuthData(userId, connectorName, data, opts) {
        const payload = JSON.stringify(data);
        // When crypto is wired up, every new write goes to data_ciphertext —
        // data_json stays NULL on insert and is explicitly nulled on update
        // so a row never carries both plaintext and ciphertext (the
        // half-state guard would refuse to boot if it did).
        if (this.crypto && this.oauthTokensEncrypted) {
            const enc = encryptOAuthTokenData(this.crypto.masterKey, userId, payload, this.crypto.keyVersion);
            this.db.prepare(`
        INSERT INTO oauth_tokens (user_id, connector_name, data_json, data_ciphertext, key_version, updated_at)
        VALUES (?, ?, NULL, ?, ?, datetime('now'))
        ON CONFLICT (user_id, connector_name)
        DO UPDATE SET data_json = NULL, data_ciphertext = excluded.data_ciphertext,
                      key_version = excluded.key_version, updated_at = excluded.updated_at
      `).run(userId, connectorName, enc.ciphertext, enc.keyVersion);
            if (!opts?.silent)
                this.notifyOAuthMutation(userId, connectorName);
            return;
        }
        // Legacy path (tests / flag-off boots / pre-migration). Writes
        // plaintext to data_json the way the v1 code always did.
        this.db.prepare(`
      INSERT INTO oauth_tokens (user_id, connector_name, data_json, updated_at)
      VALUES (?, ?, ?, datetime('now'))
      ON CONFLICT (user_id, connector_name)
      DO UPDATE SET data_json = excluded.data_json, updated_at = excluded.updated_at
    `).run(userId, connectorName, payload);
        if (!opts?.silent)
            this.notifyOAuthMutation(userId, connectorName);
    }
    /** List all connected connectors for a user. */
    getUserConnectedConnectors(userId) {
        return this.db.prepare('SELECT connector_name FROM oauth_tokens WHERE user_id = ?').all(userId);
    }
    /** Clear OAuth data for a user+connector. */
    clearOAuthData(userId, connectorName) {
        this.db.prepare('DELETE FROM oauth_tokens WHERE user_id = ? AND connector_name = ?').run(userId, connectorName);
        this.notifyOAuthMutation(userId, connectorName);
    }
    /** Check if a user has OAuth tokens for a connector. */
    hasOAuthTokens(userId, connectorName) {
        const data = this.getOAuthData(userId, connectorName);
        return !!(data?.tokens?.access_token);
    }
    hasUsers() {
        const row = this.db.prepare('SELECT COUNT(*) as count FROM users').get();
        return row.count > 0;
    }
    getUserCount() {
        const row = this.db.prepare('SELECT COUNT(*) as count FROM users').get();
        return row.count;
    }
    // ── Per-user API tokens (MCP auth, CLI, git HTTP) ──
    /**
     * Mint a new per-user API token. Returns the raw token once — only its
     * sha256 hash is stored in the database. The raw value cannot be recovered
     * later, matching how GitHub personal access tokens work.
     */
    createApiToken(userId, name) {
        const cleanName = (name || '').trim();
        if (!cleanName)
            throw new Error('Token name is required');
        const token = `sg_${randomBytes(24).toString('hex')}`;
        const hash = hashToken(token);
        const result = this.db.prepare('INSERT INTO api_tokens (user_id, token_hash, name) VALUES (?, ?, ?)').run(userId, hash, cleanName);
        return { id: result.lastInsertRowid, token, name: cleanName };
    }
    /**
     * Resolve a raw API token to its owning user, updating `last_used_at`
     * on success. Returns null if the token is unknown or the owning user
     * has been deleted.
     */
    verifyApiToken(token) {
        return this.verifyApiTokenWithMeta(token)?.user ?? null;
    }
    /**
     * Same as verifyApiToken but also returns the token's user-set name (e.g.
     * "Cursor", "Claude Desktop"). Used by surfaces that need to attribute an
     * action to a specific named integration — most importantly the
     * agent-activity feature, which renders "<owner>'s <token name>" chips.
     */
    verifyApiTokenWithMeta(token) {
        if (!token)
            return null;
        const hash = hashToken(token);
        const row = this.db.prepare('SELECT user_id, name FROM api_tokens WHERE token_hash = ?').get(hash);
        if (!row)
            return null;
        this.db.prepare('UPDATE api_tokens SET last_used_at = datetime(\'now\') WHERE token_hash = ?').run(hash);
        const user = this.findUserById(row.user_id);
        if (!user)
            return null;
        return { user, tokenName: row.name };
    }
    /** List a user's API tokens. Never returns the raw token — only metadata. */
    listApiTokens(userId) {
        return this.db.prepare('SELECT id, name, created_at, last_used_at FROM api_tokens WHERE user_id = ? ORDER BY created_at DESC').all(userId);
    }
    /** Revoke (delete) an API token owned by the given user. No-op if not found. */
    revokeApiToken(tokenId, userId) {
        const result = this.db.prepare('DELETE FROM api_tokens WHERE id = ? AND user_id = ?').run(tokenId, userId);
        return result.changes > 0;
    }
    /**
     * Ensure auth.db and auth-secret are in .studiograph/.gitignore.
     * Only the seed JSON file should be committed to the config repo.
     */
    ensureGitignore() {
        const gitignorePath = join(this.sgDir, '.gitignore');
        const entries = ['auth.db', 'auth.db-wal', 'auth.db-shm', 'auth-secret', 'api-keys', 'server-auth.json'];
        let content = '';
        if (existsSync(gitignorePath)) {
            content = readFileSync(gitignorePath, 'utf-8');
        }
        const missing = entries.filter(e => !content.includes(e));
        if (missing.length > 0) {
            const suffix = (content && !content.endsWith('\n') ? '\n' : '') +
                '# Auth database is ephemeral — rebuilt from auth-seed.json\n' +
                missing.join('\n') + '\n';
            writeFileSync(gitignorePath, content + suffix, 'utf-8');
        }
    }
    /**
     * Build and return the current auth seed data (users + JWT secret + folder access).
     * Does not write to disk — use exportSeed() for that.
     *
     * SECURITY: the returned object embeds raw secrets:
     *   • `jwtSecret` — signing key for ALL session tokens. Anyone holding this
     *     can mint a valid session for any user. Rotate by setting
     *     STUDIOGRAPH_JWT_SECRET in production and restarting.
     *   • `users[].password_hash` — bcrypt hashes (cost 12). Offline-crackable
     *     against weak passwords; treat as compromise of the listed accounts
     *     if leaked.
     *
     * Only call this from paths that write to a trusted location (auth-seed.json
     * inside the .studiograph/ config repo). Never expose over HTTP, never log,
     * never include in error messages or telemetry.
     */
    getSeedData() {
        const rows = this.db.prepare('SELECT email, handle, password_hash, display_name, role, created_at FROM users ORDER BY id ASC').all();
        const accessRows = this.db.prepare(`
      SELECT u.email AS user_email, fa.folder_name AS folder_name, fa.role
      FROM folder_access fa JOIN users u ON u.id = fa.user_id
      ORDER BY u.email, fa.folder_name
    `).all();
        // Phase 3: in production (Railway), drop the JWT secret from the
        // returned seed entirely. Hosted instances read the JWT secret from
        // STUDIOGRAPH_JWT_SECRET env-var and never need it embedded in the
        // committed seed file. In dev / single-host, callers (applySeed,
        // first-run import) still need the value to keep the
        // env > file > seed > generated chain working.
        //
        // The exportSeed() / pushSeed() paths run the result through the
        // bundle-sanitizer regardless, so even a dev seed never reaches the
        // committed file with `jwtSecret`. This split keeps in-process API
        // callers (which never write to disk) backwards-compatible.
        const inProduction = Boolean(process.env.RAILWAY_ENVIRONMENT);
        return {
            jwtSecret: inProduction ? '' : this.jwtSecret,
            users: rows,
            folder_access: accessRows,
        };
    }
    /**
     * Export all users and JWT secret to .studiograph/auth-seed.json.
     * If .studiograph/ is a git repo with a remote, auto-commits and pushes
     * the seed file so it syncs between environments.
     */
    exportSeed() {
        // Phase 3: route the seed through sanitizeAuthSeed() so the
        // committed seed file NEVER carries jwtSecret or the deprecated
        // top-level apiKey. Bcrypt hashes (users[].password_hash) are
        // retained — that's the workspace-replication mechanism, see
        // docs/SECURITY.md for the product decision.
        const seed = sanitizeAuthSeed(this.getSeedData());
        const seedPath = join(this.sgDir, 'auth-seed.json');
        // Restrictive mode by default. Even with jwtSecret stripped, the
        // file still carries every user's bcrypt hash — world-readable mode
        // would enable offline cracking by any local user.
        writeFileSync(seedPath, JSON.stringify(seed, null, 2), { mode: 0o600 });
        if (process.platform !== 'win32') {
            // Force-tighten when the file pre-existed with looser mode bits.
            try {
                chmodSync(seedPath, 0o600);
            }
            catch { /* non-fatal */ }
        }
        this.pushSeed();
    }
    /**
     * Commit and push auth-seed.json if .studiograph/ is a git repo with a remote.
     * Non-fatal — logs warnings on failure but never throws.
     */
    pushSeed() {
        try {
            // Check if .studiograph/ is a git repo
            const isRepo = spawnSync('git', ['rev-parse', '--git-dir'], {
                cwd: this.sgDir, encoding: 'utf-8', stdio: 'pipe',
            });
            if (isRepo.status !== 0)
                return;
            // Check if there's a remote to push to
            const hasRemote = spawnSync('git', ['remote'], {
                cwd: this.sgDir, encoding: 'utf-8', stdio: 'pipe',
            });
            if (hasRemote.status !== 0 || !hasRemote.stdout.trim())
                return;
            // Stage only auth-seed.json and .gitignore (in case it was just updated)
            spawnSync('git', ['add', 'auth-seed.json', '.gitignore'], {
                cwd: this.sgDir, encoding: 'utf-8', stdio: 'pipe',
            });
            // Check if there's anything to commit
            const diff = spawnSync('git', ['diff', '--cached', '--quiet'], {
                cwd: this.sgDir, encoding: 'utf-8', stdio: 'pipe',
            });
            if (diff.status === 0)
                return; // nothing staged
            // Commit (inline identity so it works even without global git config)
            const commit = spawnSync('git', [
                '-c', 'user.name=Studiograph',
                '-c', 'user.email=noreply@studiograph.local',
                'commit', '-m', 'Update auth users',
            ], {
                cwd: this.sgDir, encoding: 'utf-8', stdio: 'pipe',
            });
            if (commit.status !== 0) {
                // Phase 11: git's stderr can carry the remote URL with embedded
                // credentials when push fails (https://x-access-token:<jwt>@host).
                // Route stderr through sanitize() before logging.
                log.warn(`Auth: failed to commit seed — ${sanitize((commit.stderr || '').trim())}`);
                return;
            }
            // Push
            const push = spawnSync('git', ['push'], {
                cwd: this.sgDir, encoding: 'utf-8', stdio: 'pipe', timeout: 30_000,
            });
            if (push.status !== 0) {
                log.warn(`Auth: failed to push seed — ${sanitize((push.stderr || '').trim())}`);
            }
        }
        catch (err) {
            log.warn(`Auth: seed sync failed — ${sanitize(err.message)}`);
        }
    }
    /**
     * Import users and JWT secret from .studiograph/auth-seed.json.
     * Two modes:
     *   • `merge` (default) — INSERT OR IGNORE on email. Existing local rows win
     *     on conflict. Safe for boot-time recovery when the local DB may carry
     *     legitimate state the seed shouldn't clobber.
     *   • `overwrite` — UPSERT on email. Seed values win on conflict. Correct
     *     for explicit "remote is authoritative" callers (`studiograph pull`,
     *     `studiograph clone`, admin bundle import) where any local divergence
     *     was almost certainly the post-clone setup-wizard stamping fresh
     *     credentials over the real ones. `merge` mode silently shadowed
     *     production credentials in that exact scenario — see 2026-05 incident.
     */
    applySeed(options = {}) {
        const mode = options.mode ?? 'merge';
        const seedPath = join(this.sgDir, 'auth-seed.json');
        if (!existsSync(seedPath))
            return;
        try {
            const seed = JSON.parse(readFileSync(seedPath, 'utf-8'));
            // Restore JWT secret if the seed carried one. With our uniform sanitizer
            // this is empty on production-server pulls; the local server then generates
            // its own (sovereign-instance model).
            if (seed.jwtSecret) {
                const secretPath = join(this.sgDir, 'auth-secret');
                writeFileSync(secretPath, seed.jwtSecret, { mode: 0o600 });
            }
            // Normalize WAL state before writing. We've seen apply-seed writes
            // silently disappear when the WAL was bloated by a prior crashed server
            // — checkpointing first guarantees the page cache reflects the on-disk
            // committed state. RESTART (vs TRUNCATE) is the gentler form: it waits
            // for readers but doesn't unlink the WAL file.
            try {
                this.db.pragma('wal_checkpoint(RESTART)');
            }
            catch { /* non-fatal */ }
            // Apply users + folder_access atomically. Explicit transaction means
            // any failure aborts the whole import (no half-applied state) and
            // commit semantics are unambiguous — there's no "implicit autocommit"
            // path that can be silently skipped under WAL weirdness.
            //
            // UPSERT keyed on email preserves the autoincrement id so foreign keys
            // (folder_access, oauth_tokens, api_tokens) stay valid across an
            // overwrite. handle gets updated too — if the seed renames a handle,
            // we want the rename to land.
            const userStmt = mode === 'overwrite'
                ? this.db.prepare(`
            INSERT INTO users (email, handle, password_hash, display_name, role, created_at)
            VALUES (?, ?, ?, ?, ?, ?)
            ON CONFLICT(email) DO UPDATE SET
              handle = excluded.handle,
              password_hash = excluded.password_hash,
              display_name = excluded.display_name,
              role = excluded.role,
              created_at = excluded.created_at
          `)
                : this.db.prepare('INSERT OR IGNORE INTO users (email, handle, password_hash, display_name, role, created_at) VALUES (?, ?, ?, ?, ?, ?)');
            const accessStmt = this.db.prepare(`
        INSERT INTO folder_access (user_id, folder_name, role)
        SELECT id, ?, ? FROM users WHERE email = ?
        ON CONFLICT (user_id, folder_name) DO UPDATE SET role = excluded.role
      `);
            const accessRows = seed.folder_access ?? [];
            const apply = this.db.transaction(() => {
                for (const u of seed.users) {
                    userStmt.run(u.email, u.handle, u.password_hash, u.display_name, u.role, u.created_at);
                }
                for (const fa of accessRows) {
                    accessStmt.run(fa.folder_name, fa.role ?? 'editor', fa.user_email);
                }
            });
            apply();
            // Verify the writes survived. If the post-commit count doesn't reflect
            // at least the seed's user set, something is rolling our writes back —
            // throw loudly rather than letting the caller think the import succeeded.
            // In overwrite mode we additionally verify password_hash equality:
            // the 2026-05 incident was specifically about a stale local hash
            // surviving import, so "row exists" wasn't enough to catch it.
            const seedEmails = new Set(seed.users.map(u => u.email));
            const presentRows = this.db.prepare(`SELECT email, password_hash FROM users WHERE email IN (${seed.users.map(() => '?').join(',')})`).all(...seed.users.map(u => u.email));
            const missing = [...seedEmails].filter(e => !presentRows.some(r => r.email === e));
            if (missing.length > 0) {
                throw new Error(`seed apply verification failed — ${missing.length} user(s) not present after commit: ${missing.slice(0, 3).join(', ')}${missing.length > 3 ? '…' : ''}`);
            }
            if (mode === 'overwrite') {
                const drift = seed.users
                    .filter(u => {
                    const row = presentRows.find(r => r.email === u.email);
                    return row && row.password_hash !== u.password_hash;
                })
                    .map(u => u.email);
                if (drift.length > 0) {
                    throw new Error(`seed apply verification failed — ${drift.length} user(s) have stale password_hash after overwrite: ${drift.slice(0, 3).join(', ')}${drift.length > 3 ? '…' : ''}`);
                }
            }
            // Flush WAL to main so a subsequent reader (sqlite3 CLI, another process)
            // sees the same state we just wrote. Non-fatal — pages are committed
            // either way; this just shortens the window where the WAL is the truth.
            try {
                this.db.pragma('wal_checkpoint(TRUNCATE)');
            }
            catch { /* non-fatal */ }
            log.info(`Auth: restored ${seed.users.length} user(s) from seed`);
        }
        catch (err) {
            log.warn(`Auth: failed to import seed — ${sanitize(err.message)}`);
            throw err;
        }
    }
    // ── Views ──
    getUserViews(userId) {
        // Own ∪ adopted (D10). R1: own-only in practice for Phase 2 — the adoption
        // join is empty until Phase 4's share-to-workspace populates it. The old
        // auto-return of every `visibility='workspace'` row is gone: a shared view
        // surfaces in another user's sidebar only once they explicitly adopt it.
        const rows = this.db.prepare(`SELECT v.* FROM user_views v WHERE v.user_id = ?
       UNION
       SELECT v.* FROM user_views v
         JOIN user_view_adoptions a ON a.view_id = v.id
        WHERE a.user_id = ?
       ORDER BY sort_order, id`).all(userId, userId);
        const seen = new Set();
        return rows.filter(r => { if (seen.has(r.id))
            return false; seen.add(r.id); return true; }).map(r => this.toUserView(r));
    }
    createUserView(userId, name, viewType = 'list', config = {}, createdByName, scope = 'recent') {
        // New saves land at the TOP of the pin order (negative min - 1) so the most
        // recent view is first in the sidebar without renumbering the rest.
        const minOrder = this.db.prepare('SELECT MIN(sort_order) as min_order FROM user_views WHERE user_id = ?').get(userId)?.min_order ?? 0;
        const result = this.db.prepare('INSERT INTO user_views (user_id, name, view_type, config_json, scope, sort_order, created_by_name) VALUES (?, ?, ?, ?, ?, ?, ?)').run(userId, name, viewType, JSON.stringify(config), scope, minOrder - 1, createdByName ?? null);
        return this.getUserView(Number(result.lastInsertRowid), userId);
    }
    setViewVisibility(viewId, userId, visibility) {
        this.db.prepare(`UPDATE user_views SET visibility = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?`).run(visibility, viewId, userId);
    }
    getUserView(viewId, userId) {
        const row = this.db.prepare('SELECT * FROM user_views WHERE id = ? AND user_id = ?').get(viewId, userId);
        return row ? this.toUserView(row) : null;
    }
    updateUserView(viewId, userId, fields) {
        const sets = ['updated_at = datetime(\'now\')'];
        const params = [];
        if (fields.name !== undefined) {
            sets.push('name = ?');
            params.push(fields.name);
        }
        if (fields.config !== undefined) {
            sets.push('config_json = ?');
            params.push(JSON.stringify(fields.config));
        }
        if (fields.scope !== undefined) {
            sets.push('scope = ?');
            params.push(fields.scope);
        }
        if (fields.viewType !== undefined) {
            sets.push('view_type = ?');
            params.push(fields.viewType);
        }
        if (fields.sortOrder !== undefined) {
            sets.push('sort_order = ?');
            params.push(fields.sortOrder);
        }
        params.push(viewId, userId);
        this.db.prepare(`UPDATE user_views SET ${sets.join(', ')} WHERE id = ? AND user_id = ?`).run(...params);
    }
    deleteUserView(viewId, userId) {
        this.db.prepare('DELETE FROM user_views WHERE id = ? AND user_id = ?').run(viewId, userId);
    }
    /**
     * Persist a user's pinned-view order atomically: `sort_order` becomes the
     * row's index in `orderedIds`. Scoped to `user_id` so a caller can only
     * reorder their own views (a foreign id in the list is a no-op). Runs in one
     * transaction so a drag-reorder never leaves a half-applied (scrambled) order.
     */
    reorderUserViews(userId, orderedIds) {
        const stmt = this.db.prepare("UPDATE user_views SET sort_order = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?");
        const tx = this.db.transaction((ids) => {
            ids.forEach((id, i) => stmt.run(i, id, userId));
        });
        tx(orderedIds);
    }
    // ── Workspace views (REMOVED in Saved-Views Phase 1) ──
    // The workspace_views multi-view-checkoff system (getViewsForScope /
    // createWorkspaceView / ensureViewsForScope / …) was deleted when the
    // single-current-view + scratch model replaced it. The `workspace_views`
    // table is kept inert (see schema) so no destructive migration runs on live
    // workspaces. Saved views (Phase 2) build on user_views above.
    // ensureDefaultViews + getEntityTypeCounts were deleted in Saved-Views
    // Phase 2 (D8). Default lenses are now code-defined templates (D1), not
    // DB-seeded rows, so the full-workspace entity scan that fed the seeding is
    // gone. The pre-existing seeded rows are purged once on upgrade (D12, in the
    // schema migration above).
    toUserView(row) {
        let config = {};
        try {
            config = JSON.parse(row.config_json);
        }
        catch { /* corrupt → empty, client normalizes */ }
        return {
            id: row.id,
            name: row.name,
            viewType: row.view_type,
            config,
            scope: row.scope ?? 'recent',
            sortOrder: row.sort_order,
            visibility: row.visibility ?? 'private',
            createdByName: row.created_by_name ?? null,
            createdAt: row.created_at,
            updatedAt: row.updated_at,
        };
    }
    // ── Preferences ──
    getAccentColor(userId) {
        const row = this.db.prepare('SELECT accent_color FROM user_preferences WHERE user_id = ?').get(userId);
        return row?.accent_color ?? null;
    }
    /** Get accent colors for all users. Returns userId → color (only users with a color set). */
    getAllAccentColors() {
        const rows = this.db.prepare('SELECT user_id, accent_color FROM user_preferences WHERE accent_color IS NOT NULL').all();
        const result = {};
        for (const r of rows)
            result[r.user_id] = r.accent_color;
        return result;
    }
    setAccentColor(userId, color) {
        this.db.prepare(`INSERT INTO user_preferences (user_id, accent_color, updated_at) VALUES (?, ?, datetime('now'))
       ON CONFLICT(user_id) DO UPDATE SET accent_color = excluded.accent_color, updated_at = excluded.updated_at`).run(userId, color);
    }
    getOnboarded(userId) {
        const row = this.db.prepare('SELECT onboarded FROM user_preferences WHERE user_id = ?').get(userId);
        return row?.onboarded === 1;
    }
    setOnboarded(userId, value) {
        this.db.prepare(`INSERT INTO user_preferences (user_id, onboarded, updated_at) VALUES (?, ?, datetime('now'))
       ON CONFLICT(user_id) DO UPDATE SET onboarded = excluded.onboarded, updated_at = excluded.updated_at`).run(userId, value ? 1 : 0);
    }
    /** Whether the user deferred ("skip for now") onboarding — distinct from
     *  completing it. Silences the auto-greet + interview pill until they return. */
    getOnboardingDeferred(userId) {
        const row = this.db.prepare('SELECT onboarding_deferred FROM user_preferences WHERE user_id = ?').get(userId);
        return row?.onboarding_deferred === 1;
    }
    setOnboardingDeferred(userId, value) {
        this.db.prepare(`INSERT INTO user_preferences (user_id, onboarding_deferred, updated_at) VALUES (?, ?, datetime('now'))
       ON CONFLICT(user_id) DO UPDATE SET onboarding_deferred = excluded.onboarding_deferred, updated_at = excluded.updated_at`).run(userId, value ? 1 : 0);
    }
    /**
     * Reset per-user state used by the agent: onboarding flag and meeting-capture
     * settings (Deepgram key, calendar, default folder). Preserves the user
     * account, accent color, and minted API tokens. Used by `POST /api/reset` to
     * put a tester back to a clean slate without a redeploy or DB surgery.
     */
    resetUserState(userId) {
        // Phase 8: also wipe settings_ciphertext + key_version so the reset
        // matches the conceptual contract ("user state is gone"). Otherwise
        // a previously-stored Deepgram key would survive a reset.
        this.db.prepare(`INSERT INTO user_preferences
         (user_id, onboarded, onboarding_deferred, settings_json, settings_ciphertext, key_version, updated_at)
       VALUES (?, 0, 0, '{}', NULL, NULL, datetime('now'))
       ON CONFLICT(user_id) DO UPDATE SET
         onboarded = 0,
         onboarding_deferred = 0,
         settings_json = '{}',
         settings_ciphertext = NULL,
         key_version = NULL,
         updated_at = datetime('now')`).run(userId);
    }
    // ── Meeting capture: per-user settings (Deepgram key, calendar, default folder) ──
    getUserSettings(userId) {
        const row = this.db.prepare('SELECT settings_json, settings_ciphertext, key_version FROM user_preferences WHERE user_id = ?').get(userId);
        if (!row)
            return {};
        // When the ciphertext column is populated we MUST have a master key
        // available — otherwise we'd silently drop the secret half of the
        // user's settings (e.g. lose their Deepgram key) and the meeting-
        // capture UI would re-prompt them. Refuse to read.
        if (row.settings_ciphertext) {
            if (!this.crypto) {
                throw new Error(`user_preferences row for user_id=${userId} has encrypted secrets but AuthService was ` +
                    `constructed without a master key. Wire crypto options in serve.ts.`);
            }
            try {
                return decryptUserPreferencesSettings(this.crypto.masterKey, userId, row.settings_json, row.settings_ciphertext, row.key_version);
            }
            catch (err) {
                log.error(`[auth] user_preferences decrypt failed for user_id=${userId}: ${err instanceof Error ? err.message : String(err)}`);
                throw err;
            }
        }
        if (!row.settings_json)
            return {};
        try {
            return JSON.parse(row.settings_json);
        }
        catch {
            return {};
        }
    }
    setUserSettings(userId, settings) {
        const current = this.getUserSettings(userId);
        const merged = { ...current, ...settings };
        // Crypto on: partition by classification, encrypt the secret subset,
        // write both halves atomically. Crypto off: behave exactly like v1.
        if (this.crypto && this.userPrefsEncrypted) {
            const split = encryptUserPreferencesSettings(this.crypto.masterKey, userId, merged, this.crypto.keyVersion ?? 'mk_v1');
            this.db.prepare(`INSERT INTO user_preferences (user_id, settings_json, settings_ciphertext, key_version, updated_at)
         VALUES (?, ?, ?, ?, datetime('now'))
         ON CONFLICT(user_id) DO UPDATE SET
           settings_json = excluded.settings_json,
           settings_ciphertext = excluded.settings_ciphertext,
           key_version = excluded.key_version,
           updated_at = excluded.updated_at`).run(userId, split.settingsJson, split.settingsCiphertext, split.keyVersion);
            return;
        }
        this.db.prepare(`INSERT INTO user_preferences (user_id, settings_json, updated_at) VALUES (?, ?, datetime('now'))
       ON CONFLICT(user_id) DO UPDATE SET settings_json = excluded.settings_json, updated_at = excluded.updated_at`).run(userId, JSON.stringify(merged));
    }
    getUserSetting(userId, key) {
        return this.getUserSettings(userId)[key] ?? null;
    }
    setUserSetting(userId, key, value) {
        this.setUserSettings(userId, { [key]: value });
    }
    close() {
        this.db.close();
    }
    toAuthUser(row) {
        return {
            id: row.id,
            email: row.email,
            handle: row.handle,
            displayName: row.display_name,
            role: row.role,
            createdAt: row.created_at,
        };
    }
}
//# sourceMappingURL=auth-service.js.map