security: Phase 1 hardening - rate limit, helmet, CORS, JWT, session persistence
- express-rate-limit: 100/15min global, 5/15min on auth.login + auth.register, 3/hour reserved for password-reset endpoints; trust proxy enabled. - helmet: enabled with contentSecurityPolicy + crossOriginEmbedderPolicy off to keep Vite dev and the SPA bundle working. - CORS: explicit allowlist (https://attente.cosmolan.fr in prod, localhost in dev), credentials true, restricted methods/headers; same allowlist applied to socket.io. - JWT_SECRET: must be set and >= 32 chars; assertAuthEnv() called from the server bootstrap so the process refuses to start without one. The insecure "changeme-in-production" fallback in docker-compose.yml is removed. - qm_auth cookie: maxAge reduced from 30d to 7d, JWT expiry matches. - WhatsApp sessions: path now driven by WHATSAPP_SESSION_DIR and defaults to /app/data/whatsapp-sessions; docker-compose.yml mounts a named app_data volume so credentials survive container restarts. - scripts/backup-db.sh: timestamped, gzipped mysqldump into /app/data/backups with rotation (keeps last 7); Dockerfile installs mysql-client and bundles the script. - .env.example refreshed with documented placeholders for every required var (DATABASE_URL, JWT_SECRET, WHATSAPP_SESSION_DIR, MYSQL_*, BACKUP_*). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1dbb131d24
commit
81c6bccf8a
9 changed files with 268 additions and 20 deletions
26
.env.example
26
.env.example
|
|
@ -2,23 +2,39 @@
|
|||
# Local dev (host MySQL):
|
||||
# DATABASE_URL=mysql://queuemed:queuemed@localhost:3306/queuemed
|
||||
# Docker compose (uses the "db" service):
|
||||
# DATABASE_URL=mysql://queuemed:queuemed@db:3306/queuemed
|
||||
DATABASE_URL=mysql://queuemed:queuemed@localhost:3306/queuemed
|
||||
|
||||
# ─── Auth ───────────────────────────────────────────────────────────────────
|
||||
# Generate a strong random secret, e.g.: openssl rand -hex 64
|
||||
JWT_SECRET=change_me_to_a_long_random_string
|
||||
# REQUIRED. Must be at least 32 characters of high-entropy random data.
|
||||
# Generate one with: openssl rand -hex 64
|
||||
# The server refuses to start if this is missing or too short.
|
||||
JWT_SECRET=replace_me_with_openssl_rand_hex_64_output
|
||||
|
||||
# ─── Server ─────────────────────────────────────────────────────────────────
|
||||
PORT=5000
|
||||
NODE_ENV=development
|
||||
|
||||
# Public URL used to build QR code links (e.g. https://queuemed.example.com)
|
||||
# Public URL used to build QR code links (e.g. https://queuemed.example.com).
|
||||
# In production this should match the public origin allowed by CORS.
|
||||
PUBLIC_BASE_URL=
|
||||
|
||||
# ─── WhatsApp (Baileys) ─────────────────────────────────────────────────────
|
||||
# Persistent directory used to store Baileys auth credentials per clinic.
|
||||
# Must live on a Docker volume in production so sessions survive restarts.
|
||||
WHATSAPP_SESSION_DIR=/app/data/whatsapp-sessions
|
||||
|
||||
# ─── Docker compose only ────────────────────────────────────────────────────
|
||||
MYSQL_ROOT_PASSWORD=rootpassword
|
||||
MYSQL_ROOT_PASSWORD=replace_me_with_a_strong_password
|
||||
MYSQL_DATABASE=queuemed
|
||||
MYSQL_USER=queuemed
|
||||
MYSQL_PASSWORD=queuemed
|
||||
MYSQL_PASSWORD=replace_me_with_a_strong_password
|
||||
MYSQL_PORT=3306
|
||||
APP_PORT=5000
|
||||
|
||||
# ─── Backups (used by scripts/backup-db.sh) ─────────────────────────────────
|
||||
# Inside the `app` container these point at the `db` service.
|
||||
# Override only if running the script outside docker compose.
|
||||
# MYSQL_HOST=db
|
||||
# BACKUP_DIR=/app/data/backups
|
||||
# BACKUP_KEEP=7
|
||||
|
|
|
|||
12
Dockerfile
12
Dockerfile
|
|
@ -32,8 +32,16 @@ COPY package.json tsconfig.json drizzle.config.ts ./
|
|||
COPY server ./server
|
||||
COPY shared ./shared
|
||||
|
||||
# WhatsApp auth sessions dir
|
||||
RUN mkdir -p /tmp/whatsapp-sessions && chown -R 1000:1000 /tmp/whatsapp-sessions
|
||||
# Persistent app data directory (WhatsApp sessions, DB backups, …).
|
||||
# In production this should be backed by a Docker named volume.
|
||||
RUN mkdir -p /app/data/whatsapp-sessions /app/data/backups
|
||||
|
||||
# Bundle the operational scripts (DB backup, etc.)
|
||||
COPY scripts ./scripts
|
||||
RUN chmod +x ./scripts/*.sh || true
|
||||
|
||||
# Tools used by the backup script
|
||||
RUN apk add --no-cache mysql-client
|
||||
|
||||
RUN addgroup -S app && adduser -S app -G app && chown -R app:app /app
|
||||
USER app
|
||||
|
|
|
|||
|
|
@ -33,8 +33,17 @@ services:
|
|||
NODE_ENV: production
|
||||
PORT: 5000
|
||||
DATABASE_URL: mysql://${MYSQL_USER:-queuemed}:${MYSQL_PASSWORD:-queuemed}@db:3306/${MYSQL_DATABASE:-queuemed}
|
||||
JWT_SECRET: ${JWT_SECRET:-changeme-in-production}
|
||||
# JWT_SECRET MUST be provided via .env.docker — there is no insecure fallback.
|
||||
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set in .env.docker}
|
||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-}
|
||||
WHATSAPP_SESSION_DIR: ${WHATSAPP_SESSION_DIR:-/app/data/whatsapp-sessions}
|
||||
# MySQL credentials available to scripts/backup-db.sh inside the container
|
||||
MYSQL_HOST: db
|
||||
MYSQL_DATABASE: ${MYSQL_DATABASE:-queuemed}
|
||||
MYSQL_USER: ${MYSQL_USER:-queuemed}
|
||||
MYSQL_PASSWORD: ${MYSQL_PASSWORD:-queuemed}
|
||||
volumes:
|
||||
- app_data:/app/data
|
||||
ports:
|
||||
- "5100:5000"
|
||||
networks:
|
||||
|
|
@ -47,3 +56,5 @@ networks:
|
|||
volumes:
|
||||
mysql_data:
|
||||
driver: local
|
||||
app_data:
|
||||
driver: local
|
||||
|
|
|
|||
38
package-lock.json
generated
38
package-lock.json
generated
|
|
@ -38,7 +38,9 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.38.2",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"framer-motion": "^11.15.0",
|
||||
"helmet": "^8.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
|
|
@ -5703,6 +5705,24 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express-rate-limit": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.4.1.tgz",
|
||||
"integrity": "sha512-NGVYwQSAyEQgzxX1iCM978PP9AdO/hW93gMcF6ZwQCm+rFvLsBH6w4xcXWTcliS8La5EPRN3p9wzItqBwJrfNw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ip-address": "10.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/express-rate-limit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"express": ">= 4.11"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
|
|
@ -6061,6 +6081,15 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/helmet": {
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/helmet/-/helmet-8.1.0.tgz",
|
||||
"integrity": "sha512-jOiHyAZsmnr8LqoPGmCjYAaiuWwjAPLgY8ZX2XrmHawt99/u1y6RgrZMTeoPfpUbV96HOalYgz1qzkRbw54Pmg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/hookified": {
|
||||
"version": "1.15.1",
|
||||
"resolved": "https://registry.npmjs.org/hookified/-/hookified-1.15.1.tgz",
|
||||
|
|
@ -6144,6 +6173,15 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/ip-address": {
|
||||
"version": "10.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz",
|
||||
"integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
|
||||
|
|
|
|||
|
|
@ -46,7 +46,9 @@
|
|||
"date-fns": "^4.1.0",
|
||||
"drizzle-orm": "^0.38.2",
|
||||
"express": "^4.21.2",
|
||||
"express-rate-limit": "^8.4.1",
|
||||
"framer-motion": "^11.15.0",
|
||||
"helmet": "^8.1.0",
|
||||
"input-otp": "^1.4.1",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"lucide-react": "^0.468.0",
|
||||
|
|
|
|||
69
scripts/backup-db.sh
Executable file
69
scripts/backup-db.sh
Executable file
|
|
@ -0,0 +1,69 @@
|
|||
#!/bin/sh
|
||||
# QueueMed — MySQL backup helper.
|
||||
#
|
||||
# Dumps the configured MySQL database into /app/data/backups (or $BACKUP_DIR)
|
||||
# with a timestamped filename, then prunes everything but the 7 most recent
|
||||
# backups. Designed to run inside the `app` container — schedule from the host
|
||||
# with `docker compose exec app /app/scripts/backup-db.sh` (cron, systemd, …).
|
||||
#
|
||||
# Required environment variables (already wired through docker-compose.yml):
|
||||
# MYSQL_HOST – hostname of the MySQL service (default: db)
|
||||
# MYSQL_DATABASE – database name to dump (default: queuemed)
|
||||
# MYSQL_USER – MySQL user (default: queuemed)
|
||||
# MYSQL_PASSWORD – MySQL password (required)
|
||||
# Optional:
|
||||
# BACKUP_DIR – override backup destination (default: /app/data/backups)
|
||||
# BACKUP_KEEP – number of backups to retain (default: 7)
|
||||
|
||||
set -eu
|
||||
|
||||
MYSQL_HOST="${MYSQL_HOST:-db}"
|
||||
MYSQL_DATABASE="${MYSQL_DATABASE:-queuemed}"
|
||||
MYSQL_USER="${MYSQL_USER:-queuemed}"
|
||||
BACKUP_DIR="${BACKUP_DIR:-/app/data/backups}"
|
||||
BACKUP_KEEP="${BACKUP_KEEP:-7}"
|
||||
|
||||
if [ -z "${MYSQL_PASSWORD:-}" ]; then
|
||||
echo "[backup-db] MYSQL_PASSWORD is not set, aborting." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$BACKUP_DIR"
|
||||
|
||||
TIMESTAMP="$(date -u +%Y%m%dT%H%M%SZ)"
|
||||
OUT_FILE="$BACKUP_DIR/${MYSQL_DATABASE}-${TIMESTAMP}.sql.gz"
|
||||
TMP_FILE="${OUT_FILE}.partial"
|
||||
|
||||
echo "[backup-db] dumping ${MYSQL_DATABASE} from ${MYSQL_HOST} -> ${OUT_FILE}"
|
||||
|
||||
# --single-transaction keeps the dump consistent without locking InnoDB tables.
|
||||
# --quick streams rows row-by-row to keep memory bounded for large tables.
|
||||
mysqldump \
|
||||
--host="$MYSQL_HOST" \
|
||||
--user="$MYSQL_USER" \
|
||||
--password="$MYSQL_PASSWORD" \
|
||||
--single-transaction \
|
||||
--quick \
|
||||
--routines \
|
||||
--triggers \
|
||||
--no-tablespaces \
|
||||
--default-character-set=utf8mb4 \
|
||||
"$MYSQL_DATABASE" | gzip -9 > "$TMP_FILE"
|
||||
|
||||
mv "$TMP_FILE" "$OUT_FILE"
|
||||
|
||||
echo "[backup-db] backup written: $OUT_FILE ($(wc -c < "$OUT_FILE") bytes)"
|
||||
|
||||
# ── Rotate: keep only the last $BACKUP_KEEP backups ─────────────────────────
|
||||
# `ls -1t` sorts by mtime descending; everything after the first $BACKUP_KEEP
|
||||
# entries is removed. Filenames are constrained to our prefix to avoid eating
|
||||
# unrelated files that might share the directory.
|
||||
cd "$BACKUP_DIR"
|
||||
ls -1t "${MYSQL_DATABASE}"-*.sql.gz 2>/dev/null \
|
||||
| awk -v keep="$BACKUP_KEEP" 'NR > keep' \
|
||||
| while IFS= read -r old; do
|
||||
echo "[backup-db] pruning old backup: $old"
|
||||
rm -f -- "$old"
|
||||
done
|
||||
|
||||
echo "[backup-db] done."
|
||||
|
|
@ -4,12 +4,14 @@ import path from "node:path";
|
|||
import { fileURLToPath } from "node:url";
|
||||
import fs from "node:fs";
|
||||
import cors from "cors";
|
||||
import helmet from "helmet";
|
||||
import rateLimit from "express-rate-limit";
|
||||
import cookieParser from "cookie-parser";
|
||||
import { Server as SocketIOServer } from "socket.io";
|
||||
import { createExpressMiddleware } from "@trpc/server/adapters/express";
|
||||
import { appRouter } from "../routers.js";
|
||||
import { createContext } from "./context.js";
|
||||
import { authMiddleware } from "../auth.js";
|
||||
import { authMiddleware, assertAuthEnv } from "../auth.js";
|
||||
import { getDb } from "../db.js";
|
||||
import { startAutoAbsentJob, stopAutoAbsentJob } from "../services/autoAbsent.js";
|
||||
|
||||
|
|
@ -19,7 +21,20 @@ const PORT = Number(process.env.PORT ?? 5000);
|
|||
const NODE_ENV = process.env.NODE_ENV ?? "development";
|
||||
const IS_PROD = NODE_ENV === "production";
|
||||
|
||||
const PROD_ORIGINS = ["https://attente.cosmolan.fr"];
|
||||
const DEV_ORIGINS = [
|
||||
"http://localhost:5173",
|
||||
"http://127.0.0.1:5173",
|
||||
"http://localhost:5000",
|
||||
"http://127.0.0.1:5000",
|
||||
];
|
||||
const ALLOWED_ORIGINS = IS_PROD ? PROD_ORIGINS : DEV_ORIGINS;
|
||||
|
||||
async function bootstrap() {
|
||||
// Fail fast if critical secrets are missing — refuse to start instead of
|
||||
// erroring lazily on the first authenticated request.
|
||||
assertAuthEnv();
|
||||
|
||||
// Eagerly initialize database connection (warns early if DATABASE_URL missing)
|
||||
try {
|
||||
await getDb();
|
||||
|
|
@ -31,12 +46,14 @@ async function bootstrap() {
|
|||
}
|
||||
|
||||
const app = express();
|
||||
// Required for express-rate-limit and secure cookies behind a reverse proxy
|
||||
app.set("trust proxy", 1);
|
||||
const httpServer = http.createServer(app);
|
||||
|
||||
// ── Socket.io ────────────────────────────────────────────────────────────
|
||||
const io = new SocketIOServer(httpServer, {
|
||||
cors: {
|
||||
origin: IS_PROD ? false : true,
|
||||
origin: ALLOWED_ORIGINS,
|
||||
credentials: true,
|
||||
},
|
||||
path: "/socket.io",
|
||||
|
|
@ -66,13 +83,80 @@ async function bootstrap() {
|
|||
});
|
||||
});
|
||||
|
||||
// ── Middlewares ──────────────────────────────────────────────────────────
|
||||
// ── Security headers ─────────────────────────────────────────────────────
|
||||
// CSP is intentionally disabled here: it conflicts with the Vite dev server
|
||||
// and the inline assets generated by the SPA build. Re-enable once a proper
|
||||
// policy has been defined for the production bundle.
|
||||
app.use(
|
||||
cors({
|
||||
origin: IS_PROD ? false : ["http://localhost:5173", "http://127.0.0.1:5173"],
|
||||
credentials: true,
|
||||
helmet({
|
||||
contentSecurityPolicy: false,
|
||||
crossOriginEmbedderPolicy: false,
|
||||
})
|
||||
);
|
||||
|
||||
// ── CORS ─────────────────────────────────────────────────────────────────
|
||||
app.use(
|
||||
cors({
|
||||
origin: (origin, callback) => {
|
||||
// Allow same-origin / non-browser requests (no Origin header)
|
||||
if (!origin) return callback(null, true);
|
||||
if (ALLOWED_ORIGINS.includes(origin)) return callback(null, true);
|
||||
return callback(new Error(`Origin not allowed by CORS: ${origin}`));
|
||||
},
|
||||
credentials: true,
|
||||
methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
||||
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
|
||||
})
|
||||
);
|
||||
|
||||
// ── Rate limiting ────────────────────────────────────────────────────────
|
||||
// Global limiter applied to the whole API surface.
|
||||
const globalLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 100,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: "Too many requests, please try again later." },
|
||||
});
|
||||
|
||||
// Stricter limiter for authentication endpoints (login + register).
|
||||
const authLimiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000,
|
||||
max: 5,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
skipSuccessfulRequests: true,
|
||||
message: { error: "Too many auth attempts, please try again later." },
|
||||
});
|
||||
|
||||
// Password reset limiter — kept ready in case a reset endpoint is added.
|
||||
const passwordResetLimiter = rateLimit({
|
||||
windowMs: 60 * 60 * 1000,
|
||||
max: 3,
|
||||
standardHeaders: true,
|
||||
legacyHeaders: false,
|
||||
message: { error: "Too many password reset attempts, please try again later." },
|
||||
});
|
||||
|
||||
// Apply auth limiter BEFORE the tRPC middleware so it short-circuits abuse.
|
||||
app.use(
|
||||
[
|
||||
"/api/trpc/auth.login",
|
||||
"/api/trpc/auth.register",
|
||||
],
|
||||
authLimiter
|
||||
);
|
||||
app.use(
|
||||
[
|
||||
"/api/trpc/auth.requestPasswordReset",
|
||||
"/api/trpc/auth.resetPassword",
|
||||
"/api/trpc/auth.forgotPassword",
|
||||
],
|
||||
passwordResetLimiter
|
||||
);
|
||||
app.use("/api", globalLimiter);
|
||||
|
||||
// ── Body / cookies / auth ────────────────────────────────────────────────
|
||||
app.use(express.json({ limit: "1mb" }));
|
||||
app.use(cookieParser());
|
||||
app.use(authMiddleware);
|
||||
|
|
|
|||
|
|
@ -5,14 +5,27 @@ import { getUserById } from "./db.js";
|
|||
import type { User } from "./schema.js";
|
||||
|
||||
const COOKIE_NAME = "qm_auth";
|
||||
const COOKIE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
|
||||
const COOKIE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
|
||||
|
||||
function getJwtSecret(): string {
|
||||
const secret = process.env.JWT_SECRET;
|
||||
if (!secret) throw new Error("JWT_SECRET is not set");
|
||||
if (!secret || secret.length < 32) {
|
||||
throw new Error(
|
||||
"JWT_SECRET is not set or is too short (>= 32 chars required). " +
|
||||
"Generate one with `openssl rand -hex 64` and set it in the environment."
|
||||
);
|
||||
}
|
||||
return secret;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fail-fast validation called from the server bootstrap so a misconfigured
|
||||
* deployment refuses to start instead of erroring lazily on the first login.
|
||||
*/
|
||||
export function assertAuthEnv(): void {
|
||||
getJwtSecret();
|
||||
}
|
||||
|
||||
export interface JwtPayload {
|
||||
sub: number;
|
||||
email: string;
|
||||
|
|
@ -35,7 +48,7 @@ export function createToken(user: Pick<User, "id" | "email" | "role">): string {
|
|||
email: user.email,
|
||||
role: user.role,
|
||||
};
|
||||
return jwt.sign(payload, getJwtSecret(), { expiresIn: "30d" });
|
||||
return jwt.sign(payload, getJwtSecret(), { expiresIn: "7d" });
|
||||
}
|
||||
|
||||
export function verifyToken(token: string): JwtPayload | null {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
/**
|
||||
* WhatsApp notification service using Baileys (WhatsApp Web unofficial API)
|
||||
* Manages one WhatsApp session per clinic (identified by clinicId).
|
||||
* Sessions are stored in /tmp/whatsapp-sessions/<clinicId>/
|
||||
* Sessions are stored in $WHATSAPP_SESSION_DIR/<clinicId>/, which defaults
|
||||
* to /app/data/whatsapp-sessions inside the container so credentials survive
|
||||
* container restarts (the path must live on a persistent Docker volume).
|
||||
*
|
||||
* ⚠️ Disclaimer: Baileys uses the WhatsApp Web protocol which is unofficial.
|
||||
* Use at low volume (< 500 msg/day) to minimise ban risk.
|
||||
|
|
@ -51,8 +53,13 @@ function getMessageQueue(clinicId: number): PQueue {
|
|||
return messageQueues.get(clinicId)!;
|
||||
}
|
||||
|
||||
const SESSION_DIR = "/tmp/whatsapp-sessions";
|
||||
if (!fs.existsSync(SESSION_DIR)) fs.mkdirSync(SESSION_DIR, { recursive: true });
|
||||
const SESSION_DIR = process.env.WHATSAPP_SESSION_DIR ?? "/app/data/whatsapp-sessions";
|
||||
try {
|
||||
fs.mkdirSync(SESSION_DIR, { recursive: true });
|
||||
} catch (err) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(`[whatsapp] failed to create session dir at ${SESSION_DIR}:`, err);
|
||||
}
|
||||
|
||||
// Silent logger to avoid spamming server logs
|
||||
const logger = pino({ level: "silent" });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue