File

src/main.ts

Description

TLS configuration options for HTTPS server.

Index

Properties

Properties

ca
ca: Buffer
Type : Buffer
Optional
cert
cert: Buffer
Type : Buffer
key
key: Buffer
Type : Buffer
passphrase
passphrase: string
Type : string
Optional
import { existsSync, readFileSync, writeFileSync } from "node:fs";
import { RequestMethod, ValidationPipe } from "@nestjs/common";
import { ConfigService } from "@nestjs/config";
import { NestFactory } from "@nestjs/core";
import { NestExpressApplication } from "@nestjs/platform-express";
import {
    DocumentBuilder,
    type OpenAPIObject,
    SwaggerModule,
} from "@nestjs/swagger";
import { Logger } from "nestjs-pino";
import { cleanupOpenApiDoc } from "nestjs-zod";
import { AllExceptionsFilter } from "./all-exceptions.filter";
import { AppModule } from "./app.module";
import { ValidationErrorFilter } from "./shared/common/filters/validation-error.filter";
import otelSDK from "./tracing";

/**
 * Protocol routes excluded from the `/api` global prefix.
 * These are wallet-facing and infrastructure endpoints that must remain
 * at the root path for protocol compliance and discoverability.
 */
const PROTOCOL_ROUTE_EXCLUSIONS: { path: string; method: RequestMethod }[] = [
    // Infrastructure
    { path: "/", method: RequestMethod.GET },
    { path: "health", method: RequestMethod.ALL },
    // OAuth2 & Discovery
    { path: "oauth2/{*path}", method: RequestMethod.ALL },
    { path: ".well-known/{*path}", method: RequestMethod.ALL },
    // OID4VCI Protocol
    { path: "issuers/:tenantId/vci/{*path}", method: RequestMethod.ALL },
    { path: "issuers/:tenantId/authorize", method: RequestMethod.ALL },
    { path: "issuers/:tenantId/authorize/{*path}", method: RequestMethod.ALL },
    {
        path: "issuers/:tenantId/credentials-metadata/{*path}",
        method: RequestMethod.ALL,
    },
    { path: "issuers/:tenantId/chained-as/{*path}", method: RequestMethod.ALL },
    // OID4VP Protocol
    { path: "presentations/:sessionId/oid4vp", method: RequestMethod.ALL },
    {
        path: "presentations/:sessionId/oid4vp/{*path}",
        method: RequestMethod.ALL,
    },
    // Public Status & Trust Lists
    {
        path: "issuers/:tenantId/status-management/{*path}",
        method: RequestMethod.ALL,
    },
    { path: "issuers/:tenantId/trust-list/{*path}", method: RequestMethod.ALL },
    // Public Storage (credential images, logos)
    { path: "storage/:key", method: RequestMethod.GET },
];

/**
 * Filter an OpenAPI document to only include paths matching (or not matching)
 * a given prefix. Also prunes the tag list to only include used tags.
 */
function filterOpenApiPaths(
    document: OpenAPIObject,
    predicate: (path: string) => boolean,
): OpenAPIObject {
    const filteredPaths: OpenAPIObject["paths"] = {};
    const usedTags = new Set<string>();

    for (const [path, pathItem] of Object.entries(document.paths)) {
        if (predicate(path)) {
            filteredPaths[path] = pathItem;
            for (const operation of Object.values(
                pathItem as Record<string, any>,
            )) {
                if (operation?.tags) {
                    for (const tag of operation.tags) {
                        usedTags.add(tag);
                    }
                }
            }
        }
    }

    return {
        ...document,
        paths: filteredPaths,
        tags: document.tags?.filter((tag: { name: string }) =>
            usedTags.has(tag.name),
        ),
    };
}

/**
 * TLS configuration options for HTTPS server.
 */
interface TlsOptions {
    cert: Buffer;
    key: Buffer;
    ca?: Buffer;
    passphrase?: string;
}

/**
 * Load TLS options from certificate and key files.
 * Returns undefined if TLS is not enabled or files are not found.
 */
function loadTlsOptions(): TlsOptions | undefined {
    const tlsEnabled = process.env.TLS_ENABLED?.toLowerCase() === "true";
    if (!tlsEnabled) {
        return undefined;
    }

    const certPath = process.env.TLS_CERT_PATH;
    const keyPath = process.env.TLS_KEY_PATH;
    const caPath = process.env.TLS_CA_PATH;

    if (!certPath || !keyPath) {
        console.warn(
            "⚠️ TLS_ENABLED is true but TLS_CERT_PATH or TLS_KEY_PATH is not set. Falling back to HTTP.",
        );
        return undefined;
    }

    if (!existsSync(certPath)) {
        console.warn(
            `⚠️ TLS certificate file not found: ${certPath}. Falling back to HTTP.`,
        );
        return undefined;
    }

    if (!existsSync(keyPath)) {
        console.warn(
            `⚠️ TLS key file not found: ${keyPath}. Falling back to HTTP.`,
        );
        return undefined;
    }

    const options: TlsOptions = {
        cert: readFileSync(certPath),
        key: readFileSync(keyPath),
    };

    // Optional: Load CA certificate chain for client verification
    if (caPath && existsSync(caPath)) {
        options.ca = readFileSync(caPath);
    }

    // Optional: Passphrase for encrypted key files
    const passphrase = process.env.TLS_KEY_PASSPHRASE;
    if (passphrase) {
        options.passphrase = passphrase;
    }

    return options;
}

/**
 * Bootstrap function to initialize the NestJS application.
 */
async function bootstrap() {
    // Start OpenTelemetry SDK before NestJS initializes —
    // instrumentations must be registered before any framework code runs.
    await otelSDK.start();

    // Load TLS options if configured
    const tlsOptions = loadTlsOptions();
    const isTlsEnabled = tlsOptions !== undefined;

    const app = await NestFactory.create<NestExpressApplication>(AppModule, {
        bufferLogs: true,
        snapshot: true,
        httpsOptions: tlsOptions,
    });

    // Use Pino logger for all NestJS logging (including built-in Logger instances)
    // This ensures LOG_LEVEL env var is respected across all services
    app.useLogger(app.get(Logger));

    // Set explicit body size limits (security best practice)
    app.useBodyParser("json", { limit: "10mb" });
    app.enableCors();

    // Global route prefix: all management endpoints under /api/,
    // protocol endpoints (wallet-facing) stay at root for compliance
    app.setGlobalPrefix("api", { exclude: PROTOCOL_ROUTE_EXCLUSIONS });

    // Global exception filter for ValidationError
    app.useGlobalFilters(
        new ValidationErrorFilter(),
        new AllExceptionsFilter(),
    );

    app.useGlobalPipes(
        new ValidationPipe({
            transform: true, // required for discriminator instantiation
            whitelist: true,
            forbidUnknownValues: false, // avoid false positives on plain objects
            forbidNonWhitelisted: false,
            stopAtFirstError: false,
            validateCustomDecorators: true,
        }),
    );

    const configService = app.get(ConfigService);
    const publicUrl = configService.getOrThrow<string>("PUBLIC_URL");
    const useExternalOIDC = configService.get<string>("OIDC");

    // ── Management API OpenAPI config ────────────────────────────────
    const managementConfigBuilder = new DocumentBuilder()
        .setTitle("EUDIPLO Management API")
        .setDescription(
            "API for managing credentials, sessions, keys, and configurations. " +
                "All endpoints require OAuth2 authentication.",
        )
        .setExternalDoc(
            "Documentation",
            "https://openwallet-foundation-labs.github.io/eudiplo/latest/",
        )
        .setOpenAPIVersion("3.1.0")
        .setVersion(process.env.VERSION ?? "main");

    if (useExternalOIDC) {
        const oidcIssuerUrl = configService.get<string>(
            "OIDC_INTERNAL_ISSUER_URL",
        );
        if (oidcIssuerUrl) {
            managementConfigBuilder.addOAuth2(
                {
                    type: "openIdConnect",
                    openIdConnectUrl: `${oidcIssuerUrl}/.well-known/openid-configuration`,
                },
                "oauth2",
            );
        }
    } else if (publicUrl) {
        managementConfigBuilder.addOAuth2(
            {
                type: "oauth2",
                flows: {
                    clientCredentials: {
                        tokenUrl: `${publicUrl}/oauth2/token`,
                        scopes: {},
                    },
                },
            },
            "oauth2",
        );
    }

    const managementDocConfig = managementConfigBuilder.build();

    // ── Protocol API OpenAPI config ──────────────────────────────────
    const protocolDocConfig = new DocumentBuilder()
        .setTitle("EUDIPLO Protocol API")
        .setDescription(
            "Wallet-facing protocol endpoints for OID4VCI, OID4VP, and related standards. " +
                "These endpoints are public and secured at the protocol level (DPoP, Wallet Attestation, etc.).",
        )
        .setExternalDoc(
            "Documentation",
            "https://openwallet-foundation-labs.github.io/eudiplo/latest/",
        )
        .setOpenAPIVersion("3.1.0")
        .setVersion(process.env.VERSION ?? "main")
        .build();

    // ── Document factories ───────────────────────────────────────────
    const fullDocFactory = () =>
        cleanupOpenApiDoc(
            SwaggerModule.createDocument(app, managementDocConfig),
        );

    const managementDocFactory = () =>
        filterOpenApiPaths(fullDocFactory(), (path) =>
            path.startsWith("/api/"),
        );

    const protocolDocFactory = () =>
        filterOpenApiPaths(
            cleanupOpenApiDoc(
                SwaggerModule.createDocument(app, protocolDocConfig),
            ),
            (path) => !path.startsWith("/api/"),
        );

    if (process.env.DOC_GENERATE) {
        writeFileSync(
            "swagger-management.json",
            JSON.stringify(managementDocFactory(), null, 2),
        );
        writeFileSync(
            "swagger-protocol.json",
            JSON.stringify(protocolDocFactory(), null, 2),
        );
        process.exit();
    } else {
        const sharedSwaggerOptions = {
            swaggerOptions: {
                persistAuthorization: true,
                displayRequestDuration: true,
                filter: true,
                showExtensions: true,
                showCommonExtensions: true,
                tryItOutEnabled: true,
                deepLinking: true,
                displayOperationId: false,
                defaultModelsExpandDepth: 1,
                defaultModelExpandDepth: 1,
                docExpansion: "list",
                operationsSorter: "alpha",
                tagsSorter: "alpha",
            },
        };

        // Cache-control headers for Swagger UI assets
        for (const swaggerPath of ["/api/docs", "/docs"]) {
            app.use(swaggerPath, (_req, res, next) => {
                res.setHeader(
                    "Cache-Control",
                    "no-cache, no-store, must-revalidate",
                );
                res.setHeader("Pragma", "no-cache");
                res.setHeader("Expires", "0");
                next();
            });
        }

        SwaggerModule.setup("/api/docs", app, managementDocFactory, {
            ...sharedSwaggerOptions,
            customSiteTitle: "EUDIPLO Management API",
        });

        SwaggerModule.setup("/docs", app, protocolDocFactory, {
            ...sharedSwaggerOptions,
            customSiteTitle: "EUDIPLO Protocol API",
        });

        const logger =
            app.getHttpAdapter().getInstance().locals?.logger || console;
        const oidc = configService.get<string>("OIDC");

        await app.listen(process.env.PORT ?? 3000).then(() => {
            const port = process.env.PORT ?? 3000;
            const publicUrl = configService.get<string>("PUBLIC_URL");
            const version = process.env.VERSION ?? "main";
            const nodeEnv = process.env.NODE_ENV ?? "development";
            const protocol = isTlsEnabled ? "https" : "http";
            const baseUrl = publicUrl || `${protocol}://localhost:${port}`;

            logger.log("");
            logger.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            logger.log("🚀 EUDIPLO Service Started Successfully");
            logger.log("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━");
            logger.log(`📦 Version:        ${version}`);
            logger.log(`🌍 Environment:    ${nodeEnv}`);
            logger.log(`🔌 Port:           ${port}`);
            logger.log(
                `🔒 TLS:            ${isTlsEnabled ? "Enabled" : "Disabled (use reverse proxy for HTTPS)"}`,
            );
            logger.log(`🌐 Public URL:     ${publicUrl || "Not configured"}`);
            logger.log("");
            logger.log("📚 API Documentation:");
            logger.log(`   → Management:   ${baseUrl}/api/docs`);
            logger.log(`   → Protocol:     ${baseUrl}/docs`);
            logger.log(
                `   → Full Docs:    https://openwallet-foundation-labs.github.io/eudiplo/latest/`,
            );
            logger.log("");
            logger.log("🏥 Health Check:");
            logger.log(`   → Endpoint:     ${baseUrl}/health`);
            logger.log("");
            logger.log("🔐 Authentication:");
            if (oidc) {
                logger.log(`   → Mode:         External OIDC`);
                logger.log(`   → Provider:     ${oidc}`);
            } else {
                logger.log(
                    `   → Mode:         Integrated OAuth2 (Client Credentials)`,
                );
                logger.log(`   → Token URL:    ${publicUrl}/oauth2/token`);
            }
        });
    }
}
void bootstrap();

results matching ""

    No results matching ""