Source: http-server.js

/**
 * @module http-server
 * @description Express.js HTTP server configuration and setup
 *
 * This module creates and configures the main Express application with:
 * - CORS configuration for allowed domains
 * - Authentication middleware setup
 * - API route mounting for different modules
 * - Static file serving
 * - Error handling middleware
 * - Compression and security headers
 *
 * The server handles REST API requests for the Commtool Members system
 * including organization management, user authentication, and data operations.
 */

// @ts-check
/**
 * @import {ExpressRequestAuthorized, ExpressResponse} from './types.js'
 */
import express from 'express';
import cors from 'cors';
import compression from 'compression';
import {readLogger, errorLoggerRead} from './utils/requestLogger.js'
import {checkRoot, setOrgaRoot} from './utils/organizationUtils.js'
import { loadOrganizationDomains, createCorsOriginChecker } from '@commtool/shared-auth';
import { query, UUID2hex } from '@commtool/sql-query';
import { crons } from './cron/cron.js';
import errorMiddleware from './errorMiddleware.js';



// Keep merged Secrets, if they are needed elsewhere
export let mergedSecrets



export async function createApp() {
    console.log('[http-server] createApp CALLED');
    const app = express();

    app.set('trust proxy', 1); // trust first proxy

    app.use(express.urlencoded({ extended: false, limit: '200mb' }));
    app.use(express.json({  strict: false, limit: '200mb' }));

    // Load organization domains from Vault for CORS
    const appBaseDomain = process.env.APP_BASE || 'commtool.org';
    const domainOrgMap = await loadOrganizationDomains(appBaseDomain);
    
    // Legacy domains to support
    const legacyDomains = ['adbmv.de', 'kpe.de'];
    
    var corsOptions = {
        origin: createCorsOriginChecker({
            domainOrgMap,
            additionalDomains: legacyDomains,
            builtInDomains: [appBaseDomain, ...legacyDomains]
        }),
        methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE", "OPTIONS"],
        // `If-None-Match` ist kein safelisteter Request-Header: ohne Eintrag hier
        // blockiert der Preflight das konditionale GET, mit dem der Client einen
        // unveraenderten Stand als 304 erkennt. `ETag` muss exponiert sein, weil der
        // Browser die Antwort sonst nicht an `response.headers` durchreicht und der
        // Client die Version nie zu sehen bekaeme.
        allowedHeaders: ['Content-Type', 'Authorization', 'X-Requested-With', 'Accept', 'Origin', 'Cache-Control', 'X-Organization', 'credentials', 'X-Fake-User', 'X-UID-User', 'If-None-Match'],
        exposedHeaders: ['X-Organization', 'ETag'],
        credentials: true,
    };
    
    // @ts-ignore
    app.use(cors(corsOptions));
    app.use(/** @type {any} */ (compression()));
    
    // Dynamic import to ensure it loads after secrets are available
    const {configureAuth, makeAuthCheck, createAuthRouter, authStatusEndpoint, registerRedirectUri} = await import('@commtool/shared-auth');
    
    // Configure authentication AFTER CORS is set up
    const authConfig = await configureAuth(app, {
        secretsPath: process.env.LOGIN_SECRET_PATH, 
        appSecretsPath: process.env.GIT_SECRET_PATH
    });
    mergedSecrets = authConfig.appVaultSecrets;
    console.log(mergedSecrets)
    
    // Register redirect URI for OIDC login
    const appUrl = process.env.APP_URL || mergedSecrets.appUrl;
    if (appUrl) {
        try {
            await registerRedirectUri(appUrl, authConfig);
            console.log(`[http-server] ✅ Redirect URI registered for: ${appUrl}`);
        } catch (error) {
            console.warn(`[http-server] ⚠️  Failed to register redirect URI:`, error.message);
        }
    }
    const {default: member} = await import('./Router/extraApi.js');
    const {default: event} = await import('./RouterEvents/eventApi.js');
    const {default: location} = await import('./RouterLocation/locationApi.js');
    const {default: project} = await import('./RouterProject/projectApi.js');
    const {default: runners} = await import('./RouterRunner/runners.js');
    const {default: runnerV1} = await import('./RouterRunner/runnerV1.js');
    const {default: tailnets} = await import('./RouterTailnet/tailnets.js');
    const {filesPublic} = await import('./Router/files.js');
    const {default: docu} = await import('./combineAPIDocs.js');
    const {default: configFiles} = await import('./Router/apiConfigFile.js');
    const {default: addons} = await import('./RouterAddons/addons.js');
    const {default: visibilityProxy} = await import('./RouterAddons/visibilityProxy.js');
 
    
    const authCheckPortal = (req,res,next)=> {
        const check = makeAuthCheck({ // No roles required for portal access, just authentication
        user: [],
        bot: [],
        employee: [] 
        });
        return check(req,res,next)
    } 
    const authCheck = makeAuthCheck({
        user: ['db-user', 'db-admin'],  // User braucht eine dieser Org-Rollen
        bot: [],                        // Bot braucht keine Org-Rollen, nur app-bott gruppe
        employee: []                     // Employee braucht keine Org-Rollen
    })
   
    // Define routes AFTER auth middleware is set up
    // authCheckBot runs first to handle Bearer tokens, then authCheck for regular auth

    // Portal-accessible org root avatar — must be registered BEFORE /api/member/* (which requires db-user)
    // Only allows avatar/avatar_white/avatar_black of the session root org, not arbitrary member files
    const {getFileWithPrefix, getPublicRailFiles, getPrivateRailFiles} = await import('./Router/files/controller.js');
    const {getLanguageFileController} = await import('./Router/languageFile/controller.js');

    // Language files for portal (no org role required, just authentication)
    app.get(
        '/api/member/languages/:app/:filename',
        authCheckPortal,
        /** @type {any} */ (getLanguageFileController)
    );
    app.get(
        '/api/kpe20/languages/:app/:filename',
        authCheckPortal,
        /** @type {any} */ (getLanguageFileController)
    );

    // Rail files for portal (public app rail files + org-scoped private rail files)
    // `:app` is constrained to known app names so UUID rail paths fall through to the
    // private rail routes below / in the member router (otherwise every /rail/* request
    // would be treated as a public app rail).
    app.get(
        `/api/member/files/rail/:app(${process.env.apiApps})/:side/:filename`,
        authCheckPortal,
        /** @type {any} */ (getPublicRailFiles)
    );
    app.get(
        `/api/kpe20/files/rail/:app(${process.env.apiApps})/:side/:filename`,
        authCheckPortal,
        /** @type {any} */ (getPublicRailFiles)
    );
    app.get(
        '/api/member/files/rail/:UID/:side/:filename',
        authCheckPortal,
        /** @type {any} */ (async (req, res) => {
            const authReq = /** @type {ExpressRequestAuthorized} */ (/** @type {unknown} */ (req));
            const orgUID = req.headers['x-organization'] || authReq.user?.organization || authReq.user?.loginOrgaUID;
            if (!orgUID || req.params.UID !== orgUID) {
                return res.status(403).json({ success: false, message: 'not accessible' });
            }
            return getPrivateRailFiles(req, res);
        })
    );
    app.get(
        '/api/member/files/:UID/:prefix(avatar|avatar_white|avatar_black)/:filename',
        authCheckPortal,
        /** @type {any} */ (async (req, res) => {
            const authReq = /** @type {ExpressRequestAuthorized} */ (/** @type {unknown} */ (req));
            // Org context: prefer x-organization header (set by service worker), fall back to token claim
            const orgUID = req.headers['x-organization'] || authReq.user?.organization || authReq.user?.loginOrgaUID;
            if (!orgUID || req.params.UID !== orgUID) {
                return res.status(403).json({ success: false, message: 'not accessible' });
            }
            return getFileWithPrefix(req, res);
        })
    );

    /**
     * @route /api/kpe20/*
     * @group KPE20 API
     * @description legacy KPE20 organization and member management API endpoints
     * Requires authentication with 'bot' or 'user' role
     */
    // @ts-ignore
    app.use("/api/kpe20/",  authCheck, member);

    /**
     * @route /api/member/*
     * @group Member API
     * @description new member organization and member management API endpoints
     * Requires authentication with 'bot' or 'user' role
     */
    // @ts-ignore
    app.use("/api/member/",  authCheck, member);
    /**
     * @route /api/event/*
     * @group Event API
     * @description Event management and scheduling API endpoints
     * Requires authentication with 'bot' or 'user' role
     */
    // @ts-ignore
    app.use("/api/event/",  authCheck, event);

    /**
     * @route /api/location/*
     * @group Location API
     * @description Location and venue management API endpoints
     * Requires authentication with 'bot' or 'user' role
     */
    // @ts-ignore
    app.use("/api/location/", authCheck, location);

    /**
     * @route /api/project/*
     * @group Project API
     * @description Project and project-share management (repositories, directories)
     * Requires authentication with 'bot' or 'user' role
     */
    // @ts-ignore
    app.use("/api/project/", authCheck, project);

    /**
     * @route /api/runners/*
     * @group Runner API (user)
     * @description Runner registration and listing (Topologie) — Soll: keine
     *   deployment-/workspace-/lease-Fläche mehr (liegt beim Zusteller/ide-server).
     */
    // @ts-ignore
    app.use("/api/runners", authCheck, runners);

    /**
     * @route /api/tailnets/*
     * @group Tailnet API (user)
     * @description Tailnet network management (per team/group, multiple nets per group)
     */
    // @ts-ignore
    app.use("/api/tailnets", authCheck, tailnets);

    /**
     * @route /api/runner/v1/*
     * @group Runner API (connector)
     * @description Topologie: register/exchange + heartbeat — runner credential auth (not user session)
     */
    // @ts-ignore
    app.use("/api/runner/v1", runnerV1);

    /**
     * @route /api/public/*
     * @group Public Files API
     * @description Public file serving endpoints (no authentication required)
     */
    // @ts-ignore
    app.use("/api/public", filesPublic);

    /**
     * @route /api/docu/*
     * @group Documentation API
     * @description API documentation and OpenAPI specification endpoints
     */
    // @ts-ignore
    app.use("/api/docu", docu);

    /**
     * @route /api/configfiles/*
     * @group Configuration Files API
     * @description Configuration file management endpoints (upload, download, list, delete)
     * Requires admin authentication
     */
    // @ts-ignore
    app.use("/api/configfiles", configFiles);

    // Addon CRUD API — Bot-registrierte UI-Addons
    // Auth: makeAuthCheck(['bot']) (wird im Router selbst geprüft)
    app.use("/api/addons", addons);

    /**
     * @route /api/registry/*
     * @group Registry API
     * @description Runtime view of the app registry (host → org → app) for
     * brokers, backends and bots. Auth is enforced inside the router:
     * `bot` and `employee` only — organisation admins are deliberately
     * excluded, because /domains and /cors/origins span all tenants.
     */
    const {default: registry} = await import('./Router/registry/registryRouter.js');
    // @ts-ignore
    app.use("/api/registry", registry);

    // get the config of the current organisation
    /**
     * @route GET /api/orga/config/:app
     * @group Organization API
     * @description Get the organization configuration for the current session
     * This retrieves the organizational configuration based on the session root
     * @param {string} app - Application identifier (must match configured apps)
     * 
     * @example
     * GET /api/orga/config/member
     * Response: {"success": true, "result": {...organization config...}}
     * @returns {Object} Response object
     * @property {boolean} .success - Whether the config was successfully retrieved
     * @property {object} .result - Organization configuration data
     */
    // @ts-ignore
    app.get(`/api/orga/config/:app`,authCheckPortal,checkRoot,readLogger,async (req, res, next)=>
    {
        try
        {   
            const authReq = /** @type {ExpressRequestAuthorized} */ (/** @type {unknown} */ (req));
            const {user,config}=await setOrgaRoot(authReq.session.root,authReq)

            if(!config)
                return res.status(400).json({success:false,message:'no valid orga root found'})

            // Attach public org metadata (avatar, name) so clients don't need a
            // separate /member/group/:UID call (which requires db-user role)
            if (config.UID && !config.orgaData) {
                try {
                    const rows = await query(
                        `SELECT Member.Display, Member.Data
                         FROM ObjectBase
                         INNER JOIN Member ON Member.UID = ObjectBase.UID
                         WHERE ObjectBase.UID = ? AND ObjectBase.Type IN ('group', 'ggroup')`,
                        [UUID2hex(config.UID)],
                        { cast: ['json'] }
                    );
                    if (rows.length) {
                        const { avatar, avatar_white, name, shortName, country } = rows[0].Data || {};
                        config.orgaData = { avatar, avatar_white, name, shortName, country };
                    }
                } catch (orgaErr) {
                    errorLoggerRead(orgaErr);
                }
            }

            // Attach all supported apps so clients don't need a separate endpoint
            config.apiApps = (process.env.apiApps || 'db').split('|');

            return res.json({success:true,result:config})
        }
        catch(e)
        {
            errorLoggerRead(e)
        }
    })
    /**
     * @route GET /api/auth/status
     * @returns {object} Authentication status information
     */
    // @ts-ignore
    app.get("/api/auth/status", authStatusEndpoint);


    /**
     * @route GET /api/auth/user
     * @group Authentication API
     * @description Get current user information
     * Requires authentication with 'bot' or 'user' role
     * @returns {object} User information and permissions
     */
    // @ts-ignore
    app.get("/api/auth/user", authCheck, authStatusEndpoint);


    /**
     * @route /auth/*
     * @group Authentication Routes
     * @description User authentication routes (login, logout, callback)
     */
    // @ts-ignore
    app.use("/auth", createAuthRouter({
        redirectAfterLogout: '/',
        appSecrets: mergedSecrets
    }));
    /**
     * @route GET /
     * @group Root Route
     * @description Welcome message at the root endpoint
     * @returns {object} Welcome message
     * can be used to check if the API is reachable (Health Check)
     */
    // @ts-ignore
    app.get("/", (req, res) => {
        res.json({ message: "Welcome Commtool Members Api" });
    });

    /**
     * @route GET /api/branding
     * @group Branding API
     * @description Get branding configuration for the current organization
     * @param {string} orgUID - Organization identifier
     * @returns {object} Branding configuration including favicon URL and app title
     */
    // @ts-ignore
    app.get("/api/branding", async (req, res) => {
        try {
            const { orgUID } = req.query;
            
            if (!orgUID) {
                return res.status(400).json({ error: 'orgUID parameter is required' });
            }

            // TODO: Load organization branding config from database/S3
            // const orgBranding = await loadOrgConfig(orgUID);
            // const { faviconUrl, appTitle } = orgBranding;
            
            const faviconUrl = null; // Placeholder
            const appTitle = null; // Placeholder

            if (!faviconUrl && !appTitle) {
                return res.status(404).json({ error: 'No branding configured for this organization' });
            }

            // Set cache headers for better performance
            res.set({
                'Cache-Control': 'public, max-age=3600', // Cache for 1 hour
                'Content-Type': 'application/json'
            });

            res.json({
                faviconUrl: faviconUrl || '/default-favicon.svg',
                appTitle: appTitle || 'App'
            });

        } catch (error) {
            console.error('Error loading branding:', error);
            res.status(500).json({ error: 'Internal server error' });
        }
    });

    // visibilityCheck-Proxy — leitet Bot-visibilityCheck-Pfade (z.B.
    // /project-bot/groups/{UID}/visibility) an den jeweiligen Bot-Host weiter.
    // Greift nur bei registrierten visibilityCheck-Präfixen; alle anderen
    // Pfade gehen unverändert an den Standard-404-Handler.
    app.use(visibilityProxy);

    // Zentraler Fehler-Handler. Bildet DB-Invariantenverstoesse auf 409 ab
    // (`memberA`-Trigger, Migration 20260925) und alles andere auf 500. Siehe
    // `src/errorMiddleware.js`.
    app.use(errorMiddleware);

    // Start crons (if they don't depend on the app being listened to yet)
    crons();
    // forEveryOrga(triggerQueue);

    console.log('[http-server] createApp RETURNING configured app');
    return app;
}