/**
 * Config API routes
 *
 * Endpoints for syncing workspace configuration (auth seed, workspace.json,
 * ABOUT.md, sources, connectors) between a local machine and a remote
 * Studiograph server via `studiograph push/pull --remote`.
 *
 * Folder markdown data is NOT handled here — it flows through the embedded
 * git HTTP server at /git/:repo (see git-http.ts), which gives us native
 * conflict detection, commit history, and per-folder granularity.
 *
 * All routes require admin authentication.
 *
 *   GET  /api/config/bundle   — export workspace config as JSON bundle
 *   POST /api/config/bundle   — import workspace config bundle
 */
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from 'fs';
import { join } from 'path';
import { Workspace } from '../../core/workspace.js';
import { isWorkspaceOwner } from '../../services/auth-service.js';
import { ConnectorManager } from '../../mcp/connector-manager.js';
import { buildConfigBundle, bundleContainsBootstrapFields } from '../../core/secrets/bundle.js';
import { SchemaViolationError } from '../../core/schemas/workspace.js';
const CONFIG_BUNDLE_NAME_RE = /^[a-z0-9][a-z0-9_-]*$/;
export function isSafeConfigBundleName(name) {
    return CONFIG_BUNDLE_NAME_RE.test(name);
}
function requireAdmin(req, authService) {
    // Open mode (no users configured) or Bearer token (admin-equivalent) — allow.
    // The Fastify preHandler auth hook rejects unauthenticated requests when users exist,
    // so !req.user here means either open mode or API key auth, both of which are allowed.
    if (!authService.hasUsers())
        return true;
    const user = req.user;
    if (!user)
        return true; // Bearer token auth — no req.user set, admin-equivalent
    return isWorkspaceOwner(user.role);
}
export async function registerConfigApiRoutes(fastify, workspaceManager, workspacePath, authService) {
    const sgDir = join(workspacePath, '.studiograph');
    // ── GET /api/config/bundle ────────────────────────────────────────────────
    // Export workspace config as a JSON bundle for `studiograph pull --remote`.
    fastify.get('/api/config/bundle', async (req, reply) => {
        if (!requireAdmin(req, authService)) {
            return reply.status(403).send({ error: 'Admin access required' });
        }
        const workspace = new Workspace(workspacePath);
        const config = await workspace.loadConfig();
        const about = workspace.loadAbout();
        const authSeed = authService.getSeedData();
        // Sources
        const sources = {};
        const sourcesDir = join(sgDir, 'sources');
        if (existsSync(sourcesDir)) {
            for (const entry of readdirSync(sourcesDir)) {
                if (!entry.endsWith('.json'))
                    continue;
                try {
                    sources[entry.replace('.json', '')] = JSON.parse(readFileSync(join(sourcesDir, entry), 'utf-8'));
                }
                catch { /* skip malformed source files */ }
            }
        }
        // Connectors (workspace-level; secrets are stored separately and not exported)
        const connectors = ConnectorManager.loadWorkspaceConfigs(workspacePath);
        // Phase 3: every export route runs through buildConfigBundle() so the
        // GET /api/config/bundle response and the `studiograph push --remote`
        // CLI produce byte-identical output for the same workspace state.
        // The helper drops bootstrap-class fields (jwtSecret, apiKey) from
        // the seed and rejects secret-shaped raw values that slipped past
        // upstream sanitizers.
        const sanitized = buildConfigBundle({
            workspace: config,
            about,
            authSeed: authSeed,
            sources,
            connectors: connectors,
        });
        return reply.send(sanitized);
    });
    // ── POST /api/config/bundle ───────────────────────────────────────────────
    // Import a workspace config bundle from `studiograph push --remote`.
    fastify.post('/api/config/bundle', async (req, reply) => {
        if (!requireAdmin(req, authService)) {
            return reply.status(403).send({ error: 'Admin access required' });
        }
        const bundle = req.body;
        if (!bundle || typeof bundle !== 'object') {
            return reply.status(400).send({ error: 'Invalid config bundle' });
        }
        // Phase 3 + Codex H1/H2: reject any inbound bundle that carries
        // bootstrap-class fields (jwtSecret, deprecated apiKey), nested
        // secret slots (r2_config.secret_access_key and friends), or
        // connector secrets that aren't $ENV_VAR placeholders. The
        // detector walks the bundle to arbitrary depth and uses the same
        // predicate as the export sanitizer — asymmetry between the two
        // is itself a finding.
        const offendingFields = bundleContainsBootstrapFields({
            workspace: bundle.workspace,
            about: bundle.about,
            authSeed: bundle.authSeed,
            sources: bundle.sources,
            connectors: bundle.connectors,
        });
        if (offendingFields.length > 0) {
            return reply.status(400).send({
                error: `Bundle contains non-exportable fields: ${offendingFields.join(', ')}. ` +
                    'Secret values belong in environment variables on the target host (or $ENV_VAR ' +
                    'placeholders for connector slots), not in the workspace bundle.',
            });
        }
        const invalidSourceName = Object.keys(bundle.sources ?? {}).find(name => !isSafeConfigBundleName(name));
        if (invalidSourceName) {
            return reply.status(400).send({ error: `Invalid source name: ${invalidSourceName}` });
        }
        const invalidConnector = (bundle.connectors ?? []).find(conn => typeof conn?.name !== 'string' || !isSafeConfigBundleName(conn.name));
        if (invalidConnector) {
            const name = typeof invalidConnector?.name === 'string' ? invalidConnector.name : String(invalidConnector?.name);
            return reply.status(400).send({ error: `Invalid connector name: ${name}` });
        }
        const workspace = new Workspace(workspacePath);
        const applied = [];
        // Phase 9c: any writer below may throw `SchemaViolationError` when
        // STUDIOGRAPH_SCHEMA_STRICT is on (the default) and the validator
        // catches a secret-shaped field that slipped past the
        // bundleContainsBootstrapFields gate above. We translate the throw
        // into HTTP 400 with the stable `code: 'schema_violation'` so the
        // CLI can recognize it and the message stays redaction-safe (the
        // error explicitly does not include the offending value).
        try {
            // 1. Auth seed — restore users and API key.
            // Overwrite mode: this is an explicit admin import; the bundle is the
            // source of truth, so any local-shadow rows should be replaced.
            //
            // Codex M1: errors from `applySeed` are NOT swallowed. Upstream's
            // 2026-05 hardening made applySeed throw on verification failures
            // (missing users, stale password_hash after overwrite, etc.) — those
            // signals must reach the admin via the HTTP response, not get hidden
            // behind a cheerful "seed file written (apply pending restart)"
            // string while the running server keeps serving stale credentials.
            //
            // Only `SQLITE_BUSY` (DB-locked during heavy traffic) is translated
            // into a structured 409 retry hint. Everything else (verification
            // failures, AAD mismatch, JSON parse, unknown SQL error) bubbles to
            // the outer catch and surfaces as 500 with the redacted message.
            if (bundle.authSeed) {
                writeFileSync(join(sgDir, 'auth-seed.json'), JSON.stringify(bundle.authSeed, null, 2), 'utf-8');
                try {
                    authService.applySeed({ mode: 'overwrite' });
                    applied.push(`Users: ${bundle.authSeed.users?.length ?? 0}`);
                }
                catch (err) {
                    if (isSqliteLockError(err)) {
                        return reply.status(409).send({
                            code: 'auth_db_locked',
                            error: 'auth.db is locked by a concurrent writer; the seed file was written ' +
                                'but the in-process apply could not run. Retry the import or restart the server.',
                            retry: true,
                        });
                    }
                    throw err;
                }
            }
            // 2. Workspace config — merge, preserving server-specific fields.
            //
            // Phase 9b: route through `Workspace.writeConfig()` so this importer
            // shares the same schema-validator + dual-write seam as every other
            // writer. The previous direct `writeFileSync` bypassed Phase 9a
            // telemetry — and would bypass Phase 9c's strict reject too.
            if (bundle.workspace) {
                const local = await workspace.loadConfig().catch(() => ({}));
                const merged = { ...local, ...bundle.workspace };
                // Preserve fields that belong to the server, not the client machine
                if (local.server_url)
                    merged.server_url = local.server_url;
                if (local.railway)
                    merged.railway = local.railway;
                workspace.writeConfig(merged);
                workspaceManager.reloadConfig();
                applied.push(`Workspace: ${bundle.workspace.repos?.length ?? 0} folder(s)`);
            }
            // 3. ABOUT.md — workspace identity
            if (bundle.about !== null && bundle.about !== undefined) {
                writeFileSync(join(sgDir, 'ABOUT.md'), bundle.about, 'utf-8');
                applied.push('Workspace identity (ABOUT.md)');
            }
            // 4. Sources
            if (bundle.sources && Object.keys(bundle.sources).length > 0) {
                const sourcesDir = join(sgDir, 'sources');
                mkdirSync(sourcesDir, { recursive: true });
                for (const [name, def] of Object.entries(bundle.sources)) {
                    writeFileSync(join(sourcesDir, `${name}.json`), JSON.stringify(def, null, 2), 'utf-8');
                }
                applied.push(`Sources: ${Object.keys(bundle.sources).join(', ')}`);
            }
            // 5. Connectors (workspace-level configs, no secrets).
            //
            // Phase 9b: route through `ConnectorManager.saveWorkspaceConfig` so
            // every connector save runs the same schema validator + stripSecrets
            // re-pass. Inbound bundles should already be sanitized at the source,
            // but the re-strip is idempotent and keeps the single seam invariant.
            if (bundle.connectors && bundle.connectors.length > 0) {
                for (const conn of bundle.connectors) {
                    ConnectorManager.saveWorkspaceConfig(conn, workspacePath);
                }
                applied.push(`Connectors: ${bundle.connectors.map(c => c.name).join(', ')}`);
            }
            return reply.send({ ok: true, applied });
        }
        catch (err) {
            if (err instanceof SchemaViolationError) {
                return reply.status(400).send({
                    code: SchemaViolationError.code,
                    scope: err.scope,
                    // The message from the error class is already redaction-safe —
                    // it lists field paths + classifications, never values.
                    error: err.message,
                    violations: err.violations.map(v => ({
                        path: v.path,
                        registryKey: v.registryKey,
                        classification: v.classification,
                        reason: v.reason,
                    })),
                });
            }
            // Codex M1: applySeed throws verification failures from upstream's
            // 2026-05 hardening (missing users post-commit, stale password_hash
            // in overwrite mode). They must NOT be swallowed silently behind a
            // 200 ok=true; the admin needs to know the bundle wasn't actually
            // applied. Surface as 500 with a structured payload. Lock errors
            // are handled inline above (409 retry).
            const code = typeof err?.code === 'string' ? err.code : 'bundle_apply_failed';
            const message = typeof err?.message === 'string' ? err.message : 'config bundle import failed';
            return reply.status(500).send({
                code,
                error: message,
            });
        }
    });
}
/**
 * Detect better-sqlite3 locking errors. Surfaced when another writer
 * holds the lock (running server, prior open handle). Caller translates
 * to HTTP 409 so admin imports during heavy traffic get a sensible
 * "retry" signal instead of a 500.
 */
function isSqliteLockError(err) {
    if (!err || typeof err !== 'object')
        return false;
    const code = err.code;
    if (code === 'SQLITE_BUSY' || code === 'SQLITE_LOCKED')
        return true;
    const msg = err.message;
    return typeof msg === 'string' && /database (?:is )?locked|SQLITE_BUSY|SQLITE_LOCKED/i.test(msg);
}
//# sourceMappingURL=config-api.js.map