/**
* @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;
}