/**
 * Studiograph HTTP Server
 *
 * Hosts:
 *   - Graph API (read-only REST over WorkspaceManager)
 *   - Agent chat via POST /api/chat and SSE GET /api/chat/stream
 *   - Static web UI (SvelteKit build) with SPA fallback
 */
import Fastify from 'fastify';
import cookie from '@fastify/cookie';
import cors from '@fastify/cors';
import rateLimit from '@fastify/rate-limit';
import websocket from '@fastify/websocket';
import { sanitize } from './middleware/sanitize.js';
import { existsSync, readFileSync, createReadStream, statSync } from 'fs';
import { join, extname, resolve, sep } from 'path';
import { Workspace } from '../core/workspace.js';
import { AgentOrchestrator } from '../agent/orchestrator.js';
import { registerGraphApiRoutes } from './routes/graph-api.js';
import { registerChatRoutes } from './routes/chat.js';
import { registerWorkspaceApiRoutes } from './routes/workspace-api.js';
import { registerHealthApiRoutes } from './routes/health.js';
import { registerPermissionsApiRoutes } from './routes/permissions-api.js';
import { registerAuthApiRoutes } from './routes/auth-api.js';
import { registerGitHttpRoutes } from './routes/git-http.js';
import { registerViewsApiRoutes } from './routes/views-api.js';
import { registerInsightsApiRoutes } from './routes/insights-api.js';
import { registerMcpRoutes } from './routes/mcp.js';
import { registerConnectorsApiRoutes } from './routes/connectors-api.js';
import { registerOAuthApiRoutes, resolveCanonicalBaseUrl } from './routes/oauth-api.js';
import { registerAssetApiRoutes } from './routes/asset-api.js';
import { verifyArtifactAssetToken } from '../services/artifact-asset-token.js';
import { registerR2ApiRoutes } from './routes/r2-api.js';
import multipart from '@fastify/multipart';
import { registerEventIngestRoutes } from './routes/event-ingest-api.js';
import { registerCaptureRoutes } from './routes/capture-api.js';
import { registerUrlRoutes } from './routes/url-api.js';
import { registerConfigApiRoutes } from './routes/config-api.js';
import { registerSettingsApiRoutes } from './routes/settings-api.js';
import { registerSecretsApiRoutes } from './routes/secrets-api.js';
import { registerAuditApiRoutes } from './routes/audit-api.js';
import { registerCommentsApiRoutes } from './routes/comments-api.js';
import { registerChatSessionsApiRoutes } from './routes/chat-sessions-api.js';
import { registerShareApiRoutes } from './routes/share-api.js';
import { registerArtifactExportApiRoutes } from './routes/artifact-export-api.js';
import { AuthService, isWorkspaceOwner } from '../services/auth-service.js';
import { MemoryService } from '../services/memory-service.js';
import { CommentService } from '../services/comment-service.js';
import { ChatSessionService } from '../services/chat-session-service.js';
import { ShareLinkService } from '../services/share-link-service.js';
import { NotificationService } from '../services/notification-service.js';
import { WsHub } from './ws-hub.js';
import { registerWsRoute } from './routes/ws.js';
import { SessionManager } from './session-manager.js';
import { AssetIndexQueue } from './asset-index-queue.js';
import { YjsManager } from './yjs-manager.js';
import { YjsPersistence, acquireYjsStateLock } from './yjs-persistence.js';
import { BodyWriteCoordinator } from './body-write-coordinator.js';
import { ensurePersonalFolder } from '../services/personal-folder.js';
import { loadUserConfig, resolveProvider, resolveModelId, resolveApiKey } from '../core/user-config.js';
/** Ensure every user has a personal "My Folder" folder + admin access on it (idempotent). */
async function ensureAllPersonalFolders(authService, workspacePath, workspaceManager) {
    const users = authService.listUsers();
    const workspace = new Workspace(workspacePath);
    for (const user of users) {
        try {
            await ensurePersonalFolder(workspace, authService, user);
        }
        catch { /* non-fatal — skip and let the user re-authenticate */ }
    }
    workspaceManager.reloadConfig();
}
/** MIME types for static file serving (web UI assets). */
const MIME_TYPES = {
    '.html': 'text/html',
    '.css': 'text/css',
    '.js': 'application/javascript',
    '.mjs': 'application/javascript',
    '.json': 'application/json',
    '.png': 'image/png',
    '.jpg': 'image/jpeg',
    '.jpeg': 'image/jpeg',
    '.gif': 'image/gif',
    '.svg': 'image/svg+xml',
    '.ico': 'image/x-icon',
    '.woff': 'font/woff',
    '.woff2': 'font/woff2',
    '.ttf': 'font/ttf',
    '.otf': 'font/otf',
    '.webp': 'image/webp',
    '.avif': 'image/avif',
    '.mp4': 'video/mp4',
    '.webm': 'video/webm',
};
// EXACT two-segment immutable asset shape — the only path the artifact-asset
// capability token (`?at=`) may unlock. Excludes the legacy 3-segment route
// (which carries known repo-identity hazards) by matching to end-of-path.
const ARTIFACT_ASSET_TOKEN_PATH_RE = /^\/api\/assets\/[^/]+\/[^/]+$/;
export function resolveStaticFilePath(webDir, urlPath) {
    let decodedPath;
    try {
        decodedPath = decodeURIComponent(urlPath);
    }
    catch {
        return null;
    }
    const filePath = decodedPath === '/' ? '/index.html' : decodedPath;
    const webRoot = resolve(webDir);
    const fullPath = resolve(webRoot, `.${filePath}`);
    if (fullPath !== webRoot && !fullPath.startsWith(webRoot + sep)) {
        return null;
    }
    return fullPath;
}
function normalizeOrigin(value) {
    if (!value)
        return null;
    try {
        return new URL(value).origin;
    }
    catch {
        return null;
    }
}
export function buildAllowedCorsOrigins(workspaceConfig, port) {
    const rawOrigins = [];
    const envOrigins = process.env.STUDIOGRAPH_CORS_ORIGINS;
    if (envOrigins)
        rawOrigins.push(...envOrigins.split(',').map(o => o.trim()).filter(Boolean));
    const configured = workspaceConfig.cors_origins;
    if (Array.isArray(configured)) {
        rawOrigins.push(...configured.filter((o) => typeof o === 'string'));
    }
    else if (typeof configured === 'string') {
        rawOrigins.push(...configured.split(',').map(o => o.trim()).filter(Boolean));
    }
    const serverUrl = workspaceConfig.server_url;
    if (typeof serverUrl === 'string')
        rawOrigins.push(serverUrl);
    rawOrigins.push(`http://localhost:${port}`, `http://127.0.0.1:${port}`, `http://[::1]:${port}`);
    return new Set(rawOrigins.map(normalizeOrigin).filter((o) => Boolean(o)));
}
export function isCorsOriginAllowed(origin, allowedOrigins) {
    if (!origin)
        return true;
    const normalized = normalizeOrigin(origin);
    return Boolean(normalized && allowedOrigins.has(normalized));
}
function isTruthyEnv(value) {
    return Boolean(value && ['1', 'true', 'yes', 'on'].includes(value.trim().toLowerCase()));
}
/**
 * Auth middleware — validates JWT cookie or Authorization: Bearer <key> header.
 * When users exist in the auth database, all routes require authentication
 * (except auth endpoints and static assets). When no users exist, everything
 * is open (backward compatible).
 */
export function createAuthHook(authService, workspaceConfig) {
    return async (req, reply) => {
        // Always allow auth endpoints
        if (req.url.startsWith('/api/auth/'))
            return;
        // Managed Cloud login handoff verifies its own short-lived token.
        if (req.url.startsWith('/api/cloud/auth/'))
            return;
        // OAuth callbacks and claims identify the user via state/claim code, not cookies
        if (req.url.startsWith('/api/oauth/callback/'))
            return;
        if (req.url.startsWith('/api/oauth/claim/'))
            return;
        // OAuth authorization-server discovery + token endpoints are
        // unauthenticated by spec (the client has no user yet). They are
        // rate-limited + validated in oauth-api.ts. NOT /oauth/authorize — that
        // one must run as the logged-in user (handled explicitly below, above the
        // HTML-GET bypass which would otherwise skip cookie resolution).
        if (req.url.startsWith('/.well-known/oauth-'))
            return;
        if (req.url.startsWith('/oauth/token') ||
            req.url.startsWith('/oauth/register') ||
            req.url.startsWith('/oauth/revoke'))
            return;
        if (req.url.startsWith('/oauth/authorize')) {
            const cookieTok = req.cookies?.['__sg_token'];
            // Resolve the session cookie, but REFUSE an OAuth access token here — a
            // cookie is a browser-session surface and must never carry an
            // authorization-server-issued token (see the cookie branch below).
            const c = cookieTok ? authService.verifyTokenWithClaims(cookieTok) : null;
            if (c && !c.oauth) {
                req.user = c.user;
                return;
            }
            // Not logged in: bounce a browser GET to login (carrying the authorize
            // URL as returnTo); refuse a POST.
            if (req.method === 'GET') {
                return reply.redirect(`/login?returnTo=${encodeURIComponent(req.url)}`);
            }
            return reply.status(401).send({ error: 'login_required' });
        }
        // Git HTTP routes have their own Basic Auth
        if (req.url.startsWith('/git/'))
            return;
        // WebSocket route handles its own auth
        if (req.url.startsWith('/ws'))
            return;
        // Allow static assets (JS, CSS, fonts, images) — needed for login page.
        // Covers SvelteKit-bundled output under /_app/ and root-level public
        // files (favicon.ico, studiograph-logomark.svg, …) identified by a file
        // extension on a non-API GET.
        if (req.url.startsWith('/_app/'))
            return;
        // Public share surface: the two read-only routes are authorized by the
        // opaque token (verified in share-api.ts), not a session. Scope the bypass
        // to EXACTLY the public read + asset shapes so a future /api/share/* route
        // isn't accidentally public. Allow HEAD as well as GET — the asset route is
        // registered for both (image preflights / link unfurlers issue HEAD). The
        // mint route is POST /api/repos/... and stays fully authed.
        if ((req.method === 'GET' || req.method === 'HEAD') &&
            /^\/api\/share\/[^/]+(\/asset\/[^/]+\/[^/]+)?$/.test(req.url.split('?')[0]))
            return;
        // (The former anonymous public-fonts bypass is gone: artifact @font-face
        // loads ride the same asset-resolution contract as images — the `?at=`
        // capability token below, the share-token path, or export bundling. A
        // cookieless font request without a token fails closed like any asset.)
        // Sandboxed artifact iframes (opaque origin, no cookie) carry an
        // artifact-asset capability token in `?at=`. Only the EXACT 2-segment
        // immutable shape qualifies (never the legacy 3-seg route). Verify the
        // signature and stash the payload as a CANDIDATE — the asset route does the
        // authoritative authz against the resolved row (repo + allowlist + per-serve
        // user re-check). We do NOT set req.user. An invalid token falls through to
        // normal auth (→ 401/404), so a bad `?at=` can't downgrade security.
        if ((req.method === 'GET' || req.method === 'HEAD') &&
            ARTIFACT_ASSET_TOKEN_PATH_RE.test(req.url.split('?')[0])) {
            const at = req.query?.at;
            if (typeof at === 'string' && at.length > 0) {
                try {
                    req.artifactAssetToken = verifyArtifactAssetToken(at, authService.getJwtSecret());
                    // Only short-circuit for a cookieless caller (the sandboxed iframe).
                    // If a session cookie is present, fall through to normal auth so
                    // req.user is set and the route serves on the SESSION's access — the
                    // token is a fallback for the no-cookie case, never an override.
                    if (!req.cookies?.['__sg_token'])
                        return;
                }
                catch { /* invalid/expired → fall through to normal auth */ }
            }
        }
        if (req.method === 'GET' &&
            !req.url.startsWith('/api/') &&
            extname(req.url.split('?')[0]).length > 1)
            return;
        // Allow HTML page requests (SPA routing) — the app handles auth client-side
        if (req.method === 'GET' && req.headers.accept?.includes('text/html'))
            return;
        // If no users configured, allow everything in local open mode. Managed
        // Cloud workspaces still require auth before the first user handoff lands.
        if (!authService.hasUsers() && !isTruthyEnv(process.env.STUDIOGRAPH_MANAGED_AUTH))
            return;
        // Check JWT cookie (web UI sessions).
        //
        // Security: resolve with verifyTokenWithClaims and REJECT OAuth access
        // tokens (`oauth:true`). OAuth tokens are JWTs signed with the same
        // jwtSecret, so the legacy verifyToken() would accept one supplied as a
        // cookie and return a fully-privileged session — bypassing the MCP-only
        // audience scoping enforced on the Bearer path below. A session cookie is
        // a web-UI surface and must never carry an authorization-server token.
        const jwtToken = req.cookies?.['__sg_token'];
        if (jwtToken) {
            const claims = authService.verifyTokenWithClaims(jwtToken);
            if (claims && !claims.oauth) {
                req.user = claims.user;
                return;
            }
        }
        // Check Bearer token (API clients, MCP, CLI): JWT → per-user API token.
        // Both yield a user identity. There is no shared workspace key — every
        // caller must be identifiable so access control can enforce per-folder rules.
        const authHeader = req.headers['authorization'];
        const bearer = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : undefined;
        if (bearer) {
            const claims = authService.verifyTokenWithClaims(bearer);
            if (claims) {
                if (claims.oauth) {
                    // OAuth access tokens are confined to the MCP surface. Reject them on
                    // the rest of the REST API so a leaked connector token can't drive the
                    // whole product (least privilege — sg_ tokens stay full).
                    const path = req.url.split('?')[0];
                    const mcpScoped = path === '/mcp' || path.startsWith('/mcp/') || path.startsWith('/oauth/');
                    if (!mcpScoped) {
                        return reply.status(403).send({ error: 'insufficient_scope' });
                    }
                    // Enforce the token audience: it must be bound to THIS server's /mcp
                    // resource. Without this, any JWT signed with the workspace secret +
                    // `oauth:true` would be accepted regardless of the `aud` it was minted
                    // for (stale/wrong-resource/host-spoofed tokens).
                    let expectedAud = null;
                    try {
                        expectedAud = `${resolveCanonicalBaseUrl(workspaceConfig, req)}/mcp`;
                    }
                    catch {
                        expectedAud = null;
                    }
                    if (!expectedAud || claims.aud !== expectedAud) {
                        return reply.status(403).send({ error: 'insufficient_scope', error_description: 'token audience mismatch' });
                    }
                    // Label the integration for agent-activity attribution (parity with
                    // the api-token name path below). cid is the stable client_id.
                    if (claims.cid) {
                        const client = authService.oauthServer.getClient(claims.cid);
                        req.apiTokenName = client?.client_name ?? 'OAuth client';
                    }
                }
                req.user = claims.user;
                return;
            }
            const tokenMeta = authService.verifyApiTokenWithMeta(bearer);
            if (tokenMeta) {
                req.user = tokenMeta.user;
                // Stash the token's user-set name so downstream surfaces (MCP, agent
                // activity) can label the integration. Absent for cookie/JWT auth.
                req.apiTokenName = tokenMeta.tokenName;
                return;
            }
        }
        // Check Basic Auth (CLI join command)
        if (authHeader?.startsWith('Basic ')) {
            try {
                const decoded = Buffer.from(authHeader.slice(6), 'base64').toString('utf-8');
                const [email, password] = decoded.split(':');
                if (email && password) {
                    const result = authService.authenticate(email, password);
                    if (result) {
                        req.user = result.user;
                        return;
                    }
                }
            }
            catch { /* fall through to 401 */ }
        }
        // A verified artifact-asset candidate is its own authorization scheme for
        // the exact 2-segment asset path (the route re-checks allowlist + current
        // repo access per serve). Reached only when no stronger credential
        // resolved above — so a PRESENT-but-invalid session cookie is never worse
        // than NO cookie. WebKit sends cookies from sandboxed artifact iframes; a
        // stale/foreign __sg_token would otherwise 401 every artifact image in
        // Safari despite a valid capability token.
        if (req.artifactAssetToken)
            return;
        // Asset fetches from sandboxed artifact iframes come from an opaque
        // origin, so without an ACAO header the browser reports this 401 as a
        // CORS failure — an inscrutable "No Access-Control-Allow-Origin" console
        // error instead of the real cause. Reflect the success path's open CORS
        // policy on the denial so it reads as a clean 401. Header-only; the
        // gate itself is unchanged.
        if ((req.method === 'GET' || req.method === 'HEAD') &&
            ARTIFACT_ASSET_TOKEN_PATH_RE.test(req.url.split('?')[0])) {
            reply.header('Access-Control-Allow-Origin', '*');
        }
        // MCP OAuth discovery: an unauthenticated /mcp request gets a
        // WWW-Authenticate header pointing at the protected-resource metadata, so
        // an MCP client (Claude Chat/Cowork) knows to begin the OAuth flow.
        if (req.url.startsWith('/mcp')) {
            // Best-effort: if no canonical issuer is pinned (fails closed in prod),
            // skip the discovery hint rather than 500 the 401.
            try {
                const base = resolveCanonicalBaseUrl(workspaceConfig, req);
                reply.header('WWW-Authenticate', `Bearer resource_metadata="${base}/.well-known/oauth-protected-resource"`);
            }
            catch { /* issuer unconfigured — omit discovery header */ }
        }
        return reply.status(401).send({ error: 'Unauthorized' });
    };
}
/**
 * Phase 11 — preSerialization payload sanitizer.
 *
 * The API has many routes that respond with `{ error: err.message }` from a
 * catch block. Those messages can carry upstream provider errors, git
 * stderr (with embedded credentials), or other free-form text that we
 * don't want surfacing in HTTP responses. Walk the response payload,
 * sanitize any string value at a "message" / "error" / "detail" key,
 * and leave structured fields (status codes, IDs, classification flags)
 * alone.
 *
 * Conservatively, we ONLY redact at keys that conventionally carry
 * free-form text. Arbitrary string fields (entity names, IDs, etc.) are
 * left untouched so we don't garble legitimate content. The Fastify
 * logger sanitization (above) handles log-line redaction independently;
 * this hook focuses on the wire response.
 */
const FREEFORM_ERROR_KEYS = new Set(['error', 'message', 'detail', 'stack', 'description', 'error_description']);
function sanitizeResponsePayload(value) {
    if (typeof value === 'string')
        return sanitize(value);
    if (Array.isArray(value))
        return value.map(sanitizeResponsePayload);
    if (value && typeof value === 'object') {
        const out = {};
        for (const [k, v] of Object.entries(value)) {
            if (FREEFORM_ERROR_KEYS.has(k) && typeof v === 'string') {
                out[k] = sanitize(v);
            }
            else if (v && typeof v === 'object') {
                out[k] = sanitizeResponsePayload(v);
            }
            else {
                out[k] = v;
            }
        }
        return out;
    }
    return value;
}
export async function createServer(config) {
    // Phase 11: every Fastify log line flows through sanitize() before
    // reaching stdout. A thin Writable wrapper passes each chunk through
    // the redactor; pino accepts any `stream` with a `write()` method.
    // STUDIOGRAPH_LOG_RAW=1 disables redaction for local debugging.
    const shouldRedactFastifyLogs = process.env.STUDIOGRAPH_LOG_RAW !== '1' &&
        (process.env.NODE_ENV === 'production' || !!process.env.RAILWAY_ENVIRONMENT);
    const redactingStream = {
        write(chunk) {
            const out = shouldRedactFastifyLogs ? sanitize(chunk) : chunk;
            process.stdout.write(out);
        },
    };
    const fastify = Fastify({
        logger: process.env.STUDIOGRAPH_LOG === '1'
            ? { stream: redactingStream }
            : { level: 'warn', stream: redactingStream },
        bodyLimit: 10 * 1024 * 1024, // 10 MB — entities can be large (e.g. meeting transcripts)
    });
    // Phase 11: sanitize uncaught errors before serialization so neither
    // err.message nor err.stack echoes upstream secrets. Stable error
    // codes (err.code) pass through; we only redact freeform text.
    fastify.setErrorHandler((error, _req, reply) => {
        const sanitizedMsg = shouldRedactFastifyLogs ? sanitize(error.message) : error.message;
        const status = error.statusCode ?? 500;
        fastify.log.error({
            err: { message: sanitizedMsg, code: error.code },
        });
        reply.status(status).send({
            statusCode: status,
            code: error.code,
            error: status >= 500 ? 'Internal Server Error' : (error.name || 'Error'),
            message: status >= 500 ? 'An internal error occurred.' : sanitizedMsg,
        });
    });
    // Phase 11: pre-serialization hook scans every response body for
    // freeform error/message strings and runs them through sanitize().
    // This catches the many `reply.status(4xx).send({ error: err.message })`
    // call sites scattered across the API routes — without this, an
    // upstream provider error or git remote error would echo through to
    // the API consumer with embedded credentials. Stable error codes pass
    // through unchanged. Only kicks in when redaction is enabled.
    if (shouldRedactFastifyLogs) {
        fastify.addHook('preSerialization', async (_req, _reply, payload) => {
            if (!payload || typeof payload !== 'object')
                return payload;
            return sanitizeResponsePayload(payload);
        });
    }
    // CORS
    const allowedCorsOrigins = buildAllowedCorsOrigins(config.workspaceConfig, config.port);
    await fastify.register(cors, {
        origin: (origin, cb) => {
            cb(null, isCorsOriginAllowed(origin, allowedCorsOrigins));
        },
        credentials: true,
    });
    // Cookie support for web UI session auth
    await fastify.register(cookie);
    // WebSocket support for real-time notifications.
    //
    // perMessageDeflate is enabled here on purpose. Our origin would otherwise
    // leave compression off (ws lib default), but Railway's edge proxy
    // negotiates `permessage-deflate` with the browser anyway and does its own
    // inflate/deflate layer in the middle — which Safari/WebKit mis-decodes,
    // producing truncated buffers and `RangeError: Length out of range of buffer`
    // inside Yjs's varint reader, followed by WS disconnect loops.
    //
    // Negotiating compression at the origin forces end-to-end deflate (Node's
    // zlib ↔ browser) and prevents the proxy from inserting its own layer.
    // `noContextTakeover` on both sides keeps each frame independently
    // decompressible, which avoids cross-frame state bugs that have historically
    // hit Safari.
    await fastify.register(websocket, {
        options: {
            perMessageDeflate: {
                threshold: 1024,
                serverNoContextTakeover: true,
                clientNoContextTakeover: true,
            },
        },
    });
    // Multipart support for file uploads (images, assets)
    await fastify.register(multipart, { limits: { fileSize: 10 * 1024 * 1024 } });
    // Global rate-limit registration with a permissive default; individual
    // routes opt into tighter limits via `config: { rateLimit: ... }`. Today
    // only comment mutations apply tight limits — keeps the runtime in
    // place without changing the rest of the API's request-rate behaviour.
    await fastify.register(rateLimit, {
        global: false,
        max: 1000,
        timeWindow: '1 minute',
        keyGenerator: (req) => {
            const u = req.user;
            return u?.id ? `u:${u.id}` : (req.ip ?? 'unknown');
        },
    });
    fastify.addContentTypeParser('application/json', { parseAs: 'buffer' }, (req, body, done) => {
        try {
            req.rawBody = body;
            done(null, JSON.parse(body.toString()));
        }
        catch (err) {
            done(err, undefined);
        }
    });
    // application/x-www-form-urlencoded — the OAuth token + consent POSTs use
    // form encoding (RFC 6749). Parsed inline to avoid pulling in a dependency
    // just for two endpoints; mirrors the hand-rolled JSON parser above.
    fastify.addContentTypeParser('application/x-www-form-urlencoded', { parseAs: 'string' }, (_req, body, done) => {
        try {
            const params = new URLSearchParams(body);
            const obj = {};
            for (const [k, v] of params)
                obj[k] = v;
            done(null, obj);
        }
        catch (err) {
            done(err, undefined);
        }
    });
    // Git Smart HTTP binary content types — pass raw stream through (no parsing)
    for (const gitType of [
        'application/x-git-upload-pack-request',
        'application/x-git-receive-pack-request',
    ]) {
        fastify.addContentTypeParser(gitType, (_req, _payload, done) => {
            done(null); // Don't consume the body — the route handler pipes req.raw directly
        });
    }
    // Initialize auth service, WebSocket hub, session manager, and collab server.
    // Phase 8: when the boot path provided master-key options, hand them through
    // so OAuth tokens + per-user secret prefs use the encrypted column path.
    const authService = new AuthService(config.workspacePath, config.authCryptoOptions);
    // Ensure all users have their personal "My Folder" folder
    await ensureAllPersonalFolders(authService, config.workspacePath, config.workspaceManager);
    const { getCurrentVersion } = await import('../utils/version-checker.js');
    const wsHub = new WsHub(getCurrentVersion());
    const commentService = new CommentService(config.workspacePath);
    const chatSessionService = new ChatSessionService(config.workspacePath);
    const shareLinkService = new ShareLinkService(config.workspacePath);
    const notificationService = new NotificationService(config.workspacePath);
    const sessionManager = new SessionManager(config.workspaceManager, wsHub);
    // Recover any dirty repos before accepting requests. Replays persisted
    // pending commits from a previous process AND sweeps every working tree
    // for uncommitted state. Catches anything dropped by a crash, OOM, or
    // hard restart. Logs and continues on failure — recovery is best-effort
    // and must never block server startup.
    try {
        await sessionManager.recoverOnBoot();
    }
    catch (err) {
        console.warn('[server] sessionManager.recoverOnBoot failed:', err.message);
    }
    // Live asset-index drain: captions new image uploads and re-extracts document
    // sidecars while the server runs, instead of only at boot (a tenant can run
    // for weeks between deploys). Config (model/key/gate/repos) is resolved fresh
    // each drain so a Settings change takes effect without a restart. Web upload
    // + MCP finalize call `enqueue()` after the asset entity exists.
    const assetIndexQueue = new AssetIndexQueue({
        workspaceManager: config.workspaceManager,
        resolveConfig: () => {
            const userConfig = loadUserConfig();
            // Read the LIVE workspace config from the WorkspaceManager, NOT the boot
            // snapshot (config.workspaceConfig). reloadConfig() refreshes both the
            // repo list (so a folder auto-created at runtime — e.g. the shared
            // "Assets" store on first inline/presign upload — is visible to the
            // sweep) and the captioning gate (so toggling it in workspace.json takes
            // effect without a restart). Model/key already resolve store-first.
            const wsConfig = config.workspaceManager.getConfig();
            const apiKey = resolveApiKey(userConfig, wsConfig);
            return {
                repos: config.workspaceManager.getAllRepoConfigs(),
                captionConfig: apiKey
                    ? { provider: resolveProvider(userConfig, wsConfig), modelId: resolveModelId(userConfig, wsConfig), apiKey }
                    : null,
                captioningEnabled: wsConfig.captioning?.enabled ?? true,
            };
        },
        log: (msg) => console.warn(`[asset-index] ${msg}`),
    });
    assetIndexQueue.start();
    // Single chokepoint: VectorService fires onAssetPending exactly when a fresh
    // indexable IMAGE assets.db row is seeded `pending` (every image-upload path
    // funnels through ensureInitialAssetIndexState — import, manager/editor
    // upload, presign finalize). Registering here replaces per-call-site enqueue
    // wiring and keys the queue on the immutable assetId.
    config.workspaceManager.vectorService?.setOnAssetPending((assetId) => assetIndexQueue.enqueue(assetId));
    // Boot recovery: drain anything a prior process left queued AND re-derive the
    // full unfinished set in LanceDB (legacy assets, a mid-flight crash). Fire-and-
    // forget — it can take minutes on a large gallery and must not block listen().
    void assetIndexQueue.recoverOnBoot().catch((err) => {
        console.warn('[asset-index] recoverOnBoot failed (non-fatal):', err instanceof Error ? err.message : err);
    });
    // Persistence layer: durable Y.Doc snapshot store across server restarts.
    // Lives at `<workspace>/.studiograph/yjs-state/` — gitignored binary state
    // separate from the .md files (which remain canonical for git, MCP, agents).
    // See yjs-persistence.ts for the file format and concurrency model.
    const yjsStateDir = join(config.workspacePath, '.studiograph', 'yjs-state');
    // Single-instance lock. Throws if another studiograph server is already
    // running against this workspace. We don't support horizontal scaling today
    // (no shared Yjs pubsub), so the safe default is to fail fast on misuse
    // rather than silently corrupt snapshots via concurrent writers.
    const releaseYjsLock = acquireYjsStateLock(yjsStateDir);
    const yjsPersistence = new YjsPersistence(yjsStateDir);
    const yjsManager = new YjsManager(config.workspaceManager, yjsPersistence);
    // Yjs configuration startup log. Surfacing the flags at boot lets operators
    // verify the configuration without reading source. The fail-closed guards
    // (`assertWritable`, 412 convergence gate) prevent silent data corruption
    // regardless of how the flags are set, but `broadcast=off` while clients
    // are connected degrades UX, so we warn explicitly.
    const broadcastEnabled = process.env.STUDIOGRAPH_YJS_BROADCAST !== '0';
    const allowDegradedWrites = process.env.STUDIOGRAPH_ALLOW_DEGRADED_WRITES === '1';
    console.log(`[yjs] config: broadcast=${broadcastEnabled ? 'on' : 'OFF'} ` +
        `allow-degraded-writes=${allowDegradedWrites ? 'on' : 'off'}`);
    if (!broadcastEnabled && !allowDegradedWrites) {
        console.warn('[yjs] STUDIOGRAPH_YJS_BROADCAST=0 without STUDIOGRAPH_ALLOW_DEGRADED_WRITES=1: ' +
            'server-side body writes will REFUSE while clients are connected. ' +
            'This is the fail-closed kill-switch path — intentional during incident response, ' +
            'misconfiguration otherwise.');
    }
    // Wire the Yjs persistence layer to follow structural .md mutations.
    // delete / rename / changeType all change a doc's identity (path on disk
    // and its docName), so the snapshot file has to move with it. Without
    // these hooks, snapshots leak (orphaned files for deleted entities) or
    // serve stale state at the new identity (ghost content after a rename).
    config.workspaceManager.setPersistenceCallbacks({
        onDelete: (repo, entityType, entityId) => yjsPersistence.delete(`${repo}/${entityType}/${entityId}`),
        onRename: (repo, entityType, oldId, newId) => yjsPersistence.rename(`${repo}/${entityType}/${oldId}`, `${repo}/${entityType}/${newId}`),
        onChangeType: (repo, entityId, fromType, toType) => yjsPersistence.rename(`${repo}/${fromType}/${entityId}`, `${repo}/${toType}/${entityId}`),
    });
    // Body-write coordinator owns the single critical section for every body
    // write (autosave PUT + MCP / agent / event-processor server-writes). Holds
    // the per-doc lock, base-hash precondition, atomic disk write, commit +
    // dirty recovery, post-commit applyAgentContent broadcast, and
    // mdRevisionHash bookkeeping. Replaces the previous nine-callback shape on
    // graph.update.
    const bodyWriteCoordinator = new BodyWriteCoordinator({
        // Narrow commit hook — keeps the coordinator from reaching back
        // through WorkspaceManager. No-op when the repo has no GitService.
        commit: async (coords, actor, message) => {
            const gitService = config.workspaceManager.getGraph(coords.repo).getGitService();
            if (!gitService)
                return;
            const tx = gitService.createTransaction();
            tx.add({
                operation: 'update',
                entityType: coords.entityType,
                entityId: coords.entityId,
                filePath: coords.entityPath,
            });
            await tx.commit(actor, message);
        },
    }, yjsManager, sessionManager);
    config.workspaceManager.setBodyWriteCoordinator(bodyWriteCoordinator);
    // Register auth API routes (before the auth hook so they are always accessible)
    await registerAuthApiRoutes(fastify, authService, config.workspacePath, config.workspaceManager, config.workspaceConfig.team_name);
    // Auth hook — applied to all routes
    const authHook = createAuthHook(authService, config.workspaceConfig);
    fastify.addHook('preHandler', authHook);
    // Register WebSocket route (real-time change notifications + cursor broadcasting)
    await registerWsRoute(fastify, wsHub, authService, config.workspaceManager, yjsManager);
    // Register graph API routes (with per-folder access filtering)
    await registerGraphApiRoutes(fastify, config.workspaceManager, authService, wsHub, sessionManager);
    // Register workspace API routes (git operations)
    await registerWorkspaceApiRoutes(fastify, config.workspaceManager, config.gitUser, authService);
    // Register health endpoints (per-repo dirty status)
    await registerHealthApiRoutes(fastify, config.workspaceManager, sessionManager);
    // Register config + workspace data API routes (for `studiograph push/pull --remote`)
    await registerConfigApiRoutes(fastify, config.workspaceManager, config.workspacePath, authService);
    // Register Phase 7 settings/secrets/audit API routes. Owner-only via
    // isWorkspaceOwner() inside each handler.
    await registerSettingsApiRoutes(fastify, authService);
    await registerSecretsApiRoutes(fastify, authService);
    await registerAuditApiRoutes(fastify, authService);
    // Register comments API + notifications feed (Comments-First Phase 1).
    // The getAgent closure resolves the orchestrator lazily — it's
    // initialised below at line ~613, AFTER routes register, so route
    // handlers can't capture it eagerly. The closure binding to the outer
    // `agent` variable picks up the value once the orchestrator is alive.
    await registerCommentsApiRoutes(fastify, commentService, notificationService, authService, wsHub, config.workspaceManager, () => agent);
    // Register chat-session persistence routes (cross-device agent history).
    // `() => agent` defers the orchestrator handle (assigned below) so the scratch
    // cleanup can also evict the agent-pool session JSON on chat deletion.
    await registerChatSessionsApiRoutes(fastify, chatSessionService, config.workspaceManager, authService, wsHub, () => agent);
    // Register permissions API routes (self-hosted folder access)
    await registerPermissionsApiRoutes(fastify, authService, config.workspaceManager, wsHub);
    // Messages API routes registered after agent init (below) so @agent invocation works
    // Register connectors API routes (MCP connector management + OAuth)
    await registerConnectorsApiRoutes(fastify, authService, config.workspacePath);
    // Register OAuth 2.0 authorization-server routes (Claude Chat/Cowork connect)
    await registerOAuthApiRoutes(fastify, authService, config.workspaceConfig);
    // Register asset upload routes (image upload to R2/local)
    await registerAssetApiRoutes(fastify, config.workspaceManager, authService);
    // Register public share-link routes (mint authed; read + asset public)
    await registerShareApiRoutes(fastify, config.workspaceManager, authService, shareLinkService);
    // Register authenticated artifact export routes.
    await registerArtifactExportApiRoutes(fastify, config.workspaceManager, authService);
    // Register R2 management routes (bucket creation, local-asset sync, status)
    await registerR2ApiRoutes(fastify, config.workspaceManager, authService, config.gitUser);
    // Register git Smart HTTP routes (clone/fetch/push with Basic Auth)
    await registerGitHttpRoutes(fastify, config.workspaceManager, authService, config.workspacePath, wsHub);
    // Register views API routes (per-user saved views)
    await registerViewsApiRoutes(fastify, authService, config.workspaceManager);
    // Register insights + preferences API routes
    await registerInsightsApiRoutes(fastify, config.workspaceManager, authService, config.workspacePath, wsHub, chatSessionService);
    // Register event ingest API (meeting capture from desktop and external sources)
    await registerEventIngestRoutes(fastify, config.workspaceManager, authService, wsHub, yjsManager);
    // Register MCP Streamable HTTP endpoint
    await registerMcpRoutes(fastify, config.workspaceManager, authService, wsHub, commentService);
    // Initialize memory service and agent — non-fatal so the server can still serve graph API
    const memoryService = new MemoryService();
    let agent = null;
    try {
        const recentMemory = memoryService.loadRecentMemory();
        agent = new AgentOrchestrator({
            workspacePath: config.workspacePath,
            workspaceConfig: config.workspaceConfig,
            gitUser: config.gitUser,
            recentMemory,
            workspaceManager: config.workspaceManager,
            // Share the route-level AuthService. Without this the orchestrator
            // constructs its own crypto-less copy and (a) errors when reading
            // encrypted oauth_tokens rows, (b) subscribes to a different
            // pubsub than the one the OAuth callback fires on — so per-user
            // OAuth state changes never invalidate the agent pool.
            authService,
            // Lets the agent's generic read tools resolve the virtual `comment`
            // entity type, and lets the chat route fold an entry's comments into
            // context (see registerChatRoutes below).
            commentService,
            // Lets the agent's `save_file_to_assets` tool promote a conversation
            // scratch image into the curated asset library.
            chatSessionService,
        });
        await registerChatRoutes(fastify, agent, wsHub, authService, memoryService, commentService, chatSessionService);
        await registerCaptureRoutes(fastify, agent);
        await registerUrlRoutes(fastify);
    }
    catch (err) {
        fastify.log.warn(`Agent not available: ${err.message ?? err} — chat routes will return setup message`);
        // Register fallback chat routes that return a friendly setup message
        fastify.post('/api/chat/stream', async (_req, reply) => {
            reply.raw.setHeader('Content-Type', 'text/event-stream');
            reply.raw.setHeader('Cache-Control', 'no-cache');
            reply.raw.setHeader('Connection', 'keep-alive');
            reply.raw.flushHeaders();
            reply.raw.write(`data: ${JSON.stringify({ type: 'token', text: 'The AI agent is not configured yet. Please set the API key for your model provider in the server environment variables and restart.' })}\n\n`);
            reply.raw.write(`data: ${JSON.stringify({ type: 'done' })}\n\n`);
            reply.raw.end();
        });
        fastify.post('/api/chat', async (_req, reply) => {
            return reply.status(503).send({ error: 'The AI agent is not configured yet. Please set the API key for your model provider in the server environment variables and restart.' });
        });
    }
    // GET /api/workspace — workspace metadata
    fastify.get('/api/workspace', async (_req, reply) => {
        const workspace = new Workspace(config.workspacePath);
        const about = workspace.loadAbout();
        return reply.send({ name: config.workspaceConfig.team_name, about });
    });
    // GET /api/server-info — identity probe for the stdio MCP proxy.
    // Authed by the global middleware (not in the exempt list), so a 401/403 here
    // tells the proxy "server present, token rejected" → fail loud, never write
    // locally. The serverId + workspacePath let the proxy confirm it is talking
    // to the server that owns this exact workspace before forwarding tool calls.
    fastify.get('/api/server-info', async (_req, reply) => {
        return reply.send({
            serverId: config.serverId ?? null,
            workspacePath: config.workspacePath,
            name: config.workspaceConfig.team_name,
        });
    });
    // PUT /api/workspace — update workspace identity (owner only)
    fastify.put('/api/workspace', async (req, reply) => {
        const user = req.user;
        if (user && !isWorkspaceOwner(user.role)) {
            return reply.status(403).send({ error: 'Workspace owner access required' });
        }
        const { name, about } = req.body ?? {};
        const workspace = new Workspace(config.workspacePath);
        if (typeof name === 'string' && name.trim()) {
            config.workspaceConfig.team_name = name.trim();
            workspace.writeConfig(config.workspaceConfig);
        }
        if (typeof about === 'string') {
            workspace.writeAbout(about);
        }
        return reply.send({ success: true });
    });
    // GET /api/workspace/mcp-config — MCP client configuration snippet.
    // Returns a template with `<your-token-here>` in the Authorization header;
    // the frontend fills it in after the user mints a personal token via
    // POST /api/auth/tokens. We never embed a shared key here because doing so
    // would leak a workspace-wide credential and re-introduce the bug the
    // per-user-token model exists to solve.
    fastify.get('/api/workspace/mcp-config', async (req, reply) => {
        // Prefer the pinned canonical URL so the connect URL shown in Settings
        // matches the OAuth issuer/resource. Fall back to the request origin when
        // none is configured (local dev / unpinned), rather than failing closed —
        // this endpoint is informational, not a security boundary.
        let baseUrl;
        try {
            baseUrl = resolveCanonicalBaseUrl(config.workspaceConfig, req);
        }
        catch {
            const proto = req.headers['x-forwarded-proto'] === 'https' ? 'https' : (req.protocol ?? 'http');
            const host = req.headers['x-forwarded-host'] || req.headers.host || 'localhost';
            baseUrl = `${proto}://${host}`;
        }
        const mcpConfig = {
            mcpServers: {
                studiograph: {
                    type: 'streamable-http',
                    url: `${baseUrl}/mcp`,
                    headers: {
                        Authorization: 'Bearer <your-token-here>',
                    },
                },
            },
        };
        return reply.send({ config: mcpConfig, url: `${baseUrl}/mcp` });
    });
    // DELETE /api/memory — clear all agent memory files
    fastify.delete('/api/memory', async (_req, reply) => {
        const count = memoryService.clear();
        return reply.send({ cleared: count });
    });
    // DELETE /api/sessions — clear all saved agent sessions
    fastify.delete('/api/sessions', async (_req, reply) => {
        const count = agent?.clearSessions() ?? 0;
        return reply.send({ cleared: count });
    });
    // Serve web UI static files from dist/web/ with SPA fallback
    // When running via tsx from source, import.meta.dirname is src/server/ so ../web doesn't exist.
    // Fall back to dist/web/ relative to project root.
    const webDirCandidate = join(import.meta.dirname, '..', 'web');
    const webDir = existsSync(webDirCandidate) ? webDirCandidate : join(import.meta.dirname, '..', '..', 'dist', 'web');
    if (existsSync(webDir)) {
        const indexPath = join(webDir, 'index.html');
        // SPA fallback: if any API route returns a JSON error for a browser GET,
        // serve the SPA instead so client-side routing can handle the URL.
        // Read fresh from disk each time so rebuilds take effect without server restart.
        const readIndexHtml = () => readFileSync(indexPath);
        /**
         * Cache policy for static assets:
         * - `/_app/immutable/*` are content-hashed by SvelteKit, so the URL
         *   changes every time the file does — safe to cache forever.
         * - `index.html` (and the SPA fallback) MUST revalidate every load,
         *   otherwise stale HTML keeps pointing at expired immutable hashes.
         * - Other root assets (favicons, og images, etc.) get a modest TTL.
         *
         * Without these headers the upstream CDN (Fastly in front of Railway)
         * forwards almost every request to origin, producing multi-second
         * TTFB on cold edges and a 3-5s "black screen" while the SPA bundle
         * loads.
         */
        function setStaticCacheHeader(reply, urlPath) {
            if (urlPath.startsWith('/_app/immutable/')) {
                reply.header('Cache-Control', 'public, max-age=31536000, immutable');
            }
            else if (urlPath === '/' || urlPath === '/index.html' || !extname(urlPath)) {
                reply.header('Cache-Control', 'no-cache');
            }
            else {
                reply.header('Cache-Control', 'public, max-age=3600');
            }
        }
        fastify.addHook('onSend', async (request, reply, payload) => {
            if (reply.sent)
                return payload;
            const ct = reply.getHeader('content-type');
            if (request.method === 'GET' &&
                reply.statusCode >= 400 &&
                request.headers.accept?.includes('text/html') &&
                typeof ct === 'string' && ct.includes('application/json')) {
                reply.removeHeader('content-length');
                reply.code(200).type('text/html');
                reply.header('Cache-Control', 'no-cache');
                return readIndexHtml();
            }
            return payload;
        });
        // Use setNotFoundHandler so API routes take priority over SPA fallback
        fastify.setNotFoundHandler(async (req, reply) => {
            const urlPath = req.url.split('?')[0];
            const fullPath = resolveStaticFilePath(webDir, urlPath);
            if (!fullPath) {
                return reply.status(400).send({ error: 'Invalid path' });
            }
            // Serve the file if it exists (and is a file, not directory)
            if (existsSync(fullPath) && statSync(fullPath).isFile()) {
                const ext = extname(fullPath).toLowerCase();
                const contentType = MIME_TYPES[ext] || 'application/octet-stream';
                reply.type(contentType);
                setStaticCacheHeader(reply, urlPath);
                return reply.send(createReadStream(fullPath));
            }
            // SPA fallback — serve index.html for client-side routing.
            // Only for GET requests — POST/PUT/DELETE to unknown routes should return 404 JSON.
            // Skip for paths that look like static assets (have a file extension like .js, .css, etc.)
            // to avoid returning HTML when the browser expects JS/CSS, which would cause silent errors.
            const hasFileExt = extname(urlPath).length > 1;
            if (req.method === 'GET' && !hasFileExt && existsSync(indexPath)) {
                reply.type('text/html');
                reply.header('Cache-Control', 'no-cache');
                return reply.send(createReadStream(indexPath));
            }
            return reply.status(404).send({ error: 'Not found' });
        });
        fastify.log.info('Web UI enabled — serving from dist/web/');
    }
    // Graceful shutdown — sessionManager.shutdown() flushes pending commits
    // BEFORE tearing down the timer (the old destroy()-only path could lose
    // dirty sessions if called without an explicit flush).
    fastify.addHook('onClose', async () => {
        await sessionManager.shutdown();
        await assetIndexQueue.shutdown();
        await yjsManager.destroy();
        wsHub.destroy();
        releaseYjsLock();
    });
    return fastify;
}
//# sourceMappingURL=index.js.map