mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-11 08:43:48 +00:00
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
GitNexus Index / index (push) Has been cancelled
GitNexus Index / post-index (push) Has been cancelled
* 🪝 feat: Programmatic tool-approval hook seam (configurable beyond on/off) Adds a process-wide registry so host code can plug context-aware PreToolUse decision hooks into the tool-approval policy, composing with the static `endpoints.agents.toolApproval` config instead of replacing it. - `registerToolApprovalHook(factory, { matcher? })` — register a factory that builds a PreToolUse hook per run from a ToolApprovalHookContext (userId, conversationId, tenantId, appConfig); return undefined to opt the run out. Returns an unregister fn. - `buildHITLRunWiring(policy, context)` now registers the static-config policy hook as the baseline, then layers each resolved host hook after it. Decisions fold in the SDK as deny > ask > allow, so a host hook can only TIGHTEN a configured ask/deny — it can never silently auto-approve past policy (to loosen, change the static policy). updatedInput / allowedDecisions follow the SDK's last-writer-wins, so host hooks win over the baseline. - `createRun` threads the per-run context (user / conversation / tenant / appConfig) into the wiring; non-HITL and HITL-disabled runs never invoke any factory. This unlocks dynamic policy the static name-lists can't express — per-args (e.g. ask before write_file outside a workspace, the SDK's createWorkspacePolicyHook shape), per-agent, per-user. Inert until tool approval is enabled and the caller is hitlCapable. Tests: registry register/unregister/opt-out/order (hooks.spec.ts) + wiring composition, context passthrough, and disabled-path inertness (runtime.spec.ts). Full HITL suite green. * 🪝 feat: Config-driven tool-approval hook loader (librechat.yaml → hook modules) Lets operators declare programmatic tool-approval hooks in config instead of code, so the registerToolApprovalHook seam is usable without a custom build. - Config (data-provider): `endpoints.agents.toolApproval.hooks[]`, each entry `{ module, matcher?, options? }`. `module` is a bare package name or a path (resolved against the app root); its default export is a builder `(options?) => ToolApprovalHookFactory`. - Loader (@librechat/api `loadToolApprovalHooks`): imports each module, builds the factory with the entry's options, and registers it (with its optional tool-name matcher). Reload- safe (each call first unregisters its previous batch, leaving code-registered hooks alone) and robust — an unimportable module / non-function export / throwing builder is logged and skipped, never crashing startup or blocking the other hooks. Importer is injectable for tests. - Startup (api/server/index.js): loads the configured hooks once after appConfig resolves. SECURITY: modules are dynamically imported + executed in-process; this is admin-level config, documented as trusted-code-only. Tests: 9 loader cases (default/no-default export, options passthrough, bad-export skip, builder-returns-non-function skip, import-failure resilience, continue-past-bad-entry, reload de-dup). Full HITL suite green (80). * 💄 style: Sort imports in HITL hook spec files (CI sort-imports:check) * 🛡️ fix: Harden tool-approval hook loader (Codex review) Six P2 findings on the hook loader / startup wiring: - CJS/transpiled interop: unwrap a nested `default` (TS/Babel `exports.default = fn` surfaces through import() as `{ default: { default: fn } }`) before rejecting a module, so documented default-export hook modules actually load. - Validate the matcher regex at load time and skip invalid ones — the SDK compiles it with `new RegExp` at run-build time, where a bad pattern would throw out of buildHITLRunWiring and break EVERY HITL run instead of just skipping the one bad hook. - Honor the `enabled` kill switch: startup now passes hooks to the loader only when toolApproval is enabled, so a disabled endpoint imports/runs nothing (and unregisters any prior batch). - Resolve app-root-relative paths without a leading dot: a bare specifier that is a real file under basePath (e.g. `config/hooks/workspace.js`) resolves as a path; scoped/other bare names still import as packages. - Base-config-only: documented that hooks register once process-wide at startup and are NOT reloaded from per-role/user/tenant overrides — encode per-tenant logic inside the hook. - Wire the loader into the clustered startup path (api/server/experimental.js) too, not just the standard server. Tests: CJS-interop unwrap, invalid-matcher skip (+ sibling still loads), and specifier resolution (app-root file / bare package / ./relative). Full HITL suite green. * 🛡️ fix: Read tool-approval hooks from base config in clustered startup (Codex) The clustered experimental.js path read toolApproval from getAppConfig() (which merges DB __base__ overrides with no principal), so a DB override could enable/disable/replace toolApproval.hooks and import those modules in every worker — violating the base-config-only contract and diverging from the standard server. Fetch getAppConfig({ baseOnly: true }) specifically for the hook loader, matching api/server/index.js.
448 lines
16 KiB
JavaScript
448 lines
16 KiB
JavaScript
const telemetry = require('./telemetry');
|
|
const fs = require('fs');
|
|
const path = require('path');
|
|
require('module-alias')({ base: path.resolve(__dirname, '..') });
|
|
const cors = require('cors');
|
|
const axios = require('axios');
|
|
const express = require('express');
|
|
const passport = require('passport');
|
|
const compression = require('compression');
|
|
const cookieParser = require('cookie-parser');
|
|
const mongoSanitize = require('express-mongo-sanitize');
|
|
const { logger, runAsSystem } = require('@librechat/data-schemas');
|
|
const {
|
|
isEnabled,
|
|
apiNotFound,
|
|
createMetrics,
|
|
ErrorController,
|
|
memoryDiagnostics,
|
|
performStartupChecks,
|
|
handleJsonParseError,
|
|
GenerationJobManager,
|
|
QUERY_DEVTOOLS_HEADER,
|
|
createStreamServices,
|
|
initializeFileStorage,
|
|
initializeDeploymentSkills,
|
|
loadToolApprovalHooks,
|
|
maybeInjectQueryDevtoolsBootstrap,
|
|
preAuthTenantMiddleware,
|
|
setupGracefulShutdown,
|
|
updateInterfacePermissions,
|
|
} = require('@librechat/api');
|
|
const { connectDb, indexSync } = require('~/db');
|
|
const {
|
|
updateAccessPermissions,
|
|
sweepOrphanedPreviews,
|
|
getRoleByName,
|
|
seedDatabase,
|
|
} = require('~/models');
|
|
const initializeOAuthReconnectManager = require('./services/initializeOAuthReconnectManager');
|
|
const { capabilityContextMiddleware } = require('./middleware/roles/capabilities');
|
|
const createValidateImageRequest = require('./middleware/validateImageRequest');
|
|
const { startExpiredFileSweep } = require('./services/Files/process');
|
|
const { initializeGitHubSkillSync } = require('./services/Skills/sync');
|
|
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
|
|
const { checkMigrations } = require('./services/start/migration');
|
|
const optionalJwtAuth = require('./middleware/optionalJwtAuth');
|
|
const initializeMCPs = require('./services/initializeMCPs');
|
|
const configureSocialLogins = require('./socialLogins');
|
|
const createSpaFallback = require('./utils/fallback');
|
|
const { getAppConfig } = require('./services/Config');
|
|
const staticCache = require('./utils/staticCache');
|
|
const noIndex = require('./middleware/noIndex');
|
|
const routes = require('./routes');
|
|
|
|
const { PORT, HOST, ALLOW_SOCIAL_LOGIN, DISABLE_COMPRESSION, TRUST_PROXY } = process.env ?? {};
|
|
|
|
// Allow PORT=0 to be used for automatic free port assignment
|
|
const port = isNaN(Number(PORT)) ? 3080 : Number(PORT);
|
|
const host = HOST || 'localhost';
|
|
const trusted_proxy = Number(TRUST_PROXY) || 1; /* trust first proxy by default */
|
|
|
|
const app = express();
|
|
let serverReady = false;
|
|
|
|
const SERVER_NOT_READY_CODE = 'SERVER_NOT_READY';
|
|
const CHAT_START_RETRY_AFTER_SECONDS = '1';
|
|
|
|
const rejectChatStartsUntilReady = (req, res, next) => {
|
|
if (serverReady || req.method !== 'POST' || req.path === '/abort') {
|
|
return next();
|
|
}
|
|
|
|
res.set('Retry-After', CHAT_START_RETRY_AFTER_SECONDS);
|
|
return res.status(503).json({
|
|
code: SERVER_NOT_READY_CODE,
|
|
error: 'Server is still starting. Please retry shortly.',
|
|
});
|
|
};
|
|
|
|
const configureGenerationStreams = () => {
|
|
const streamServices = createStreamServices();
|
|
GenerationJobManager.configure({
|
|
...streamServices,
|
|
cleanupOnComplete: !isEnabled(process.env.STREAM_KEEP_COMPLETED_JOBS),
|
|
});
|
|
GenerationJobManager.initialize();
|
|
};
|
|
|
|
const startServer = async () => {
|
|
const { metricsMiddleware, metricsRouter } = createMetrics();
|
|
if (!process.env.METRICS_SECRET) {
|
|
logger.warn('[metrics] METRICS_SECRET is not set - /metrics will return 401 for all requests');
|
|
}
|
|
|
|
if (typeof Bun !== 'undefined') {
|
|
axios.defaults.headers.common['Accept-Encoding'] = 'gzip';
|
|
}
|
|
await connectDb();
|
|
|
|
logger.info('Connected to MongoDB');
|
|
indexSync().catch((err) => {
|
|
logger.error('[indexSync] Background sync failed:', err);
|
|
});
|
|
|
|
app.disable('x-powered-by');
|
|
app.set('trust proxy', trusted_proxy);
|
|
|
|
if (isEnabled(process.env.TENANT_ISOLATION_STRICT)) {
|
|
logger.warn(
|
|
'[Security] TENANT_ISOLATION_STRICT is active. Ensure your reverse proxy strips or sets ' +
|
|
'the X-Tenant-Id header — untrusted clients must not be able to set it directly.',
|
|
);
|
|
}
|
|
|
|
await runAsSystem(seedDatabase);
|
|
/* Recover stuck `status: 'pending'` records from a crash mid-render.
|
|
* `runAsSystem` is required — `File` is tenant-isolated and strict
|
|
* mode rejects unscoped queries. Lazy sweep in the preview endpoint
|
|
* covers anything younger than the boot cutoff. */
|
|
runAsSystem(sweepOrphanedPreviews).catch((err) => {
|
|
logger.error('[sweepOrphanedPreviews] Background sweep failed:', err);
|
|
});
|
|
const appConfig = await getAppConfig({ baseOnly: true });
|
|
initializeFileStorage(appConfig);
|
|
await initializeDeploymentSkills({ projectRoot: path.resolve(__dirname, '../..') });
|
|
initializeGitHubSkillSync(appConfig);
|
|
startExpiredFileSweep({ appConfig, loadAppConfig: getAppConfig });
|
|
// Register any programmatic tool-approval policy hooks declared in
|
|
// `endpoints.agents.toolApproval.hooks`. Honor the `enabled` kill switch: when tool
|
|
// approval is off we pass no hooks, so a disabled endpoint imports/runs nothing (and any
|
|
// previously loaded batch is unregistered). Hooks are read from the BASE config only —
|
|
// they register once, process-wide; per-user/tenant differences belong inside the hook
|
|
// (via its context), not in per-override module lists.
|
|
const toolApproval = appConfig?.endpoints?.agents?.toolApproval;
|
|
await loadToolApprovalHooks(toolApproval?.enabled ? toolApproval.hooks : undefined, {
|
|
basePath: path.resolve(__dirname, '../..'),
|
|
});
|
|
await runAsSystem(async () => {
|
|
await performStartupChecks(appConfig);
|
|
await updateInterfacePermissions({ appConfig, getRoleByName, updateAccessPermissions });
|
|
});
|
|
|
|
const indexPath = path.join(appConfig.paths.dist, 'index.html');
|
|
let indexHTML = fs.readFileSync(indexPath, 'utf8');
|
|
|
|
// In order to provide support to serving the application in a sub-directory
|
|
// We need to update the base href if the DOMAIN_CLIENT is specified and not the root path
|
|
if (process.env.DOMAIN_CLIENT) {
|
|
const clientUrl = new URL(process.env.DOMAIN_CLIENT);
|
|
const baseHref = clientUrl.pathname.endsWith('/')
|
|
? clientUrl.pathname
|
|
: `${clientUrl.pathname}/`;
|
|
if (baseHref !== '/') {
|
|
logger.info(`Setting base href to ${baseHref}`);
|
|
indexHTML = indexHTML.replace(/base href="\/"/, `base href="${baseHref}"`);
|
|
}
|
|
}
|
|
|
|
const sendIndexHtml = (req, res) => {
|
|
res.set({
|
|
'Cache-Control': process.env.INDEX_CACHE_CONTROL || 'no-cache, no-store, must-revalidate',
|
|
Pragma: process.env.INDEX_PRAGMA || 'no-cache',
|
|
Expires: process.env.INDEX_EXPIRES || '0',
|
|
});
|
|
res.vary(QUERY_DEVTOOLS_HEADER);
|
|
|
|
const lang = req.cookies.lang || req.headers['accept-language']?.split(',')[0] || 'en-US';
|
|
const saneLang = lang.replace(/"/g, '"');
|
|
let updatedIndexHtml = indexHTML.replace(/lang="en-US"/g, `lang="${saneLang}"`);
|
|
updatedIndexHtml = maybeInjectQueryDevtoolsBootstrap(updatedIndexHtml, req);
|
|
|
|
res.type('html');
|
|
res.send(updatedIndexHtml);
|
|
};
|
|
|
|
app.get('/health', (_req, res) => res.status(200).send('OK'));
|
|
app.get('/livez', (_req, res) => res.status(200).send('OK'));
|
|
app.get('/readyz', (_req, res) => {
|
|
if (!serverReady) {
|
|
return res.status(503).send('NOT_READY');
|
|
}
|
|
return res.status(200).send('OK');
|
|
});
|
|
|
|
/* Middleware */
|
|
app.use(metricsMiddleware);
|
|
app.use(noIndex);
|
|
app.use(express.json({ limit: '3mb' }));
|
|
app.use(express.urlencoded({ extended: true, limit: '3mb' }));
|
|
app.use(handleJsonParseError);
|
|
|
|
/**
|
|
* Express 5 Compatibility: Make req.query writable for mongoSanitize
|
|
* In Express 5, req.query is read-only by default, but express-mongo-sanitize needs to modify it
|
|
*/
|
|
app.use((req, _res, next) => {
|
|
Object.defineProperty(req, 'query', {
|
|
...Object.getOwnPropertyDescriptor(req, 'query'),
|
|
value: req.query,
|
|
writable: true,
|
|
});
|
|
next();
|
|
});
|
|
|
|
app.use(mongoSanitize());
|
|
app.use(cors());
|
|
app.use(cookieParser());
|
|
|
|
if (!isEnabled(DISABLE_COMPRESSION)) {
|
|
app.use(compression());
|
|
} else {
|
|
console.warn('Response compression has been disabled via DISABLE_COMPRESSION.');
|
|
}
|
|
|
|
app.get('/index.html', sendIndexHtml);
|
|
app.use(staticCache(appConfig.paths.dist));
|
|
app.use(staticCache(appConfig.paths.fonts));
|
|
app.use(staticCache(appConfig.paths.assets));
|
|
|
|
if (telemetry.enabled) {
|
|
app.use(telemetry.telemetryMiddleware);
|
|
}
|
|
|
|
if (!ALLOW_SOCIAL_LOGIN) {
|
|
console.warn('Social logins are disabled. Set ALLOW_SOCIAL_LOGIN=true to enable them.');
|
|
}
|
|
|
|
/* OAUTH */
|
|
app.use(passport.initialize());
|
|
passport.use(jwtLogin());
|
|
passport.use(passportLogin());
|
|
|
|
/* LDAP Auth */
|
|
if (process.env.LDAP_URL && process.env.LDAP_USER_SEARCH_BASE) {
|
|
passport.use(ldapLogin);
|
|
}
|
|
|
|
if (isEnabled(ALLOW_SOCIAL_LOGIN)) {
|
|
await configureSocialLogins(app);
|
|
}
|
|
|
|
/* Per-request capability cache — must be registered before any route that calls hasCapability */
|
|
app.use(capabilityContextMiddleware);
|
|
|
|
/* Pre-auth tenant context for unauthenticated routes that need tenant scoping.
|
|
* The reverse proxy / auth gateway sets `X-Tenant-Id` header for multi-tenant deployments. */
|
|
app.use('/oauth', preAuthTenantMiddleware, routes.oauth);
|
|
/* API Endpoints */
|
|
app.use('/api/auth', preAuthTenantMiddleware, routes.auth);
|
|
app.use('/api/admin', routes.adminAuth);
|
|
app.use('/api/admin/config', routes.adminConfig);
|
|
app.use('/api/admin/grants', routes.adminGrants);
|
|
app.use('/api/admin/groups', routes.adminGroups);
|
|
app.use('/api/admin/roles', routes.adminRoles);
|
|
app.use('/api/admin/skills', routes.adminSkills);
|
|
app.use('/api/admin/users', routes.adminUsers);
|
|
app.use('/api/admin/audit-log', routes.adminAuditLog);
|
|
app.use('/api/actions', routes.actions);
|
|
app.use('/api/keys', routes.keys);
|
|
app.use('/api/api-keys', routes.apiKeys);
|
|
app.use('/api/user', routes.user);
|
|
app.use('/api/search', routes.search);
|
|
app.use('/api/messages', routes.messages);
|
|
app.use('/api/convos', routes.convos);
|
|
app.use('/api/presets', routes.presets);
|
|
app.use('/api/projects', routes.projects);
|
|
app.use('/api/prompts', routes.prompts);
|
|
app.use('/api/skills', routes.skills);
|
|
app.use('/api/categories', routes.categories);
|
|
app.use('/api/endpoints', routes.endpoints);
|
|
app.use('/api/balance', routes.balance);
|
|
app.use('/api/models', routes.models);
|
|
app.use('/api/config', preAuthTenantMiddleware, optionalJwtAuth, routes.config);
|
|
app.use('/api/assistants', routes.assistants);
|
|
app.use('/api/files', await routes.files.initialize());
|
|
app.use('/images/', createValidateImageRequest(appConfig.secureImageLinks), routes.staticRoute);
|
|
app.use('/api/share', preAuthTenantMiddleware, routes.share);
|
|
app.use('/api/roles', routes.roles);
|
|
app.use('/api/agents/chat', rejectChatStartsUntilReady);
|
|
app.use('/api/agents', routes.agents);
|
|
app.use('/api/banner', routes.banner);
|
|
app.use('/api/memories', routes.memories);
|
|
app.use('/api/permissions', routes.accessPermissions);
|
|
|
|
app.use('/api/tags', routes.tags);
|
|
app.use('/api/mcp', routes.mcp);
|
|
app.use('/api/rum', routes.rum);
|
|
|
|
app.use('/metrics', metricsRouter);
|
|
|
|
/** 404 for unmatched API routes */
|
|
app.use('/api', apiNotFound);
|
|
|
|
/** SPA fallback - serve index.html for all unmatched routes */
|
|
app.use(createSpaFallback(sendIndexHtml));
|
|
|
|
/** Record trace errors before the final error controller. */
|
|
if (telemetry.enabled) {
|
|
app.use(telemetry.telemetryErrorMiddleware);
|
|
}
|
|
/** Error handler (must be last - Express identifies error middleware by its 4-arg signature) */
|
|
app.use(ErrorController);
|
|
|
|
configureGenerationStreams();
|
|
|
|
const server = app.listen(port, host, async (err) => {
|
|
if (err) {
|
|
logger.error('Failed to start server:', err);
|
|
process.exit(1);
|
|
}
|
|
|
|
if (host === '0.0.0.0') {
|
|
logger.info(
|
|
`Server listening on all interfaces at port ${port}. Use http://localhost:${port} to access it`,
|
|
);
|
|
} else {
|
|
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
|
|
}
|
|
|
|
/**
|
|
* The listen callback is async, so any rejection from these awaits would
|
|
* otherwise be detached from `startServer().catch(...)` (which only
|
|
* catches errors that happen before `app.listen`). Without explicit
|
|
* handling, the global `unhandledRejection` handler would swallow init
|
|
* failures and leave the server listening but only partially
|
|
* initialized — passing liveness checks while serving broken requests.
|
|
*/
|
|
try {
|
|
await runAsSystem(async () => {
|
|
await initializeMCPs();
|
|
await initializeOAuthReconnectManager();
|
|
});
|
|
await checkMigrations();
|
|
|
|
const inspectFlags = process.execArgv.some((arg) => arg.startsWith('--inspect'));
|
|
if (inspectFlags || isEnabled(process.env.MEM_DIAG)) {
|
|
memoryDiagnostics.start();
|
|
}
|
|
serverReady = true;
|
|
logger.info('Server readiness checks passing.');
|
|
} catch (initErr) {
|
|
serverReady = false;
|
|
logger.error('Post-listen initialization failed:', initErr);
|
|
process.exit(1);
|
|
}
|
|
});
|
|
|
|
setupGracefulShutdown(server);
|
|
};
|
|
|
|
/**
|
|
* Boot rejections (e.g. `connectDb`, `getAppConfig`, `performStartupChecks`)
|
|
* must remain fail-fast: a half-initialized process with no listening HTTP
|
|
* server should die immediately so the orchestrator restarts it, instead of
|
|
* being kept alive by the `unhandledRejection` handler below until the
|
|
* liveness probe eventually times out. Mirrors the pattern in
|
|
* `experimental.js`.
|
|
*/
|
|
startServer().catch((err) => {
|
|
logger.error('Failed to start server:', err);
|
|
process.exit(1);
|
|
});
|
|
|
|
let messageCount = 0;
|
|
process.on('uncaughtException', (err) => {
|
|
if (!err.message.includes('fetch failed')) {
|
|
logger.error('There was an uncaught error:', err);
|
|
}
|
|
|
|
if (err.message && err.message?.toLowerCase()?.includes('abort')) {
|
|
logger.warn('There was an uncatchable abort error.');
|
|
return;
|
|
}
|
|
|
|
if (err.message.includes('GoogleGenerativeAI')) {
|
|
logger.warn(
|
|
'\n\n`GoogleGenerativeAI` errors cannot be caught due to an upstream issue, see: https://github.com/google-gemini/generative-ai-js/issues/303',
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (err.message.includes('fetch failed')) {
|
|
if (messageCount === 0) {
|
|
logger.warn('Meilisearch error, search will be disabled');
|
|
messageCount++;
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
if (err.message.includes('OpenAIError') || err.message.includes('ChatCompletionMessage')) {
|
|
logger.error(
|
|
'\n\nAn Uncaught `OpenAIError` error may be due to your reverse-proxy setup or stream configuration, or a bug in the `openai` node package.',
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (err.stack && err.stack.includes('@librechat/agents')) {
|
|
logger.error(
|
|
'\n\nAn error occurred in the agents system. The error has been logged and the app will continue running.',
|
|
{
|
|
message: err.message,
|
|
stack: err.stack,
|
|
},
|
|
);
|
|
return;
|
|
}
|
|
|
|
if (isEnabled(process.env.CONTINUE_ON_UNCAUGHT_EXCEPTION)) {
|
|
logger.error('Unhandled error encountered. The app will continue running.', {
|
|
name: err?.name,
|
|
message: err?.message,
|
|
stack: err?.stack,
|
|
});
|
|
return;
|
|
}
|
|
|
|
process.exit(1);
|
|
});
|
|
|
|
/**
|
|
* Unhandled promise rejection handler.
|
|
*
|
|
* Node 15+ terminates the process by default when a promise rejection is
|
|
* unhandled. MCP OAuth reconnect storms and streamable-HTTP transport resets
|
|
* can produce transient fire-and-forget rejections (ECONNRESET, token refresh
|
|
* races) that are recoverable — the server should log and keep serving other
|
|
* requests rather than silently crash under load.
|
|
*
|
|
* Non-Error reasons are forwarded as-is so structured payloads (e.g.
|
|
* `{ code: "ECONNRESET", errno: -104 }`) survive instead of being collapsed to
|
|
* "[object Object]" by `String()`.
|
|
*/
|
|
process.on('unhandledRejection', (reason) => {
|
|
if (reason instanceof Error) {
|
|
logger.error('Unhandled promise rejection. The app will continue running.', {
|
|
name: reason.name,
|
|
message: reason.message,
|
|
stack: reason.stack,
|
|
cause: reason.cause,
|
|
});
|
|
return;
|
|
}
|
|
logger.error('Unhandled promise rejection. The app will continue running.', { reason });
|
|
});
|
|
|
|
/** Export app for easier testing purposes */
|
|
module.exports = app;
|