Skip to content

Engineering Conventions & Code Style

This document is the canonical reference for how code is structured and written in the mr-mentor-backend repository. It captures the real, observed conventions of the codebase — the layered entity → service → controller → route flow, the class-based dependency-injected Routes aggregator, the DataSource/Redis/Queue singletons, the TypeORM repository pattern, the try/catch error-handling style, queue offloading for slow work, and the implications of tsconfig running with strict: false. Use it to keep new code consistent with what already exists, and to understand where a given piece of code belongs.

Status: documented from source on this branch.


Overview

mr-mentor-backend is a single Express + TypeScript monolith that hosts many MAS products (Mr. Mentor mentorship + meetings + tokens, Mr. Hire recruitment/ATS, MAS LMS, Sales CRM, Finance/GST, an AI platform, workflow automation, a vendor API, and more). Despite the breadth, every domain follows the same five-layer skeleton, so a developer who learns one feature can navigate any other.

Who relies on these conventions

Persona Why the conventions matter
Backend engineers adding a feature The entity → service → controller → route → mount recipe is the contract for "where does my code go".
Reviewers (/review, /code-review) Knowing controllers are thin and services hold logic makes "logic in the controller" an instant review flag.
New joiners The DI-of-DataSource pattern and @/ alias are non-obvious without this doc.
AI assistants strict: false + no-explicit-any: warn means the compiler will not catch many mistakes — conventions are the guardrail.

The codebase began as an "express-typescript-template" (the package.json name field still says so) and grew organically; conventions are therefore enforced by consistency and review, not by strict tooling.


Key concepts & entities

This is a guideline doc, so the "entities" here are the structural building blocks rather than business tables.

Term What it is Representative file
Entity A TypeORM @Entity() class = one DB table. src/entities/Tokens.ts, src/entities/User.ts
Service Holds all business logic; owns repositories; calls other services. src/services/MentorService.ts
Controller Thin HTTP adapter: parse req, call service, shape res.json. src/controllers/mentors.controller.ts
Route class Class exposing public router, wires paths + middleware to controller methods. src/routes/mentor.routes.ts
Routes aggregator Top-level class that instantiates every route class with the shared DataSource and mounts each under a prefix. src/routes/index.ts
App Builds the Express app: middleware, routes, error handlers, lifecycle. src/app.ts
DatabaseService Singleton wrapping the TypeORM DataSource. src/config/database.ts
RedisService Singleton ioredis client. src/config/redis.ts
QueueService Singleton owning all BullMQ Queue producers. src/services/QueueService.ts
Middleware authMiddleware, adminMiddleware, expertMiddleware, etc. — pure functions. src/middleware/
@/ alias Import alias for src/ (e.g. import { X } from "@/entities/X"). tsconfig.json, package.json _moduleAliases

Architecture

The request path is strictly layered. Each layer only talks to the one directly below it.

flowchart TD
  Client["HTTP / Socket.IO client"]
  subgraph APP["Express App (src/app.ts)"]
    MW["Middleware (cors, helmet, json, authMiddleware, role guards)"]
    AGG["Routes aggregator (src/routes/index.ts)"]
    RC["Route class e.g. MentorRoutes (src/routes/*.routes.ts)"]
    CTRL["Controller e.g. MentorsController (src/controllers/*.ts)"]
    SVC["Service e.g. MentorService (src/services/*.ts)"]
  end
  subgraph SINGLETONS["Process singletons"]
    DB["DatabaseService.dataSource (TypeORM)"]
    REDIS["RedisService.client (ioredis)"]
    Q["QueueService (BullMQ producers)"]
  end
  ENT["Entities / Repositories (src/entities/*.ts)"]
  PG[("PostgreSQL")]
  WORKERS["BullMQ workers (src/workers/*.ts)"]
  EXT["External: S3, Razorpay, Gmail SMTP, Google, Exotel, LLM gateway"]

  Client --> MW --> AGG --> RC --> CTRL --> SVC
  SVC --> ENT --> DB --> PG
  SVC --> REDIS
  SVC --> Q --> REDIS
  Q -.enqueues.-> WORKERS
  WORKERS --> ENT
  WORKERS --> EXT
  SVC --> EXT

The golden rule: controllers are HTTP-only; services are framework-agnostic business logic. A controller never touches a TypeORM repository directly, and a service never touches req/res.


Data model

There are no business tables owned by this doc, but the structural composition pattern — how a Routes aggregator owns route classes, which own controllers, which own services, which own repositories — is itself a "model" worth drawing.

erDiagram
  ROUTES_AGG ||--o{ ROUTE_CLASS : "instantiates"
  ROUTE_CLASS ||--|| CONTROLLER : "owns one"
  CONTROLLER ||--o{ SERVICE : "calls"
  SERVICE ||--o{ REPOSITORY : "gets from DataSource"
  REPOSITORY ||--|| ENTITY : "maps"
  SERVICE ||--o{ SERVICE : "composes"
  SERVICE ||--o{ QUEUE : "enqueues to"

  ROUTES_AGG {
    DataSource dataSource
    Router router
  }
  ROUTE_CLASS {
    Router router PK
    string mountPrefix
  }
  CONTROLLER {
    arrow_fn handlers
  }
  SERVICE {
    Repository repos
    Service deps
  }
  ENTITY {
    decorator Entity
    uuid id PK
  }

API surface

This doc does not own endpoints. The conventions for the API surface, derived from src/routes/index.ts and the route classes, are:

Aspect Convention Evidence
Global prefix Almost everything mounts under /api. this.router.use('/api', ...) in src/routes/index.ts
Admin sub-prefix Admin features mount under /api/admin. this.router.use('/api/admin', this.adminRoutes.router)
Feature sub-prefix Some classes get a dedicated prefix (e.g. /api/finance, /api/sales, /api/v1/vendor, /api/candidate-quiz). src/routes/index.ts lines mounting financeRoutes, vendorRoutes, etc.
Path declaration Inside a route class, paths are relative and declared in initializeRoutes(). this.router.get('/mentors', ...) in src/routes/mentor.routes.ts
Auth Applied per-route as middleware args, not globally. this.router.get('/...', authMiddleware, expertMiddleware, ctrl.method)
Public vs protected Same router mixes public and protected routes; protection is opt-in per line. mentor.routes.ts (e.g. /mentors public, /mentors/multiplier guarded)
Bull Board UI Mounted at root, not /api. this.router.use('/', this.bullBoardRoutes.router)

When adding an endpoint, derive the full path as <mountPrefix in index.ts> + <relative path in the route class>. Do not hard-code a full /api/... path inside a route class.


User journeys

These journeys describe the developer workflows and the runtime control flow that the conventions produce — they are the headline of this guideline.

Journey 1 — A request flows through the layers (happy path)

A GET /api/mentors/:mentorId/slots/available request, end to end.

sequenceDiagram
  participant FE as Frontend
  participant MW as Middleware chain
  participant RC as MentorRoutes
  participant CTRL as MentorsController
  participant SVC as MentorService
  participant REPO as TypeORM repository
  participant PG as PostgreSQL

  FE->>MW: GET /api/mentors/ID/slots/available
  MW->>RC: cors then helmet then json pass through
  RC->>CTRL: route matched then call getMentorAvailableSlots
  CTRL->>SVC: parse params then call service method
  SVC->>REPO: slotsRepository.find with where clause
  REPO->>PG: SELECT query
  PG-->>REPO: rows
  REPO-->>SVC: entity objects
  SVC-->>CTRL: plain data
  CTRL-->>FE: res.status 200 json with status OK and data

The controller method is an arrow-function class property (so this is bound) wrapped in a try/catch; the service does the querying. See "annotated snippets" below.

Journey 2 — A protected, role-gated request

POST /api/mentors/slots/available requires a logged-in EXPERT.

sequenceDiagram
  participant FE as Frontend
  participant AUTH as authMiddleware
  participant EXP as expertMiddleware
  participant DB as DatabaseService
  participant CTRL as MentorsController
  participant SVC as MentorService

  FE->>AUTH: POST with Bearer token or cookie
  AUTH->>AUTH: read token from header or cookie
  AUTH->>DB: getRepository User then findOne by id
  DB-->>AUTH: user row with isActive and role
  alt token missing or user blocked or session revoked
    AUTH-->>FE: 401 or 403 json error
  else valid
    AUTH->>EXP: attach req.user then next
    EXP->>EXP: check req.user.role is EXPERT
    alt wrong role
      EXP-->>FE: 403 Access denied
    else allowed
      EXP->>CTRL: next then call markSlotsAvailable
      CTRL->>SVC: delegate to service
      SVC-->>CTRL: result
      CTRL-->>FE: 200 json
    end
  end

Key convention: auth state lives on req.user (typed via the AuthenticatedRequest interface in src/middleware/auth.middleware.ts), and DB re-verification happens inside authMiddleware itself (account-blocked, password-changed, session-revoked checks).

Journey 3 — Offloading slow work to a queue (async)

Services never block an HTTP response on email/IO-heavy work. They enqueue and return.

sequenceDiagram
  participant CTRL as Controller
  participant SVC as Service
  participant Q as QueueService
  participant REDIS as Redis
  participant WORKER as BullMQ worker
  participant EXT as Gmail SMTP or S3 or external

  CTRL->>SVC: call business method
  SVC->>SVC: do the DB write synchronously
  SVC->>Q: add job to a queue such as emailQueue
  Q->>REDIS: persist job
  SVC-->>CTRL: return immediately
  CTRL-->>CTRL: respond to client without waiting
  Note over WORKER: separate process pulls the job
  WORKER->>REDIS: fetch next job
  WORKER->>EXT: perform slow side effect
  alt success
    WORKER->>REDIS: mark complete and keep last 50
  else failure
    WORKER->>REDIS: mark failed for retry or inspection
  end

QueueService is a singleton holding 20+ named Queue producers (emailQueue, notificationQueue, resumeAnalysisQueue, mrlearnSyncQueue, workflowQueue, …) all pointed at the same Redis connection with removeOnComplete/removeOnFail retention. See src/services/QueueService.ts.

Journey 4 — Adding a new feature (developer workflow)

The canonical "where does my code go" recipe, expressed as a flow.

flowchart TD
  START["New feature request"]
  Q1{"Needs a new DB table?"}
  E["Create entity in src/entities and register it in src/config/database.ts entities array"]
  Q2{"Has business logic / DB access / external calls?"}
  S["Put logic in a Service (src/services). Inject DataSource then getRepository in constructor"]
  C["Create thin Controller (src/controllers) with arrow-fn handlers wrapping try/catch"]
  R["Create Route class (src/routes/*.routes.ts) exposing public router and initializeRoutes"]
  M["Register + instantiate the route class in src/routes/index.ts and mount under a prefix"]
  Q3{"Slow side effect (email, AI, big IO)?"}
  W["Enqueue via QueueService and handle in a worker (src/workers)"]
  DONE["Done"]

  START --> Q1
  Q1 -- yes --> E --> Q2
  Q1 -- no --> Q2
  Q2 -- yes --> S --> C
  Q2 -- no --> C
  C --> R --> M --> Q3
  Q3 -- yes --> W --> DONE
  Q3 -- no --> DONE

This mirrors the "Adding a new feature" steps in the repo's own CLAUDE.md.


Background jobs & async

Convention Detail File
One singleton producer All BullMQ Queue objects live on QueueService (singleton). Producers are created in its private constructor. src/services/QueueService.ts
Workers run in-process Workers are started during the boot sequence (see src/index.ts), one file per queue. src/workers/*.worker.ts
Retention Jobs use removeOnComplete / removeOnFail (50 or 100) to bound Redis growth. QueueService constructor
Scheduled jobs Cleanup (~24h) and KPI (~15min) are scheduled at startup; sync queues (mrlearn/mrtest/aarya) and reminders run on their own cadence. src/index.ts, src/workers/
Socket.IO Real-time meeting/recording/presence logic lives in src/socket.ts (large, separate from the HTTP layers). src/socket.ts
Never block responses If an action triggers email/AI/heavy IO, the service does the DB write then enqueues; it does not await the side effect before responding. pattern across services

Workers present (non-exhaustive): email, database, cleanup, kpi, resumeAnalysis, salaryBenchmark, warning, leadAssignment, daily-cards, course-plan, missOzone, mrlearnSync, mrlearnReminder, mrlearnNewStudentSync, mrtestSync, badgeEvaluation, studentRiskComputation, assignmentReminder, aaryaSync, workflow, whatsapp.


External integrations

The conventions for talking to third parties:

Convention Detail
Wrap in a service Each integration has a dedicated service (EmailService, ExotelService, LlmGatewayService, GoogleCalendarService, LeegalitySandboxService, S3 uploader services). Controllers never call SDKs directly.
Config via process.env Read env vars with a sensible fallback: process.env.REDIS_HOST || 'localhost'. No config library; dotenv.config() is called at the top of src/app.ts.
Feature flags / kill switches src/utils/featureFlags.ts exposes FeatureFlags.* helpers that default ON and turn OFF only when the env var equals the string "false" or "0". Restart required after a change.
Graceful disable Optional integrations (e.g. Exotel) self-disable when their env vars are unset rather than crashing — feature falls back.
Secrets Never hard-coded; loaded from env (deployed via AWS Secrets Manager). See repo CLAUDE.md.

Status lifecycles

The relevant "lifecycle" for this guideline is the application boot sequence in src/app.ts / src/index.ts, which is itself a state machine the conventions depend on (singletons must be initialized before routes serve traffic).

stateDiagram-v2
  [*] --> Constructing
  Constructing --> MiddlewareReady : App constructor wires cors helmet json
  MiddlewareReady --> RoutesMounted : new Routes dataSource then mount under api
  RoutesMounted --> DbInitializing : listen called
  DbInitializing --> SchemasEnsured : ensure named schemas mrlearn mrtest mas_crm superadmin
  SchemasEnsured --> DbSynced : dataSource initialize with synchronize true
  DbSynced --> Seeded : seedColleges then seedUsers
  Seeded --> RedisReady : redis initialize
  RedisReady --> WorkersStarted : BullMQ workers start
  WorkersStarted --> Listening : http server listen on PORT
  Listening --> [*]
  DbInitializing --> Failed : error then process exit 1
  Failed --> [*]

DatabaseService, RedisService, and QueueService are all the singleton pattern: private constructor() + static getInstance(). Never new them directly; always getInstance().


Annotated code snippets (good / bad)

Route class — the construction pattern

Every route file exports a class with a public router, builds its controller in the constructor, and declares relative paths in a private initializeRoutes(). From src/routes/mentor.routes.ts:

export class MentorRoutes {
  public router: Router;
  private readonly mentorController: MentorsController;

  constructor(dataSource: DataSource) {        // DataSource injected from Routes aggregator
    this.router = Router();
    this.mentorController = new MentorsController(dataSource);
    this.initializeRoutes();
  }

  private initializeRoutes(): void {
    this.router.get('/mentors', this.mentorController.getMentors);                 // public
    this.router.post('/mentors/slots/available',
      authMiddleware, expertMiddleware,                                            // per-route guards
      this.mentorController.markSlotsAvailable);
  }
}

The class is then declared, instantiated with the shared dataSource, and mounted in src/routes/index.ts:

this.mentorsRoutes = new MentorRoutes(dataSource);   // in the Routes constructor
// ...
this.router.use('/api', this.mentorsRoutes.router);  // in initializeRoutes()

Controller — thin, arrow-fn, try/catch (GOOD)

From src/controllers/mentors.controller.ts. Note: handlers are arrow-function class properties (preserves this), they parse the request, delegate to the service, and shape the response. The common success shape is { status: 'OK', data }; the common error shape is { success: false, message, error }.

export class MentorsController {
  private readonly mentorsService: MentorService;

  constructor(dataSource: DataSource) {
    this.mentorsService = new MentorService(dataSource);   // DI down to the service
  }

  public getMentors = async (req: Request, res: Response): Promise<void> => {
    try {
      const mentorsData = await this.mentorsService.getMentors();   // logic lives in service
      res.status(200).json({ status: 'OK', data: mentorsData });
    } catch (error) {
      res.status(500).json({
        success: false,
        message: 'Internal server error',
        error: error instanceof Error ? error.message : 'Unknown error',
      });
    }
  };
}

Note error instanceof Error ? error.message : 'Unknown error' — this is the repo's standard way to safely narrow a caught unknown/any error (a strict: false codebase convention).

Controller doing too much (BAD — do not do this)

// BAD: business logic + raw repository access inside the controller.
public createSlot = async (req: Request, res: Response) => {
  const repo = DatabaseService.getInstance().dataSource.getRepository(Slots); // ❌ DB in controller
  const slot = repo.create({ ...req.body, status: 'available' });             // ❌ logic in controller
  if (await repo.findOne({ where: { startTime: req.body.startTime } })) {     // ❌ rules in controller
    return res.status(409).json({ message: 'conflict' });
  }
  await repo.save(slot);
  await sendEmail(...);                                                        // ❌ direct side effect, blocks response
  res.json(slot);
};

The fix: move repository access, the conflict rule, and the email into a SlotService method; enqueue the email via QueueService; keep the controller to parse → call → respond.

Service — owns repositories via the injected DataSource (GOOD)

From src/services/MentorService.ts. The service constructor resolves every repository it needs and composes other services. Repositories are obtained with dataSource.getRepository(Entity) — never via a global import.

export class MentorService {
  public userRepository: Repository<User>;
  public slotsRepository: Repository<Slots>;
  public tokenService: TokenService;
  public queueService: QueueService;

  constructor(dataSource: DataSource) {
    this.userRepository = dataSource.getRepository(User);
    this.slotsRepository = dataSource.getRepository(Slots);
    this.tokenService = new TokenService(dataSource);     // compose sibling services
    this.queueService = QueueService.getInstance();       // singleton, not new
  }

  public async getMentors(): Promise<MentorResponse> {
    const mentors = await this.userRepository.find({
      relations: ['mentorProfile'],
      where: { role: UserRole.EXPERT },
    });
    for (const mentor of mentors) {
      delete (mentor as any).password;   // strip secrets before returning (strict:false `as any`)
    }
    return { status: 'OK', mentors };
  }
}

Entity — decorator style (GOOD)

From src/entities/Tokens.ts. Conventions: @Entity('table_name') with an explicit snake/camel table name, @PrimaryGeneratedColumn('uuid') ids, explicit { type, nullable, default } on columns, relations with explicit @JoinColumn, and a small amount of domain behaviour as methods is acceptable.

@Entity('tokens')
export class Token {
  @PrimaryGeneratedColumn('uuid')
  id: string;

  @Column({ type: 'float', nullable: true, default: 0 })
  token: number | null;

  @OneToOne(() => User, { onDelete: 'CASCADE' })
  @JoinColumn({ name: 'userId' })
  user: User;

  @Column({ type: 'uuid' })
  userId: string;

  getEarningValue(): number {          // small domain helper on the entity is OK
    if (!this.token) return 0;
    return this.token / TOKEN_VALUE;
  }
}

Every new entity MUST be added to the entities: [...] array in src/config/database.ts, otherwise TypeORM will not create/sync its table (auto-sync synchronize: true only manages registered entities).


Naming conventions

Observed, consistent naming (mixed historically but trending toward the right column):

Thing Convention Examples
Entity files PascalCase.ts; newer ones add .entity.ts User.ts, Tokens.ts, ResumeAnalysis.entity.ts
Entity classes PascalCase singular User, MentorEarnings, JobApplication
Service files & classes PascalCaseService.ts MentorService.ts, EmailService.ts, FinanceReportsService.ts
Controller files mixed: name.controller.ts (newer) or PascalCaseController.ts mentors.controller.ts, JobPostController.ts
Controller classes PascalCaseController MentorsController, FinanceController
Route files name.routes.ts mentor.routes.ts, finance.routes.ts
Route classes PascalCaseRoutes (or a createXRoutes(dataSource) factory function for some) MentorRoutes, createSalesRoutes(...)
Route paths kebab-case /mentors/slots/available, /candidate-quiz
DB column names mostly camelCase quoted identifiers ("createdAt"), some snake_case in CRM tables userId, createdAt, session_year
Imports @/... alias OR relative ../... (both appear; alias preferred for cross-domain) import { DiscountRequest } from '@/entities/DiscountRequest'

Inconsistency exists (e.g. mentors.controller.ts vs JobPostController.ts). Prefer the name.controller.ts / name.routes.ts / PascalCaseService.ts / Name.entity.ts forms for new code, but match the surrounding domain's style when extending an existing feature.


TypeORM patterns

  • One DataSource, injected everywhere. DatabaseService.getInstance().dataSource is the single DataSource. The Routes aggregator receives it once and threads it into every route class → controller → service. Do not create new DataSources.
  • Repositories obtained in service constructors via dataSource.getRepository(Entity); stored as public/readonly fields. Query with .find, .findOne, .save, .create, query builders, and operators (In, MoreThan, IsNull) imported from typeorm.
  • synchronize: true is ON (src/config/database.ts) — entity changes auto-apply to the DB in dev. There are no hand-written migrations in the normal flow (a migrations/ dir exists and is loaded if present, but the day-to-day model is auto-sync).
  • Non-default schemas (mrlearn, mrtest, mas_crm, superadmin) are pre-created with raw SQL before dataSource.initialize() because synchronize only creates tables, not schemas.
  • One-shot data migrations for tricky changes are done with raw QueryRunner SQL guarded to be idempotent (see migrateLegacyRawLeadsToMasCrm and migrateSuperadminRoleBeforeSync in src/config/database.ts).

Error handling & logging

  • Per-handler try/catch is the norm. There is a global error handler in src/app.ts (returns { success: false, message: 'Internal Server Error' }, leaking error.message only when NODE_ENV === 'development'), but controllers generally catch locally and return a structured JSON error rather than relying on it.
  • error instanceof Error ? error.message : 'Unknown error' is the standard narrowing idiom for caught errors (necessary because catch binds unknown/any).
  • Status codes: 200 (and a { status: 'OK', data } body) for success, 400 for bad input, 401 missing/invalid auth, 403 wrong role / blocked account, 404 not found (also the catch-all route), 409 conflicts, 500 for unhandled.
  • Logging is console.* — no logging library. Use console.info for lifecycle, console.warn for recoverable issues, console.error for failures, console.debug for verbose tracing. Emoji prefixes (, , ⚠️, 📡) are used widely as visual log markers. morgan is a dependency but the request logger line is commented out in src/app.ts.

ESLint / tsconfig implications

.eslintrc.js:

extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended'],
rules: {
  '@typescript-eslint/no-unused-vars': 'error',
  '@typescript-eslint/no-explicit-any': 'warn',   // any is allowed, just warned
  'prefer-const': 'error',
  'no-var': 'error',
}

tsconfig.json runs with strict: false and strictPropertyInitialization: false. Practical consequences you must keep in mind:

  • null/undefined are not checked. The compiler will not flag a missing null guard — add them by hand. Many entity fields are T | null.
  • any flows freely (only a warning), so type errors that strict mode would catch slip through. The (mentor as any).password deletes and the safe-error-narrowing idiom are direct consequences.
  • Class fields need no initializer (strictPropertyInitialization: false), which is why entities and services can declare public repo: Repository<X>; without ! or assignment.
  • Decorators on (experimentalDecorators + emitDecoratorMetadata) — required for TypeORM @Entity/@Column and reflect-metadata (imported first in src/app.ts and src/index.ts).
  • Build is via esbuild (npm run build), not tsc, so type errors do not fail the build — only npm run lint and runtime catch problems. Run npm run lint before pushing.

Edge cases, limits & gotchas

  • Register every new entity in the entities: [...] array in src/config/database.ts, or its table will never be created (auto-sync silently ignores unregistered classes).
  • Singletons only via getInstance()DatabaseService, RedisService, QueueService. Calling new will create a second instance with its own connections/pool.
  • @/ alias works because of two registrations: tsconfig.json paths (for ts-node via tsconfig-paths/register) and package.json _moduleAliases (for runtime via module-alias/register, bundled by esbuild). Both must stay in sync.
  • Auth is opt-in per route. A route with no authMiddleware is public. Several mentor/bot/webhook routes are intentionally public — do not assume a router is protected.
  • adminMiddleware is exact-match ADMIN. ADMIN is the universal-access role; it inherits everything. Note the former SUPERADMIN role was removed and migrated to admin — see the pre-sync migration in src/config/database.ts and the role enum in src/types/UserTypes.ts (USER, ADMIN, EXPERT, SALES, SALES_HEAD, …).
  • Multi-platform via x-platform header. Auth and some flows branch on req.headers['x-platform'] (mr-mentor vs my-analytics-school vs mr-hire). It is allowed as a CORS header (X-Platform in src/app.ts) and drives cross-platform lead creation in src/controllers/auth.controller.ts. New cross-product behaviour should read this header, not guess from the origin.
  • Feature flags default ON. FeatureFlags.* (src/utils/featureFlags.ts) only disable on the literal strings "false"/"0"; a process restart is required after changing the env var.
  • CORS in development is wide open (NODE_ENV === 'development' allows any origin in src/app.ts). Production relies on CORS_ALLOWED_ORIGINS (a JSON array string) plus the hard-coded default origins.
  • Body limit is 20mb (express.json({ limit: '20mb' })) — larger uploads must go through the S3 presigned-URL path, not the JSON body.
  • Tests run on Bun (bun test) while runtime is Node 20 — keep test code Bun-compatible.

Sibling-doc links are relative to docs/guidelines/; adjust if the surrounding tree differs.