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.

JavaScript
// 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#

BuilderStored asAcceptsNotes
f.string()StringstringsTrimmed by default (.trim(false) to keep spaces)
f.text()StringstringsLike string, not trimmed; for long text
f.email()Stringvalid emailTrimmed and lowercased
f.url()Stringhttp(s):// URLsOther schemes (javascript:) are rejected
f.password()Stringstrings, 8 to 128 charsHashed with scrypt, never returned, cannot be changed with PATCH
f.number()Numberfinite numbers"5" is rejected: no silent coercion of JSON strings
f.integer()Numberwhole numbers
f.boolean()Booleantrue, false
f.date()DateISO strings or epoch millisecondsReturned as ISO strings
f.enum(['a', 'b'])Stringone of the values
f.id()ObjectId24-char hex idAn id that is not a reference
f.ref('category')ObjectId24-char hex idReference to another resource, can be included
f.array(f.string())ArrayarraysEvery item is validated with the inner field
f.object({ city: f.string() })SubdocumentobjectsUnknown keys are rejected, no _id
f.json()Mixedany JSONNot 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).

JavaScript
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#

ModifierEffect
.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 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)
.pii()Marks personal data for calm doctor, the exposure matrix and the privacy block (see Personal data)
JavaScript
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 and MCP tools.

ModifierReturnedAccepted from clientsTypical use
.hidden()nevernever (422)Internal data your service sets: costs, storage keys, flags
.writeOnly()neveryesSecrets the client sends but must not read back
.readOnly()yesnever (422)Values your service computes: slugs, totals, timestamps
.immutable()yeson create only (422 on update)Email, account type
.writableBy(policy)yesonly users matching the policyroles: ...writableBy('admin')
.visibleTo(policy)only to users matching the policy, per recordyesemail: ...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, evaluated against each record. With 'owner', it means the owner of that record:

JavaScript
// 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:

JavaScript
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.

Personal data#

Mark fields that hold personal data with .pii():

JavaScript
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, which calm 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 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#

ModifierLets clients
.filterable(ops?)Filter with ?field=value and ?field[op]=value. Without ops, the defaults for the type apply (see Querying).
.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#

ModifierEffect
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=<field>

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#

JavaScript
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 can read it: softDelete: true (soft-delete), audit: false (audit-log), webhooks: false (webhooks), onOwnerDelete and personalFields (privacy).

The CRUD routes#

RouteActionSuccessBody
GET /api/productslist200{ data: [...], meta: { total, page, limit, pages } }
GET /api/products/:idread200{ data }
POST /api/productscreate201{ data }
PATCH /api/products/:idupdate200{ data } (partial update)
PUT /api/products/:idupdate200alias of PATCH, hidden from OpenAPI
DELETE /api/products/:iddelete204empty

Errors follow one shape (see Errors): 401 when a signed-in user is required, 403 or 404 depending on what the user may see (see Policies), 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:

Shell
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:

MessageCause
Unknown fieldNot declared (set unknownFields: 'strip' in config to drop them silently instead)
Is read-onlyhidden(), readOnly() or an owner() field
Cannot be changed after creationimmutable() field in an update
You are not allowed to set this fieldwritableBy() policy not met
Use POST /api/auth/password (or the reset flow) to change passwordsPassword field in an update
Is required, Must be ..., your own messagesValidation 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 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:

JavaScript
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). 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:

JavaScript
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 block for an interactive reference.