/**
 * Secrets API routes — Phase 7 of the secrets refactor.
 *
 * Write-only-from-the-outside contract for secret-classified registry
 * keys. The value never leaves the server via these endpoints: GET
 * returns metadata only (label, provider, last_tested_at, …), and the
 * test-connection probe runs server-side without echoing the value.
 *
 *   GET    /api/secrets                       — list metadata
 *   PUT    /api/secrets/:key                  — set or overwrite (audited)
 *   DELETE /api/secrets/:key                  — delete (audited)
 *   POST   /api/secrets/:key/test             — dispatch registry probe
 *
 * Owner-only. Bootstrap-classified keys (jwt.secret, master.key, …)
 * reject with 400 — those live in env-vars only and are not store-
 * settable. User-secret keys also reject — those live in auth.db per
 * Phase 8.
 *
 * The test endpoint dispatches `def.testConnection(value)` from the
 * registry. The returned shape is `{ ok: bool, message?: string }` —
 * server-derived, no upstream error body echoed. Tests are audited
 * (success + duration), no upstream message in the audit row either.
 *
 * After the scope-collapse refactor, requests carry no `scope` /
 * `scope_id` — Studiograph is single-workspace-per-process. Any legacy
 * `scope` query param or body field is silently ignored.
 */
import { isWorkspaceOwner } from '../../services/auth-service.js';
import { getKeyDef, getSecretStore, hasSecretStore, notifySecretMutation, } from '../../core/secrets/index.js';
/**
 * Test-connection cache.
 *
 * Probes are expensive (real network roundtrip) and operators may click
 * "Test" repeatedly while debugging. We cache the result by key for 60s
 * and return the cached `{ ok, message, cached: true }` envelope. The
 * UI surfaces this as a greyed-out "test again in Ns" button.
 *
 * Cache lives in-process — fine for v1; per-process Railway services
 * each carry their own cache, which is the right trade-off (Test =
 * audit event, so a separate-instance probe still produces an audit
 * row).
 */
const TEST_CACHE_TTL_MS = 60_000;
const testCache = new Map();
/**
 * Test-only hook: clear the process-wide cache. Vitest setups that swap
 * the store between cases need to call this so a prior test's result
 * doesn't leak into the next. Underscored to mark it as non-public.
 */
export function _resetSecretsTestCache() {
    testCache.clear();
}
function requireOwner(req, authService) {
    if (!authService.hasUsers())
        return true;
    const user = req.user;
    if (!user)
        return true; // API key — admin-equivalent (matches connectors-api / config-api)
    return isWorkspaceOwner(user.role);
}
function actorFromReq(req) {
    const user = req.user;
    const ip = req.headers['x-forwarded-for']?.split(',')[0]?.trim()
        || req.ip;
    const userAgent = req.headers['user-agent'];
    if (user)
        return { type: 'user', userId: String(user.id), ip, userAgent };
    return { type: 'system', systemId: 'api-token', ip, userAgent };
}
/**
 * Reject classifications that should never go through this API:
 *   - `bootstrap` — env-only by design
 *   - `user-*` — handled by auth.db routes (Phase 8)
 * The remaining secret classification (workspace-secret) is the valid
 * target.
 */
function rejectIfNonStoreSecret(classification) {
    if (!classification)
        return 'Unknown classification';
    if (classification === 'bootstrap')
        return 'Bootstrap-class keys are set via environment variables, not API';
    if (classification.startsWith('user-'))
        return 'User-scope secrets are managed via /api/preferences/settings';
    if (!classification.endsWith('-secret'))
        return 'Key is not classified as a secret — use /api/settings instead';
    return null;
}
export async function registerSecretsApiRoutes(fastify, authService) {
    // ── GET /api/secrets ─────────────────────────────────────────────────────
    // List metadata. NEVER returns plaintext. The UI uses this to render
    // the secret list with the actual values masked behind a "set |
    // overwrite" widget.
    fastify.get('/api/secrets', async (req, reply) => {
        if (!requireOwner(req, authService)) {
            return reply.status(403).send({ error: 'Workspace owner access required' });
        }
        if (!hasSecretStore()) {
            return reply.status(503).send({ error: 'Secrets store not initialized' });
        }
        const store = getSecretStore();
        const rows = store.listSecrets(actorFromReq(req));
        return reply.send({
            items: rows.map((r) => ({
                key: r.key,
                classification: r.classification,
                label: r.label,
                provider: r.provider,
                last_tested_at: r.lastTestedAt,
                last_test_result: r.lastTestResult,
                updated_at: r.updatedAt,
                updated_by: r.updatedBy,
                key_version: r.keyVersion,
            })),
        });
    });
    // ── PUT /api/secrets/:key ────────────────────────────────────────────────
    // Set or overwrite a secret. Audited. Value validated via registry zod.
    fastify.put('/api/secrets/:key', async (req, reply) => {
        if (!requireOwner(req, authService)) {
            return reply.status(403).send({ error: 'Workspace owner access required' });
        }
        if (!hasSecretStore()) {
            return reply.status(503).send({ error: 'Secrets store not initialized' });
        }
        const { key } = req.params;
        const body = req.body ?? {};
        const { value, label, provider } = body;
        if (typeof value !== 'string' || value.length === 0) {
            return reply.status(400).send({ error: 'body.value must be a non-empty string' });
        }
        const def = getKeyDef(key);
        if (!def)
            return reply.status(404).send({ error: `Unknown key "${key}"` });
        const rejection = rejectIfNonStoreSecret(def.classification);
        if (rejection)
            return reply.status(400).send({ error: rejection });
        const parsed = def.validate.safeParse(value);
        if (!parsed.success) {
            return reply.status(400).send({
                error: 'Validation failed',
                details: parsed.error.issues.map((i) => i.message),
            });
        }
        const store = getSecretStore();
        const meta = await store.set({ key, value: String(parsed.data), label, provider }, actorFromReq(req));
        // Invalidate any cached test-result for this key — a fresh value
        // deserves a fresh probe on the next click.
        testCache.delete(key);
        // Notify subscribers (e.g., the agent pool) so cached agents that
        // captured the prior value can evict and re-create on next chat.
        notifySecretMutation(key);
        // Don't echo the value back — we deliberately treat secrets as
        // write-only-from-the-outside. The UI confirms the write via the
        // metadata-only response.
        return reply.send({
            ok: true,
            meta: {
                key: meta.key,
                classification: meta.classification,
                label: meta.label,
                provider: meta.provider,
                last_tested_at: meta.lastTestedAt,
                last_test_result: meta.lastTestResult,
                updated_at: meta.updatedAt,
                updated_by: meta.updatedBy,
                key_version: meta.keyVersion,
            },
        });
    });
    // ── DELETE /api/secrets/:key ─────────────────────────────────────────────
    fastify.delete('/api/secrets/:key', async (req, reply) => {
        if (!requireOwner(req, authService)) {
            return reply.status(403).send({ error: 'Workspace owner access required' });
        }
        if (!hasSecretStore()) {
            return reply.status(503).send({ error: 'Secrets store not initialized' });
        }
        const { key } = req.params;
        const store = getSecretStore();
        const removed = await store.delete(key, actorFromReq(req));
        testCache.delete(key);
        if (removed)
            notifySecretMutation(key);
        return reply.send({ removed });
    });
    // ── POST /api/secrets/:key/test ──────────────────────────────────────────
    // Dispatch the registry's testConnection probe. The probe reads the
    // current value from the store (so the caller doesn't have to send
    // the value again) and pings the upstream. Result is { ok, message }
    // — no upstream body echoed. Audited via the store.audit logger so
    // a failed probe is observable.
    fastify.post('/api/secrets/:key/test', async (req, reply) => {
        if (!requireOwner(req, authService)) {
            return reply.status(403).send({ error: 'Workspace owner access required' });
        }
        if (!hasSecretStore()) {
            return reply.status(503).send({ error: 'Secrets store not initialized' });
        }
        const { key } = req.params;
        const def = getKeyDef(key);
        if (!def)
            return reply.status(404).send({ error: `Unknown key "${key}"` });
        if (!def.testConnection) {
            return reply.status(400).send({ error: `No test-connection probe registered for "${key}"` });
        }
        // Confirm a value exists BEFORE consulting the cache — a deleted /
        // unset key must always 404, even if a prior test populated the
        // cache. (Cache invalidation handles the common deletion path, but
        // some test/CLI flows may bypass it.)
        const store = getSecretStore();
        const value = store.unsafeGetForResolver(key);
        if (!value) {
            // Defensive: drop any stale cache entry too.
            testCache.delete(key);
            return reply.status(404).send({ error: `No value set for "${key}"` });
        }
        // Cache hit (within TTL) — return the prior result without re-probing
        // upstream. UI greys out the button until the cached entry expires.
        // Cache is per key; setting a new value invalidates it (PUT clears
        // the cache above via testCache.delete).
        const cached = testCache.get(key);
        if (cached && Date.now() - cached.cachedAt < TEST_CACHE_TTL_MS) {
            return reply.send({
                ok: cached.ok,
                message: cached.message,
                duration_ms: cached.duration_ms,
                cached: true,
                last_tested_at: cached.cachedAt,
                ttl_ms: TEST_CACHE_TTL_MS - (Date.now() - cached.cachedAt),
                stages: cached.stages,
                advisory: cached.advisory,
            });
        }
        // Phase 10.5: hand the probe extra context. Today only the Anthropic
        // probe consumes `model_id` (stage 4: model_in_list). Stub probes
        // ignore the second arg — TypeScript-wise the registry calls
        // `def.testConnection(value)` so we widen the call here via `any`.
        // The probe signature is `(value, ctx?, fetch?) => TestResult`.
        //
        // Any model key (`model.<provider>.api_key`) opts into the model.id
        // context so the probe can check whether the configured model is in
        // the provider's catalog. Non-model keys (r2.*, search.*) get
        // undefined context.
        let contextModelId;
        if (/^model\.[^.]+\.api_key$/.test(key)) {
            try {
                contextModelId = store.unsafeGetForResolver('model.id');
            }
            catch { /* no model.id set — probe skips stage 4 */ }
        }
        const startedAt = Date.now();
        let ok = false;
        let message;
        let stages;
        let advisory;
        try {
            const probeFn = def.testConnection;
            const result = await probeFn(value, { model_id: contextModelId });
            ok = !!result?.ok;
            message = result?.message;
            stages = result?.stages;
            advisory = result?.advisory;
        }
        catch (err) {
            ok = false;
            // Surface a generic message — never echo the upstream error text;
            // the upstream may include the auth header in its body.
            message = err instanceof Error ? err.message.slice(0, 200) : 'probe failed';
        }
        const duration = Date.now() - startedAt;
        // Capture cachedAt once so the response and the cache entry agree —
        // tests assert that a follow-up cached lookup returns the same
        // last_tested_at value the first call reported.
        const cachedAt = Date.now();
        // Populate the cache before the audit/UPDATE side effects so a
        // follow-up click within TTL hits the cache regardless of write-side
        // failures.
        testCache.set(key, { ok, message, duration_ms: duration, cachedAt, stages, advisory });
        // Persist the last_test_result on the row so the UI's badge stays
        // consistent across refreshes. Use a direct UPDATE rather than a
        // setSecret() roundtrip — we don't want to re-encrypt the row.
        try {
            store.db
                .prepare(`UPDATE secrets SET last_tested_at=?, last_test_result=?
             WHERE key=?`)
                .run(Date.now(), ok ? 'ok' : 'fail', key);
        }
        catch { /* non-fatal — audit captures the test event below */ }
        store.audit.write({
            action: 'test',
            actor: actorFromReq(req),
            key,
            success: ok,
            detail: { duration_ms: duration },
        });
        return reply.send({
            ok,
            message,
            duration_ms: duration,
            cached: false,
            last_tested_at: cachedAt,
            ttl_ms: TEST_CACHE_TTL_MS,
            stages,
            advisory,
        });
    });
}
//# sourceMappingURL=secrets-api.js.map