# CalmAPI documentation > CalmAPI 2 is a backend framework for Node.js and MongoDB where every line of framework code lives in your repo: resources, policies, auth, devtools, blocks and an upgrade path. --- Source: https://docs.calmapi.dev/ # Why CalmAPI **CalmAPI 2 is the backend framework you own.** Resources, policies, auth, devtools and an upgrade path for Node.js and MongoDB, with every line of framework code living in your repo. ```bash npm create calmapi@latest my-api cd my-api npm run dev ``` ```text CalmAPI my-api v0.1.0 (development) ➜ API http://localhost:5001/api ➜ OpenAPI http://localhost:5001/api/openapi.json ➜ Devtools http://localhost:5001/_calm ``` > **Status: 2.0.0-alpha.** APIs may still change before 2.0. Coming from CalmAPI 1.x? Run `npx calmapi@next doctor` inside your 1.x project first, then read [Migrating from 1.x](/migrate-v1). ## What makes it different ### Owned code, nothing hidden in node_modules `npm create calmapi` copies the framework into your project as `calm/`: around 5,500 lines of plain, commented JavaScript on Express 5 and Mongoose. Read it, step through it in a debugger, change it. When something behaves unexpectedly, the answer is in your editor, not in a minified dependency. A new project has **two runtime dependencies**: `express` and `mongoose`. The CLI (`calmapi`) and the devtools UI (`@calmapi/devtools`) are dev dependencies and never ship to production. ### Owned, but upgradeable Copied code usually means "no more updates". CalmAPI keeps a `calm.lock` with a hash of every framework file you started from. `calm upgrade` replaces files you never touched, three-way merges upstream fixes into files you edited, and marks real conflicts for you to resolve. See [Upgrading](/upgrading). ### Declare a field once One `f.*()` line drives the Mongoose schema, input validation, what the API returns (and to whom), which filters and sorts clients may use, and the OpenAPI document. No DTO classes to keep in sync. ### Secure by default - Hidden fields never leave the server, not even through populated references. - Every route has an access policy. Routes without an explicit one default to `authenticated` and `calm doctor` flags them. - Unknown, read-only and hidden fields in a request body are rejected with `422`, so there is no mass assignment. - List queries are allowlisted: clients can only filter, sort and search on fields you marked. - Sessions and password reset tokens are random, opaque and stored as SHA-256 hashes. Passwords use scrypt from `node:crypto`, and common passwords are refused. - Auth endpoints are rate limited, accounts lock after repeated failed sign-ins, and responses never reveal whether an email is registered. - `.encrypted()` fields are stored as AES-256-GCM ciphertext, with key rotation. - Anything but `development` or `test` runs as production. Production refuses to boot without `APP_URL`, and devtools cannot be turned on there. You can check the result: `calm audit` calls every route as every kind of user on a throwaway database and reports where the API and your policies disagree. See [Security](/security) and [Security audit](/audit). ### Devtools A local dashboard at `/_calm` shows every request with its MongoDB queries and timings, explain plans, logs, a privacy lens that puts what is stored next to what the API returns, a mail catcher for password reset links, "act as" any user, doctor findings, and who can access what. See [Devtools](/devtools). ### Agent native Every project ships with `AGENTS.md`. `calm mcp` is an MCP server that gives coding agents tools to inspect routes and resources, scaffold resources, call the API as any user, run tests, doctor and the security audit, and read live requests from the dev server. Everything an agent does (tool calls, file diffs, restarts, crashes, requests) shows up live in devtools, so you can watch it work. See [AI agents](/ai-agents). ### Blocks, shadcn style `calm add jobs` copies production code into your project: a MongoDB job queue, file storage on disk or S3, realtime events, an audit log, soft delete, two-factor authentication, an MCP server for your API and more. You own that code too, and anyone can publish blocks in the shadcn registry format. See [Blocks](/blocks). ## A resource ```js // app/resources/post/post.resource.js import { resource, f } from '#calm'; export default resource('post', { fields: { title: f.string().required().max(200).searchable().sortable(), body: f.text().required(), status: f.enum(['draft', 'published']).default('draft').filterable(), tags: f.array(f.string()).filterable(), author: f.ref('user').owner(), // set from the session, powers 'owner' internalNote: f.string().hidden(), // stored, never returned or accepted }, policy: { list: 'public', read: 'public', create: 'authenticated', update: 'owner | admin', delete: 'owner | admin', }, }); ``` That file alone gives you: | Route | Notes | |---|---| | `GET /api/posts` | filters, search, sort, pages, includes: `?status=published&tags[in]=a,b`, `q=hello`, `sort=-createdAt`, `page=2&limit=20`, `include=author`, `fields=title` | | `GET /api/posts/:id` | `?include=author` populates the author with fields the viewer may see | | `POST /api/posts` | validated, unknown or read-only fields rejected with 422, owner set automatically, 201 | | `PATCH /api/posts/:id` | partial update; 403 for users who can read the post, 404 for those who cannot | | `DELETE /api/posts/:id` | 204 | Business logic goes in a service, custom endpoints in a routes file: ```js // app/resources/post/post.service.js import { ResourceService, hasRole } from '#calm'; export default class PostService extends ResourceService { // Applied to every query: visitors see published posts, authors also see their drafts. scope({ user } = {}) { if (hasRole(user, 'admin')) return {}; return user ? { $or: [{ status: 'published' }, { author: user.id }] } : { status: 'published' }; } } ``` ```js // app/resources/post/post.routes.js (mounted before CRUD, so /featured never hits /:id) export default (r) => { r.post('/:id/publish', { policy: 'owner | admin' }, (ctx) => ctx.service.update(ctx.params.id, { status: 'published' }, ctx)); }; ``` Handlers **return** values (`{ data }` responses, `undefined` becomes `204`) and throw `notFound()`, `forbidden()` or `validationError({...})`. Express 5 forwards async errors, so there is no try/catch boilerplate. ## Where to go next - [Getting started](/getting-started): create a project and make your first requests. - [Resources and fields](/resources): everything a resource can declare. - [Policies](/policies): who may do what, and why some errors are 404 instead of 403. - [Blocks](/blocks): jobs, uploads, realtime, MCP and more, one command each. - [Quick reference](/quick-reference): the whole API on one page. --- Source: https://docs.calmapi.dev/getting-started # Getting started Create a project, run it, and talk to it with `curl`. This takes about five minutes. ## Requirements - **Node.js 22.12 or newer** (24 LTS recommended). The CLI refuses to run on older versions. - **MongoDB** 6 or newer, local or hosted (for example MongoDB Atlas). For a quick local server: ```bash docker run -d --name mongo -p 27017:27017 mongo:8 ``` ## Create a project ```bash npm create calmapi@latest my-api ``` The wizard asks for: 1. **Project folder** (default `my-api`). 2. **Template**: `api` or `minimal` (see below). 3. **MongoDB URI** (default `mongodb://127.0.0.1:27017/`), written to `.env`. 4. Whether to **install dependencies** and **initialize git**. To skip the questions, pass flags after `--` so npm forwards them: ```bash npm create calmapi@latest my-api -- --yes --template api --db mongodb://127.0.0.1:27017/my_api ``` | Template | What you get | |---|---| | `api` (default) | A `user` resource with sign up, login, sessions and password reset under `/api/auth`, an example `post` resource with a service and custom routes, and a test. | | `minimal` | Just the framework, an `app/routes.js` with `GET /api/hello`, and a test. No `user` resource, so no auth until you add one. | Every template also contains `calm/` (the framework), `calm.config.js`, `calm.lock`, `.env`, `AGENTS.md`, a `Dockerfile` and an ESLint config. See [Project structure](/project-structure). ## Run it ```bash cd my-api npm run dev ``` ```text CalmAPI my-api v0.1.0 (development) ➜ API http://localhost:5001/api ➜ OpenAPI http://localhost:5001/api/openapi.json ➜ Devtools http://localhost:5001/_calm 2 resources · 25 routes · 0 blocks · MongoDB connected ``` `npm run dev` runs `calm dev`: development mode, a restart whenever something in `app/`, `calm/`, `calm.config.js`, `server.js` or `.env` changes, and [devtools](/devtools) at `/_calm`. If MongoDB is not reachable, the app retries a few times and then exits with a clear message. ## First requests The examples below use the `api` template. ### 1. Register ```bash curl -X POST http://localhost:5001/api/auth/register \ -H 'Content-Type: application/json' \ -d '{"name":"Ada Lovelace","email":"ada@example.com","password":"correct-horse-battery"}' ``` ```json { "data": { "user": { "id": "6acb46a57e4a738ebdd998b1", "name": "Ada Lovelace", "email": "ada@example.com", "roles": ["user"], "status": "active", "createdAt": "2026-10-11T08:19:49.069Z", "updatedAt": "2026-10-11T08:19:49.069Z" }, "token": "calm_M_vAh-pIohIgNEe8gapu1LMoYxlH6oYvepNVGWtYPWk", "expiresAt": "2026-11-10T08:19:49.085Z" } } ``` Registering signs you in. The password is never returned, and `roles` cannot be set by the client. ### 2. Log in ```bash curl -X POST http://localhost:5001/api/auth/login \ -H 'Content-Type: application/json' \ -d '{"email":"ada@example.com","password":"correct-horse-battery"}' ``` The response has the same shape. Keep the token for the next requests: ```bash TOKEN=calm_GZWY3MMqzg4aRbB80iRCKScHbbnjrLSDu2-sfYER27E ``` ### 3. Create a post ```bash curl -X POST http://localhost:5001/api/posts \ -H 'Content-Type: application/json' \ -H "Authorization: Bearer $TOKEN" \ -d '{"title":"Hello CalmAPI","body":"My first post","tags":["intro","news"],"status":"published"}' ``` ```json { "data": { "id": "6acb46a57e4a738ebdd998b4", "title": "Hello CalmAPI", "slug": "hello-calmapi", "body": "My first post", "status": "published", "tags": ["intro", "news"], "author": "6acb46a57e4a738ebdd998b1", "publishedAt": "2026-10-11T08:19:49.447Z", "createdAt": "2026-10-11T08:19:49.448Z", "updatedAt": "2026-10-11T08:19:49.448Z" } } ``` The status is `201`. `author` was set from your session, and `slug` and `publishedAt` were filled in by `post.service.js`. Sending `slug` yourself is refused, because it is read-only: ```json { "error": { "code": "VALIDATION_ERROR", "message": "Validation failed", "requestId": "5c397446-0c18-4cfe-99be-d08c78419bec", "details": { "slug": "Is read-only", "likes": "Unknown field" } } } ``` ### 4. List with filters ```bash curl -g "http://localhost:5001/api/posts?status=published&tags[in]=intro,news&sort=-createdAt&include=author&fields=title,author" \ -H "Authorization: Bearer $TOKEN" ``` `-g` stops curl from reading `[in]` as a glob pattern. ```json { "data": [ { "id": "6acb46a57e4a738ebdd998b4", "title": "Hello CalmAPI", "author": { "id": "6acb46a57e4a738ebdd998b1", "name": "Ada Lovelace", "email": "ada@example.com", "roles": ["user"], "status": "active", "createdAt": "2026-10-11T08:19:49.069Z", "updatedAt": "2026-10-11T08:19:49.069Z" }, "createdAt": "2026-10-11T08:19:49.448Z", "updatedAt": "2026-10-11T08:19:49.448Z" } ], "meta": { "total": 1, "page": 1, "limit": 20, "pages": 1 } } ``` You see the author's `email` and `roles` because the author is you. Anyone else gets the author without them: the `user` resource marks them `.visibleTo('owner | admin')`. Without a token, the list only contains published posts, because `post.service.js` scopes every query. Ask for something that is not allowed and you get a `400` that explains why: ```bash curl -g "http://localhost:5001/api/posts?body=x&sort=body&limit=500" ``` ```json { "error": { "code": "BAD_REQUEST", "message": "Invalid query", "requestId": "86f489d3-d343-4d90-a614-86bdca30b700", "details": { "body": "Not filterable. Filterable fields: slug, status, tags", "sort": "Cannot sort by \"body\". Sortable: id, title, publishedAt, createdAt, updatedAt", "limit": "Must be an integer between 1 and 100" } } } ``` The full query language is in [Querying](/querying). ## Open the devtools Visit `http://localhost:5001/_calm`. Every request you just made is there with its MongoDB queries, timings and logs. Ask for a password reset (`POST /api/auth/password/forgot`) and the email shows up in the **Mail** panel, link included. ## Add your own resource ```bash npx calm make resource product name:string!+searchable price:number! status:enum:draft,live=draft --service ``` ```text ✔ Created resource product at /api/products + app/resources/product/product.resource.js + app/resources/product/product.service.js + test/products.test.js ``` The dev server restarts and `/api/products` is live with all five CRUD routes. Open the resource file to adjust fields and policies, then: ```bash npx calm routes # every route with its policy npx calm doctor # security and correctness checks npx calm audit # every route as every role, on a throwaway database npm test # node --test, needs MongoDB ``` ## Next steps - [Project structure](/project-structure): what each file and folder is for. - [Resources and fields](/resources): every field type and modifier. - [Policies](/policies): who may list, read, create, update and delete. --- Source: https://docs.calmapi.dev/project-structure # Project structure A CalmAPI project is a plain Node.js (ESM) app. The framework is in `calm/`, your code is in `app/`. ```text my-api/ ├── calm/ the framework (yours, tracked by calm.lock) ├── app/ │ ├── resources/ │ │ ├── post/ │ │ │ ├── post.resource.js fields + access policy (required) │ │ │ ├── post.service.js business logic (optional) │ │ │ └── post.routes.js custom endpoints (optional) │ │ └── user/ │ │ └── user.resource.js │ ├── blocks/ blocks added with `calm add` (and your own plugins) │ └── routes.js app-wide custom routes (optional) ├── test/ │ └── posts.test.js node:test, uses createTestApp ├── server.js starts the app ├── calm.config.js config overrides (no secrets) ├── calm.lock framework and block file hashes, used by `calm upgrade` ├── .env / .env.example secrets and environment ├── .calm/ activity log written by calm dev and calm mcp (git-ignored) ├── AGENTS.md / CLAUDE.md instructions for AI coding agents ├── Dockerfile / .dockerignore ├── eslint.config.js └── package.json ``` ## calm/ The framework itself, copied from the `calmapi` package when the project was created. It is your code: read it, debug it, change it. `calm upgrade` merges upstream fixes into it without discarding your edits (see [Upgrading](/upgrading)). | File | What it does | |---|---| | `index.js` | Everything `#calm` exports | | `app.js` | `createApp()`: loads config, resources and blocks, builds the Express app, starts and stops it | | `config.js` | Defaults, `.env` and `calm.config.js` loading, validation at boot | | `loader.js` | Finds `app/resources/*`, `app/blocks/*` and `app/routes.js`; `definePlugin()` | | `resource/resource.js` | `resource()`, Mongoose compilation, input validation, output serialization | | `resource/fields.js` | The `f.*()` field builders and value validation | | `resource/policy.js` | Policy parsing and evaluation, `hasRole()`, `isOwner()` | | `resource/query.js` | The list query language (filters, search, sort, pagination, include, fields) | | `resource/routes.js` | The five CRUD routes | | `resource/service.js` | `ResourceService` | | `http/router.js` | The route builder, request context (`ctx`), item authorization, responses | | `http/middleware.js` | Request ids, security headers, CORS, rate limiting, JSON guard, access log | | `auth/` | Register, login, sessions, password change and reset, lockout, password rules; scrypt hashing; tokens | | `security/` | Field encryption (`encryption.js`), the exposure matrix (`exposure.js`) and the `calm audit` engine (`audit.js`) | | `errors.js` | `CalmError`, helpers and the mapping of every error to a safe response | | `events.js`, `mail.js`, `logger.js`, `context.js` | Event bus, mailer, structured logger, per-request context | | `openapi.js`, `doctor.js`, `testing.js` | OpenAPI 3.1 generator, runtime checks, test helpers | | `devtools/index.js` | The devtools collector and API behind `/_calm` (development only) | ## app/resources One folder per resource. The folder name is the resource name, and files are named after the folder: - `.resource.js` (required) default-exports `resource('', { fields, policy })`. See [Resources and fields](/resources). - `.service.js` (optional) default-exports a class that extends `ResourceService`. See [Services and hooks](/services). - `.routes.js` (optional) default-exports `(r) => { ... }`. Its routes are mounted under the resource path, before the CRUD routes. See [Custom routes](/routes). A folder without a `.resource.js` file is ignored. Create resources with `npx calm make resource`, which also writes a test. ## app/blocks Blocks added with `calm add` land here as `app/blocks//index.js` (some also add resources, `app/jobs/` or `app/notifications/`). Every folder with an `index.js` that default-exports `definePlugin({ name, setup })` is loaded at boot, so this is also the place for your own blocks, for example an event listener. `npx calm make block ` starts one. See [Building your own block](/building-blocks) for the plugin API. ## app/routes.js Routes that do not belong to a resource, mounted under `/api`: ```js // app/routes.js export default (r) => { r.get('/hello', { policy: 'public', summary: 'Say hello' }, (ctx) => ({ message: `Hello from ${ctx.config.app.name}`, })); }; ``` ## server.js ```js import { createApp } from '#calm'; try { const app = await createApp({ root: import.meta.dirname }); await app.listen(); } catch (err) { console.error(err.code === 'CALM_CONFIG' ? `\n${err.message}\n` : err); process.exit(1); } ``` `npm start` runs it (production mode unless `NODE_ENV` says otherwise), `npm run dev` runs it through `calm dev`. ## calm.config.js and .env `calm.config.js` overrides the defaults in `calm/config.js`. Secrets (database URI, API keys) belong in `.env`, which is git-ignored. See [Configuration](/configuration). ```js import { defineConfig } from '#calm'; export default defineConfig({ pagination: { defaultLimit: 20, maxLimit: 100 }, auth: { sessionTtlDays: 30, resetTokenTtlMinutes: 30 }, mail: { from: 'My API ' }, }); ``` ## calm.lock Written by `calm create` and updated by `calm add` and `calm upgrade`. Commit it. ```json { "lockfileVersion": 1, "calmapi": "2.0.0-alpha.0", "template": "api", "core": { "calm/app.js": "sha256:585bd812cfc3d1343684effb382612d0074b82c7da27ede3673bb4f76df5e4a2", "calm/auth/index.js": "sha256:b4633aa522492d2f0065ba052660ca6f12fd813d7c90a824c7f0375ba2b130d1" }, "blocks": { "@calmapi/jobs": { "version": "1.0.0", "source": "@calmapi (bundled)", "files": { "app/blocks/jobs/index.js": "sha256:..." } } } } ``` `core` holds the hash of each framework file as it was shipped, which is how `calm upgrade` knows whether you edited a file and `calm doctor` reports local changes. `blocks` records what `calm add` installed. ## AGENTS.md and CLAUDE.md `AGENTS.md` tells AI coding agents (Claude Code, Codex, Cursor, Copilot) how this project is laid out and which rules to follow. `CLAUDE.md` points Claude Code to it. See [AI agents](/ai-agents). ## #calm and #app imports `package.json` declares [subpath imports](https://nodejs.org/api/packages.html#subpath-imports), so you never write `../../../calm`: ```json { "imports": { "#calm": "./calm/index.js", "#calm/*": "./calm/*", "#app/*": "./app/*" } } ``` ```js import { resource, f, ResourceService, notFound } from '#calm'; // the public API import { createTestApp } from '#calm/testing.js'; // any framework file import { seed } from '#calm/seed.js'; import { defineJob } from '#app/blocks/jobs/index.js'; // your own code ``` Subpath imports need the `.js` extension. Inside services and routes, reach other resources through `this.service('name')` or `ctx.services.name` instead of importing their models. ### What #calm exports | Export | Use | |---|---| | `resource`, `f`, `Field` | Declare resources and fields | | `ResourceService` | Base class for services | | `definePlugin` | Declare a plugin (what blocks export) | | `createApp`, `defineConfig` | Build the app, type-friendly config | | `CalmError`, `badRequest`, `unauthorized`, `forbidden`, `notFound`, `conflict`, `validationError`, `tooManyRequests` | Errors (see [Errors](/errors)) | | `hasRole`, `isOwner`, `evaluatePolicy`, `parsePolicy` | Policy helpers | | `matchesFilter` | Whether a record matches a MongoDB filter, in memory | | `serialize`, `validateInput`, `standardSchema`, `resourceJSONSchema` | Apply a resource's output and input rules yourself, or describe it as JSON Schema | | `Reply` | What `ctx.reply()` returns | | `createToken`, `hashToken` | Random tokens with a prefix, and their SHA-256 for storage | | `hashPassword`, `verifyPassword` | scrypt helpers | | `isEncrypted` | Whether a stored value is `.encrypted()` ciphertext | | `maskToken`, `providerPayloads` | Push helpers: a masked device token, the payload each provider receives | | `currentRequest` | The current request's context from anywhere (AsyncLocalStorage) | Blocks import only from `#calm`: the other files in `calm/` are internals that can change between versions. ## How the app boots `createApp()` does the following, in order: 1. Load `.env`, `calm.config.js` and the defaults; fail fast on invalid config. 2. Load `app/resources/*` and `app/blocks/*`, then run each block's `setup()` with its settings (`blocks.` in `calm.config.js` merged over the block's defaults). 3. Compile every resource into a Mongoose model and create its service. 4. Set up auth, then register routes: auth, block routes, `app/routes.js`, then for each resource its custom routes followed by its CRUD routes. 5. Build the Express app. `listen()` then connects to MongoDB, builds indexes, runs start hooks and prints the banner. --- Source: https://docs.calmapi.dev/resources # Resources and fields A resource is a model, its API and its rules in one file. You declare fields once with `f.*()`, and CalmAPI derives the Mongoose schema, input validation, output visibility, the allowed list queries and the OpenAPI schema from them. ```js // app/resources/product/product.resource.js import { resource, f } from '#calm'; export default resource('product', { fields: { name: f.string().required().max(120).searchable().sortable(), price: f.number().required().min(0).filterable().sortable(), cost: f.number().hidden(), // stored, never returned status: f.enum(['draft', 'live']).default('draft').filterable(['eq', 'in']), category: f.ref('category').filterable(), tags: f.array(f.string().max(30)).max(10).default([]).filterable(), owner: f.ref('user').owner(), // set from the session supplierNote: f.string().visibleTo('owner | admin'), }, policy: { list: 'authenticated', read: 'authenticated', create: 'authenticated', update: 'owner | admin', delete: 'owner | admin', }, }); ``` Every record also gets `id`, `createdAt` and `updatedAt` (unless `timestamps: false`). These names are reserved: declaring a field called `id`, `_id`, `createdAt`, `updatedAt` or `__v` throws at boot. ## Field types | Builder | Stored as | Accepts | Notes | |---|---|---|---| | `f.string()` | String | strings | Trimmed by default (`.trim(false)` to keep spaces) | | `f.text()` | String | strings | Like `string`, not trimmed; for long text | | `f.email()` | String | valid email | Trimmed and lowercased | | `f.url()` | String | `http(s)://` URLs | Other schemes (`javascript:`) are rejected | | `f.password()` | String | strings, 8 to 128 chars | Hashed with scrypt, never returned, cannot be changed with PATCH | | `f.number()` | Number | finite numbers | `"5"` is rejected: no silent coercion of JSON strings | | `f.integer()` | Number | whole numbers | | | `f.boolean()` | Boolean | `true`, `false` | | | `f.date()` | Date | ISO strings or epoch milliseconds | Returned as ISO strings | | `f.enum(['a', 'b'])` | String | one of the values | | | `f.id()` | ObjectId | 24-char hex id | An id that is not a reference | | `f.ref('category')` | ObjectId | 24-char hex id | Reference to another resource, can be included | | `f.array(f.string())` | Array | arrays | Every item is validated with the inner field | | `f.object({ city: f.string() })` | Subdocument | objects | Unknown keys are rejected, no `_id` | | `f.json()` | Mixed | any JSON | Not validated; only the `exists` filter applies | `f.ref()` checks that the value is a well-formed id, not that the record exists. If that matters, check it in a service hook (`beforeCreate`). ```js fields: { address: f.object({ street: f.string().required(), city: f.string().required(), zip: f.string().pattern(/^\d{5}$/, 'Must be 5 digits'), }), coordinates: f.array(f.number()).min(2).max(2), launchedAt: f.date().min('2020-01-01'), settings: f.json().default({}), } ``` ## Modifiers | Modifier | Effect | |---|---| | `.required(message?)` | Must be present on create (unless it has a default). Empty strings and `null` are rejected. | | `.optional()` | Undo `required()` | | `.default(value)` | Default value, or a function: `.default(() => new Date())`. Object defaults are cloned per record. | | `.min(n, message?)` / `.max(n, message?)` | String length, number value, date bound or array item count | | `.pattern(regex, message?)` | Strings must match | | `.trim(on = true)`, `.lowercase()`, `.uppercase()` | Normalize strings before validation and storage | | `.unique()` | Unique index; a duplicate answers `409 CONFLICT` | | `.index()` | Plain index | | `.check(fn, message?)` | Custom rule, sync or async: return `true`, or an error message string | | `.schema(standardSchema)` | Validate with Zod, Valibot, ArkType or any [Standard Schema](https://standardschema.dev) library | | `.describe(text)`, `.example(value)` | Description and example in the OpenAPI document | | `.encrypted()` | Stored as AES-256-GCM ciphertext, decrypted on read (see [Encrypted fields](#encrypted-fields)) | | `.pii()` | Marks personal data for `calm doctor`, the exposure matrix and the privacy block (see [Personal data](#personal-data)) | ```js fields: { sku: f.string().required().uppercase().pattern(/^[A-Z0-9-]{4,20}$/, 'Use 4 to 20 letters, digits or dashes').unique(), quantity: f.integer().min(0).check((n) => n % 6 === 0 || 'Sold in packs of 6'), } ``` ## Visibility and write rules These decide what clients may send and what they get back. They apply to every response, including populated references, [realtime events](/blocks/realtime-sse) and [MCP tools](/blocks/mcp-server). | Modifier | Returned | Accepted from clients | Typical use | |---|---|---|---| | `.hidden()` | never | never (422) | Internal data your service sets: costs, storage keys, flags | | `.writeOnly()` | never | yes | Secrets the client sends but must not read back | | `.readOnly()` | yes | never (422) | Values your service computes: slugs, totals, timestamps | | `.immutable()` | yes | on create only (422 on update) | Email, account type | | `.writableBy(policy)` | yes | only users matching the policy | `roles: ...writableBy('admin')` | | `.visibleTo(policy)` | only to users matching the policy, per record | yes | `email: ...visibleTo('owner \| admin')` | Hidden and write-only fields can never be filtered, sorted, selected with `?fields=` or included. Your service still sees them: hooks receive the full record (except password hashes), and API responses are serialized again on the way out. `visibleTo` and `writableBy` take the same tokens as [policies](/policies), evaluated against each record. With `'owner'`, it means the owner of *that* record: ```js // app/resources/user/user.resource.js (from the api template) export default resource('user', { owner: '_id', // a user owns their own record fields: { name: f.string().required().max(100).searchable().sortable(), email: f.email().required().unique().immutable().filterable().visibleTo('owner | admin'), password: f.password().required(), roles: f.array(f.enum(['user', 'admin'])).default(['user']).writableBy('admin').visibleTo('owner | admin').filterable(), status: f.enum(['active', 'disabled']).default('active').writableBy('admin').filterable(), avatarUrl: f.url(), }, policy: { list: 'admin', read: 'owner | admin', create: 'admin', update: 'owner | admin', delete: 'admin' }, }); ``` ## Encrypted fields `.encrypted()` encrypts a value before it reaches MongoDB and decrypts it when it is read, so database dumps and backups only hold ciphertext while the API, your services and hooks see the plain value: ```js fields: { nationalId: f.string().required().encrypted().visibleTo('owner | admin'), webhookSecret: f.string().encrypted().hidden(), } ``` It needs `CALM_ENCRYPTION_KEYS` (create it with `npx calm keygen --write`). Encryption is about storage, not about who reads the field, so combine it with `.visibleTo()` or `.hidden()`. Ciphertext cannot be queried: an encrypted field cannot also be `.filterable()`, `.sortable()`, `.searchable()`, `.unique()`, `.index()` or `.owner()`, and `f.ref()` and `f.id()` fields cannot be encrypted. Keys and rotation are covered in [Security](/security#encryption-at-rest). ## Personal data Mark fields that hold personal data with `.pii()`: ```js fields: { nickname: f.string().max(50).pii(), licensePlate: f.string().pii().visibleTo('owner | admin'), shippingNotes: f.text().pii(), } ``` `.pii()` does not change what the API accepts or returns. It tells the tools that look at your data: - The [exposure matrix](/security#exposure-matrix), which [`calm audit`](/audit) tests your API against, flags the field as personal data. The devtools **Security** panel marks it `pii` and highlights it when anyone can read it. - `calm doctor` warns when anyone can read it (`pii-public`), and notes when every signed-in user can read it on a resource with an owner (`pii-users`). Fix it with `.visibleTo('owner | admin')` or `.hidden()`. - The [privacy](/blocks/privacy) block removes it when an account is deleted and the records it owned are anonymized. Fields whose name already says personal data (`email`, `phone`, `address`, `iban`, `salary`, `dateOfBirth`...) are flagged without it. `.pii()` covers the names a pattern cannot guess. ## Query modifiers | Modifier | Lets clients | |---|---| | `.filterable(ops?)` | Filter with `?field=value` and `?field[op]=value`. Without `ops`, the defaults for the type apply (see [Querying](/querying#operators)). | | `.sortable()` | Sort with `?sort=field` or `?sort=-field`. Also adds an index. | | `.searchable()` | Be matched by `?q=text` (case-insensitive substring) | Anything not marked cannot be used in a query: the request fails with `400` instead of silently scanning private data. ## Relations | Modifier | Effect | |---|---| | `f.ref('user').owner()` | Marks the record's owner. Set to the signed-in user on create, read-only and indexed. Powers the `'owner'` policy. One per resource. | | `.populate()` | Always populate this reference on reads, as if every request had `?include=` | A populated reference is serialized with the **target** resource's rules for the current viewer, so including `author` never leaks the author's email or password hash. Only fields that could ever be returned are loaded from the database in the first place. For the `user` resource itself, `owner: '_id'` in the resource options says "a user owns their own record". ## Resource options ```js resource('product-category', { fields: { ... }, policy: { ... }, path: '/categories', // default: '/product-categories' collection: 'categories', // default: 'product_categories' timestamps: true, // createdAt / updatedAt (default true) defaultSort: '-createdAt', // default; '_id' when timestamps is false crud: ['list', 'read'], // which CRUD routes exist: true (all, default), false, or a list indexes: [{ fields: { status: 1, createdAt: -1 } }, { fields: { name: 'text' } }], owner: '_id', // owner field when it is not an f.ref('user').owner() field description: 'Product groups', // used in MCP tool descriptions (mcp-server block) schemaPlugins: [myMongoosePlugin], }); ``` Names are normalized: `resource('ProductCategory')` and `resource('product-category')` both become `product-category`, served at `/api/product-categories`, stored in `product_categories`, and reachable as `ctx.services.productCategory` or `ctx.services['product-category']`. Any other key is kept in `def.options`, where [blocks](/blocks) can read it: `softDelete: true` ([soft-delete](/blocks/soft-delete)), `audit: false` ([audit-log](/blocks/audit-log)), `webhooks: false` ([webhooks](/blocks/webhooks)), `onOwnerDelete` and `personalFields` ([privacy](/blocks/privacy)). ## The CRUD routes | Route | Action | Success | Body | |---|---|---|---| | `GET /api/products` | `list` | `200` | `{ data: [...], meta: { total, page, limit, pages } }` | | `GET /api/products/:id` | `read` | `200` | `{ data }` | | `POST /api/products` | `create` | `201` | `{ data }` | | `PATCH /api/products/:id` | `update` | `200` | `{ data }` (partial update) | | `PUT /api/products/:id` | `update` | `200` | alias of PATCH, hidden from OpenAPI | | `DELETE /api/products/:id` | `delete` | `204` | empty | Errors follow one shape (see [Errors](/errors)): `401` when a signed-in user is required, `403` or `404` depending on what the user may see (see [Policies](/policies#401-403-or-404)), `404` for malformed or unknown ids, `422` for invalid bodies and `409` for duplicates on unique fields. On update, `null` clears an optional field; sending `null` for a required field is a `422`. ## Unknown fields are rejected Bodies are checked against the fields, so clients cannot write what you did not declare: ```bash curl -X POST http://localhost:5001/api/products -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"name":"Hammer","price":"20","cost":1,"owner":"6acb46a57e4a738ebdd998b9","role":"admin"}' ``` ```json { "error": { "code": "VALIDATION_ERROR", "message": "Validation failed", "requestId": "8a3c1f4e-6d6b-4a0e-9a57-1f2f5b3a9c11", "details": { "price": "Must be a number", "cost": "Is read-only", "owner": "Is read-only", "role": "Unknown field" } } } ``` The messages per field are: | Message | Cause | |---|---| | `Unknown field` | Not declared (set `unknownFields: 'strip'` in config to drop them silently instead) | | `Is read-only` | `hidden()`, `readOnly()` or an `owner()` field | | `Cannot be changed after creation` | `immutable()` field in an update | | `You are not allowed to set this field` | `writableBy()` policy not met | | `Use POST /api/auth/password (or the reset flow) to change passwords` | Password field in an update | | `Is required`, `Must be ...`, your own messages | Validation rules | `id`, `createdAt` and `updatedAt` in a body are ignored, so a client can send back a record it received. ## Standard Schema Use any [Standard Schema](https://standardschema.dev) validator (Zod 3.24+, Valibot, ArkType...) for rules the built-ins do not cover. It runs after the built-in checks, and its output replaces the value, so transforms work: ```js import { z } from 'zod'; // npm install zod fields: { website: f.string().schema(z.string().url().endsWith('.org')), slug: f.string().schema(z.string().transform((s) => s.toLowerCase().replace(/\s+/g, '-'))), } ``` Custom routes accept a Standard Schema as their `input` too (see [Custom routes](/routes#validating-input)). The other way round, `standardSchema(def, mode)` exposes a resource's own input rules as a Standard Schema, for scripts or tests that should validate exactly like the API: ```js import { standardSchema } from '#calm'; import product from '#app/resources/product/product.resource.js'; const result = await standardSchema(product, 'create')['~standard'].validate({ name: 'Saw' }); // { issues: [{ message: 'Is required', path: ['price'] }] } ``` ## OpenAPI The running app serves an OpenAPI 3.1 document at `/api/openapi.json`, generated from fields, policies and route options. Each resource has three schemas (`Product`, `ProductCreate`, `ProductUpdate`); hidden and write-only fields never appear in the output schema. Write it to a file with `npx calm openapi`, or add the [api-docs](/blocks/api-docs) block for an interactive reference. --- Source: https://docs.calmapi.dev/policies # Policies A policy says who may perform an action. Every CRUD action and every custom route has one, and the same rules drive field visibility, [realtime events](/blocks/realtime-sse) and [MCP tool calls](/blocks/mcp-server). ```js export default resource('post', { fields: { ... , author: f.ref('user').owner() }, policy: { list: 'public', read: 'public', create: 'authenticated', update: 'owner | admin', delete: 'owner | admin', }, }); ``` ## Tokens | Token | Allows | |---|---| | `'public'` | Anyone, signed in or not | | `'authenticated'` | Any signed-in user | | `'owner'` | The record's owner (the resource's `.owner()` field). On `create` it always passes: you become the owner. | | `'admin'`, `'editor'`, any other word | Users whose `roles` array contains that role | | `'none'` | Nobody. The route still exists and answers 401, 403 or 404; to remove CRUD routes use the resource's `crud` option. | | `(ctx) => boolean` | Your own rule (sync or async) | Aliases: `'auth'` and `'user'` mean `'authenticated'`, `'self'` means `'owner'`, `'nobody'` means `'none'`, and `'role:editor'` is the same as `'editor'`. ## Combining Alternatives are combined with `|`: the action is allowed if **any** of them allows it. You can also pass an array, which is how you mix strings and functions: ```js const isPublished = ({ doc }) => doc?.status === 'published'; policy: { read: ['owner | editor | admin', isPublished], } ``` Functions receive `{ user, action, doc, req }`. `doc` is the stored record for routes with an `:id`, and `undefined` for collection routes (`list`, `create`, custom routes without `:id`). Name your functions: the name is what `calm routes` and devtools display. There is no "and". When you need one, write a function, or narrow what users can see with the service's [scope()](/services#scope). ## Defaults Leave an action out and it gets a default: | Action | Default | |---|---| | `list`, `read` | `'authenticated'` | | `create`, `update`, `delete` | `'owner \| admin'` when the resource has an owner field, `'admin'` otherwise | Custom routes without a `policy` option default to `'authenticated'`. Both cases work, but `calm doctor` warns about them (`policy-defaulted`, `route-policy`) so the intent is written down. ## Owner scoping on lists `'owner'` on `list` does not reject anyone who is signed in: it **filters**. `list: 'owner | admin'` lets admins list everything and everyone else only their own records: ```js // app/resources/note/note.resource.js export default resource('note', { fields: { text: f.string().required(), author: f.ref('user').owner() }, policy: { list: 'owner', read: 'owner', create: 'authenticated', update: 'owner', delete: 'owner' }, }); ``` ```text GET /api/notes as Alice -> only Alice's notes GET /api/notes as Bob -> only Bob's notes GET /api/notes signed out -> 401 ``` The same applies to custom routes without `:id`: with `policy: 'owner'`, `ctx.scope` is `'owner'` and service calls that receive `ctx` only see the user's records. ## 401, 403 or 404 For routes with an `:id`, CalmAPI first loads the record (through the service's `scope()`), then applies the policy with the record in hand. The answer is chosen so that private ids are never confirmed to people who cannot see them: | Situation | Status | |---|---| | The route needs a signed-in user and there is no valid session | `401 UNAUTHORIZED` | | The id is malformed, does not exist, or `scope()` hides it from this user | `404 NOT_FOUND` | | The user may read the record but not do this (for example edit someone else's post) | `403 FORBIDDEN` | | The user may not even read the record | `404 NOT_FOUND` | | Collection routes (`list`, `create`, custom without `:id`): signed in but not allowed | `403 FORBIDDEN` | With the post resource above, Bob gets `403` when he tries to edit Alice's published post (he can read it), but `404` for her notes, which he cannot read at all. ## Field-level policies `.visibleTo(policy)` and `.writableBy(policy)` take the same tokens and are evaluated per record: ```js fields: { email: f.email().required().visibleTo('owner | admin'), // others do not see it roles: f.array(f.enum(['user', 'editor', 'admin'])).writableBy('admin'), internalRating: f.integer().visibleTo('editor | admin').writableBy('editor | admin'), } ``` - A field a viewer may not see is left out of the response, including when the record is populated into another one with `?include=`. - A field a user may not write answers `422` with `You are not allowed to set this field`. On create, `'owner'` passes (the creator becomes the owner). ## Roles Roles are strings in the user's `roles` array. The `api` template allows `user` and `admin`; add your own to the enum in `user.resource.js`: ```js roles: f.array(f.enum(['user', 'editor', 'admin'])).default(['user']).writableBy('admin').visibleTo('owner | admin').filterable(), ``` Then use them in policies (`'editor | admin'`) and in code with `hasRole(ctx.user, 'editor')` from `#calm`. Users cannot give themselves roles: `roles` is `writableBy('admin')`, and `calm doctor` reports an error if the user resource has a writable `roles` field without a `writableBy` rule. To make your first admin, sign up normally, then: ```bash npx calm admin ada@example.com # add the admin role npx calm admin ada@example.com --revoke # remove it ``` After that, admins can change roles through `PATCH /api/users/:id`. ## Function policies in practice ```js // Only the author, and only while the post is a draft. const ownDraft = ({ user, doc }) => Boolean(user && doc && String(doc.author) === user.id && doc.status === 'draft'); // Only users with a verified flag (a boolean field you added to user.resource.js). const verified = ({ user }) => user?.verified === true; policy: { update: ['admin', ownDraft], create: [verified], } ``` A function that returns `true` allows the action outright, without owner scoping. On `list` there is no `doc`, so filter lists with `scope()` in the service instead. ## Checking policies ```bash npx calm routes ``` ```text METHOD PATH POLICY FROM POST /api/auth/login public calm/auth/index.js GET /api/posts/featured public app/resources/post/post.routes.js POST /api/posts/:id/publish owner | admin app/resources/post/post.routes.js GET /api/posts public app/resources/post/post.resource.js PATCH /api/posts/:id owner | admin app/resources/post/post.resource.js GET /api/users admin app/resources/user/user.resource.js ``` Defaulted policies are shown as `authenticated (default)`. The devtools **Routes** panel shows the same, with a filter for routes whose policy is not explicit. To see the result of all your rules at once, open the devtools **Security** panel: the [exposure matrix](/security#exposure-matrix) lists, for each resource, what anonymous visitors, signed-in users, owners, each custom role and admins may do, and which fields each of them can read and write. Then check that the running API agrees: ```bash npx calm audit ``` [`calm audit`](/audit) calls every route as every one of those personas on a throwaway database, and reports each answer that does not match the policy, owner-only lists that return other people's records, and fields that leak to someone who should not see them. --- Source: https://docs.calmapi.dev/services # Services and hooks Every resource has a service: an instance of `ResourceService` that does all reads and writes. CRUD routes, custom routes, blocks and jobs go through it, so logic you put here applies everywhere. Create `app/resources//.service.js` (or pass `--service` to `calm make resource`) and override only what you need: hooks for small tweaks, whole methods for full control. ```js // app/resources/post/post.service.js (from the api template) import { ResourceService, hasRole } from '#calm'; export default class PostService extends ResourceService { // Every query goes through scope(): visitors see published posts, authors also see // their own drafts, admins see everything. scope({ user } = {}) { if (hasRole(user, 'admin')) return {}; if (user) return { $or: [{ status: 'published' }, { author: user.id }] }; return { status: 'published' }; } async beforeCreate(data) { return { ...data, slug: await this.uniqueSlug(data.title), publishedAt: data.status === 'published' ? new Date() : undefined, }; } async beforeUpdate(data, existing) { if (data.status === 'published' && existing.status !== 'published') data.publishedAt = new Date(); return data; } /** Used by POST /api/posts/:id/publish (post.routes.js). */ async publish(id, ctx) { return this.update(id, { status: 'published' }, ctx); } async uniqueSlug(title) { const base = title.toLowerCase().normalize('NFKD').replace(/[^\w\s-]/g, '').trim().replace(/[\s_-]+/g, '-').slice(0, 80) || 'post'; let slug = base; for (let i = 2; await this.model.exists({ slug }); i++) slug = `${base}-${i}`; return slug; } } ``` The class must `export default` and extend `ResourceService`, otherwise the app refuses to boot with a clear message. ## Methods All methods take the request context `ctx` as their last argument. Pass it along: it carries the user and the owner scope. | Method | Returns | |---|---| | `list({ filter, sort, skip, limit, populate }, ctx)` | `{ items, total }` | | `get(id, { populate }, ctx)` | the record, or throws `notFound()` | | `find(filter, ctx)` | an array of records | | `findOne(filter, ctx)` | a record or `null` | | `create(data, ctx)` | the created record | | `update(id, data, ctx)` | the updated record (throws `notFound()` if it is not visible) | | `remove(id, ctx)` | the deleted record (throws `notFound()` if it is not visible) | Records are plain objects (Mongoose `lean()`) with `_id`. Every service method (`list`, `get`, `find`, `findOne`, `create`, `update`, `remove`) and every hook works with the whole record, `hidden()` and `writeOnly()` fields included. Password hashes are never included. Return records from a resource route and they are serialized for you (see [Custom routes](/routes#auto-serialization)). Services trust their input: they do not run field validation or policies. The CRUD routes validate the body before calling `create` and `update`. When you call a service from your own route, validate with the route's `input` option first. Useful properties: `this.model` (the Mongoose model), `this.def` (the resource definition), `this.app`, `this.log`, `this.events`, `this.name`. ## Hooks | Hook | Called | Return | |---|---|---| | `scope(ctx)` | before every query | a Mongo filter (`{}` for no restriction) | | `beforeCreate(data, ctx)` | after validation, before insert. The owner field is already set. | the data to insert | | `afterCreate(doc, ctx)` | after insert | nothing | | `beforeUpdate(data, existing, ctx)` | before the update, with the current record | the changes to apply | | `afterUpdate(doc, previous, ctx)` | after the update | nothing | | `beforeDelete(doc, ctx)` | before delete. Throw to prevent it. | nothing | | `afterDelete(doc, ctx)` | after delete | nothing | `before*` hooks are the place to set `hidden()` and `readOnly()` fields, which clients cannot send: ```js export default class ProductService extends ResourceService { async beforeCreate(data) { return { ...data, cost: data.price / 2 }; // cost is .hidden() } async beforeDelete(doc) { const orders = await this.service('order').find({ product: doc._id }); if (orders.length) throw conflict('This product has orders and cannot be deleted'); } } ``` (`conflict` is imported from `#calm`, like every [error helper](/errors).) ## scope() `scope(ctx)` returns a filter that is added to **every** query the service makes: lists, reads, updates, deletes, and the record lookup that authorizes `/:id` routes. A record outside the scope does not exist for that user: it is a `404`. ```js // Multi-tenancy: everyone only ever sees their organization's data. scope({ user } = {}) { return user ? { org: user.org } : { _id: null }; // signed out: nothing } ``` `scope()` also applies to calls without a user, which are treated like anonymous visitors. For trusted internal work (jobs, scripts, migrations) pass `{ system: true }` as the context: `scope()` is skipped and the call sees every record. ```js await ctx.app.service('post').update(id, { status: 'archived' }, { system: true }); ``` Service methods return whole records, hidden fields included (password hashes excepted): they are server-side data. Routes serialize them with the resource rules before anything is sent. ## filterFor() `this.filterFor(ctx, extra)` builds the full filter for a request: your `scope()`, the owner filter when the policy is `'owner'`, and `extra`. Use it whenever you query `this.model` yourself, so custom queries obey the same rules: ```js async countPublished(ctx) { return this.model.countDocuments(this.filterFor(ctx, { status: 'published' })); } ``` Mongoose casts ids in `find` and `countDocuments`, but not in `aggregate`. In an aggregation pipeline, convert ids yourself. Mongoose's `strictQuery` is on, so conditions on fields the resource does not declare are dropped from filters rather than matching nothing. ## Calling other services ```js // inside a service const author = await this.service('user').get(post.author, {}, ctx); // inside a route handler const categories = await ctx.services.category.find({}, ctx); const sub = ctx.services['product-category']; // or ctx.services.productCategory ``` Go through services rather than importing another resource's model: you keep its scope, hooks and events. ## Overriding whole methods When hooks are not enough, override the method and call `super`: ```js async list(query, ctx) { const result = await super.list(query, ctx); this.log.debug('Listed products', { total: result.total }); return result; } ``` ## Events After each write, the service emits an event on the app's in-process bus: | Event | Payload | |---|---| | `.created` | `{ doc, resource, user }` | | `.updated` | `{ doc, previous, resource, user }` | | `.deleted` | `{ doc, resource, user }` | | `auth.registered`, `auth.login`, `auth.password_changed`, `auth.password_reset_requested` | `{ user }` | | `auth.password_reset` | `{ userId }` | | `security.login_failed`, `security.account_locked`, `security.forbidden`... | Depends on the event: failed sign-ins, lockouts, bad tokens, rejected writes, `403`s, rate limits (see [Security events](/security#security-events)) | `user` is the signed-in user who made the change, or `null`. Blocks add their own (`job.completed`, `job.failed`, `.restored`). Listeners run after the write, asynchronously. They never break a request: an exception is logged. Patterns are an exact name, `'post.*'` or `'*'`. Register listeners in a small plugin in `app/blocks/` (any folder there with an `index.js` is loaded at boot): ```js // app/blocks/notifications/index.js import { definePlugin } from '#calm'; export default definePlugin({ name: 'notifications', setup(calm) { calm.on('post.created', async ({ doc, user }) => { calm.log.info(`New post "${doc.title}" by ${user?.name ?? 'system'}`); }); }, }); ``` `app.events.on()` returns a function that removes the listener, which is handy in tests. For work that must survive a restart or be retried, use the [jobs](/blocks/jobs) block, whose jobs can be triggered by these events. --- Source: https://docs.calmapi.dev/routes # Custom routes CRUD covers most endpoints. For everything else, add routes with the route builder: in `app/resources//.routes.js` (mounted under the resource path) or in `app/routes.js` (mounted under `/api`). ```js // app/resources/post/post.routes.js (from the api template) export default (r) => { r.get('/featured', { policy: 'public', summary: 'Latest published posts' }, async (ctx) => { const { items } = await ctx.service.list({ filter: { status: 'published' }, sort: { publishedAt: -1 }, limit: 5 }, ctx); return items; // raw documents are serialized with the post visibility rules }); r.post('/:id/publish', { policy: 'owner | admin', summary: 'Publish a draft' }, (ctx) => ctx.service.publish(ctx.params.id, ctx)); }; ``` This adds `GET /api/posts/featured` and `POST /api/posts/:id/publish`. ## The route builder `r.get`, `r.post`, `r.put`, `r.patch` and `r.delete` take `(path, options, handler)`; `options` can be left out. Paths are relative to the resource path (or to `/api` in `app/routes.js`) and use Express syntax (`/:id`, `/:id/comments/:commentId`). | Option | Default | Meaning | |---|---|---| | `policy` | `'authenticated'` | Who may call it ([Policies](/policies)). Always set it: `calm doctor` warns when it is missing. | | `input` | none | Validate the JSON body: an object of `f.*()` fields or a Standard Schema. The result is `ctx.body`. | | `status` | `200` | Status code for a returned value | | `summary`, `description`, `tags` | | OpenAPI documentation | | `middleware` | `[]` | Express middleware run before the handler (after a quick sign-in check), for example an upload parser | | `rateLimit` | none | `'auth'` for the auth limiter, or `{ windowMs, max }` for a limit on this route only | | `hidden` | `false` | Leave the route out of OpenAPI and `calm routes` | | `scope` | none | A permission name for credentials that carry scopes, such as `'reports:export'` for [API keys](/blocks/api-keys#custom-route-scopes) | | `meta` | `{}` | Anything [blocks](/building-blocks#routes) read from the route in their guards: `{ audit: true }` | ## The handler context Handlers receive `ctx`: | Property | What it is | |---|---| | `ctx.user` | The signed-in user (with `id` as a string), or `null` | | `ctx.params`, `ctx.query` | Route parameters and query string | | `ctx.body` | The validated body when `input` is set, otherwise the raw JSON body | | `ctx.doc` | For resource routes with `:id`: the record, loaded and authorized (see below) | | `ctx.service` | This resource's service (resource routes only) | | `ctx.services` | Every service: `ctx.services.post`, `ctx.services['product-category']` | | `ctx.scope` | `'owner'` when the policy matched through `owner`, else `null` | | `ctx.reply(data, { status, meta, headers })` | Build a response with a custom status, `meta` or headers | | `ctx.serialize(doc, resourceName?)` | Apply a resource's visibility rules to a raw record | | `ctx.req`, `ctx.res` | The Express request and response | | `ctx.app`, `ctx.config`, `ctx.log`, `ctx.events` | The app, its config, the logger (every line is tagged with the request id) and the event bus | | `ctx.session`, `ctx.requestId` | The current session `{ id, expiresAt }` and the request id | | `ctx.auth` | How the request signed in: `{ via: 'session', scopes: ['*'], id }`, `{ via: 'api-key', scopes, id }` with the [api-keys](/blocks/api-keys) block, or `null` | | `ctx.push` | [Push notifications](/push-notifications): `ctx.push.send({ to, title, body })` | Blocks can add more: `ctx.notify()` with [notifications](/blocks/notifications), `ctx.flags` with [feature-flags](/blocks/feature-flags). ## Return values | Handler returns | Response | |---|---| | a value | `200` (or `status`) with `{ "data": value }` | | `undefined` | `204 No Content` | | `ctx.reply(data, { status: 201, meta, headers })` | that status, `{ data, meta }` and headers | | `ctx.reply(undefined, { status: 202 })` | that status with an empty body | | throws `notFound()`, `forbidden()`, `badRequest()`... | the [error response](/errors) | ```js import { badRequest, notFound } from '#calm'; export default (r) => { r.get('/stats', { policy: 'admin', summary: 'Post counts by status' }, async (ctx) => { const [drafts, published] = await Promise.all([ ctx.service.model.countDocuments({ status: 'draft' }), ctx.service.model.countDocuments({ status: 'published' }), ]); return { drafts, published }; // 200 { data: { drafts, published } } }); r.post('/import', { policy: 'admin', status: 202 }, async (ctx) => { if (!Array.isArray(ctx.body?.items)) throw badRequest('items must be an array'); ctx.app.background(() => ctx.service.importMany(ctx.body.items)); // your method, runs after the response return { queued: ctx.body.items.length }; // 202 { data: { queued } } }); r.get('/by-slug/:slug', { policy: 'public' }, async (ctx) => { const post = await ctx.service.findOne({ slug: ctx.params.slug }, ctx); if (!post) throw notFound('Post not found'); return post; // serialized automatically }); }; ``` Errors thrown in async handlers reach the error handler by themselves (Express 5), so there is no `try/catch` and no `next`. You can still write to `ctx.res` directly (file downloads, redirects, streams); CalmAPI does not send a second response. Prefer returning values when you can: you get serialization and a consistent shape for free. ## Routes with :id In a resource's routes file, a path parameter named exactly `id` gets the same treatment as the CRUD routes: the record is loaded through the service's `scope()`, the policy is checked with the record in hand (so `'owner'` means the owner of *this* record), and the answer is `401`, `403` or `404` [as for CRUD](/policies#401-403-or-404). The record is in `ctx.doc`: ```js r.post('/:id/discount', { policy: 'owner' }, async (ctx) => { if (ctx.doc.price < 1) throw badRequest('Too cheap to discount'); return ctx.service.update(ctx.doc._id, { price: ctx.doc.price - 1 }, ctx); }); ``` `ctx.doc` does not contain `hidden()` fields; load them explicitly when you need one (`ctx.service.model.findById(ctx.doc._id).select('+key').lean()`). Name a parameter differently (`:postId`) when you do not want this behavior. ## Validating input `input` validates the body before your handler runs. Invalid bodies get a `422` with per-field details, unknown fields included: ```js import { f } from '#calm'; r.post('/quote', { policy: 'public', input: { qty: f.integer().required().min(1), coupon: f.string().max(20) }, }, (ctx) => ({ total: ctx.body.qty * 3 })); ``` ```json { "error": { "code": "VALIDATION_ERROR", "message": "Validation failed", "requestId": "...", "details": { "qty": "Must be at least 1" } } } ``` A Standard Schema works too: ```js import { z } from 'zod'; r.post('/contact', { policy: 'public', input: z.object({ email: z.string().email(), message: z.string().min(10).max(2000) }), rateLimit: { windowMs: 60_000, max: 5 }, }, async (ctx) => { await ctx.app.mailer.send({ to: 'team@example.com', subject: 'Contact form', text: `${ctx.body.email}\n\n${ctx.body.message}` }); }); ``` ## Auto-serialization Raw records returned from a **resource** route (a record, an array of records, or the `data` of `ctx.reply()`) are serialized with that resource's visibility rules: hidden, write-only and `visibleTo` fields are removed for the current user. This is a safety net, so `return ctx.service.find(...)` never leaks a private field. It does not look inside other shapes. When you return `{ file, upload }`, or records of another resource, or anything from `app/routes.js` or a block, serialize explicitly: ```js // app/routes.js r.get('/me/summary', { policy: 'authenticated' }, async (ctx) => { const posts = await ctx.services.post.find({ author: ctx.user.id }, ctx); return { user: await ctx.serialize(ctx.user, 'user'), posts: await Promise.all(posts.map((p) => ctx.serialize(p, 'post'))), }; }); ``` ## Order: custom routes before CRUD Routes are registered in this order: auth routes, block routes, `app/routes.js`, then for each resource its custom routes followed by its CRUD routes. That is why `GET /api/posts/featured` works: it is registered before `GET /api/posts/:id`, which would otherwise treat `featured` as an id. If two routes have the same method and path, the first one wins and the app logs a warning at boot. ## Middleware Express middleware in `middleware` runs after a cheap check that rejects signed-out users when the policy needs a user, and after the [guards](/building-blocks#guards) of your blocks, so an upload parser never runs for anonymous requests or requests a guard refuses. This is how the [storage-local](/blocks/storage-local) block handles uploads: ```js r.post('/upload', { policy: 'authenticated', status: 201, summary: 'Upload a file (multipart/form-data, field "file")', middleware: [uploadMiddleware('file')], }, async (ctx) => { const file = ctx.req.file; if (!file) throw badRequest('Send the file as multipart/form-data in a field named "file"'); return ctx.service.create({ originalName: file.originalname, mimeType: file.mimetype, size: file.size, key: file.filename }, ctx); }); ``` ## App-wide routes `app/routes.js` is for endpoints that do not belong to a resource. Paths are relative to `/api`: ```js // app/routes.js export default (r) => { r.get('/hello', { policy: 'public', summary: 'Say hello' }, (ctx) => ({ message: `Hello from ${ctx.config.app.name}`, time: new Date().toISOString(), })); r.get('/dashboard', { policy: 'authenticated' }, async (ctx) => { const { total } = await ctx.services.post.list({ limit: 1 }, ctx); return { posts: total }; }); }; ``` There is no `ctx.service`, `ctx.doc` or auto-serialization here: use `ctx.services.` and `ctx.serialize(doc, '')`. --- Source: https://docs.calmapi.dev/querying # Querying `GET /api/` accepts filters, search, sorting, pagination, includes and field selection. Only what you declared on the fields is allowed, so clients can never filter on private data or send raw MongoDB operators. ```text GET /api/products?status=live&price[gte]=10&price[lt]=50&tags[in]=tools,garden &q=saw&sort=-price,name&page=2&limit=20 &include=category&fields=name,price,category ``` ```json { "data": [{ "id": "...", "name": "Saw (large)", "price": 30, "category": { "id": "...", "name": "Tools" }, "createdAt": "...", "updatedAt": "..." }], "meta": { "total": 41, "page": 2, "limit": 20, "pages": 3 } } ``` With curl, add `-g` so the square brackets are not read as globs: `curl -g "http://localhost:5001/api/products?price[gte]=10"`. ## Filters Mark a field `.filterable()` to allow `?field=value` (equality) and `?field[op]=value`: ```js price: f.number().filterable(), // default operators for numbers status: f.enum(['draft', 'live']).filterable(['eq', 'in']), // only these two ``` ```text ?status=live status equals "live" ?price[gte]=10&price[lt]=50 10 <= price < 50 (operators on one field combine) ?status[in]=draft,live one of (comma separated, 1 to 100 values) ?tags=garden array contains "garden" ?tags[all]=garden,tools array contains both ?category[exists]=false field is missing ?id[in]=6acb46a5...,6acb46a6... by id (eq, in, nin) ``` ### Operators | Operator | Meaning | |---|---| | `eq` (or no operator) | equals | | `ne` | not equal | | `gt`, `gte`, `lt`, `lte` | greater or less than (numbers and dates) | | `in`, `nin` | in / not in a comma separated list | | `all` | array contains all values | | `exists` | `true`/`false` (or `1`/`0`) | Default operators by field type, when you call `.filterable()` without a list: | Field types | Operators | |---|---| | `string`, `text`, `email`, `url`, `enum` | `eq ne in nin exists` | | `number`, `integer` | `eq ne gt gte lt lte in nin exists` | | `date` | `eq ne gt gte lt lte exists` | | `boolean` | `eq ne exists` | | `id`, `ref` | `eq ne in nin exists` | | `array` | `eq in nin all exists` | | `json` | `exists` | Values are converted to the field's type and checked: numbers must be numbers, booleans `true`/`false`/`1`/`0`, dates anything `new Date()` understands (`2026-01-31`, ISO timestamps), ids 24 hex characters, enum values must be in the list, and `email` values are lowercased. The query string is parsed flat (Express `simple` parser), so nested objects or `$` operators cannot be injected. ## Search `?q=text` searches every `.searchable()` field with a case-insensitive substring match. The text is escaped, so `?q=(a+)+$` searches for those characters literally. It is limited to 100 characters, and a resource without searchable fields answers `400`. ```js name: f.string().searchable(), description: f.text().searchable(), ``` Search uses regular expressions, which cannot use a normal index. That is fine for thousands of records; for large collections, add a text index (`indexes: [{ fields: { name: 'text' } }]`) and a custom route that uses `$text`. ## Sorting `?sort=` takes a comma separated list; prefix a field with `-` for descending: ```text ?sort=-price,name ``` You can sort by `.sortable()` fields, `id`, and `createdAt`/`updatedAt` (when timestamps are on). Without `sort`, the resource's `defaultSort` applies (`-createdAt` by default). `_id` is always added as a final tie-breaker, so pagination is stable. ## Pagination | Parameter | Default | Rules | |---|---|---| | `page` | `1` | integer, 1 or more | | `limit` | `pagination.defaultLimit` (20) | integer from 1 to `pagination.maxLimit` (100) | Every list response has `meta: { total, page, limit, pages }`. Change the defaults in `calm.config.js`: ```js export default defineConfig({ pagination: { defaultLimit: 25, maxLimit: 200 } }); ``` ## Include references `?include=` populates `f.ref()` fields (and arrays of refs), comma separated. It works on lists and on `GET /:id`: ```text GET /api/posts?include=author GET /api/products/6acb46a5...?include=category,owner ``` The included record is serialized with the **target** resource's rules for the current viewer: a visitor including `author` sees the author's name, not their email, roles or password hash. Fields marked `.populate()` are always included. Hidden refs cannot be included. ## Pick fields `?fields=name,price` returns only those fields (plus `id`, `createdAt` and `updatedAt`). Unknown, hidden and write-only fields are rejected. ## Errors Anything not allowed fails the whole request with `400 BAD_REQUEST` and one message per parameter, so mistakes are never silently ignored: ```json { "error": { "code": "BAD_REQUEST", "message": "Invalid query", "requestId": "86f489d3-d343-4d90-a614-86bdca30b700", "details": { "cost": "Not filterable. Filterable fields: name, price, status, owner", "status[ne]": "Operator \"ne\" not allowed on status. Allowed: eq, in", "sort": "Cannot sort by \"cost\". Sortable: id, name, price, createdAt, updatedAt", "limit": "Must be an integer between 1 and 100" } } } ``` Unknown query parameters are rejected the same way. If your frontend adds its own parameters (cache busters, tracking), strip them before calling the API, or handle them in a custom route. ## From code Inside services and routes, the same options are available as plain objects: ```js const { items, total } = await ctx.service.list( { filter: { status: 'published' }, sort: { publishedAt: -1 }, skip: 0, limit: 5, populate: [{ path: 'author', select: 'name' }] }, ctx, ); ``` Code is trusted: filters there are not restricted to filterable fields. When you populate by hand, always pass a `select` (or serialize the result) so private fields of the referenced record are not exposed. `calm doctor` warns about `populate()` calls without one. --- Source: https://docs.calmapi.dev/authentication # Authentication Projects created from the `api` template have accounts, sessions and password flows under `/api/auth`. The code is in `calm/auth/`, so you can read and change every decision. For two-factor authentication, add the [totp](/blocks/totp) block. Auth turns on when `config.auth.enabled` is `true` (the default) **and** the project has a `user` resource with an `email` field and an `f.password()` field. The `minimal` template has no `user` resource and therefore no auth. ## Endpoints | Method and path | Policy | Body | Success | |---|---|---|---| | `POST /api/auth/register` | public, rate limited | user fields (`name`, `email`, `password`...) | `201 { data: { user, token, expiresAt } }` | | `POST /api/auth/login` | public, rate limited | `{ email, password }` | `200 { data: { user, token, expiresAt } }` | | `POST /api/auth/logout` | authenticated | | `204`, ends this session | | `POST /api/auth/logout-all` | authenticated | | `204`, ends every session of the user | | `GET /api/auth/me` | authenticated | | `200 { data: user }` | | `PATCH /api/auth/me` | authenticated | profile fields | `200 { data: user }` | | `POST /api/auth/password` | authenticated, rate limited | `{ currentPassword, newPassword }` | `204`, other sessions end | | `POST /api/auth/password/forgot` | public, rate limited | `{ email }` | `202`, always the same answer | | `POST /api/auth/password/reset` | public, rate limited | `{ token, password }` | `204`, every session ends | | `GET /api/auth/sessions` | authenticated | | `200 { data: [session...] }` | | `DELETE /api/auth/sessions/:sessionId` | authenticated | | `204` | | `POST /api/auth/devices` | authenticated | `{ token, platform, name?, appVersion?, locale? }` | `201 { data: device }`, registers a device for [push notifications](/push-notifications#registering-devices) | | `GET /api/auth/devices` | authenticated | | `200 { data: [device...] }` | | `DELETE /api/auth/devices/:deviceId` | authenticated | | `204` | | `POST /api/auth/devices/unregister` | authenticated | `{ token }` | `204`, stops push notifications to the device holding that token | | `POST /api/auth/devices/opened` | authenticated | `{ pushId?, data? }` | `204`, reports a tapped notification (emits `push.opened`) | ### Register ```bash curl -X POST http://localhost:5001/api/auth/register -H 'Content-Type: application/json' \ -d '{"name":"Ada Lovelace","email":"ada@example.com","password":"correct-horse-battery"}' ``` ```json { "data": { "user": { "id": "6acb46a57e4a738ebdd998b1", "name": "Ada Lovelace", "email": "ada@example.com", "roles": ["user"], "status": "active", "createdAt": "2026-10-11T08:19:49.069Z", "updatedAt": "2026-10-11T08:19:49.069Z" }, "token": "calm_M_vAh-pIohIgNEe8gapu1LMoYxlH6oYvepNVGWtYPWk", "expiresAt": "2026-11-10T08:19:49.085Z" } } ``` The body is validated against the `user` resource, like `POST /api/users`, but with no signed-in user: fields protected by `writableBy('admin')` (`roles`, `status`) are rejected with `422`. A duplicate email is `409 CONFLICT`. A password shorter than 8 characters, a common one, or one that contains the user's email or name is `422` (see [Password rules](#password-rules)). Set `auth.register: false` to close sign-ups (`403`). ### Login ```bash curl -X POST http://localhost:5001/api/auth/login -H 'Content-Type: application/json' \ -d '{"email":"ada@example.com","password":"correct-horse-battery"}' ``` Same response shape as register. A wrong email and a wrong password both answer `401` with `Invalid email or password`, after the same amount of work, so the response does not reveal whether an account exists. A user whose `status` is `disabled` (or `deleting`, while the [privacy](/blocks/privacy) block erases the account) gets `403`. After 5 failed attempts for the same email, sign-ins for it answer `429` for 15 minutes (see [Account lockout](#account-lockout)). With the [totp](/blocks/totp) block, users who turned on two-factor authentication get `{ mfaRequired: true, mfaToken, methods, expiresAt }` instead of a session, and finish with `POST /api/auth/2fa/verify`. ### Use the token Send it as a bearer token on every request: ```bash curl http://localhost:5001/api/auth/me -H "Authorization: Bearer calm_M_vAh-pIohIgNEe8gapu1LMoYxlH6oYvepNVGWtYPWk" ``` Only the `Authorization` header is read: tokens in query strings or cookies are ignored. A malformed, expired or revoked token answers `401 Session is invalid or has expired` on **every** route, public ones included, so clients notice a dead session instead of silently browsing as a visitor. ### Profile `PATCH /api/auth/me` updates your own user with the `user` resource rules: `email` is immutable, `roles` and `status` are admin only, and the password cannot be changed here (use `/api/auth/password`). ```bash curl -X PATCH http://localhost:5001/api/auth/me -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' -d '{"name":"Ada King"}' ``` ### Change password ```bash curl -X POST http://localhost:5001/api/auth/password -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' -d '{"currentPassword":"correct-horse-battery","newPassword":"a-much-longer-passphrase"}' ``` `204` on success. A wrong current password is `422` with `details.currentPassword`. Every other session of the user is revoked; the current one stays signed in. ### Sessions ```bash curl http://localhost:5001/api/auth/sessions -H "Authorization: Bearer $TOKEN" ``` ```json { "data": [ { "id": "6acb46a57e4a738ebdd998b3", "current": true, "createdAt": "2026-10-11T08:19:49.389Z", "lastUsedAt": "2026-10-11T08:19:49.388Z", "expiresAt": "2026-11-10T08:19:49.388Z", "ip": "127.0.0.1", "userAgent": "curl/8.5.0" }, { "id": "6acb46a57e4a738ebdd998b2", "current": false, "createdAt": "2026-10-11T08:19:49.087Z", "lastUsedAt": "2026-10-11T08:19:49.085Z", "expiresAt": "2026-11-10T08:19:49.085Z", "ip": "127.0.0.1", "userAgent": "curl/8.5.0" } ] } ``` `DELETE /api/auth/sessions/:sessionId` revokes one of your sessions (for a "sign out other devices" screen), `POST /api/auth/logout-all` revokes all of them. Ending a session also stops [push notifications](/push-notifications#devices-follow-sessions) to the devices it registered. ## Password reset 1. The user asks for a reset link: ```bash curl -X POST http://localhost:5001/api/auth/password/forgot -H 'Content-Type: application/json' -d '{"email":"ada@example.com"}' ``` ```json { "data": { "message": "If that email is registered, a reset link is on its way." } } ``` The answer (`202`) is identical whether or not the email exists, and the work happens in the background so timing does not tell either. 2. CalmAPI emails a link to `auth.resetUrl` (default `${APP_URL}/reset-password`) with a `token` query parameter. The link is valid for `auth.resetTokenTtlMinutes` (30). In development the email lands in the devtools **Mail** panel; in production you need a mail block ([Resend](/blocks/mail-resend) or [SMTP](/blocks/mail-smtp)). 3. Your frontend's reset page reads the token and posts the new password: ```bash curl -X POST http://localhost:5001/api/auth/password/reset -H 'Content-Type: application/json' \ -d '{"token":"reset_f9l4o-ANKiqUMnW512f5ksuDjZLNh1GwNJSDXCIRGwc","password":"a-brand-new-password"}' ``` `204` on success. Every session of the user is revoked and other pending reset links stop working. An unknown, expired or already used token answers: ```json { "error": { "code": "INVALID_TOKEN", "message": "This reset link is invalid or has expired", "requestId": "..." } } ``` ## Rate limits Register, login, password change, forgot and reset share the **auth** limiter: 10 requests per 15 minutes per client IP and endpoint (`rateLimit.auth`). Every `/api` route also has a **global** limit of 300 requests per minute per IP (`rateLimit.global`). Responses carry `RateLimit-Limit`, `RateLimit-Remaining` and `RateLimit-Reset`; over the limit you get `429 RATE_LIMITED` with `Retry-After`. Limits are counted per process in memory. Behind a proxy set `TRUST_PROXY` so the real client IP is used, and with several instances add [rate-limit-mongo](/blocks/rate-limit-mongo). Rate limiting is off in tests (`mode: 'test'`). ## Account lockout Rate limits count requests per IP. Lockout counts failed sign-ins per account, so guesses spread over many IPs still hit it. After 5 failed sign-ins for the same email within 15 minutes, that email is locked for 15 minutes. While it is locked, every sign-in answers `429`, even with the right password: ```json { "error": { "code": "RATE_LIMITED", "message": "Too many failed sign-in attempts. Try again in 15 minutes.", "requestId": "..." } } ``` - Attempts are counted per email address whether or not an account exists, so a lock does not reveal which emails are registered. - A successful sign-in clears the count, and so does a password reset. - Attempts are stored in `calm_login_attempts`, keyed by a hash of the email, and expire on their own (TTL index). - A failed sign-in emits `security.login_failed`, a new lock `security.account_locked`, and a sign-in during a lock `security.locked_login_attempt` (see [Security events](/security#security-events)). Change the numbers with `auth.lockout` (see [Configuration](#configuration)); `maxAttempts: 0` turns lockout off. ## Password rules `f.password()` fields accept 8 to 128 characters. There are no composition rules ("one digit, one symbol"), but passwords that are easy to guess are refused with `422`: | Rule | Message | |---|---| | One of the most common leaked passwords (`password123`, `qwerty123`, `iloveyou`...) | `This password is too common, choose another one` | | One repeated character or a straight run (`aaaaaaaa`, `23456789`, `abcdefghij`) | `This password is too easy to guess` | | Contains the part of the email before the `@` (when it has 4 or more characters) | `Your password must not contain your email address` | | Contains a word of the user's name (4 or more characters) | `Your password must not contain your name` | The first two rules apply wherever a password field is validated. The email and name rules apply on register, password change and reset. The list of common passwords is in `calm/auth/password-policy.js`; add your own words (your product name, for example) there. ## Configuration ```js // calm.config.js export default defineConfig({ auth: { enabled: true, register: true, // false: no public sign-up sessionTtlDays: 30, resetTokenTtlMinutes: 30, resetUrl: 'https://app.example.com/reset-password', // default: APP_URL + /reset-password password: { logN: 15, r: 8, p: 3 }, // scrypt cost lockout: { maxAttempts: 5, windowMinutes: 15, lockMinutes: 15 }, // per account, 0 disables }, rateLimit: { auth: { windowMs: 15 * 60_000, max: 10 } }, }); ``` ## Design choices - **Opaque session tokens, not JWT.** A token is 32 random bytes (`calm_...`). Only its SHA-256 hash is stored (collection `calm_sessions`), so a database leak does not leak usable sessions. There is no signing secret to configure, rotate or leak, and logout really ends a session. The price is one indexed lookup per request. - **scrypt from `node:crypto`.** No native addon to compile. Parameters follow the OWASP cheat sheet (N=2^15, r=8, p=3) and are stored inside each hash, so raising `auth.password` later upgrades users transparently the next time they log in. - **No account enumeration.** Login spends the same time for unknown emails, forgot always answers the same, and lockout counts unknown emails like real ones. - **Single-use reset tokens.** Random, hashed at rest (collection `calm_password_resets`), claimed atomically, short-lived, and they revoke every session when used. - **Expiry is automatic.** Both collections have TTL indexes, so MongoDB deletes expired sessions and tokens itself. `lastUsedAt` is refreshed at most every 5 minutes. - **Disabled accounts stop at once.** Setting `status: 'disabled'` rejects their existing sessions on the next request. ## In your code `ctx.user` is the signed-in user (the stored record with `id` as a string, without the password hash), `ctx.session` is `{ id, expiresAt }`. The auth service is available as `app.auth.service` (for example `app.auth.service.createSession(user)` in a script). Events: `auth.registered`, `auth.login`, `auth.password_changed`, `auth.password_reset_requested`, `auth.password_reset`, plus the [`security.*` events](/security#security-events) for failed sign-ins, lockouts and bad tokens. Two-factor authentication is available as the [totp](/blocks/totp) block, passwordless sign-in by email as the [magic-link](/blocks/magic-link) block, and API keys for scripts as the [api-keys](/blocks/api-keys) block. They plug into login with the `loginChallenge` hook, the shared [auth service helpers](/building-blocks#the-auth-service) (lockout, disabled accounts, `completeLogin`) and `calm.authenticate` for other credentials, which you can use for your own sign-in methods too. There are no social logins built in. Since `calm/auth/` is your code, you can add them, and the session model stays the same. --- Source: https://docs.calmapi.dev/push-notifications # Push notifications `app.push` sends notifications to your users' phones and browsers. Apps register a device token once, and your code sends to a user, not to tokens. In development nothing leaves your machine. Every notification lands in the devtools **Push** panel, drawn the way an iPhone, an Android phone and a desktop browser would show it, next to the exact payload Apple, Google, Expo and the browser receive. [Simulated devices](#simulated-devices) answer like real phones that are offline, muted or uninstalled, so you see what your app does in the cases that usually only show up in production. When you are ready, add a [transport block](#delivering-for-real) and the same code delivers for real. To send one notification to the inbox, the phone and the mailbox at once, with per-user preferences and quiet hours, use the [notifications](/blocks/notifications) block on top of `app.push`. ## Send a notification ```js // app/resources/order/order.routes.js r.post('/:id/ship', { policy: 'admin' }, async (ctx) => { const order = await ctx.service.update(ctx.params.id, { status: 'shipped' }, ctx); await ctx.push.send({ to: order.customer, // a user id: every device they registered title: 'Your order shipped', body: 'It arrives Thursday between 9 and 12.', url: `/orders/${order.id}`, // opened on tap, also sent as data.url data: { orderId: String(order.id) }, }); return order; }); ``` `ctx.push` is available in routes and services, `app.push` everywhere else (jobs get `{ app }`, plugins get `calm.push`). `send()` resolves to `{ id, status, devices, results }`: | `status` | Meaning | |---|---| | `captured` | Recorded in devtools (and `t.pushes` in tests), not delivered: development or tests without a transport | | `sent` | Accepted for every device: by the transport, or by the simulator for [simulated devices](#simulated-devices) | | `partial` | Some devices failed. The delivery log in devtools says which and why. | | `failed` | No device received it, or there is no transport in production | | `no-devices` | The user has no registered devices | An invalid message throws a `422` with the field errors, like any other validation error. Delivery problems never throw: a push should not fail the request that triggered it. They are in the result, the log and devtools. ## Who receives it | Option | Sends to | |---|---| | `to` | A user id (or an array of them): every device those users registered | | `deviceIds` | Specific devices by id | | `tokens` | Raw device tokens, with `platform` (`ios`, `android` or `web`, default `android`). Use this when you keep tokens yourself. | ## The message | Field | Type | Notes | |---|---|---| | `title` | string, up to 250 | About 36 characters show on an iPhone, 40 on Android, 45 in a browser | | `subtitle` | string | iPhone only, under the title | | `body` | string, up to 2000 | About 4 lines on an iPhone, one line (46 characters) on a collapsed Android notification | | `image` | https URL | A picture in the expanded notification. iOS needs a notification service extension in the app. | | `icon` | https URL | Browser notifications | | `url` | string | Where a tap should go (`/orders/42` or `myapp://orders/42`), added to `data.url` | | `badge` | integer, 0 or more | The number on the iPhone app icon | | `sound` | string or `false` | `default` unless the message is silent. `false` for none. | | `channel` | string | Android notification channel id | | `category` | string | iOS category, for notification actions | | `group` | string | iOS thread: notifications with the same group are stacked together | | `collapseId` | string, up to 64 | A newer notification with the same id replaces the older one on the device (APNs `apns-collapse-id`, FCM `collapse_key` and `tag`, Expo `collapseId` and `tag`, Web Push `tag`) | | `priority` | `high` or `normal` | `high` (default) is shown right away, `normal` may wait to save battery | | `ttl` | seconds | How long the provider keeps trying an offline device | | `silent` | boolean | No banner and no sound: the app wakes in the background with `data`, which is then required | | `data` | object | Delivered to the app, never shown. FCM only carries strings, so other values are sent as JSON text. | A message needs a `title` or a `body`, or `silent: true` with `data`. Payloads are limited to 4 KB by every provider. Devtools shows the size of each one and warns before a provider would reject it. ## Registering devices Apps register their push token after sign in, with the user's session: | Endpoint | Body | Returns | |---|---|---| | `POST /api/auth/devices` | `{ token, platform, name?, appVersion?, locale? }` | `201 { data: device }` | | `GET /api/auth/devices` | | `200 { data: [device...] }`, your devices | | `DELETE /api/auth/devices/:deviceId` | | `204` | | `POST /api/auth/devices/unregister` | `{ token }` | `204`, turns notifications off for the device holding that token | | `POST /api/auth/devices/opened` | `{ pushId?, data? }` | `204`, reports that the user tapped a notification (emits `push.opened`) | `platform` is `ios`, `android` or `web`. Tokens are never returned in full (`a3f9c2…b7d3f8`). Registering a token that is already known moves it to the current user and session, which is what you want when someone signs out and another person signs in on the same phone. Tokens starting with `sim_` are reserved for simulated devices (`422`). Apps know their own token, not the device id, so a "notifications" switch in the app's settings calls `POST /api/auth/devices/unregister` with the token to turn them off, and `POST /api/auth/devices` to turn them back on. With Expo: ```js import * as Notifications from 'expo-notifications'; import { Platform } from 'react-native'; const { data: token } = await Notifications.getExpoPushTokenAsync({ projectId }); await fetch(`${API_URL}/api/auth/devices`, { method: 'POST', headers: { authorization: `Bearer ${session}`, 'content-type': 'application/json' }, body: JSON.stringify({ token, platform: Platform.OS === 'ios' ? 'ios' : 'android' }), }); ``` With Firebase (Android, iOS or the web), send the token from `getToken()` the same way. Devices are stored in the `calm_devices` collection. They are not a resource, so there are no CRUD routes for them beyond the ones above, and tokens never appear in other API responses. When a provider says a token stopped working (the app was uninstalled), the device is removed for you. ### Devices follow sessions A device belongs to the session that registered it. When that session ends, the phone stops getting notifications, so nobody keeps receiving someone else's notifications after they signed out: - Signing out (`/auth/logout`), signing out everywhere, revoking a session, a password change (other sessions end) and a password reset remove the devices of those sessions. - Devices of sessions that expired are skipped and removed the next time you send to them. The delivery log says so. - `GET /api/auth/devices` shows `signedIn: true` for devices bound to a session. There is nothing to call on sign out. Devices registered with another credential (an [API key](/blocks/api-keys)) are not bound to a session and stay until they are removed. `config.auth.devicesFollowSessions` (default `true`) controls the removal at sign out. ## In development: the Push panel Open **Push** in [devtools](/devtools#push). For every notification: ![The devtools Push panel: a notification shown on an iPhone lock screen and in the Android notification shade, with a warning that the body is cut off on Android](/images/devtools-push.webp) - **Preview**: the iPhone lock screen, the Android notification shade and a desktop browser notification, closed or expanded, at real device widths and type sizes, so text cuts off where a phone cuts it. - **Payloads**: the exact JSON for APNs, FCM, Expo and Web Push, with its size against the 4 KB limit. - **Warnings**: a title or body that will be cut off, data that looks sensitive (`password`, `token`, `secret`...), non-string data for FCM, payloads a provider would reject. - **Delivery**: what happened on each device, step by step: which transport took it (or the simulator), the provider's answer, the receipt that came later, whether it was shown on the lock screen, and a link to the request that sent it. **New notification** composes one with a live preview and sends it through the same `app.push.send()` your code uses. **Send again** reopens a sent one in the composer, and **Copy as code** gives you the `ctx.push.send()` call for it. Agents see the same notifications with the [`calm_pushes`](/ai-agents#calm-mcp-tools-for-coding-agents) tool. ### Simulated devices No phone yet, or no phone that is offline right now? **Devices** (in the Push panel) adds devices to any user, as many as you like: an iPhone, an Android phone and a browser, each with its own behavior. A simulated device answers like a real phone would, and is never sent to a real provider, even when a transport is installed. In production, simulated devices are skipped. | Behavior | What happens | |---|---| | Delivers | Accepted, then shown on the device's lock screen | | Offline | Accepted, and shown when the device comes back online (**Bring online**) | | Notifications off | Accepted and never shown. Providers cannot tell, and neither can your server. | | App uninstalled | The provider answers `DeviceNotRegistered`: the device is removed | | Invalid token | The provider answers `InvalidRegistration` (malformed, or replaced by a new token): the device is removed | | Rate limited | The provider answers `MessageRateExceeded`: too many notifications to this device | Change a device's behavior at any time from its row. Each device can also: - **Send test**: send it a notification right from devtools. - **Rotate** its token: the provider gives the app a new token and the old one stops working. The app registers the new one, and the old device fails once on the next send and is removed, just as your server would find out in production. - **Uninstall app**: the next send fails with `DeviceNotRegistered` and removes the device. - **Remove**: unregister it, as the app would when the user turns notifications off. Click a simulated device to open it as a phone: its own lock screen with every notification it received, newest first, and which ones are waiting (offline) or hidden (notifications off). **Bring online** shows the waiting ones, **Clear** empties the lock screen, and tapping a notification opens it: the app "goes to" its `url`, and `push.opened` is emitted with `{ id, device, url, data }`, the event your app would report. The lock screen follows new notifications live, so keep it open while you trigger them from your app. ### Your own phone To try a real device in development, install a [transport block](#delivering-for-real), then either let your app register its token, or paste the token in devtools: **Devices**, **Add device**, **Real token** (for example the Expo push token your app logs). Real devices go through the transport and arrive on your phone. Their rows show which transport each one goes through. ## Delivering for real Add the block that matches how your app receives notifications: | Block | Use it for | |---|---| | [`push-expo`](/blocks/push-expo) | Expo and React Native apps using `expo-notifications` | | [`push-fcm`](/blocks/push-fcm) | Native Android and iOS apps and web apps using the Firebase SDK (iOS goes through FCM's APNs integration) | ```bash npx calm add push-expo npx calm add push-fcm # both, if some devices register Expo tokens and others native ones ``` Several transports can be active at once. Each device goes to the transport that takes its token: push-expo takes Expo push tokens, push-fcm the native ones, so one `send()` reaches both kinds of apps. With a transport installed: - **Development** delivers to real tokens, so you can test on your own phone. Simulated devices stay in devtools. - **Tests** keep capturing notifications in `t.pushes` and never call the provider. - **Production** delivers, removes tokens the provider reports as gone, and logs failures. In production without a transport, nothing is sent: `send()` returns `failed` and an error is logged. ### Your own transport A transport is an object with a `name`, a `send(message, devices, { id })` method that returns one result per device, and optionally `accepts(device)` to take only the tokens it understands: ```js import { definePlugin, providerPayloads } from '#calm'; export default definePlugin({ name: 'push-onesignal', setup(calm) { calm.push.use({ name: 'onesignal', accepts: (device) => device.token.startsWith('onesignal:'), async send(message, devices) { return Promise.all(devices.map(async (device) => { const payload = providerPayloads(message, device.token).fcm.payload; // or apns, expo, web // deliver it ... return { ok: true, id: 'provider-message-id' }; // or { ok: false, error: 'reason', unregistered: true } to remove the device })); }, }); }, }); ``` `devices` holds `{ id, user, token, platform, name }` for real devices only. `providerPayloads()` builds the same payloads devtools shows. A device goes to the first transport whose `accepts()` says yes, otherwise to a transport without `accepts()`. Import from `#calm` only: `providerPayloads` and `maskToken` are exported there. When the provider reports later (a receipt), call `app.push.receipt(pushId, deviceId, { ok, error, unregistered, transport })` with the `id` your `send()` received. It lands in the same delivery log, removes the device when `unregistered` is `true`, and emits `push.receipt`. ## Testing Notifications are captured in tests, with everything devtools shows: ```js test('shipping an order notifies the customer', async () => { const customer = await t.signup(); await t.post('/auth/devices', { token: 'test-token-1', platform: 'ios' }, customer); const order = await createOrder(customer); await t.post(`/orders/${order.id}/ship`, {}, admin); const push = t.pushes.at(-1); assert.equal(push.message.title, 'Your order shipped'); assert.equal(push.devices[0].platform, 'ios'); assert.deepEqual(push.warnings.filter((w) => w.level !== 'info'), []); }); ``` Each record has `message`, `devices` (tokens masked), `payloads`, `warnings`, `status` and `log`. Simulated devices work in tests too, and answer at once. Create one directly to see what your code does when a phone was uninstalled: ```js test('an uninstalled app removes the device', async () => { const user = await t.signup(); await t.app.push.devices.create({ user: user.id, platform: 'ios', token: 'sim_ios_test1', simulated: true, behavior: 'uninstalled' }); const res = await t.app.push.send({ to: user.id, title: 'Hello' }); assert.equal(res.status, 'failed'); assert.equal(await t.app.push.devices.countDocuments({ user: user.id }), 0); }); ``` ## Events | Event | When | Payload | |---|---|---| | `push.sent` | A send finished with any status but `failed` | `{ id, status, devices }` | | `push.failed` | No device received it | `{ id, status, devices }` | | `push.receipt` | A provider reported later (Expo receipts, the simulator) | `{ id, device, ok, error, unregistered }` | | `push.opened` | The app reported a tap (`POST /api/auth/devices/opened`), or a notification was tapped on a simulated phone | `{ id, user, device, url, data, simulated }`. `data` comes from the client: only act on the user's own records | | `push.simulated` | A simulated device showed a notification, kept it waiting (offline) or hid it (notifications off) | `{ id, device, state }` | Use them for metrics or an audit trail: ```js calm.on('push.failed', ({ id, devices }) => calm.log.warn('Push failed', { id, devices })); ``` ## Good practice - **Send an id, not the content.** Payloads travel through Apple, Google or a browser vendor and are shown on a lock screen. Put `{ orderId }` in `data` and let the app fetch the details from your API. Devtools warns about keys that look sensitive. - **Lead with the point.** A closed Android notification shows one line of the body. - **Send to many users from a [job](/blocks/jobs)**, not from the request. - **Let people turn notifications off**: per device with `POST /api/auth/devices/unregister`, or per type and with quiet hours through the [notifications](/blocks/notifications) block's preferences. --- Source: https://docs.calmapi.dev/errors # Errors Every error that leaves the API has the same shape, whatever caused it: ```json { "error": { "code": "VALIDATION_ERROR", "message": "Validation failed", "requestId": "5c397446-0c18-4cfe-99be-d08c78419bec", "details": { "price": "Must be a number", "role": "Unknown field" } } } ``` | Key | Always | Meaning | |---|---|---| | `code` | yes | Stable, machine readable. Switch on this in clients. | | `message` | yes | Human readable and safe to show | | `requestId` | yes | Same as the `X-Request-Id` response header. Search logs and devtools by it. | | `details` | sometimes | One message per field or query parameter | ## Codes | Status | Code | When | |---|---|---| | 400 | `BAD_REQUEST` | Invalid list query, a JSON body nested too deep or with too many values (`jsonLimits`), or `badRequest()` from your code | | 400 | `INVALID_JSON` | The body is not valid JSON | | 400 | `INVALID_TOKEN` | Password reset token unknown, expired or used | | 401 | `UNAUTHORIZED` | No valid session where one is needed, or wrong login | | 403 | `FORBIDDEN` | Signed in but not allowed | | 404 | `NOT_FOUND` | Unknown route, malformed id, missing record, or a record the user may not see | | 409 | `CONFLICT` | Duplicate value on a `.unique()` field | | 413 | `PAYLOAD_TOO_LARGE` | Body larger than `bodyLimit` (1 MB) | | 422 | `VALIDATION_ERROR` | Body failed validation; see `details` | | 429 | `RATE_LIMITED` | Too many requests (see `Retry-After`), or a sign-in for a locked account | | 500 | `INTERNAL_ERROR` | Anything unexpected | ## Throwing errors Import the helpers from `#calm` and throw. Express 5 forwards errors from async handlers, so there is no `try/catch` or `next(err)`: ```js import { badRequest, unauthorized, forbidden, notFound, conflict, validationError, tooManyRequests, CalmError } from '#calm'; throw badRequest('items must be an array'); // 400 BAD_REQUEST throw badRequest('Invalid date range', { to: 'Must be after from' }); // with details throw unauthorized(); // 401 "Authentication required" throw forbidden('Only the organizer can cancel this event'); // 403 throw notFound('Invoice not found'); // 404 throw conflict('This product has orders', { product: 'In use' }); // 409 throw validationError({ email: 'Already invited' }); // 422 "Validation failed" throw tooManyRequests(); // 429 throw new CalmError(402, 'PAYMENT_REQUIRED', 'Upgrade your plan to add more projects'); ``` | Helper | Signature | Default message | |---|---|---| | `badRequest` | `(message?, details?)` | `Bad request` | | `unauthorized` | `(message?)` | `Authentication required` | | `forbidden` | `(message?)` | `You do not have permission to do this` | | `notFound` | `(message?)` | `Resource not found` | | `conflict` | `(message?, details?)` | `Resource already exists` | | `validationError` | `(details, message?)` | `Validation failed` | | `tooManyRequests` | `(message?)` | `Too many requests, slow down` | | `new CalmError` | `(status, code, message, details?)` | | Messages of `CalmError`s are sent to clients as they are, so write them for your users. ## What other errors become Errors that are not `CalmError`s are translated in `calm/errors.js`: | Thrown | Response | |---|---| | Malformed JSON body | `400 INVALID_JSON` | | Body over `bodyLimit` | `413 PAYLOAD_TOO_LARGE` | | Other `400` errors from Express middleware | `400 BAD_REQUEST` with their message | | Mongoose `CastError` on `_id` | `404 NOT_FOUND` | | Mongoose `CastError` on another path | `422 VALIDATION_ERROR` with `{ : "Invalid value for " }` | | Mongoose `ValidationError` | `422 VALIDATION_ERROR` with one message per path | | MongoDB duplicate key (`E11000`) | `409 CONFLICT`, `"email already exists"`, details `{ email: "Already taken" }` | | Anything else | `500 INTERNAL_ERROR`, `"Something went wrong"` | ## Nothing internal leaks For a `500`, the client only ever gets `Something went wrong`. The real message and stack trace go to the log (with the request id) and to devtools: ```js r.get('/boom', { policy: 'public' }, () => { throw new Error('db password is hunter2'); }); ``` ```json { "error": { "code": "INTERNAL_ERROR", "message": "Something went wrong", "requestId": "0b7e6f3c-5f8e-4d7b-9a43-2c1f8d7e6a10" } } ``` In **development** mode only, `500` responses also include the original error, which saves a trip to the terminal: ```json { "error": { "code": "INTERNAL_ERROR", "message": "Something went wrong", "requestId": "0b7e6f3c-5f8e-4d7b-9a43-2c1f8d7e6a10", "debug": { "message": "db password is hunter2", "stack": "Error: db password is hunter2\n at ..." } } } ``` The devtools **Requests** panel shows the same error with a clickable stack trace. ## Request ids Every response has an `X-Request-Id` header. A client can send its own (letters, digits, `.`, `_`, `-`, up to 128 characters) to correlate logs across services; otherwise a UUID is generated. Every log line written during the request carries it, including your own `ctx.log.info(...)` calls. ## Handling errors in clients ```js const res = await fetch('/api/posts', { method: 'POST', headers, body: JSON.stringify(post) }); if (!res.ok) { const { error } = await res.json(); if (error.code === 'VALIDATION_ERROR') showFieldErrors(error.details); else if (error.code === 'UNAUTHORIZED') redirectToLogin(); else toast(`${error.message} (ref ${error.requestId})`); } ``` `204` responses have no body; do not call `res.json()` on them. --- Source: https://docs.calmapi.dev/configuration # Configuration Configuration comes from three places, merged in this order: 1. The defaults in `calm/config.js` (listed below). 2. `calm.config.js` in your project. 3. Overrides passed to `createApp({ config })`, which is what tests use. Before that, `.env` is loaded into `process.env` (with Node's built-in `process.loadEnvFile`). Keep secrets in `.env` and the environment; keep `calm.config.js` for behavior, and commit it. ## calm.config.js ```js import { defineConfig } from '#calm'; export default defineConfig({ pagination: { defaultLimit: 20, maxLimit: 100 }, auth: { sessionTtlDays: 30, resetTokenTtlMinutes: 30 }, mail: { from: 'My API ' }, }); ``` It can also export a function of the environment and mode: ```js export default defineConfig(({ env, mode }) => ({ bodyLimit: mode === 'production' ? '256kb' : '5mb', cors: { origins: env.CORS_ORIGINS?.split(',') ?? ['http://localhost:3000'] }, })); ``` `defineConfig` returns its argument unchanged; it only helps editors autocomplete. Nested objects are merged key by key, so `auth: { register: false }` keeps the other auth defaults. Arrays are replaced. ## Mode The mode is `NODE_ENV`, and **defaults to `production`** when `NODE_ENV` is not set. `calm dev` sets `development`, tests use `test`. | | development | test | production | |---|---|---|---| | Devtools at `/_calm` | on | off | off, cannot be enabled | | CORS without `CORS_ORIGINS` | any origin (`*`) | any origin | no origin | | `APP_URL` | optional | optional | **required** | | Log format | pretty | pretty, silent | JSON lines | | `Strict-Transport-Security` header | | | sent | | Rate limits | on | off | on | | `500` responses include `debug` | yes | | | Only `development` and `test` relax the rules. Any other value (`staging`, `preview`, unset) runs as production: CORS needs an explicit list, `APP_URL` is required and devtools stay off. The raw value is available as `config.nodeEnv`. ## Validation at boot The app refuses to start, with a readable message, when: - `MONGODB_URI` is not set (`db.uri`); - the mode is `production` and `APP_URL` is not set. ```text Invalid configuration: - APP_URL must be set in production (used in emails and links) ``` ## Every key | Key | Default | Notes | |---|---|---| | `app.name` | `name` in package.json | Banner, emails, OpenAPI title | | `app.version` | `version` in package.json | | | `app.url` | `APP_URL`, else `http://localhost:` | Public URL of the API, used in reset links and OpenAPI `servers` | | `port` | `PORT`, else `5001` | | | `host` | `HOST`, else all interfaces | | | `apiPrefix` | `'/api'` | Where every route is mounted | | `trustProxy` | `TRUST_PROXY`, else `false` | Express `trust proxy`. `'true'`, `'false'`, a hop count (`1`) or a value like `'loopback'` | | `bodyLimit` | `'1mb'` | Maximum JSON body | | `jsonLimits` | `{ maxDepth: 20, maxKeys: 10000 }` | Deepest nesting and most values a JSON body may have; more is a `400` (see [Security](/security#json-guard)) | | `db.uri` | `MONGODB_URI` | Required | | `db.autoIndex` | `true` | Build indexes declared on fields at startup | | `db.connectRetries` | `5` | Attempts with exponential backoff (up to 10 s apart) | | `cors.origins` | `CORS_ORIGINS` (comma separated), else `[]` in production and `'*'` otherwise | Allowed browser origins | | `cors.credentials` | `false` | Send `Access-Control-Allow-Credentials` (not needed for bearer tokens) | | `cors.maxAge` | `600` | Preflight cache, in seconds | | `cors.exposeHeaders` | `[]` | Response headers browsers may read, besides `X-Request-Id`, the rate limit headers, `Retry-After` and `Idempotent-Replayed` | | `pagination.defaultLimit` | `20` | Page size when `?limit` is absent | | `pagination.maxLimit` | `100` | Largest `?limit` allowed | | `unknownFields` | `'reject'` | Unknown body fields are a `422`. Any other value (`'strip'`) drops them silently. | | `rateLimit.enabled` | `true` (`false` in test mode) | | | `rateLimit.global` | `{ windowMs: 60000, max: 300 }` | Every `/api` route, per IP | | `rateLimit.auth` | `{ windowMs: 900000, max: 10 }` | Login, register, password routes, per IP and route | | `auth.enabled` | `true` | Auth also needs a `user` resource | | `auth.register` | `true` | `false` closes public sign-up | | `auth.sessionTtlDays` | `30` | | | `auth.resetTokenTtlMinutes` | `30` | | | `auth.resetUrl` | `/reset-password` | The page of your frontend that receives `?token=` | | `auth.password` | `{ logN: 15, r: 8, p: 3 }` | scrypt cost; length rules live on `f.password()` (8 to 128 characters) | | `auth.lockout` | `{ maxAttempts: 5, windowMinutes: 15, lockMinutes: 15 }` | Per account [lockout](/authentication#account-lockout) after failed sign-ins; `maxAttempts: 0` turns it off | | `auth.devicesFollowSessions` | `true` | Remove push devices when the session that registered them ends (see [Devices follow sessions](/push-notifications#devices-follow-sessions)) | | `encryption.keys` | `CALM_ENCRYPTION_KEYS` | Keys for `.encrypted()` fields. Keep them in the environment, not in this file. | | `openapi.enabled` | `true` | Serve the OpenAPI document | | `openapi.path` | `'/openapi.json'` | Relative to `apiPrefix` | | `devtools.enabled` | `true` in development | Forced off in production | | `log.level` | `LOG_LEVEL`, else `info` (`silent` in tests) | `debug`, `info`, `warn`, `error`, `silent` | | `log.format` | `json` in production, else `pretty` | | | `shutdownTimeoutMs` | `10000` | How long open connections may finish on shutdown | | `mail.from` | ` ` | Sender for app emails | | `blocks` | `{}` | Settings per block, merged over the block's defaults: `blocks: { webhooks: { maxEndpointsPerUser: 5 } }`. See each block's page. | | `plugins` | none | Extra plugins (`definePlugin(...)` objects) besides `app/blocks` | Read the effective configuration anywhere through `ctx.config` or `app.config`. ## Environment variables | Variable | Used for | |---|---| | `NODE_ENV` | `development` or `test`; anything else (or unset) runs as production | | `MONGODB_URI` | Database connection string (required) | | `APP_URL` | Public URL (required in production) | | `PORT`, `HOST` | Where to listen (default `5001`, all interfaces) | | `CORS_ORIGINS` | Comma separated allowed origins, for example `https://app.example.com,https://admin.example.com` | | `TRUST_PROXY` | Set behind a load balancer, usually `1` | | `LOG_LEVEL` | `debug`, `info`, `warn`, `error`, `silent` | | `CALM_SKIP_ENV_FILE` | `1` to ignore `.env` | | `CALM_MAIL_PRINT` | `1` to print email bodies to the console in development | | `CALM_ENCRYPTION_KEYS` | Keys for `.encrypted()` fields: `k2:,k1:`, the first one encrypts. Create with `npx calm keygen --write` (see [Encryption at rest](/security#encryption-at-rest)) | | `MONGODB_TEST_URI` | Server used by `createTestApp` and `calm audit` (default `mongodb://127.0.0.1:27017`) | | `CALM_TEST_LOG` | Log level inside tests, for example `info` | | `CALM_DEBUG` | Print stack traces for CLI errors | Blocks add their own variables (for example `JOBS_WORKER`, `RESEND_API_KEY`); `calm add` appends them to `.env.example` and `.env`. Blocks declare the variables they read, so `calm doctor` reports required ones that are missing, and the devtools **Blocks** panel shows which are set (never the values of secret ones). ```ini # .env (never commit it) MONGODB_URI=mongodb://127.0.0.1:27017/my_api APP_URL=http://localhost:5001 PORT=5001 # CORS_ORIGINS=https://app.example.com # TRUST_PROXY=1 # LOG_LEVEL=info ``` --- Source: https://docs.calmapi.dev/devtools # Devtools In development, every CalmAPI app serves a dashboard at **`http://localhost:5001/_calm`**. It shows what your API is doing right now: requests with their MongoDB queries, logs, emails, push notifications on simulated phones, routes, resources, data and the blocks you installed, plus a playground to call the API as any user. It also shows what an AI agent is changing in your project, and who can access what. ```bash npm run dev # open http://localhost:5001/_calm ``` The collector is part of your app (`calm/devtools/index.js`). The interface is the `@calmapi/devtools` package, a dev dependency of every new project. If it is missing, `/_calm` shows how to install it (`npm install -D @calmapi/devtools`) and the JSON API keeps working. ## Panels ### Requests A live list of every request (over Server-Sent Events): method, path, status, duration, the user who made it, and how many MongoDB queries it ran. Quick filters show **Errors**, **Slow** requests (300 ms or more) and **N+1** suspects (more than 10 queries). A second view lists **background queries** that ran outside any request (jobs, startup). Open a request to see: - **Timeline**: the request and each query as a waterfall, with log lines placed in time. - **Queries**: every MongoDB command with its filter, duration and document count. Repeated query shapes are flagged. **Explain** runs the query planner and tells you whether an index was used or the collection was scanned (`COLLSCAN`). - **Request** and **Response**: headers and bodies. Passwords, tokens, keys and other secret-looking values are redacted in both (in responses the first characters of a long value stay, so you can tell them apart), and the `Authorization` header is shortened. - **Logs**: everything logged during the request, linked by request id. - **Error**: the original error and stack trace, with links that open the file in VS Code. From there you can copy the request as `curl`, edit it in the Playground, replay it, or replay it as the user who made it. Requests that carry an `X-Calm-Agent` header (the [`calm_http`](/ai-agents#calm-mcp-tools-for-coding-agents) tool sends it) are marked as sent by an AI agent, with its name, and the **Agent** filter shows only those. ### Activity A live feed of what is happening to the project, read from `.calm/activity.jsonl` (see [Watching an agent work](/ai-agents#watching-an-agent-work)): agent tool calls, file changes with their diffs, marked as made by an agent or by you, server restarts and crashes with their error output. Filter by agent, files or server events. ### Logs A live tail of the app log with level filters, text search and a link from each line to the request that wrote it. ### Mail In development, nothing is delivered: emails go to this inbox instead, password reset links included. The empty state shows a `curl` command (and a Playground shortcut) to trigger a reset email. ### Push Every [push notification](/push-notifications) the app sent, live, as it would appear on an iPhone lock screen, in the Android notification shade and as a desktop browser notification. Switch between closed and expanded; previews use real device widths and type sizes, so text is cut off where a phone cuts it. Each notification also shows: - **Payloads**: the exact JSON for APNs, FCM, Expo and Web Push, with its size against the 4 KB limit. - **Warnings**: titles and bodies that will be cut off, data keys that look sensitive, non-string data for FCM, payloads a provider would reject. - **Delivery**: what happened on each device, step by step: the transport that took it (or the simulator), the provider's answer, receipts that came later, whether the phone showed it, and the request that sent it. **New notification** composes one with a live preview and sends it through `app.push.send()`; **Send again** reopens a sent one in the composer and **Copy as code** gives you the `ctx.push.send()` call. **Devices** lists every registered device with its user, masked token, the transport it goes through and when it was last seen. **Add device** gives any user as many devices as you like: - **Simulated** iPhones, Android phones and browsers, each with a behavior: delivers, offline, notifications off, app uninstalled, invalid token or rate limited. They answer like real phones and never reach a real provider. See [Simulated devices](/push-notifications#simulated-devices). - **Real tokens** pasted from your phone (an Expo or FCM token), delivered through the installed transport. From a device's row you can change its behavior, send it a test, **Rotate** its token (the old one fails once and is removed, as in production) or **Remove** it. Click a simulated device to open it as a **phone**: its own lock screen with the notifications it received, the ones waiting while it is offline and the ones hidden because notifications are off. **Bring online**, **Uninstall app**, **Clear** and tapping a notification (which emits `push.opened`) are there too, and the lock screen updates live. Without a transport block, notifications to real devices are only captured here. With [push-expo](/blocks/push-expo) or [push-fcm](/blocks/push-fcm) installed (or both), real devices receive them in development too. ### Routes Every endpoint grouped by resource, block or source, with its policy and the file that defines it. Filter by policy, and spot routes whose policy is **not explicit**. ### Resources A diagram of how resources reference each other, then each resource with its policies (defaulted ones highlighted), owner field and fields. Each field shows its type and flags (`hidden`, `readOnly`, `filterable`...) and whether it is readable (R) and writable (W) through the API. ### Data Browse the documents of each resource with the **privacy lens**: for each record, what MongoDB stores next to what the API returns, and why each missing field is missing (`hidden`, `password`, `visible to owner | admin`...). **View as** switches the viewer between anonymous and any user, so you can check exactly what Alice sees of Bob's records. Password hashes are masked. **Sample data** adds 10 records that look real to the resource you are looking at: names that look like names, prices like prices, values from enums, dates in the past or the future depending on the field name, and references to records that exist (referenced resources get a few records first). Records are created through the service, so validation and hooks run. When there are no users yet, demo users are created to own the records, and their email and password are shown. From a terminal: [`calm seed`](/cli#calm-seed). ### Blocks Every block in the app, from [`calm add`](/blocks) or [written here](/building-blocks): - **Origin**: the address and version it was installed from (`calm.lock`), or "your own block", and the files you changed since installing it. - **Hooks**: the extension points it uses, such as a guard, an authenticator, named policies, the events it listens to, a push or mail transport, what it provides on `app.ext`, doctor checks and devtools panels. - **Environment**: each variable it declares, set or missing (missing required ones are flagged, and the panel's badge counts blocks with missing variables). The values of secret variables never leave the server. - **Options**: its settings after `calm.config.js` overrides, with secret-looking keys redacted. - **Routes**: every route it adds, with its policy. ### Playground Send requests to your API with route suggestions and sample bodies built from the resource fields. **Act as** signs you in as any user: devtools creates a real session for them and keeps it in memory for the Playground and replays. It is the quickest way to test policies. ### Doctor The runtime checks of `calm doctor` (configuration, policies, exposed secrets, personal data exposure, unknown references), grouped by level and updated live. Run `npx calm doctor` in a terminal to add the static checks on your source files. See [Security](/security#what-calm-doctor-checks). ### Security Who can access what, and what happened: - **Access**: the [exposure matrix](/security#exposure-matrix), per resource: which actions anonymous visitors, users, owners, custom roles and admins may perform, and which fields each can read and write. - **Events**: the [security events](/security#security-events) of the dev server, live: failed sign-ins, lockouts, bad tokens, rejected writes, `403`s. - **Posture**: sessions, password rules, lockout, rate limits, CORS, encryption keys and encrypted fields, JSON limits and security headers. **Run audit** runs [`calm audit`](/audit) on a throwaway database on the dev server's MongoDB and shows the result in the matrix. ### Block panels Blocks can add panels, listed under **Blocks** in the sidebar: | Block | Panel | |---|---| | [webhooks](/blocks/webhooks) | Endpoints with their status, the latest deliveries with status codes, errors and next retries, and how many are pending | | [notifications](/blocks/notifications) | Every notification type with its channels, and the latest inbox entries | | [api-keys](/blocks/api-keys) | Keys with owner, prefix, scopes, status and last use | | [feature-flags](/blocks/feature-flags) | Every flag with its rollout, targeting, variants and last change | | [jobs](/blocks/jobs) | Queue statistics and recent jobs | | [realtime-sse](/blocks/realtime-sse) | Connected subscribers and pending tickets | | storage blocks | Their configuration | Your own block adds one with [`calm.devtools.panel()`](/building-blocks#extension-points). ### Keyboard shortcuts | Key | Action | |---|---| | `/` | Focus the filter | | `j` / `k` | Next / previous item | | `Enter` | Open the selected item | | `Esc` | Close the detail view | | `1` to `9` | Switch panel | | `Ctrl + Enter` | Send (Playground, Push) | | `n` | New notification (Push) | | `?` | Show the shortcuts | The theme follows your system, or pick light or dark in the top bar. ## Safety model Devtools can read your data and create sessions, so it is locked down: - **Development only.** It is on when the mode is `development` (`calm dev`). In production it is forced off, even if `calm.config.js` turns it on. MongoDB command monitoring is only enabled while devtools is on. - **This machine only.** Every `/_calm` request must arrive on a loopback socket (`127.0.0.1` or `::1`), with a `Host` header of `localhost`, `127.0.0.1` or `::1`, and without proxy headers (`X-Forwarded-For`, `Forwarded`). Anything else gets a plain `404`, which also defeats DNS rebinding. - **No cross-site writes.** Requests that change something (act as, explain, clear, run audit, sample data, send a test notification, add or change a device) need an `X-Calm-Devtools: 1` header, which a page on another site cannot send. `/_calm` has no CORS headers, a strict Content-Security-Policy and refuses to be framed. - **Secrets stay out.** Request and response bodies are stored with secret-looking values redacted, the `Cookie` header is redacted, and the `Authorization` header is shortened. Block environment variables marked secret are never sent to the page. - **Bounded memory.** It keeps the last 300 requests, 1,000 log lines, 100 emails, 100 push notifications, 200 background queries, 500 activity events and 300 security events. Push records never hold a full device token. "Act as" is logged (`Devtools: acting as ada@example.com`) and creates an ordinary session that expires like any other. ## For tools and agents The panels use a small JSON API under `/_calm/api` that you can call from scripts on the same machine (writes need the `X-Calm-Devtools: 1` header): | Endpoint | Returns | |---|---| | `GET /_calm/api/overview` | App, runtime, database, resources, routes, and blocks (origin, changed files, hooks, env vars without secret values, options, routes) | | `GET /_calm/api/requests`, `GET /_calm/api/requests/:id` | Recent requests, one request in full | | `GET /_calm/api/logs`, `GET /_calm/api/mails` | Recent logs and emails | | `GET /_calm/api/pushes` | Recent push notifications: message, devices, payloads, warnings and delivery log | | `GET /_calm/api/devices?user=` | Registered and simulated devices (tokens masked), with the transport each goes through | | `POST /_calm/api/devices` `{ userId, platform, behavior?, name?, token? }` | Adds a simulated device, or a real one when `token` is given | | `GET /_calm/api/devices/behaviors` | The simulated behaviors and what each does | | `PATCH /_calm/api/devices/:id` `{ behavior?, name? }` | Changes a device (behavior: simulated devices only) | | `POST /_calm/api/devices/:id/rotate` | Rotates a simulated device's token: returns the new device, the old one fails on the next send | | `GET /_calm/api/devices/:id/inbox` | A simulated device's lock screen | | `POST /_calm/api/devices/:id/online` | Brings an offline device back and shows what was waiting | | `POST /_calm/api/devices/:id/inbox/:pushId/open` | Taps a notification on the lock screen (emits `push.opened`) | | `DELETE /_calm/api/devices/:id/inbox`, `DELETE /_calm/api/devices/:id` | Clears a lock screen, removes a device | | `POST /_calm/api/push/test` `{ userId \| deviceIds, ...message }` | Sends a notification through `app.push.send()` | | `POST /_calm/api/seed` `{ resource, count }` | Creates sample records (up to 200) through the services | | `GET /_calm/api/doctor` | Runtime doctor findings | | `GET /_calm/api/activity` | Project activity from `.calm/activity.jsonl`, newest first | | `GET /_calm/api/security` | Exposure matrix, security events and posture | | `POST /_calm/api/audit` | Runs the security audit on a throwaway database and returns the report | | `GET /_calm/api/resources/:name/docs?as=` | Stored vs returned documents | | `POST /_calm/api/act-as` `{ userId }` | A session token for that user | The [`calm mcp`](/ai-agents) server uses this API to give coding agents the recent requests, logs, emails, push notifications and security events of your dev server, and to sign in as other users for `calm_http`. --- Source: https://docs.calmapi.dev/testing # Testing CalmAPI tests are plain [`node:test`](https://nodejs.org/api/test.html) files that call your API over HTTP. `createTestApp()` boots your app against a throwaway database on a random port, and helpers sign users up and send requests. ```js // test/posts.test.js (from the api template) import { test, before, after } from 'node:test'; import assert from 'node:assert/strict'; import { join } from 'node:path'; import { createTestApp } from '#calm/testing.js'; let t; before(async () => { t = await createTestApp({ root: join(import.meta.dirname, '..') }); }); after(() => t.close()); test('signed-in users write posts, visitors only see published ones', async () => { const alice = await t.signup(); const draft = await t.post('/posts', { title: 'Hello world', body: 'First!' }, alice); assert.equal(draft.status, 201); assert.equal(draft.body.data.slug, 'hello-world'); const anonymous = await t.get('/posts'); assert.equal(anonymous.body.meta.total, 0, 'drafts are not public'); }); test('only the author (or an admin) can edit a post', async () => { const alice = await t.signup(); const bob = await t.signup(); const post = await t.post('/posts', { title: 'Mine', body: '...', status: 'published' }, alice); const edit = await t.patch(`/posts/${post.body.data.id}`, { title: 'Hacked' }, bob); assert.equal(edit.status, 403); }); ``` ```bash npm test ``` `npm test` runs `node --test`, which finds `test/**/*.test.js`. It needs a MongoDB server. ## createTestApp(options) ```js const t = await createTestApp({ root, config, baseUri }); ``` | Option | Meaning | |---|---| | `root` | The project folder (default `process.cwd()`) | | `config` | Config overrides, merged over everything else (see below) | | `baseUri` | MongoDB server to create the test database on (default `MONGODB_TEST_URI`) | It creates a database named `calm_test_