/**
 * Connectors API routes
 *
 * REST endpoints for MCP connector management:
 *   GET    /api/connectors                        — list configured MCP connectors
 *   POST   /api/connectors/import                 — import connector(s) from JSON config block
 *   GET    /api/connectors/:name/config           — get connector config (secrets masked)
 *   POST   /api/connectors/:name/config           — create/update connector config
 *   DELETE /api/connectors/:name/config           — remove connector config
 *   POST   /api/connectors/:name/oauth/connect    — initiate OAuth flow (MCP SDK auto-discovery)
 *   GET    /api/oauth/callback/:name              — OAuth callback (browser redirect)
 *   GET    /api/connectors/:name/oauth/status     — poll OAuth connection status
 *   POST   /api/connectors/:name/oauth/disconnect — clear OAuth tokens
 */
import { isWorkspaceOwner } from '../../services/auth-service.js';
import { ConnectorManager } from '../../mcp/connector-manager.js';
import { McpOAuthProvider } from '../../mcp/oauth-provider.js';
import { getBrokerConnectUrl, hasBrokerSupport, listAvailableBrokerConnectors, BROKER_URL } from '../../mcp/oauth-registry.js';
import { auth as mcpAuth } from '@modelcontextprotocol/sdk/client/auth.js';
import { log } from '../../utils/log.js';
function isOwner(req) {
    const user = req.user;
    if (!user)
        return true; // API key / open mode
    return isWorkspaceOwner(user.role);
}
/**
 * Minimal HTML escape for string interpolation into static templates served
 * by this route. Covers the five characters that can break out of element
 * content or attribute context. Used for the OAuth claim success page which
 * embeds the route's `:connector` path param — that param is supplied by
 * the broker's redirect (instance can't fully control its content) and the
 * route is served without auth, so an unescaped value is an XSS surface
 * for any user who clicks a crafted broker callback URL.
 */
function htmlEscape(s) {
    return s
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
}
/** Resolve OAuth credentials from connector config (user-provided). */
function resolveOAuthCredentials(config) {
    if (config.oauth?.client_id) {
        return {
            client_id: config.oauth.client_id,
            client_secret: config.oauth.client_secret,
        };
    }
    return undefined;
}
/** Probe an MCP server URL to detect if it requires OAuth. */
async function probeOAuthSupport(url) {
    try {
        // Try the protected resource discovery endpoint first
        const prUrl = new URL('/.well-known/oauth-protected-resource', new URL(url).origin);
        const prRes = await fetch(prUrl.toString(), { signal: AbortSignal.timeout(5000) });
        if (prRes.ok)
            return true;
        // Fall back to authorization server discovery
        const asUrl = new URL('/.well-known/oauth-authorization-server', new URL(url).origin);
        const asRes = await fetch(asUrl.toString(), { signal: AbortSignal.timeout(5000) });
        if (asRes.ok)
            return true;
        return false;
    }
    catch {
        return false;
    }
}
function getServerUrl(req) {
    const proto = req.headers['x-forwarded-proto'] || req.protocol || 'http';
    const host = req.headers['x-forwarded-host'] || req.headers.host || req.hostname;
    return `${proto}://${host}`;
}
export async function registerConnectorsApiRoutes(fastify, authService, workspacePath) {
    // GET /api/connectors — list configured MCP connectors
    //
    // Returns the two connection states separately so the UI can render the
    // correct affordances per connector:
    //   - `workspaceConnected`: a shared `Authorization` header exists in
    //     config — anyone (or no one — non-interactive callers) uses it.
    //   - `userConnected`: the calling user has personally OAuth-bound this
    //     connector — the agent will use their tokens when serving their
    //     session (precedence: user OAuth wins over workspace header).
    //
    // Both flags can be true at once — the user's OAuth takes precedence at
    // tool-call time, the workspace header serves non-interactive callers.
    fastify.get('/api/connectors', async (req, _reply) => {
        const user = req.user;
        const configs = ConnectorManager.loadConfigs(workspacePath);
        const connectors = configs.map(c => ({
            name: c.name,
            url: c.url,
            description: c.description || '',
            configured: true,
            workspaceConnected: !!(c.headers?.['Authorization']),
            userConnected: user ? authService.hasOAuthTokens(user.id, c.name) : false,
            hasOAuth: !!resolveOAuthCredentials(c) || hasBrokerSupport(c.url) || c.requiresOAuth === true,
        }));
        // Curated catalog of broker-backed connectors that aren't yet
        // configured in this workspace. The owner-only UI surfaces these
        // as one-click "Add" tiles so an admin doesn't have to know the
        // canonical URL. Filter out the ones already configured (by name
        // OR display-name match) so the same connector isn't visible in
        // both lists.
        const configuredNames = new Set(configs.map(c => c.name));
        const available = listAvailableBrokerConnectors(configuredNames);
        return { success: true, connectors, available };
    });
    // POST /api/connectors/import — import connector(s) from JSON config block
    fastify.post('/api/connectors/import', async (req, reply) => {
        if (!isOwner(req))
            return reply.status(403).send({ error: 'Workspace owner access required' });
        const { json, name } = req.body;
        if (!json)
            return reply.status(400).send({ error: 'json is required' });
        if (!name?.trim())
            return reply.status(400).send({ error: 'name is required' });
        try {
            const configs = ConnectorManager.parseConfigBlock(json, name);
            const created = [];
            for (const config of configs) {
                // Probe for OAuth support if no auth is configured
                if (!config.headers?.['Authorization'] && !config.oauth) {
                    config.requiresOAuth = await probeOAuthSupport(config.url);
                }
                ConnectorManager.saveConfig(config);
                ConnectorManager.saveWorkspaceConfig(config, workspacePath);
                created.push(config.name);
            }
            return { success: true, created };
        }
        catch (err) {
            return reply.status(400).send({ error: err.message || 'Failed to parse config' });
        }
    });
    // GET /api/connectors/:name/config — get connector config with masked secrets
    fastify.get('/api/connectors/:name/config', async (req, reply) => {
        if (!isOwner(req))
            return reply.status(403).send({ error: 'Workspace owner access required' });
        const config = ConnectorManager.loadConfig(req.params.name, workspacePath);
        if (!config)
            return { success: true, config: null };
        const masked = { ...config };
        if (masked.headers) {
            masked.headers = { ...masked.headers };
            for (const key of Object.keys(masked.headers)) {
                if (key.toLowerCase() === 'authorization')
                    masked.headers[key] = '••••••••';
            }
        }
        if (masked.oauth?.client_secret) {
            masked.oauth = { ...masked.oauth, client_secret: '••••••••' };
        }
        return { success: true, config: masked };
    });
    // POST /api/connectors/:name/config — create or update connector config
    fastify.post('/api/connectors/:name/config', async (req, reply) => {
        if (!isOwner(req))
            return reply.status(403).send({ error: 'Workspace owner access required' });
        const body = req.body;
        if (!body?.url)
            return reply.status(400).send({ error: 'url is required' });
        const config = {
            name: req.params.name,
            url: body.url,
            ...(body.headers ? { headers: body.headers } : {}),
            ...(body.oauth ? { oauth: body.oauth } : {}),
            description: body.description ?? req.params.name,
        };
        // Re-probe for OAuth support if no auth is configured
        if (!config.headers?.['Authorization'] && !config.oauth) {
            config.requiresOAuth = await probeOAuthSupport(config.url);
        }
        ConnectorManager.saveConfig(config);
        ConnectorManager.saveWorkspaceConfig(config, workspacePath);
        return { success: true, message: `Connector "${req.params.name}" configured` };
    });
    // DELETE /api/connectors/:name/config — remove connector config
    fastify.delete('/api/connectors/:name/config', async (req, reply) => {
        if (!isOwner(req))
            return reply.status(403).send({ error: 'Workspace owner access required' });
        const removed = ConnectorManager.removeConfig(req.params.name, workspacePath);
        if (removed)
            return { success: true, message: `Connector "${req.params.name}" removed` };
        return reply.status(404).send({ success: false, error: `No config for "${req.params.name}"` });
    });
    // ── OAuth flow (MCP SDK auto-discovery) ──
    // POST /api/connectors/:name/oauth/connect — initiate OAuth flow
    fastify.post('/api/connectors/:name/oauth/connect', async (req, reply) => {
        const user = req.user;
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        const { name } = req.params;
        const config = ConnectorManager.loadConfig(name, workspacePath);
        if (!config?.url)
            return reply.status(400).send({ error: `Connector "${name}" not found or not an HTTP connector` });
        const serverUrl = getServerUrl(req);
        // Check if broker handles this connector (pre-registered OAuth app).
        // HMAC-signed state so the broker's eventual /claim payload — which
        // round-trips this exact `state` — proves the userId came from us.
        const state = authService.signOAuthState({
            connectorName: name,
            userId: user.id,
            origin: serverUrl,
        });
        const brokerUrl = getBrokerConnectUrl(config.url, state);
        if (brokerUrl) {
            // Broker handles OAuth — redirect the user there
            return { success: true, authorizationUrl: brokerUrl };
        }
        // Construct the provider unconditionally. When the connector config has
        // an explicit `oauth.client_id`, we use those credentials. Otherwise
        // we hand the SDK a provider with no preRegistered creds and let it
        // try Dynamic Client Registration (RFC 7591) — most modern MCP
        // servers advertise a `registration_endpoint` in
        // `.well-known/oauth-authorization-server` and the SDK + our
        // McpOAuthProvider already implement the `clientInformation` /
        // `saveClientInformation` hooks needed to persist the dynamically-
        // assigned client id back into the user's `oauth_tokens` row.
        //
        // If the server supports neither pre-registered creds nor DCR, the
        // SDK throws inside `mcpAuth` — surfaced below as a 500 with the
        // SDK's error message intact.
        const oauthCreds = resolveOAuthCredentials(config);
        const provider = new McpOAuthProvider(name, user.id, authService, serverUrl, oauthCreds);
        try {
            const result = await mcpAuth(provider, { serverUrl: config.url });
            if (result === 'AUTHORIZED') {
                return { success: true, connected: true };
            }
            if (!provider.authorizationUrl) {
                return reply.status(500).send({ error: 'OAuth flow did not produce an authorization URL' });
            }
            return { success: true, authorizationUrl: provider.authorizationUrl };
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            log.error(`[oauth] connect error for ${name}:`, msg);
            // Detect the specific case where the server doesn't support DCR
            // AND no client_id was supplied — that's a config problem the
            // user can fix by editing the connector JSON, not an upstream
            // outage. The SDK's error messages around this vary, so we
            // pattern-match the common shapes (no registration endpoint,
            // 400/404 on /register, missing client_id).
            const looksLikeRegistrationGap = !oauthCreds &&
                /registration|register|client_id|unauthorized_client/i.test(msg);
            if (looksLikeRegistrationGap) {
                return reply.status(400).send({
                    error: `This server requires manual OAuth setup (it doesn't support automatic registration). ` +
                        `Register an OAuth app with the provider, then edit the connector and add oauth.client_id and oauth.client_secret to the JSON config.`,
                });
            }
            return reply.status(500).send({ error: `OAuth initialization failed: ${msg}` });
        }
    });
    // GET /api/oauth/callback/:name — OAuth callback (browser redirect from provider)
    // Public endpoint — user identity comes from the state parameter.
    fastify.get('/api/oauth/callback/:name', async (req, reply) => {
        const { name } = req.params;
        const { code, state } = req.query;
        if (!code) {
            // Phase 11: don't echo `error_description` / `error` verbatim — log
            // detail server-side, return a generic message to the browser.
            const errDetail = req.query.error || 'missing authorization code';
            log.warn(`[oauth] callback for ${name} without code: ${errDetail}`);
            return reply.type('text/html').send(`<html><body><h2>Authorization failed</h2><p>The provider returned an authorization error. You can close this tab.</p></body></html>`);
        }
        // HMAC-verified — rejects forged, tampered, expired (>10min), or unsigned
        // pre-launch states. Pre-launch breaking-change: any OAuth flows in
        // flight at the time of this deploy will fail on callback; users restart.
        const decoded = state ? authService.verifyOAuthState(state) : null;
        if (!decoded) {
            log.warn(`[oauth] callback for ${name} rejected: invalid or expired state`);
            return reply.status(400).type('text/html').send('<html><body><h2>Authorization failed</h2><p>Invalid or expired authorization session. Please try connecting again.</p></body></html>');
        }
        // Cross-check the URL path against the signed connector binding — same
        // defense-in-depth posture as the /claim handler. The signed state is
        // already trusted for connector/userId attribution; rejecting a path
        // mismatch removes the misleading "Connected to <name>" success UI when
        // the path was forged independent of the signed binding.
        if (decoded.connectorName !== name) {
            log.warn(`[oauth] callback for ${name} rejected: state binds connector=${decoded.connectorName}, path mismatch`);
            return reply.status(400).type('text/html').send('<html><body><h2>Authorization failed</h2><p>Connector mismatch. Please try connecting again.</p></body></html>');
        }
        const config = ConnectorManager.loadConfig(decoded.connectorName, workspacePath);
        if (!config?.url) {
            return reply.status(400).type('text/html').send(`<html><body><h2>Unknown connector</h2><p>"${decoded.connectorName}" not found.</p></body></html>`);
        }
        const serverUrl = getServerUrl(req);
        const oauthCreds = resolveOAuthCredentials(config);
        const provider = new McpOAuthProvider(decoded.connectorName, decoded.userId, authService, serverUrl, oauthCreds);
        try {
            await mcpAuth(provider, { serverUrl: config.url, authorizationCode: code });
            return reply.type('text/html').send(`<html><body><h2>Connected to ${name}</h2><p>You can close this tab and return to Studiograph.</p></body></html>`);
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            log.error(`[oauth] callback error for ${name}:`, msg);
            // Phase 11: don't echo upstream error message verbatim.
            return reply.type('text/html').send(`<html><body><h2>Authorization failed</h2><p>Could not complete the connection. Please try again.</p></body></html>`);
        }
    });
    // GET /api/connectors/:name/oauth/status — poll for OAuth connection status
    fastify.get('/api/connectors/:name/oauth/status', async (req, reply) => {
        const user = req.user;
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        const connected = authService.hasOAuthTokens(user.id, req.params.name);
        return { connected };
    });
    // POST /api/connectors/:name/oauth/disconnect — clear OAuth tokens
    fastify.post('/api/connectors/:name/oauth/disconnect', async (req, reply) => {
        const user = req.user;
        if (!user)
            return reply.status(401).send({ error: 'Authentication required' });
        authService.clearOAuthData(user.id, req.params.name);
        return { success: true };
    });
    // GET /api/oauth/claim/:connector — Receive tokens from the OAuth broker
    // Browser is redirected here after the broker exchanges the code for tokens.
    // Fetches tokens from the broker using the one-time claim code, stores them, redirects to settings.
    fastify.get('/api/oauth/claim/:connector', async (req, reply) => {
        const { connector } = req.params;
        const claimCode = req.query.code;
        // Escape the connector name before interpolating into the HTML success
        // page below — the path param is user-influenced (whoever crafts the
        // broker callback chooses it) and the response is served unauthed.
        // Without this a connector name like `<script>...</script>` becomes XSS.
        const connectorHtml = htmlEscape(connector);
        if (!claimCode) {
            return reply.type('text/html').send('<html><body><h2>Missing claim code</h2><p>No claim code provided.</p></body></html>');
        }
        try {
            // Fetch tokens from the broker (server-to-server)
            const claimRes = await fetch(`${BROKER_URL}/claim/${encodeURIComponent(claimCode)}`);
            if (!claimRes.ok) {
                return reply.type('text/html').send('<html><body><h2>Claim failed</h2><p>The claim code has expired or was already used. Please try connecting again.</p></body></html>');
            }
            const claim = await claimRes.json();
            // Verify the broker round-tripped the original signed state. Without
            // this, the instance would have to trust the broker's `userId` field
            // verbatim — which means anyone who can hit /claim/:connector with a
            // crafted claim code can attribute tokens to an arbitrary user. The
            // signature pins {connector, userId, origin} to this instance's
            // master key, so a forged or stale state can't pass verification.
            if (!claim.state) {
                log.warn(`[oauth] claim for ${connector} rejected: broker did not return state`);
                return reply.type('text/html').send('<html><body><h2>Authorization failed</h2><p>The authorization broker did not return a valid session. Please try connecting again.</p></body></html>');
            }
            const verified = authService.verifyOAuthState(claim.state);
            if (!verified) {
                log.warn(`[oauth] claim for ${connector} rejected: state failed verification`);
                return reply.type('text/html').send('<html><body><h2>Authorization failed</h2><p>Invalid or expired authorization session. Please try connecting again.</p></body></html>');
            }
            // Cross-check the URL path matches the signed connector binding —
            // otherwise an attacker who obtains a valid signed state for one
            // connector could redirect to /api/oauth/claim/<other> and bind the
            // tokens to a different connector slot.
            if (verified.connectorName !== connector) {
                log.warn(`[oauth] claim for ${connector} rejected: state binds connector=${verified.connectorName}, path mismatch`);
                return reply.type('text/html').send('<html><body><h2>Authorization failed</h2><p>Connector mismatch. Please try connecting again.</p></body></html>');
            }
            // Bind the tokens to the verified userId/connector from state — NOT
            // the broker's untrusted `userId` field. Use the verified connector
            // name as the storage key for the same reason.
            const userId = verified.userId;
            const verifiedConnector = verified.connectorName;
            // Store tokens in auth.db
            const data = authService.getOAuthData(userId, verifiedConnector) ?? {};
            data.tokens = claim.tokens;
            authService.saveOAuthData(userId, verifiedConnector, data);
            // Don't redirect to /settings — the original tab is already there and
            // its polling will pick up the new `userConnected: true` state within
            // a couple seconds. Show a self-closing "all done" page in the new
            // tab so the user isn't left staring at a duplicate Settings view.
            // window.close() works because the tab was opened via window.open in
            // handleOAuthConnect.
            return reply.type('text/html').send(`<!doctype html><html><head><meta charset="utf-8"><title>Connected</title>
<style>
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #fafafa; color: #1a1a1a; margin: 0; min-height: 100vh; display: flex; align-items: center; justify-content: center; }
.card { text-align: center; padding: 2rem 3rem; }
.check { font-size: 3rem; line-height: 1; margin-bottom: 0.5rem; color: #2a8f4a; }
h2 { font-size: 1.25rem; font-weight: 500; margin: 0 0 0.5rem; }
p { color: #666; margin: 0; font-size: 0.95rem; }
@media (prefers-color-scheme: dark) {
  body { background: #0d0d0d; color: #f0f0f0; }
  p { color: #999; }
}
</style></head>
<body><div class="card">
<div class="check">✓</div>
<h2>Connected to ${connectorHtml}</h2>
<p>You can close this tab.</p>
</div>
<script>setTimeout(function(){ try { window.close(); } catch (e) {} }, 1200);</script>
</body></html>`);
        }
        catch (err) {
            const msg = err instanceof Error ? err.message : String(err);
            log.error(`[oauth] claim error for ${connector}:`, msg);
            // Phase 11: don't echo upstream/internal error message verbatim.
            return reply.type('text/html').send(`<html><body><h2>Connection failed</h2><p>Could not retrieve the connection token. Please try again.</p></body></html>`);
        }
    });
}
//# sourceMappingURL=connectors-api.js.map