volcanic-backend

License: MIT opensource volcanic-backend npm CI

volcanic-backend

A Node.js framework based on Fastify to build robust APIs quickly, featuring an automatic routing system, integrated authentication, and a powerful data access layer.

This branch is 5.0.0-alpha, and the data layer is being rewritten. The TypeORM data layer and the @volcanicminds/backend/typeorm subpath were removed; the Drizzle one, exposed as @volcanicminds/backend/db, is being built. Everything below this line still describes v4 until the rewrite lands, so read it as the 4.x reference.

What v5 will be is specified, not improvised: the plan is EVO_FRAMEWORK.md, the contracts are docs/SCHEMA_V5.md, docs/MAGIC_QUERY_V5.md, docs/MANAGERS_V5.md, docs/AUTHORIZATION_V5.md, docs/API_V5.md, docs/CONFIGURATION_V5.md and docs/TESTING_V5.md. For production, use 4.x from main.

What goes in the control plane

Outside the customer’s container sits only what you could publish.

The control plane holds the tenant registry, the platform administrators, identifiers, counters, status and configuration. It does not hold content written or uploaded by a customer, not even a title. A feature that needs to read across tenants is designed as an explicit aggregation; it is never solved by moving the data up into the control plane, because everything that lands there loses the boundary that makes a container deliverable, restorable and destroyable on its own.

The rule is short on purpose: it is meant to be quotable in a review, where the question is always the same — could this row be published without harming the customer it belongs to?

Two layers in one package

@volcanicminds/backend ships a DB-agnostic HTTP core and an optional data layer, cleanly separated:

The layers meet at one seam: start(decorators) on the core, into which you inject managers (or nothing — it falls back to Null-Object defaults). See llms.txt Part 0 for the model and Part 12 for end-to-end scenarios (public/private, Bearer/Cookie, with/without DB, single/multi-tenant, with @volcanicminds/tools).

Feature Matrix

A synthetic overview of the out-of-the-box (OOTB) capabilities of this opinionated package. ✅ = yes · — = no.

Feature In @volcanicminds/backend Via @volcanicminds/tools Active on startup Description
JWT auth (Bearer) ✅ — ✅ @fastify/jwt. Login/logout, isAuthenticated. Signing secret required (assertSecretStrength)
Refresh token ✅ — ✅ Separate refreshToken JWT namespace. Disable with JWT_REFRESH=false
Cookie auth mode ✅ — — @fastify/cookie. HttpOnly/Secure/SameSite signed cookie; needs COOKIE_SECRET. Enabled by AUTH_MODE=COOKIE
Token revocation ✅ — ✅ externalId pattern in the JWT: regenerating it invalidates all tokens (global logout / password change)
CORS ✅ — ✅ @fastify/cors. Preset exposing v-* pagination headers (v-total, v-page, …)
Helmet ✅ — ✅ @fastify/helmet. Security HTTP headers
Rate limit ✅ — ✅ @fastify/rate-limit, registered global:false → limits only opt-in routes (e.g. MFA) + 404 handler
Central error handler ✅ — ✅ Preserves controller status; hides details via HIDE_ERROR_DETAILS (default on in prod)
Route autodiscovery ✅ — ✅ Convention-based router loader
Schema autodiscovery ✅ — ✅ JSON Schema, deep-merge override on matching $id
Hooks autodiscovery ✅ — ✅ onRequest / onResponse / onError / preHandler / preSerialization
Middleware ✅ — ✅ isAuthenticated, isAdmin, pre/post auth & forgot-password, auto-loaded
Roles / RBAC ✅ — ✅ Per-route roles loader
i18n ✅ — ✅ i18n package, global.t
Logging ✅ — ✅ pino + pino-pretty, global.log
Native APIs ✅ — ✅ auth, users, token, health, tenants, tool, admin
Swagger / OpenAPI ✅ — — @fastify/swagger + @fastify/swagger-ui at /api-docs. Enabled by SWAGGER=true
Compression ✅ — — @fastify/compress. Opt-in (enable)
Multipart / uploads ✅ — — @fastify/multipart. Opt-in (enable)
Static files ✅ — — @fastify/static, single or multiple mounts. Opt-in (enable)
Raw body ✅ — — fastify-raw-body for webhooks/signatures. Opt-in (enable)
Scheduler / cron ✅ — — @fastify/schedule + toad-scheduler. Enabled by options.scheduler
In-memory cache ✅ — — LRU+TTL per-route cache (cache:), invalidateCache. Enabled by options.cache.enabled
Manifest endpoint ✅ — — GET /admin/manifest (gated by the manifest capability) for the admin console. Enabled by options.manifest.enabled
Multi-tenant ✅ — — Subdomain / header / query resolver. Enabled by options.multi_tenant.enabled (data layer)
Data layer (Magic Query) ✅ — — typeorm + query builder via subpath /typeorm. Optional peer deps (typeorm, pg, bcrypt, pluralize)
DB entity autoloading ✅ — — Auto-discovered entities/repositories; access via service.use(req.db)
MFA / TOTP (gatekeeper) ✅ — Core gatekeeper (202 + tempToken, /auth/mfa/*); TOTP implementation via injected mfaManager. Policy via MFA_POLICY
Resumable uploads (TUS) (mount) ✅ — TUS route mounted from injected transferManager
Mailer — ✅ — Email sending via tools
Object storage — ✅ — S3 / MinIO storage via tools
AI utilities — ✅ — AI helpers (Mastra) via tools

Runtime requirements & notable behavior (v3)

Changelog

4.0.1

Authorization redesign (breaking). Full model in docs/AUTHORIZATION_MODEL.md.

Migration. Consumers referencing backoffice must re-declare it in config/roles.ts (with the capabilities they need); stop bootstrapping an admin via /auth/register and set ADMIN_EMAIL; ensure a single-tenant instance has an admin or ADMIN_EMAIL at boot.

3.5.0

3.1.0

Documentation & Guides

llms.txt is the single, exhaustive, self-contained guide (for humans and LLM/Context7 agents): mental model, configuration, Magic Query, auth, the manager contract, the native API surface, and usage scenarios. The focused docs below drill into specific topics:

Based on

Volcanic Backend is a powerful, opinionated, and extensible Node.js framework for creating robust and scalable RESTful APIs. It’s built on modern, high-performance libraries like Fastify.

The framework provides a comprehensive set of built-in features including a filesystem-based router, JWT authentication, role-based access control, task scheduling, and seamless database integration, allowing developers to focus on business logic rather than boilerplate.

And, what you see in package.json.

Core Philosophy

Project sample

Volcanic Backend Sample - GitHub

Quick Start

Installation

npm install @volcanicminds/backend

For database interactions, the data layer is the subpath @volcanicminds/backend/typeorm. Install its optional peer dependencies only if you use it:

npm install typeorm bcrypt pluralize reflect-metadata pg

Minimal Working Example

This example demonstrates how to set up a basic server with a single endpoint.

1. Create your server entrypoint (index.ts):

// index.ts
import { start } from '@volcanicminds/backend'
import { start as startDatabase, userManager } from '@volcanicminds/backend/typeorm'
import { myDbConfig } from './src/config/database.js' // Assume you have a db config file

async function main() {
  // 1. Initialize the database connection (optional but recommended)
  await startDatabase(myDbConfig)

  // 2. Start the Volcanic Backend server
  // We pass the 'userManager' from the typeorm package to enable authentication
  await start({ userManager })
}

main().catch((err) => {
  console.error(err)
  process.exit(1)
})

2. Define a route (src/api/hello/routes.ts):

// src/api/hello/routes.ts
export default {
  routes: [
    {
      method: 'GET',
      path: '/',
      handler: 'world.sayHello'
    }
  ]
}

3. Create the controller (src/api/hello/controller/world.ts):

// src/api/hello/controller/world.ts
import { FastifyReply, FastifyRequest } from '@volcanicminds/backend'

export function sayHello(req: FastifyRequest, reply: FastifyReply) {
  reply.send({ message: 'Hello, World!' })
}

4. Run your server:

npm run dev

Now you can visit http://localhost:2230/hello and you will see {"message":"Hello, World!"}.

How to upgrade packages

npm run upgrade-deps

Project Structure

A typical project using volcanic-backend follows a convention-based structure to keep your code organized and predictable.

.
├── src/
│   ├── api/
│   │   └── products/                  # A feature or resource module
│   │       ├── controller/
│   │       │   └── product.ts         # Business logic for products
│   │       └── routes.ts              # Route definitions for products
│   │
│   ├── config/
│   │   ├── general.ts                 # General configuration settings
│   │   ├── database.ts                # Database connection settings
│   │   ├── plugins.ts                 # Configuration for Fastify plugins (CORS, Helmet, etc.)
│   │   ├── roles.ts                   # Custom role definitions
│   │   └── tracking.ts                # Auto tracking changes configuration (sperimental)
│   │
│   ├── entities/
│   │   └── product.e.ts               # TypeORM entity definitions
│   │
│   ├── hooks/
│   │   └── onRequest.ts               # Custom logic for the 'onRequest' lifecycle hook
│   │
│   ├── middleware/
│   │   └── myMiddleware.ts            # Custom middleware functions
│   │
│   ├── schedules/
│   │   └── example.job.ts             # Custom Job schedules (cron, interval)
│   │
│   └── schemas/
│       └── product.ts                 # JSON schemas for validation and Swagger
│
├── .env                               # Environment variables
└── index.ts                           # Server entrypoint

Environment (example)

NODE_ENV=development

HOST=0.0.0.0
PORT=2230

JWT_SECRET=yourSecret
JWT_EXPIRES_IN=5d

JWT_REFRESH=true
JWT_REFRESH_SECRET=yourRefreshSecret
JWT_REFRESH_EXPIRES_IN=180d

# LOG_LEVEL: trace, debug, info, warn, error, fatal
LOG_LEVEL=info
LOG_COLORIZE=true
LOG_TIMESTAMP=true
LOG_TIMESTAMP_READABLE=true
LOG_FASTIFY=false

SWAGGER=true
SWAGGER_HOST=myawesome.backend.com
SWAGGER_TITLE=API Documentation
SWAGGER_DESCRIPTION=List of available APIs and schemas to use
SWAGGER_VERSION=0.1.0

# MFA
MFA_POLICY=OPTIONAL

For docker may be useful set HOST as 0.0.0.0 (instead 127.0.0.1).

How to run

npm run dev
npm run start
npm run prod

When you execute npm run dev the server is restarted whenever a .js/.ts file is changed (thanks to nodemon)

How to test (logic)

npm run test          # core + typeorm units (includes loose perf GUARDRAILS)
npm run test:pglite   # data-layer integration on embedded PGlite
npm run test:perf     # PERFORMANCE suite (run on demand) — see below

Performance: two layers

Configuration Reference

Environment Variables

The framework is configured via .env variables. Below is a comprehensive list:

Variable Description Required Default
NODE_ENV The application environment. No development
HOST The host address for the server to listen on. Use 0.0.0.0 for Docker. No 0.0.0.0
PORT The port for the server to listen on. No 2230
JWT_SECRET Secret key for signing JWTs. Yes  
JWT_EXPIRES_IN Expiration time for JWTs (e.g., 5d, 12h). No 5d
JWT_REFRESH Enable refresh tokens. No true
JWT_REFRESH_SECRET Secret key for signing refresh tokens. Yes¹  
JWT_REFRESH_EXPIRES_IN Expiration time for refresh tokens. No 180d
LOG_LEVEL Logging verbosity (trace, debug, info, warn, error, fatal). No info
LOG_COLORIZE Enable colorized log output. No true
LOG_TIMESTAMP Enable timestamps in logs. No true
LOG_TIMESTAMP_READABLE Use a human-readable timestamp format. No true
LOG_FASTIFY Enable Fastify’s built-in logger. No false
SWAGGER Enable Swagger/OpenAPI documentation. No true
SWAGGER_HOST The base URL for the API, used in Swagger docs. No localhost:2230
SWAGGER_TITLE The title of the API documentation. No API Documentation
SWAGGER_DESCRIPTION The description for the API documentation. No  
SWAGGER_VERSION The version of the API. No 0.1.0
SWAGGER_PREFIX_URL The path where Swagger UI is available. No /api-docs
MFA_POLICY MFA Security Policy (OPTIONAL, MANDATORY, ONE_WAY) No OPTIONAL
AUTH_CODE_SIZE Length of the generated authorization codes (nanoid). No 10
MFA_APP_NAME Name of the application displayed in Authenticator apps. No VolcanicApp
MFA_ADMIN_FORCED_RESET_EMAIL Admin email for emergency MFA reset No  
MFA_ADMIN_FORCED_RESET_UNTIL ISO Date string until which the reset is active No  
AUTH_MODE Authentication mode: BEARER (default) or COOKIE No BEARER
COOKIE_SECRET Secret for signing cookies (Required if AUTH_MODE=COOKIE) Yes²  
ADMIN_EMAIL Seeds the first identity at boot, and is read at no other time (see below). Yes³  
ADMIN_PASSWORD Password for the founder created from ADMIN_EMAIL; if unset, a strong one is generated and printed to stdout. No  
HIDE_ERROR_DETAILS Prevent error details (message) from being sent in response. No true (prod)

² Required if AUTH_MODE is COOKIE.

³ Read only at boot, to seed the first identity: the application’s sovereign founder on a single-tenant instance, the first platform administrator where a tenants block is declared. If one already exists it may be omitted; with no identity and no ADMIN_EMAIL, startup fails fast.

After that first write the variable is never read again. Being the founder is the is_founder column of the user row, inside its own container, so two tenants each have their own and neither inherits the other’s protections. In v4 the check compared the address against this variable at request time, which made the same address sovereign inside every tenant (defect D-27). Changing ADMIN_EMAIL afterwards does not move the sovereignty: a container that already has a founder keeps it, and the boot log says so. See docs/AUTHORIZATION_MODEL.md.

¹ Required if JWT_REFRESH is enabled.

Logging levels

In the .env file you can change log settings in this way:

# LOG_LEVEL: trace, debug, info, warn, error, fatal
LOG_LEVEL=debug
LOG_TIMESTAMP=true
LOG_TIMESTAMP_READABLE=false
LOG_COLORIZE=true

Log levels:

a bit of code:

log.trace('Annoying message')
log.debug('Where is my bug?')
log.info('Useful information')
log.warn(`Hey pay attention: ${message}`)
log.error(`Catch an exception: ${message}`)
log.fatal(`Catch an exception: ${message} even if it's too late, sorry.`)

// use the proper flag to check if the level log is active (to minimize phantom loads)
log.i && log.info('Total commissions -> %d', aHugeCalculation())

// f.e.
log.t && log.trace('print a message')
log.d && log.debug('print a message')
log.i && log.info('print a message')
log.w && log.warn('print a message')
log.e && log.error('print a message')
log.f && log.fatal('print a message')

Other settings:

Defaults, see logger.ts:

const logColorize = yn(LOG_COLORIZE, true)
const logTimestamp = yn(LOG_TIMESTAMP, true)
const logTimestampReadable = yn(LOG_TIMESTAMP_READABLE, true)

Bearer token

JWT_SECRET=yourSecret
JWT_EXPIRES_IN=5d

JWT_REFRESH=true
JWT_REFRESH_SECRET=yourRefreshSecret
JWT_REFRESH_EXPIRES_IN=180d

# Auth Mode: BEARER (default) or COOKIE
AUTH_MODE=BEARER
COOKIE_SECRET=super_secret_cookie_key_change_me

The framework supports two mutually exclusive authentication modes, controlled by AUTH_MODE in .env.

1. Bearer Token Mode (AUTH_MODE=BEARER) - Default

To enable Cookie mode, you must set COOKIE_SECRET in .env.

With reply.jwtSign(payload) is possible obtain a fresh JWT token. Each authenticated calls must be recalled specifying in the header:

Authorization: Bearer <generated-token>

With await reply.server.jwt['refreshToken'].sign(payload) is possible obtain a new Refresh JWT token.

All tokens (authorization and refresh) can be invalidated through the appropriate route. Example: Both JWT_SECRET and JWT_REFRESH_SECRET can be generated with a command like openssl rand -base64 64

Swagger

In the .env file you can change swagger settings in this way:

SWAGGER=true
SWAGGER_HOST=localhost:2230
SWAGGER_TITLE=Volcanic API Documentation
SWAGGER_DESCRIPTION=List of available APIs and schemes to use
SWAGGER_VERSION=0.1.0
SWAGGER_PREFIX_URL=/documentation

Fastify modules

Under the folder src/config is possible add a file plugin.ts where you can activate/customize some modules in this way:

// src/config/plugins.ts
export default [
  {
    name: 'cors',
    enable: false,
    options: {}
  },
  {
    name: 'rateLimit',
    enable: false,
    options: {}
  },
  {
    name: 'helmet',
    enable: false,
    options: {}
  },
  {
    name: 'compress',
    enable: false,
    options: {}
  },
  {
    name: 'multipart',
    enable: false,
    options: {}
  },
  {
    name: 'rawBody',
    enable: false,
    options: {}
  }
]

Here the plugins used:

import cors from '@fastify/cors'
import helmet from '@fastify/helmet'
import compress from '@fastify/compress'
import rateLimit from '@fastify/rate-limit'
import multipart from '@fastify/multipart'
import rawBody from 'fastify-raw-body'

Database Context (req.db)

One of the most important features of Volcanic Backend is the Universal Database Context. Every request object (req) is guaranteed to have a req.db property populated with a valid TypeORM EntityManager.

This allows you to write code that works Out-Of-The-Box for both single-tenant and multi-tenant applications without changing a single line of business logic.

Usage:

// In your controller
const { headers, records } = await myService.use(req.db).findAll(...)

Core Concepts: Routes and Controllers

The routing system is one of the core strengths of the framework. It’s file-system based, meaning the framework automatically discovers and registers any routes.ts file within the src/api/ directory.

Routes

At its simplest, a route needs only a method, path, and handler. The handler is a string that points to a function in a controller file.

Minimal setup (routes.ts):

export default {
  routes: [
    {
      method: 'GET',
      path: '/',
      handler: 'myController.test'
    }
  ]
}

Some notes:

// src/api/example/routes.ts
export default {
  config: {
    title: 'Example of routes.ts',
    description: 'Example of routes.ts',
    controller: 'controller',
    tags: ['user', 'code'], // swagger
    enable: true,
    deprecated: false, // swagger
    version: false // swagger
  },
  routes: [
    {
      method: 'GET',
      path: '/',
      roles: [],
      handler: 'demo.user',
      middlewares: ['global.isAuthenticated'],
      config: {
        enable: true,
        title: 'Demo title', // swagger summary
        description: 'Demo description', // swagger
        tags: ['user', 'code'], // swagger
        deprecated: false, // swagger
        version: false, // swagger
        response: {
          200: {
            $description: 'Successful response',
            type: 'object',
            properties: {
              id: { type: 'number' }
            }$
          }
        } // swagger
      }
    }
  ]
}

Securing a Route with Roles: You can easily protect routes using Role-Based Access Control (RBAC). The framework includes built-in roles (public, admin) and allows you to define your own.

{
  method: 'POST',
  path: '/',
  handler: 'product.create',
  roles: [roles.admin] // Only users with the 'admin' role can access this
}

Alternatively, gate by capability: the allowed set becomes admin plus every role that declares it. The framework reserves users, tokens and manifest; a consumer grants its own capabilities to its roles in config/roles.ts.

{
  method: 'GET',
  path: '/',
  handler: 'product.find',
  requireCapability: 'catalog' // admin, or any role that declares the `catalog` capability
}

Adding Middleware: Apply custom logic before your controller is executed using middleware. The framework comes with global.isAuthenticated to ensure a user is logged in.

{
  method: 'GET',
  path: '/:id',
  handler: 'product.findOne',
  middlewares: ['global.isAuthenticated'] // Requires a valid JWT, accessible to any role
}

Documenting with Swagger (config): The config object allows you to enrich your route with information for the automatically generated Swagger/OpenAPI documentation.

{
  method: 'GET',
  path: '/:id',
  handler: 'product.findOne',
  middlewares: ['global.isAuthenticated'],
  config: {
    title: 'Find a Product',
    description: 'Retrieves a single product by its unique ID.',
    params: { $ref: 'onlyIdSchema#' }, // References a JSON schema for validation
    response: {
      200: {
        description: 'Successful response',
        $ref: 'productSchema#'
      }
    }
  }
}

Controllers

Controllers contain the functions that handle requests. The req object is enriched with helpers to simplify data access.

// src/api/example/controller/demo.ts
import { FastifyReply, FastifyRequest } from '@volcanicminds/backend'

export function user(req: FastifyRequest, reply: FastifyReply) {
  reply.send(req.user || {})
}

Useful methods / objects:

Advanced Data Access with req.data() and @volcanicminds/backend/typeorm

The real power is unlocked when combining req.data() with @volcanicminds/backend/typeorm.

// src/api/products/controller/product.ts
import { FastifyReply, FastifyRequest } from '@volcanicminds/backend'
import { executeFindQuery } from '@volcanicminds/backend/typeorm'
import { Product } from '../../../entities/product.e.js'

export async function find(req: FastifyRequest, reply: FastifyReply) {
  // Always resolve the repository from the request-scoped EntityManager (`req.db`):
  // it is multi-tenant safe. NEVER use the `global.repository.X` accessor — it is
  // forbidden at runtime by a fail-fast Proxy.
  const productRepo = req.db.getRepository(Product)

  // req.data() automatically gets all query string (or body!) parameters
  // executeFindQuery translates them into a full TypeORM query with pagination, sorting, and filtering
  const { headers, records } = await executeFindQuery(
    productRepo,
    { category: true }, // Eagerly load the 'category' relation
    req.data()
  )

  // The 'headers' object contains pagination metadata (v-total, v-pageCount, etc.)
  return reply.headers(headers).send(records)
}

This single controller function can handle a wide variety of requests without any additional code, such as:

Database Synergy with @volcanicminds/backend/typeorm

While volcanic-backend can run with any data layer (or none), it ships a built-in one as the subpath @volcanicminds/backend/typeorm. This combination provides a powerful, query-string-driven API out-of-the-box.

How it Works:

  1. Client Request: A client sends a request with query parameters for filtering, sorting, and pagination. GET /api/products?page=1&sort=name:asc&price:gt=100

  2. volcanic-backend Controller: The controller uses the req.data() helper to grab all query parameters.

  3. Data-layer Translation: The executeFindQuery function from @volcanicminds/backend/typeorm receives these parameters and uses its internal applyQuery engine to translate them into a rich TypeORM query object, including where, order, skip, and take clauses. It automatically handles the syntax for different databases (e.g., ILIKE for PostgreSQL, $regex for MongoDB).

  4. Database Execution: TypeORM executes the optimized query against the database.

  5. Response with Headers: executeFindQuery returns the records and a set of custom pagination headers (v-total, v-pageCount, etc.), which the controller then sends back to the client.

This powerful synergy allows you to build complex, high-performance data endpoints with minimal effort. See the Database (data layer) section below for the complete query syntax, multi-tenancy rules, and API reference (also in llms.txt, Part 3).

Roles

By default, there are two built-in roles:

Roles may also carry capabilities — the framework reserves users, tokens and manifest — so a consumer-defined role can be granted a native surface (user or token management, or loading the admin console) without being admin. Gate a route with requireCapability instead of a role list; at boot the allowed set becomes admin plus every role that declares the capability. The admin apex is protected: only an admin can grant the admin role (and only with allow_multiple_admin), no capability holder can act on an admin subject, and the instance never boots with zero admins. The sovereign founder is provisioned at boot from ADMIN_EMAIL, and from then on it is the is_founder column of that row, not a comparison against the environment. See docs/AUTHORIZATION_MODEL.md.

Authorization responses: a request with no authenticated subject gets 401 (must log in); an authenticated user lacking the required role gets 403. Error bodies follow a single shape: { statusCode, error, code?, message? }.

In this way you can add custom roles:

// src/config/roles.ts
import { Role } from '@volcanicminds/backend'

export const roles: Role[] = [
  {
    code: 'customer',
    name: 'Customer',
    description: 'Customer role'
  }
]

You can use something like this to specify which roles (routes.ts) can recall some routes:

roles: [roles.admin, roles.public]

Database (data layer)

This chapter still describes v4, and v5 has replaced it. It is left standing because a project on 4.x is still reading it, and it will be rewritten when phase 7 of the rebuild closes. What already changed: the subpath is @volcanicminds/backend/db, the ORM is not part of the API any more, global.connection and req.runner are gone, a container is chosen by qualifying the tables rather than by switching a session, and managers take the data handle as their first argument. The conversion, entry by entry, is in docs/MIGRATION_V4_V5.md; the v5 contracts are in docs/SCHEMA_V5.md, docs/MAGIC_QUERY_V5.md, docs/MANAGERS_V5.md and docs/CONFIGURATION_V5.md.

In particular, runInTenantContext and switchContext below no longer exist. A background job declares its plane and receives its handle: see Where a job runs.

The data layer (Magic Query + multi-tenant) is the subpath @volcanicminds/backend/typeorm. It dynamically translates HTTP query-string parameters into complex pagination, sorting, and filtering queries, with a database-agnostic abstraction layer that works with both SQL (e.g. PostgreSQL) and NoSQL (e.g. MongoDB) for most common use cases. Import it directly:

import { start as startDatabase, userManager, DataSource } from '@volcanicminds/backend/typeorm'

Install its optional peer dependencies in your app (only if you use the data layer):

npm install typeorm bcrypt pluralize reflect-metadata pg

For the full options and environment variables see docs/CONFIGURATION.md; llms.txt (Part 3) is the exhaustive reference. The essentials you need day-to-day are below.

Embedded engine (PGlite) — zero‑setup Postgres

For local dev, tests, demos and prototypes you can swap the external Postgres for PGlite, an in‑process WASM Postgres — no server, no Docker. Same dialect, same code; just change the config type:

export const database: Database = {
  default: { type: 'pglite', vector: true, synchronize: true } // in-memory; add dataDir to persist
}
npm install typeorm-pglite @electric-sql/pglite   # + @electric-sql/pglite-pgvector for vector:true

A real Postgres server stays the production‑grade choice. See docs/PGLITE.md for the full options, the Postgres‑vs‑PGlite trade‑off table, pgvector usage and multi‑tenant caveats. Run the embedded integration suite with npm run test:pglite.

Core features

Core concept

The library bridges flat HTTP query strings and the structured query objects TypeORM expects:

HTTP Query String → applyQuery() → TypeORM Query Object

Usage

Integrated (recommended) — executeFindQuery does everything:

// src/api/users/controller/user.ts
import { FastifyReply, FastifyRequest } from '@volcanicminds/backend'
import { executeFindQuery } from '@volcanicminds/backend/typeorm'
import { User } from '../../../entities/user.e.js' // Your Entity

export async function find(req: FastifyRequest, reply: FastifyReply) {
  // 1. Resolve the repository from the request context (multi-tenant safe)
  const userRepo = req.db.getRepository(User)

  // 2. executeFindQuery handles pagination, sorting, filtering and headers
  const { headers, records } = await executeFindQuery(
    userRepo,
    { company: true }, // Optional relations to include
    req.data()
  )

  return reply.type('application/json').headers(headers).send(records)
}

Standalone — use applyQuery directly in any TypeORM project:

import { applyQuery } from '@volcanicminds/backend/typeorm'
import { myUserRepository } from './repositories' // Your TypeORM repository instance

app.get('/users', async (req, reply) => {
  // applyQuery translates the request query into a TypeORM query object
  const typeOrmQuery = applyQuery(req.query, {}, myUserRepository)

  const [records, total] = await myUserRepository.findAndCount(typeOrmQuery)

  reply.send({ data: records, total })
})

Query string guide

Pagination — page (default 1) and pageSize (default 25). GET /users?page=2&pageSize=50

Sorting — sort=field (asc) or sort=field:desc; repeat for multi-field sorting. GET /users?sort=lastName:asc&sort=createdAt:desc

Filtering — field:operator=value. With no operator it defaults to equality (:eq).

Case sensitivity (since v3). The text operators are case-INsensitive by default: ?name=mario matches “Mario”. The convention is base = insensitive, suffix s = strict/case-sensitive, suffix i = insensitive (explicit alias). So :eq ⇄ :eqi (insensitive) and :eqs (sensitive). :eq stays type-aware: numbers/booleans/null use exact matching (no ILIKE on an int column). Flip the global default with the data-layer option caseInsensitiveByDefault: false (or env VOLCANIC_CASE_INSENSITIVE_DEFAULT=false) to restore legacy case-sensitive base operators. Note: insensitive matching can’t use a plain B-tree index — prefer *s for indexed exact lookups.

Operator Description Example PostgreSQL MongoDB
:eq · :eqi · :eqs Equals — insensitive · insensitive · strict ...&country=it ✅ ✅
:neq · :neqi · :neqs Not equals (same scheme) ...&status:neq=archived ✅ ✅
:gt :ge :lt :le Greater/less than (or equal) ...&visits:gt=100 ✅ ✅
:between · :nbetween (NOT) between two values, colon-sep. ...&created:between=2024-01-01:2024-12-31 ✅ ✅
:in · :nin (NOT) included in a comma list ...&status:in=active,pending ✅ ✅
:null · :notNull Is (not) null ...&deletedAt:null=true ✅ ✅
:isEmpty · :isNotEmpty Equals (not) empty string '' ...&note:isEmpty=1 ✅ ✅
:contains · :containss · :containsi Contains — insensitive · strict · insensitive ...&name:contains=corp ✅ ✅*
:ncontains · :ncontainss · :ncontainsi Does NOT contain ...&tag:ncontains=old ✅ ✅*
:starts · :startss · :startsi · :nstarts… (NOT) starts with ...&code:starts=inv- ✅ ✅*
:ends · :endss · :endsi · :nends… (NOT) ends with ...&file:ends=.pdf ✅ ✅*
:like · :likes · :likei · :nlike… (NOT) manual LIKE pattern ...&code:like=a-% ✅ ✅*
:overlap Array overlap && (common elements) ...&tags:overlap=acme,globex ✅ ✅
:arrayContains Array contains all @> ...&tags:arrayContains=fruit,red ✅ ✅
:arrayContainedBy Array contained by <@ ...&tags:arrayContainedBy=a,b,c ✅ ✅
:jsonHasKey JSONB has key ? ...&meta:jsonHasKey=color ✅ ✅
:jsonHasAnyKey JSONB has any key ?\| ...&meta:jsonHasAnyKey=color,size ✅ ✅
:jsonHasAllKeys JSONB has all keys ?& ...&meta:jsonHasAllKeys=color,size ✅ ✅
:raw Raw SQL ⚠️ Dangerous — disabled by default (see security note). ...&age:raw=> 18 ✅ ✅

* The s/i text variants map to LIKE/ILIKE on Postgres and to (case-insensitive) RegExp on MongoDB. :between/:nbetween work on numbers and dates. Operator names are case-insensitive: :isEmpty, :isempty and :ISEMPTY are all valid and equivalent.

Nested relation filters — dot notation on related entities: GET /users?company.name:eq=Volcanic Minds

Complex boolean logic with _logic — give conditions short aliases and combine them with nested AND/OR. Syntax: field:operator[alias]=value (the [alias] is optional; it defaults to the full parameter key).

# Find users whose first name is 'Mario' OR last name is 'Rossi'
?firstName:eq[fn]=Mario&lastName:eq[ln]=Rossi&_logic=(fn OR ln)

# (active users from Italy) OR (pending users from Germany)
?status:eq[s1]=active&country:eq[c1]=IT&status:eq[s2]=pending&country:eq[c2]=DE&_logic=((s1 AND c1) OR (s2 AND c2))

Security: sensitive fields & the :raw operator

Multi-Tenancy (Unified Context Pattern)

Postgres multi-tenancy is enforced via schema isolation (SET search_path) with a strict, leak-proof pattern.

Global context switching is forbidden. switchContext(tenant) without an EntityManager throws a fatal error — changing the global search_path would poison the shared pool and leak data across tenants. Always pass the EntityManager of a dedicated QueryRunner:

// ❌ FORBIDDEN — throws "CRITICAL: Attempted UNSAFE global context switch"
await tenantManager.switchContext(tenant)

// ✅ CORRECT — bound to a single QueryRunner
const qr = dataSource.createQueryRunner()
await qr.connect()
await tenantManager.switchContext(tenant, qr.manager)

Tenant resolution is strict. resolveTenant(req) requires the tenant header (default x-tenant-id). If the JWT carries a tenant binding (req.user.tid) and it does not match the header, the request is rejected (tenant-isolation / IDOR protection). Only active tenants resolve.

Background jobs / system tasks must use the helper, which creates, switches, and safely releases a QueryRunner (always resetting search_path to public on exit):

await tenantManager.runInTenantContext('tenant-slug', async (em) => {
  // `em` is an isolated EntityManager already bound to the tenant schema
  return em.getRepository(Order).find()
})

API reference

executeFindView / executeCountView are the view-backed counterparts, with the same signatures.

Useful scripts

Migrations

The schema of every container is versioned. There is no synchronise-at-startup and no POST /tool/synchronize-schemas: both are incompatible with a schema that has a version, and both are gone in v5.

npm run db:generate          # the control set: registry, platform identities, app tables
npm run db:generate:tenant   # the tenant set: what lives inside a customer's container

drizzle-kit emits plain SQL into lib/database/migrations/<set>/, and it is committed and reviewed like any other code: what runs against a customer’s database is what a reviewer reads, in the language the database speaks. Your own entities’ migrations go in ./migrations/<set>/ in your project; the framework applies both, its own folder first.

Two sets, because they have different lives

Set Contains Applied
control the tenant registry, the platform identities, the impersonation log, and the application tables (they live here when there is no tenants block) once, npm run db:migrate
tenant the application tables and the container’s own audit trail. Nothing about the platform once per container, by provisioning and by the fleet migrator

The tables that exist in both (user, token, change, migration) are duplicated, on purpose: the two sets never share a file. A shared migration would make “two sets” a naming convention, and one edit would move a customer’s container and the registry together whether or not that was the intent. npm run check:migration-sets enforces it in CI, including that no tenant container ever gains a copy of the registry.

npm run db:migrate           # the control plane, once
npm run db:migrate -- --dry  # list what would be applied, touch nothing

The applied version is recorded in a migration table inside each container, never in a central one: when a tenant is restored from a backup its schema version has to travel back with it.

Forward only, and why

There is no down. drizzle-kit does not generate one, and a down on a destructive migration restores the shape and not the data, which is a promise that fails exactly when it is called on.

Reversibility lives in the release, and the rule is expand / contract:

  1. the migration that ships with a release is additive: a new column, a new table, a double write. The old code still works against it;
  2. the destructive half (dropping the old column, removing the old table) ships in a later release, once the new code is running everywhere.

So rolling back means deploying the previous code, with the data untouched. A release that adds and drops in one step is a release that cannot be rolled back, whatever the migration tool claims.

Editing a migration that already ran

You cannot. The framework stores each file’s checksum next to its name and refuses to touch a container whose applied migration no longer matches the repository: the two disagree about what happened to that schema, and guessing which one is right is how a schema becomes unreadable. Add a new migration instead.

Change tracking (audit trail)

Declare which routes are tracked in src/config/tracking.ts. Every tracked write appends a row to the change table inside the container the request worked in, so a tenant’s audit trail lives next to the data it describes.

// src/config/tracking.ts
export default {
  config: {
    enableAll: true,
    primaryKey: 'id',
    strict: true // the deployment-wide default; see below
  },
  changes: [{ method: 'PUT', path: '/users/:id', entity: 'user', fields: { excludes: ['password'] } }]
}

When the trail cannot be written

The default is strict: the request fails, with HTTP 500 and the code TRACKING_FAILED. A system that promises an audit trail and silently keeps none is worse than one that fails visibly, and in v4 that is exactly what happened: in multi-tenant the write was refused, the refusal was swallowed into a log line, and the response was still a 200.

Where the trail is genuinely accessory, a route opts out:

{ method: 'PUT', path: '/preferences/:id', handler: 'prefs.update', config: { tracking: { strict: false } } }

Precedence is route, then config.strict in src/config/tracking.ts, then strict.

Two things worth knowing before choosing:

Hooks

It’s possible add hook to application or request/reply lifecycles. More info on Fastify Hooks.

Available hooks are:

const hooks = [
  'onRequest',
  'onError',
  'onSend',
  'onResponse',
  'onTimeout',
  'onReady',
  'onClose',
  'onRoute',
  'onRegistry',
  'preParsing',
  'preValidation',
  'preSeralization',
  'preHandler'
]

Under src create the hooks folder and inside add the hook as shown in the fastify docs, for example:

// src/hooks/onRequest.ts

async function hook(req, reply) {
  log.debug('onRequest called')
}

export { hook }

Schemas

It’s possible add schemas referenceable by $ref. More info on Fastify Validation & Serialization.

Under src create the schemas folder and inside add the schema as shown in the fastify docs, for example:

// src/schemas/commonSchemas.ts

export const commonSchema = {
  $id: 'commonSchema',
  type: 'object',
  properties: {
    hello: { type: 'string' }
  }
}

export const commonSchemaAlt = {
  $id: 'commonSchemaAlt',
  type: 'object',
  properties: {
    world: { type: 'string' }
  }
}

So, in your routes.ts (under the section config) you’ll can use something like this:

  params: { $ref: 'commonSchema#' },
  query: { $ref: 'commonSchema#' },
  body: { $ref: 'commonSchema#' },
  headers: { $ref: 'commonSchema#' }

Reset tokens on login

It’s possible to specify that all JWT tokens belonging to the user who logs in are reset at each login. To enable this feature, it’s necessary to add or change the property reset_external_id_on_login to true (the default is false).

// src/config/general.ts
'use strict'

export default {
  name: 'general',
  options: {
    reset_external_id_on_login: true
  }
}

Multi-Factor Authentication (MFA)

The framework provides a robust, built-in Multi-Factor Authentication system based on TOTP (Time-Based One-Time Password).

For detailed configuration, security policies, and setup flows, please refer to the Security & MFA Guide.

MFA behavior is controlled globally via environment variables (MFA_POLICY) or the configuration file src/config/general.ts.

MFA Policies

Note: In all cases, an administrator can force an MFA reset for a user.

Disable embedded authorization

Out-of-the-box, the framework automatically secures all routes by checking for a valid (Bearer) JWT token if roles are defined for that route. However, if you want to disable this automatic authorization check and handle it manually within your controllers or middleware, you can do so by setting the embedded_auth option to false.

// src/config/general.ts
'use strict'

export default {
  name: 'general',
  options: {
    embedded_auth: false
  }
}

Job Scheduler

It’s possible to add a job scheduler. For more information, go to Fastify Schedule. To enable this feature, it’s necessary to add or change the property scheduler to true (the default is false).

// src/config/general.ts
'use strict'

export default {
  name: 'general',
  options: {
    scheduler: true
  }
}

All jobs are to be created and placed in appropriate files under the /src/schedules/ folder. Each file name must follow the pattern *.job.ts (for example, test.job.ts).

Inside each job, both the configuration part and the job to be executed must be included using this syntax:

// src/schedules/test.job.ts
import { DataHandle, JobRun, JobSchedule } from '@volcanicminds/backend'

export const schedule: JobSchedule = {
  active: true,
  scope: 'control',
  interval: {
    seconds: 2
  }
}

export async function job(ctx: DataHandle, run: JobRun) {
  log.info(`tick job ${run.jobName} every 2 seconds`)
}

Where a job runs

A job declares its plane and receives its handle. It never looks a connection up, and that is the whole difference from v4, where a job was called with no arguments and everything it read went through a global connection: in a multi-tenant deployment it therefore ran inside whichever customer’s schema the pool happened to hold.

scope Runs Receives
'control' (default) once, on the platform a ControlHandle
'tenant' once, inside the container named by tenant: '<slug>' that tenant’s handle, and its registry row in run.tenant
'every-tenant' once per active tenant each tenant’s handle in turn

The default is control on purpose: a job that says nothing must not end up inside a customer’s data. A job that names a plane the deployment does not have (tenant without a slug, every-tenant with no tenants block, an unknown scope) is refused at load, with the reason on the error log, rather than failing at its first tick.

every-tenant walks the fleet with concurrency containers at a time (default 1, maximum 16). One tenant’s failure does not cancel the others: they all run, each failure is logged with its tenant, and the job then fails once with the whole list. run.signal is aborted when the server closes, so a long sweep stops instead of running on against a closing pool.

export const schedule: JobSchedule = {
  active: true,
  scope: 'every-tenant',
  concurrency: 4,
  cron: { expression: '0 3 * * *' }
}

export async function job(ctx: DataHandle, run: JobRun) {
  if (run.signal.aborted) return
  log.info(`nightly cleanup for ${run.tenant?.slug}`)
}

The job scheduling can have this configuration:

export interface JobSchedule {
  active: boolean // boolean (required)
  type?: string // cron|interval, default: interval
  async?: boolean // boolean, default: true
  preventOverrun?: boolean // boolean, default: true

  scope?: 'control' | 'tenant' | 'every-tenant' // default: 'control'
  tenant?: string // required with scope 'tenant': the tenant slug
  concurrency?: number // scope 'every-tenant' only, default 1, max 16

  cron?: {
    expression?: string // required if type = 'cron', use cron syntax (if not specified, cron will be disabled)
    timezone?: string // optional, like "Europe/Rome" (to test)
  }

  interval?: {
    days?: number // number, default 0
    hours?: number // number, default 0
    minutes?: number // number, default 0
    seconds?: number // number, default 0
    milliseconds?: number // number, default 0
    runImmediately?: boolean // boolean, default: false
  }
}

The active property is a boolean and is mandatory. The type property can have values of cron or interval (default). If the type is cron, the properties defined under cron are also considered. If the type is interval, the properties defined under interval are also considered.

For cron type, the cron.expression property is mandatory and indicates the scheduling to be executed. The timezone property is considered experimental and should be defined, for example, as "Europe/Rome".

Below an example:

// src/schedules/test.job.ts
import { JobSchedule } from '@volcanicminds/backend'

export const schedule: JobSchedule = {
  active: true,
  type: 'cron',

  // Run a task every 2 seconds
  cron: {
    expression: '*/2 * * * * *'
  }
}

Below the cron schema:

┌──────────────── (optional) second (0 - 59)
│ ┌────────────── minute (0 - 59)
│ │ ┌──────────── hour (0 - 23)
│ │ │ ┌────────── day of month (1 - 31)
│ │ │ │ ┌──────── month (1 - 12, January - December)
│ │ │ │ │ ┌────── day of week (0 - 6, Sunday-Monday, Sunday is equal 0 or 7)
│ │ │ │ │ │
│ │ │ │ │ │
* * * * * *

// f.e. for every 2 seconds
const expression = '*/2 * * * * *'

A useful site that can be used to check a cron configuration is crontab.guru

For interval type, the sum of the properties days, hours, minutes, seconds, milliseconds (properly converted) must be equal to or greater than 1 second, otherwise the job will not be executed.

The runImmediately property indicates that the interval task will be executed for the first time immediately and not after the defined wait time.

Below an example:

// src/schedules/test.job.ts
import { JobSchedule } from '@volcanicminds/backend'

export const schedule: JobSchedule = {
  active: true,
  type: 'interval',

  // Run a task every 1h 5m 30s
  interval: {
    days: 0,
    hours: 1,
    minutes: 5,
    seconds: 30,
    milliseconds: 0,
    runImmediately: false
  }
}

Other properties common to both types of jobs are:

Raw Body

Sometimes, it’s useful to have the original raw body of the incoming request. For this purpose, the rawBody plugin is available out-of-the-box.

Under config/plugins.ts, modify your plugin configuration as follows to activate it:

  {
    name: 'rawBody',
    enable: true,
    options: {}
  }

Fot the options refers to (fastify-raw-body - github)[https://github.com/Eomm/fastify-raw-body] but for common use, you can configure it in this way:

{
  name: 'rawBody',
  enable: true,
  options: {
    global: false, // adds the rawBody to every request. **Default is true**. If false, you need to enable it for specific routes.
    runFirst: true, // get the body before any preParsing hook change/uncompress it. **Default false**
  }
}

Normally, it’s a good choice set global to falseand runFirst to true.

Please, do not change the field value in the options above. The default is rawBody, and this field name will be used in each request.

Warning

Setting global: false and then the route configuration { config: { rawBody: true } } will save memory and imporove perfromance of your server since the rawBody is a copy of the body and it will double the memory usage. So use it only for the routes that you need to.

Rate Limit

It is possible to enable rate limiting either globally or at the individual route level. All configuration and functionality are managed by the Fastify Rate Limit plugin.

At the global configuration level, you can set something like:

// config/plugin.ts
{
    name: 'rateLimit',
    enable: true,
    options: {
      global: true, // default true
      max: 40, // default 1000
      timeWindow: 3000, // default 1000 * 60
      cache: 10000, // default 5000
      nameSpace: 'your-application-ratelimit-', // default is 'fastify-rate-limit-'
      skipOnError: true // default false
    }
  },

While at the route level, if necessary, you can redefine or set different rate limits:

// f.e. /api/example/routes.ts
{
      method: 'GET',
      path: '/test',
      roles: [],
      handler: 'example.test',
      middlewares: [],
      rateLimit: {
        max: 10,
        timeWindows: 20000 // milliseconds
      },
      config: {
        title: 'Rate limit example',
        description: 'Rate limit example',
        response: {
          200: { $ref: 'defaultResponse#' }
        }
      }
    }

rawBody on specific route

If you set global to false, you can enable the rawBody for a specific route (common use, hooks to validate Stripe signature). Alternatively, if you set global to true or leave global not specified/undefined, it will enable rawBody on all routes, and you can disable it for single route in the following way.

A simple note: in the example below, you can see rawBody enabled on the /example endpoint. You can also disable rawBody by setting it to false instead of true.

// f.e. /api/example/routes.ts
{
  method: 'GET',
  path: '/',
  roles: [],
  handler: 'example.test',
  middlewares: [],
  config: {
    title: 'How to use req.rawBody',
    description: 'How to use req.rawBody',
    rawBody: true,
    response: {
      200: { $ref: 'defaultResponse#' }
    }
  }
}