Skip to content

Latest commit

 

History

History
1426 lines (1205 loc) · 49.7 KB

http-protocol.mdx

File metadata and controls

1426 lines (1205 loc) · 49.7 KB
title HTTP API protocol — routes and verbs
navTitle HTTP API
description Standard REST mapping rules, CRUD operations, and request/response formats for ObjectStack

import { Radio, Code, Database, Lock, Zap, CheckCircle, AlertCircle } from 'lucide-react';

The HTTP API defines how ObjectStack maps data operations to RESTful HTTP endpoints. Every object you define automatically gets a complete set of CRUD (Create, Read, Update, Delete) operations with consistent request/response formats.

Core Principles

  1. Convention over Configuration: REST endpoints follow predictable patterns
  2. Consistency: Every object uses the same URL structure and response format
  3. Discoverability: API schema available via discovery endpoint
  4. Security First: Authentication and permissions enforced on every request
  5. Performance: Built-in caching, pagination, and field selection

API Discovery

Before making any API calls, clients should request a discovery endpoint to learn about available services. Two endpoints answer that question, and in a stack that mounts @objectstack/rest they are built by different packages — so they carry different values (and different envelopes), even though both now satisfy the same DiscoverySchema (#4828). Read the one that matches your composition.

GET /api/v1 (and GET /api/v1/discovery)

Returns the full discovery manifest. @objectstack/rest registers one handler at both paths — the API base path and <basePath>/discovery — so the two are the same document, not a redirect and not two shapes. In a REST-less composition the runtime dispatcher registers <basePath>/discovery as the fallback owner instead, and then serves its own /.well-known/objectstack payload there (see below); when @objectstack/rest is mounted the dispatcher cedes the route to it, so a single owner answers it (ADR-0076 D11).

Request:

GET /api/v1/discovery HTTP/1.1
Host: api.acme.com

Response:

{
  "version": "<the serving artifact's version>",
  "name": "ObjectStack API",
  "apiName": "ObjectStack API",
  "environment": "development",
  "routes": {
    "data": "/api/v1/data",
    "metadata": "/api/v1/meta"
  },
  "locale": {
    "default": "en",
    "supported": ["en"],
    "timezone": "UTC"
  },
  "services": {
    "metadata": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/meta", "provider": "objectql" },
    "data": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/data", "provider": "objectql" },
    "search": { "enabled": false, "status": "unavailable", "message": "No implementation ships for the 'search' slot — register a service under it to enable" },
    "ai": { "enabled": false, "status": "unavailable", "message": "Provided by @objectstack/service-ai in ObjectOS Cloud/Enterprise — no implementation ships in the open framework" }
  },
  "capabilities": {
    "cron": { "enabled": false },
    "automation": { "enabled": false },
    "search": { "enabled": false },
    "transactionalBatch": { "enabled": true, "description": "Atomic cross-object batch endpoint (POST {basePath}/batch)…" }
  },
  "scoping": {
    "enabled": false,
    "resolution": "auto",
    "scoped": false
  }
}

Three things about this body are worth stating explicitly:

  • version is the serving artifact's version, not the configured API version. The handler serves the protocol builder's own value — the OS_RUNTIME_VERSION stamp when a deployment injects one, otherwise the resolved @objectstack/metadata-protocol version — so a semantic version is the normal answer. It used to be overwritten with api.version (the same "v1" string that forms the path segment); #11292 removed that override, because the identity field then answered with the path segment the caller had just typed to get there. The API version has not left this document: every routes entry below is prefixed with the mounted base path, which is built from api.version.
  • name is canonical; apiName is a deprecated alias with the same value. Both are emitted today so clients pinned to the old spelling keep working; apiName is removed in protocol 18 (#4828). Read name.
  • scoping is added by the REST server, so clients can detect dual-mode routing; environmentId is present only on the environment-scoped mount (/api/v1/environments/:environmentId/...).
**Both discovery documents now satisfy one schema (#4828).** They used to diverge on the required identity fields — this response omitted `name` / `environment` / `locale` entirely, and the dispatcher document below spelled the capability map `features` while this one spelled it `capabilities`. Both producers are now checked against `DiscoverySchema` in CI, so a client can read the same keys from either. `environment` is always one of `production` / `sandbox` / `development` — never a raw `NODE_ENV`.

Disabled/uninstalled route keys are omitted from routes entirely rather than set to null; check services to tell "not installed" apart from "installed but not yet mounted here." capabilities maps each platform capability (comments, automation, cron, search, export, chunkedUpload, transactionalBatch) to a { "enabled": … } descriptor, each derived from what is actually registered — never hardcoded. See API → Discovery for the field-by-field reference.

GET /.well-known/objectstack

Served by the runtime dispatcher (@objectstack/runtime), not @objectstack/rest — its body is wrapped in the response envelope as { "success": true, "data": { ... } } and includes fields (name, environment, features, locale) that the @objectstack/rest-served /api/v1 response above does not. This path is unconditionally dispatcher-owned: no other plugin registers it, so it answers with this shape whether or not REST is mounted. The client SDK's connect() tries /api/v1/discovery first and falls back to this endpoint, unwrapping either body.data or the bare body.

Request:

GET /.well-known/objectstack HTTP/1.1
Host: api.acme.com

Response:

{
  "success": true,
  "data": {
    "name": "ObjectOS",
    "version": "<the serving artifact's version>",
    "environment": "production",
    "routes": {
      "data": "/api/v1/data",
      "metadata": "/api/v1/meta",
      "packages": "/api/v1/packages",
      "auth": "/api/v1/auth",
      "ui": "/api/v1/ui",
      "i18n": "/api/v1/i18n"
    },
    "capabilities": {
      "search": { "enabled": false },
      "websockets": { "enabled": false },
      "files": { "enabled": false },
      "analytics": { "enabled": false },
      "ai": { "enabled": false },
      "notifications": { "enabled": false },
      "i18n": { "enabled": true }
    },
    "services": {
      "metadata": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/meta", "provider": "kernel" },
      "data": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/data", "provider": "kernel" },
      "auth": { "enabled": true, "status": "available", "handlerReady": true, "route": "/api/v1/auth" },
      "search": { "enabled": false, "status": "unavailable", "handlerReady": false, "message": "No implementation ships for the 'search' slot — register a service under it to enable" }
    },
    "locale": {
      "default": "en-US",
      "supported": ["en-US", "zh-CN"],
      "timezone": "UTC"
    }
  }
}

name and version are the dispatcher's own build identity, not your app's name — do not display them as the deployment's title. name is a fixed string; version is the serving artifact's version — the OS_RUNTIME_VERSION stamp when a deployment injects one, otherwise the resolved @objectstack/runtime version (#10993), never a hardcoded literal. locale is derived from the registered i18n service (getDefaultLocale() / getLocales()); with no i18n service it degrades to { "default": "en", "supported": ["en"], "timezone": "UTC" }.

environment is derived from NODE_ENV, not the raw value — the field is an enum (production / sandbox / development), so out-of-enum spellings are mapped rather than advertised verbatim (#4828):

NODE_ENV advertised environment
production, prod production
sandbox sandbox
staging sandbox — pre-production and production-like
development, dev development
test development — an ephemeral developer-class run
unset (absent, or NODE_ENV=) production — the conservative reading of "the host did not say" (#5673)
anything else development — never claims production on a guess

The last two rows answer two different questions and were one row until #5673. Unset is not a spelling, it is the absence of one, and the rest of the platform already read that absence as production: os start forces NODE_ENV=production when it is unset, and both os serve and os doctor resolve the .env* cascade for NODE_ENV || production. This field is machine-readable — a client uses it to decide whether it is talking to production — so a real production deployment whose operator forgot the variable must not be told development. An unrecognised spelling (qa, preview, uat) is a different case: it is a guess, and this field never claims production on a guess.

This table is the whole answer for every producer of /discovery (#5936). The mapping and the unset default both live in one shared function, so the dispatcher and the @objectstack/metadata-protocol builder served by @objectstack/rest cannot disagree, and a future producer inherits the same answers without copying anything. Until #5936 the unset default lived at the dispatcher's own call site, so a deployment with no NODE_ENV was advertised as production there and development through @objectstack/rest. If you read environment from a REST-served /discovery and relied on the old answer, set NODE_ENV=development explicitly.

Local development is unaffected: os dev runs serve --dev, which sets NODE_ENV=development in-process before the runtime loads. Anything that boots the runtime without os dev — a bare os serve, an embedded host, a hand-written container entry point — must now set NODE_ENV=development explicitly to keep being advertised as such.

**The `@objectstack/rest`-served `/api/v1/discovery` document still reads an unset `NODE_ENV` as `development`.** #5673 landed on the dispatcher producer only; the second producer is tracked in [#5936](#5936). Until it lands, the two documents agree on every *set* value and differ only when nothing is set. **Retired in protocol 17 (#4828):** this document used to carry a top-level `features` map and an `endpoints` key that duplicated `routes` verbatim. Neither was ever declared in `DiscoverySchema`. `features` is now the canonical `capabilities` (same flags, in the declared `{ "enabled": … }` shape, so it matches the REST-served response); `endpoints` was removed outright after a consumer census across `objectstack`, `objectui` and `cloud` found no reader — use `routes`. **"Both paths return the same document" holds only in a REST-less composition.** There, the dispatcher owns `/api/v1/discovery` as the fallback registrant, so that path and `/.well-known/objectstack` both answer with the dispatcher payload above (the bare `/api/v1` is registered by `@objectstack/rest` alone and is not served at all). As soon as `@objectstack/rest` is mounted it takes `/api/v1/discovery` under the single-owner rule (ADR-0076 D11) and the two paths answer different *documents* — same schema, different producers, so the envelope (`{ "success": true, "data": … }` here, bare there) and the values differ even though the key set no longer does.

Why discovery matters:

  • Environment agnostic: Works across dev, staging, production without hardcoding URLs
  • Version tolerance: API routes can change without breaking clients
  • Feature detection: Clients enable/disable features by inspecting each entry's enabled / status in the services map
  • Automatic configuration: SDKs auto-configure from discovery response

Standard Data API

All data operations use the base path from routes.data (default: /api/v1/data).

URL Structure

{base_path}/{object_name}/{record_id?}

Examples:

  • /api/v1/data/account - Account collection
  • /api/v1/data/account/acc_123 - Specific account
  • /api/v1/data/project_task - Project task collection (snake_case)

Authentication

All requests require authentication via one of these methods:

1. Bearer Token (JWT):

GET /api/v1/data/task
Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...

2. API Key:

GET /api/v1/data/task
X-API-Key: osk_abc123...

ObjectStack API keys always carry the osk_ prefix, and the prefix is load-bearing rather than decorative. Besides the X-API-Key header, a key may be presented with the Authorization header using either the ApiKey scheme or the Bearer scheme — but the Bearer form is accepted only when the token starts with osk_. That prefix test is what keeps the two senses of Bearer apart: a session token never starts with osk_, so it still resolves down the session path in method 1 and can never be read as an API key. A key without the prefix does not authenticate.

3. Session Cookie:

GET /api/v1/data/task
Cookie: session_id=xyz789...

Query Operations (List/Search)

Retrieve multiple records from an object.

Endpoint:

GET /{base_path}/{object_name}

Query Parameters:

Parameter Type Description Canonical Equivalent Example
select string Comma-separated field list fields id,name,status
filter JSON Filter criteria (see Filtering section) where {"status":"active"}
sort string Sort fields (prefix - for desc) orderBy -created_at,name
top number Max records to return limit 25
skip number Records to skip (offset) offset 50
expand string Related objects to embed expand assignee,comments
search string Full-text search query search acme
count boolean Include total count in response count true

Transport → Protocol normalization: The HTTP dispatcher normalizes transport-level parameter names to Spec canonical (QueryAST) field names before forwarding to the broker layer: filter→where, select→fields, sort→orderBy, top→limit, skip→offset. The deprecated filters (plural) parameter is also accepted and normalized to where.

Example Request:

GET /api/v1/data/task?select=id,title,status&filter={"assignee_id":"user_123"}&sort=-created_at&top=25&count=true
Authorization: Bearer <token>

Success Response:

A list query returns a FindDataResponse directly — there is no outer envelope. The response carries the object name, the records array, and the optional total / hasMore pagination hints.

{
  "object": "task",
  "records": [
    {
      "id": "task_456",
      "title": "Implement login page",
      "status": "in_progress",
      "created_at": "2024-01-15T10:30:00Z"
    },
    {
      "id": "task_789",
      "title": "Fix navigation bug",
      "status": "todo",
      "created_at": "2024-01-14T16:20:00Z"
    }
  ],
  "total": 47,
  "hasMore": true
}

Filtering

Filters are passed as JSON in the filter query parameter.

Basic equality:

{ "status": "active" }
GET /api/data/account?filter={"status":"active"}

Multiple conditions (AND):

{
  "status": "active",
  "industry": "Technology"
}

Operators:

{
  "revenue": { "$gte": 100000 },
  "employees": { "$lte": 500 },
  "name": { "$contains": "Tech" },
  "created_at": { "$between": ["2024-01-01", "2024-12-31"] }
}

Supported operators:

  • $eq - Equals (default)
  • $ne - Not equals
  • $gt - Greater than
  • $gte - Greater than or equal
  • $lt - Less than
  • $lte - Less than or equal
  • $in - In array
  • $nin - Not in array
  • $contains - String contains
  • $notContains - String does not contain
  • $startsWith - String starts with
  • $endsWith - String ends with
  • $between - Between two values (tuple)
  • $null - Null check ({ "$null": true } for IS NULL, { "$null": false } for IS NOT NULL)
  • $exists - Field has a value — the inverse of $null ({ "$exists": true } is IS NOT NULL, { "$exists": false } is IS NULL). Not a key-presence test: a stored null counts as no value on every backend, MongoDB included

OR conditions:

{
  "$or": [
    { "status": "urgent" },
    { "priority": "high" }
  ]
}

Complex nested filters:

{
  "$and": [
    { "status": "active" },
    {
      "$or": [
        { "industry": "Technology" },
        { "industry": "SaaS" }
      ]
    },
    { "revenue": { "$gte": 1000000 } }
  ]
}

Sorting

Sort by one or more fields using the sort parameter:

Single field ascending:

GET /api/data/account?sort=name

Single field descending (prefix with -):

GET /api/data/account?sort=-created_at

Multiple fields:

GET /api/data/account?sort=-priority,created_at

First sort by priority descending, then by created_at ascending.

Pagination

ObjectStack uses offset-based pagination via the top (limit) and skip (offset) parameters:

Request 50 items, skipping the first 50 (i.e. the "second page"):

GET /api/data/account?top=50&skip=50

Response includes optional pagination hints:

When count=true is requested, the FindDataResponse carries a total record count and a hasMore flag:

{
  "object": "account",
  "records": [],
  "total": 247,
  "hasMore": true
}

Note: The transport names top/skip are normalized to the canonical limit/offset QueryAST fields before reaching the data layer.

Field Selection

Request only the fields you need to reduce payload size:

Request:

GET /api/data/account?select=id,name,industry,revenue

Response:

{
  "object": "account",
  "records": [
    {
      "id": "acc_123",
      "name": "Acme Corp",
      "industry": "Technology",
      "revenue": 5000000
    }
  ]
}

Benefits:

  • Reduced bandwidth (especially for mobile)
  • Faster response times
  • Lower server CPU usage

Note: System fields (id, created_at, updated_at) are always included even if not in select.

Including Related Objects

Embed related objects to avoid N+1 queries using the expand parameter:

Request:

GET /api/data/task?expand=assignee,project

Response:

{
  "object": "task",
  "records": [
    {
      "id": "task_123",
      "title": "Implement API",
      "assignee_id": "user_456",
      "project_id": "proj_789",
      "assignee": {
        "id": "user_456",
        "name": "John Doe",
        "email": "john@acme.com"
      },
      "project": {
        "id": "proj_789",
        "name": "CRM Rebuild",
        "status": "active"
      }
    }
  ]
}

Multiple levels:

GET /api/data/task?expand=assignee.department,project.owner

Limits:

  • Maximum expand depth: 3 levels by default (configurable via the query adapter's maxDepth)

Retrieve Single Record

Get a specific record by ID.

Endpoint:

GET /{base_path}/{object_name}/{record_id}

Example Request:

GET /api/v1/data/account/acc_123
Authorization: Bearer <token>

Success Response (HTTP 200):

A single-record read returns a GetDataResponse: the object name, the record id, and the full record under record.

{
  "object": "account",
  "id": "acc_123",
  "record": {
    "id": "acc_123",
    "name": "Acme Corporation",
    "industry": "Technology",
    "revenue": 5000000,
    "status": "active",
    "owner_id": "user_456",
    "created_at": "2024-01-10T14:30:00Z",
    "updated_at": "2024-01-15T09:20:00Z"
  }
}

Not Found (HTTP 404):

REST error responses use a flat envelope: a top-level error message string and a string code, plus optional context fields (object, per-field fields):

{
  "error": "Record acc_999 not found in account",
  "code": "RECORD_NOT_FOUND",
  "object": "account"
}

Create Record

Create a new record.

Endpoint:

POST /{base_path}/{object_name}

Request:

POST /api/v1/data/account
Authorization: Bearer <token>
Content-Type: application/json

{
  "name": "TechStart Inc",
  "industry": "SaaS",
  "revenue": 250000,
  "owner_id": "user_789"
}

Success Response (HTTP 201):

Create returns a CreateDataResponse: the object name, the new record id, and the created record (including server-generated fields) under record.

{
  "object": "account",
  "id": "acc_124",
  "record": {
    "id": "acc_124",
    "name": "TechStart Inc",
    "industry": "SaaS",
    "revenue": 250000,
    "status": "active",
    "owner_id": "user_789",
    "created_at": "2024-01-16T10:15:00Z",
    "updated_at": "2024-01-16T10:15:00Z"
  }
}

Validation Error (HTTP 400):

{
  "error": "Validation failed",
  "code": "VALIDATION_FAILED",
  "object": "account",
  "fields": [
    {
      "field": "name",
      "code": "required",
      "message": "Name is required"
    },
    {
      "field": "industry",
      "code": "enum",
      "message": "Must be one of: Technology, SaaS, Healthcare, Finance"
    }
  ]
}

Caller-supplied id: A create may carry its own id; when no record holds it, the record is stored under that id and the response echoes it. id is the one key that is unique across the whole deployment — unlike a field's unique, which is scoped to the organization — so a create whose id is already held is refused with HTTP 409, and nothing is written, on a store that enforces the primary key (the SQL-family drivers do; the in-memory driver, the development and test driver, not a production store, declares no primary key, and a duplicate id lands there):

Duplicate id (HTTP 409):

{
  "error": "A record with this value already exists",
  "code": "UNIQUE_VIOLATION",
  "field": "id",
  "object": "account"
}

The refusal is the same whichever record holds the id: one you can read, one that row-level access or the org-wide default hides from you inside your own organization, or one in another organization. It names neither that record's organization nor its owner. Every create door answers it the same way: POST /{base_path}/{object_name}/batch with operation: "create" (the row's error carries UNIQUE_VIOLATION with httpStatus: 409), createMany, the cross-object POST /api/v1/batch (the whole batch rolls back), import, and clone's overrides.id. Key on code: field is best-effort, as the Constraint Violations note under Delete Record describes. A create never overwrites — change an existing record with Update Record. Why a global key answers this way is recorded in ADR-0120 (the 2026-11-01 amendment under its business-requirement matrix).

Update Record

Update an existing record (partial update).

Endpoint:

PATCH /{base_path}/{object_name}/{record_id}

Request:

PATCH /api/v1/data/account/acc_123
Authorization: Bearer <token>
Content-Type: application/json

{
  "revenue": 6000000,
  "status": "vip"
}

Success Response (HTTP 200):

Update returns an UpdateDataResponse: the object name, the record id, and the updated record under record.

{
  "object": "account",
  "id": "acc_123",
  "record": {
    "id": "acc_123",
    "name": "Acme Corporation",
    "industry": "Technology",
    "revenue": 6000000,
    "status": "vip",
    "owner_id": "user_456",
    "created_at": "2024-01-10T14:30:00Z",
    "updated_at": "2024-01-16T11:45:00Z"
  }
}

Note: Only fields included in the request body are updated. Other fields remain unchanged.

Read-only fields: Caller-supplied writes to statically read-only fields (e.g., id, created_at) are silently stripped from a non-system update rather than rejected (#2948): the request succeeds with HTTP 200 and every other field is applied, but the read-only field is left unchanged.

Note: This differs from field-level security. A write to a field the caller lacks edit permission on is rejected with 403 PermissionDeniedError, not stripped.

Refusals come before the record lookup: The update is judged before the record is looked up, as a delete is. A caller who may not update the object (no edit permission, or an external data source that refuses writes) gets the same 403 whether or not {record_id} names a record, and so does each row of updateMany and of batch with operation: "update". A caller who may update the object gets 404 RECORD_NOT_FOUND for an id that names no record, and for a record its row-level access hides.

A version token (If-Match, or expectedVersion) is compared only against a record the caller can read: a stale token on such a record is answered 409 CONCURRENT_UPDATE, whether or not the caller may write it. For a record the caller's read does not return, and for an id that names no record, a stale token changes none of the answers above. A caller refused reading the object gets that read refusal — and so does the same caller's update or delete of a permission assignment (sys_user_permission_set, sys_user_position), which is refused before the object's grants are read: for any {record_id}, with or without a token. A caller who reads the assignment object gets the administration refusal's own sentence instead.

Delete Record

Delete a record by ID.

Endpoint:

DELETE /{base_path}/{object_name}/{record_id}

Request:

DELETE /api/v1/data/account/acc_123
Authorization: Bearer <token>

Success Response (HTTP 200):

Delete returns a DeleteDataResponse: the object name, the record id, and a success flag.

{
  "object": "account",
  "id": "acc_123",
  "success": true
}
There is **no soft-delete / recycle-bin runtime**: `DELETE` performs a hard delete and returns the `{ object, id, success }` shape above. Records are removed permanently — there are no `deleted_at` / `deleted_by` fields and no restore semantics. The `enable.trash` flag that once promised this was removed in v17 (#2377, ADR-0049 enforce-or-remove): authoring it is now a parse error rather than a silent no-op.

Constraint Violations: Database constraint failures are surfaced as structured errors. For example, a unique-constraint violation returns HTTP 409, naming the conflicting field when the database determinably reports one:

{
  "error": "A record with this email already exists",
  "code": "UNIQUE_VIOLATION",
  "field": "email",
  "object": "account"
}
`field` is **best-effort and optional**. It is present only when the driver's error determinably names a *column*; when it names an index instead (MySQL's `for key 'idx_email_unique'` always does), when the constraint is a **composite** key, or when the message cannot be parsed, the response omits `field` entirely and falls back to the unnamed sentence:
{
  "error": "A record with this value already exists",
  "code": "UNIQUE_VIOLATION",
  "object": "account"
}

That degradation is deliberate — a wrong field name would send the user to correct an input that was never the problem. Key on code, not on the message, and treat field as an enhancement: it is present when the platform can prove it, absent when it cannot, and never guessed. The response body never echoes the driver's own text, the offending value, or the index name.

Cascade behavior on delete (cascade / restrict / set-null) is governed by each relationship field's configuration in the object schema, enforced by the ObjectQL engine.

Batch Operations

Perform multiple create/update/delete operations across objects in a single atomic transaction.

Endpoint: the batch endpoint is mounted at the top of the API surface (not under /data):

POST /api/v1/batch

The typed SDK surface for this route is client.data.batchTransaction(operations).

Each operation specifies an action (create, update, or delete), the target object, and the relevant data / id. A field value of { "$ref": <earlier op index> } resolves to the id created by an earlier operation in the same batch — useful for inserting a parent and its children together (master-detail).

Request:

POST /api/v1/batch
Authorization: Bearer <token>
Content-Type: application/json

{
  "operations": [
    {
      "action": "create",
      "object": "account",
      "data": { "name": "Company A", "industry": "Tech" }
    },
    {
      "action": "update",
      "object": "account",
      "id": "acc_123",
      "data": { "status": "active" }
    },
    {
      "action": "delete",
      "object": "account",
      "id": "acc_456"
    }
  ]
}

Response: an ordered results array mirroring the input operations:

{
  "results": [
    { "id": "acc_789", "name": "Company A" },
    { "id": "acc_123", "status": "active" },
    { "id": "acc_456", "deleted": true }
  ]
}

Behavior:

  • Maximum batch size: 200 operations. The cap is batch.maxBatchSize, and it is embedder-only (#15543): it is written only by a host that constructs the RestServerConfig itself, never by os serve or the dev plugin, so a CLI-started deployment always gets the default of 200. Over the cap is 400 BATCH_TOO_LARGE, carrying the count sent and the max allowed. The same cap and the same code apply to every bulk write route (createMany / updateMany / deleteMany / per-object batch)
  • The entire batch runs inside one engine transaction — if any operation fails, all are rolled back (commit-all-or-nothing). The batch is always atomic; an explicit "atomic": false is rejected with 400 BATCH_NOT_ATOMIC (use POST /data/{object}/batch for a non-atomic per-object batch)
  • Every operation is subject to the same per-object API-exposure gate as the single-record routes, enforced before the transaction opens: an object with enable.apiEnabled: false returns 404 OBJECT_API_DISABLED, and an action outside an object's enable.apiMethods whitelist returns 405 OBJECT_API_METHOD_NOT_ALLOWED
  • The request shape is validated: a malformed operation, an unknown action, or a missing object returns 400; update / delete require an id
  • A { "$ref": <index> } that does not resolve to an earlier create's id returns 400 BATCH_UNRESOLVED_REF (never a silently-written null value)
  • Returns HTTP 501 if the underlying runtime does not support transactions

Metadata API

Retrieve object schemas and configuration.

Base path: From routes.metadata (default: /api/v1/meta)

List All Objects

Request:

GET /api/v1/meta/object
Authorization: Bearer <token>

The metadata API is keyed by metadata type — GET /api/v1/meta/{type} lists items of that type. Types are singular (object, view, app, …), so objects are listed at /api/v1/meta/object.

Response:

A type listing returns { type, items } — the requested metadata type plus the items array of matching entries.

{
  "type": "object",
  "items": [
    {
      "name": "account",
      "label": "Account",
      "plural_label": "Accounts",
      "description": "Business accounts and customers",
      "api_enabled": true,
      "searchable": true
    },
    {
      "name": "contact",
      "label": "Contact",
      "plural_label": "Contacts",
      "api_enabled": true,
      "searchable": true
    }
  ]
}

To read several items of one type in a single request, name them: GET /api/v1/meta/object?names=account,contact answers only those items, among the ones the caller may see. The value is one comma-separated list; a name that matches no item is simply absent from items, and an empty value (?names=) is refused with 400 VALIDATION_ERROR. ?names= works on every type except package, whose items are keyed by id rather than name; the package list refuses it with 400 VALIDATION_ERROR. The list reads a closed set of query parameters (package, preview, object, include, id, names and locale) and refuses any other name with 400 VALIDATION_ERROR instead of ignoring it.

Get Object Schema

Request:

GET /api/v1/meta/object/account
Authorization: Bearer <token>

Response:

A single-item read returns { type, name, item } — the metadata type, the item name, and the full schema under item.

{
  "type": "object",
  "name": "account",
  "item": {
    "name": "account",
    "label": "Account",
    "plural_label": "Accounts",
    "fields": {
      "id": {
        "name": "id",
        "label": "ID",
        "type": "text",
        "readonly": true,
        "required": true
      },
      "name": {
        "name": "name",
        "label": "Account Name",
        "type": "text",
        "required": true,
        "maxLength": 255
      },
      "industry": {
        "name": "industry",
        "label": "Industry",
        "type": "select",
        "options": ["Technology", "SaaS", "Healthcare", "Finance"]
      },
      "revenue": {
        "name": "revenue",
        "label": "Annual Revenue",
        "type": "number",
        "format": "currency"
      }
    },
    "enable": {
      "trackHistory": true,
      "apiEnabled": true,
      "trash": true
    }
  }
}

Request Headers

Standard Headers

Required:

Authorization: Bearer <token>
Content-Type: application/json  # For POST/PATCH

Optional:

Accept-Language: en-US  # Preferred language
X-Request-ID: uuid  # Request tracking
X-API-Version: 2  # API version preference

CORS Headers

ObjectStack sends CORS headers automatically:

Access-Control-Allow-Origin: https://app.acme.com
Access-Control-Allow-Methods: GET, POST, PUT, DELETE, PATCH, HEAD, OPTIONS
Access-Control-Allow-Headers: Content-Type, Authorization, X-Requested-With, X-Tenant-ID, X-Environment-Id, If-Match, If-None-Match, X-Share-Password, X-Share-Password-Encoding
Access-Control-Expose-Headers: set-auth-token, x-objectstack-dropped-fields, ETag
Access-Control-Max-Age: 86400

Six of the allowed request headers are easy to overlook, and each one disables a feature if an intermediate proxy strips it:

Header Why it is allowed
X-Tenant-ID / X-Environment-Id Route the request to its environment on a multi-tenant host.
If-Match Carries the OCC token on record PATCHes. Without it, a cross-origin save fails in the browser with "Failed to fetch".
If-None-Match Carries the /meta draft save's first-write pin (If-None-Match: *) and the ETag a cached metadata read sends back. Without it, the preflight refuses the request and the browser answers "Failed to fetch" before it reaches the server.
X-Share-Password Carries a share-link password to the public /share-links/:token/resolve and /messages routes. It is the preferred form because a header stays out of URLs; without it, a cross-origin client can only send the password as a query parameter.
X-Share-Password-Encoding Declares how X-Share-Password is encoded. Its one accepted value is utf-8 (any case): the password header then carries the password's UTF-8 bytes percent-encoded, as encodeURIComponent(password) produces them. A browser cannot put a character above U+00FF in a header, and strips leading and trailing spaces, so this is how such a password is sent. Without this header, X-Share-Password is read raw, as it arrives. Any other value, or a password header that is not percent-encoded UTF-8 under utf-8, is refused with 400 VALIDATION_FAILED.

The three exposed response headers matter to browser clients specifically: set-auth-token delivers a rotated session token (without it a cross-origin session silently breaks even though every request succeeds), x-objectstack-dropped-fields warns that a write dropped undeclared keys — the response body's droppedFields stays the primary channel for that — and ETag is the version a metadata read answers, which a client sends back as If-None-Match or pins a save to as If-Match (without it cross-origin JS reads it as null).

These are the defaults exported as DEFAULT_CORS_ALLOW_HEADERS and DEFAULT_CORS_EXPOSE_HEADERS from @objectstack/plugin-hono-server. Supplying allowHeaders replaces the default; supplying exposeHeaders merges with it.

Preflight request:

OPTIONS /api/v1/data/account
Origin: https://app.acme.com
Access-Control-Request-Method: POST

Preflight response:

HTTP/1.1 204 No Content
Access-Control-Allow-Origin: https://app.acme.com
Access-Control-Allow-Methods: POST
Access-Control-Max-Age: 86400

Caching

ObjectStack supports HTTP caching for GET requests:

Response with cache headers:

HTTP/1.1 200 OK
Cache-Control: private, max-age=60
ETag: "abc123def456"
Last-Modified: Wed, 15 Jan 2024 10:30:00 GMT

Conditional request:

GET /api/v1/data/account/acc_123
If-None-Match: "abc123def456"

Not modified response:

HTTP/1.1 304 Not Modified
ETag: "abc123def456"

Cache behavior:

  • GET requests: Cacheable with ETags
  • POST/PATCH/DELETE: Not cacheable
  • Cache duration: Configurable per object (default 60 seconds)

Rate Limiting

Inbound rate limiting is off unless a stack declares it, and it is declared in one place — the stack's server block:

import { defineStack } from '@objectstack/spec';

export default defineStack({
  manifest: { /* … */ },
  server: {
    security: {
      rateLimit: {
        enabled: true,
        windowMs: 60_000,   // budget window, in MILLISECONDS
        maxRequests: 600,   // requests permitted per window, per caller
      },
    },
    // Believe `X-Forwarded-For` / `X-Real-IP`? Only behind a proxy you control.
    trustProxy: false,
  },
});

objectstack serve / dev forward that block to the dispatcher plugin, which arms a token bucket in front of every route the server mounts — not just the dispatcher's own. capacity is maxRequests (so a full bucket absorbs one window's worth of traffic as a burst) and it refills at maxRequests / (windowMs / 1000) tokens per second (so the sustained rate is exactly the declared one).

What the bucket is keyed on

  1. The resolved principal, when the request carries a valid session. One user cannot spend another's budget, and users behind a shared NAT do not throttle each other.
  2. The caller's IP, for anonymous traffic — the case that most needs a limit (credential stuffing, scraping) and has no identity yet.

That IP comes from X-Forwarded-For / X-Real-IP only when server.trustProxy is declared true. Left at its default, the address is the transport's own peer address, which a client cannot forge. This is deliberate: an attacker who can choose their own X-Forwarded-For otherwise gets an unlimited supply of fresh buckets and can drain a chosen victim's. Declare trustProxy only when a reverse proxy you control overwrites those headers on every inbound request.

CORS preflights (OPTIONS) are never metered.

When the limit is exceeded

HTTP/1.1 429 Too Many Requests
Retry-After: 45
Content-Type: application/json

{
  "success": false,
  "error": {
    "code": "RATE_LIMIT_EXCEEDED",
    "message": "Rate limit exceeded. Retry after the interval in the Retry-After header.",
    "httpStatus": 429,
    "details": { "retryAfterSeconds": 45, "resetAt": "2026-08-03T12:00:45.000Z" }
  }
}

Retry-After is computed from the bucket itself, so the wait it advertises is the wait the bucket will actually take to refill. The body is the standard error envelope — see Error Handling.

Counting across nodes

Counters live in the kernel cache service when one is registered, so a multi-node deployment enforces one budget rather than one per node (ADR-0069 D2). With no cache service the limiter falls back to a per-process store and says so once, at warn, naming the consequence: until a shared cache is registered the effective limit is the declared budget multiplied by the number of nodes.

**Not implemented, deliberately named rather than implied.** ObjectStack does **not** emit `X-RateLimit-Limit` / `-Remaining` / `-Reset` headers on successful responses — only `Retry-After` on a 429. That is still true of every budget on the platform, including the per-endpoint one below. (The second spelling this callout used to name, `ApiEndpointRegistrationSchema`, was retired outright in [#4939](#4939).)

The per-endpoint rateLimit on ApiEndpointSchema is a second, independent budget. Between #4936 and protocol 17 it was unreachable — that surface had no executor and a non-empty apis: was rejected outright — but the executor shipped with #5040 and the key is now enforced. Endpoint buckets are keyed in their own namespace, so an endpoint budget and the server.security.rateLimit budget above meter separately rather than sharing a counter. See the next section.

Declarative Endpoints (apis:)

A stack can declare an HTTP endpoint as metadata instead of writing a handler:

**Declared endpoints are LIVE from protocol 17.** An `apis:` block written against an older major changes meaning without changing a byte — what used to be inert documentation becomes an execution entry point. Before upgrading, work through the `declarative-apis-endpoints-live` entry of the protocol upgrade guide; it is a security review, not a rename.

{/* os:check */}

import { defineStack } from '@objectstack/spec';

export default defineStack({
  manifest: {
    id: 'com.acme.crm',
    name: 'Acme CRM',
    version: '1.0.0',
    type: 'app',
    // REQUIRED to declare `apis:` — the URL carve-out is derived from it, and
    // there is deliberately no fallback that derives it from `manifest.id`.
    namespace: 'acme',
  },
  apis: [
    {
      name: 'acme_lead_feed',
      // `/api/v1/apps/<manifest.namespace>/<subpath>` — only the subpath is yours.
      path: '/api/v1/apps/acme/leads',
      method: 'GET',
      summary: 'Lead feed',
      type: 'object_operation',
      // No `target` — an object_operation endpoint is addressed by
      // `objectParams` alone; `target` is required (at publish) only for
      // `type: 'flow'`, where it names the flow to trigger.
      objectParams: { object: 'acme_lead', operation: 'find' },
      // Defaults to `true`. Omitting it is safe; see the policy table below.
      authRequired: true,
      // GET-only, and only ever on a successful answer. The unit is in the key.
      cacheTtlSeconds: 30,
    },
  ],
});

How a request is served

Declared endpoints are not registered routes. They run in the dispatcher's unmatched-request seam, which is what makes it structurally impossible for a declaration to shadow a built-in route:

  1. Match — the request path must be under <prefix>/apps/, and METHOD + path (one trailing slash trimmed) must hit exactly one declaration.
  2. Policy chain — rateLimit → authRequired → cacheTtlSeconds, in that order. Metering runs before the auth gate on purpose: the traffic that most needs a budget (credential stuffing, scraping) is exactly the traffic that ends in a 401, so a denied request still spends a token.
  3. Delegation — a request that passed is executed by the same pipelines the built-in routes use, under the caller's own execution context, so RLS/FLS and the exposure gate apply identically. A declared endpoint is a stable URL plus a policy layer over an existing pipeline, never a second execution dialect.
Endpoint declares Answer
type: 'object_operation' delegated to the same callData binding that serves /api/v1/data/{object} — byte-identical data
type: 'flow' delegated to the same automation pipeline as POST /api/v1/automation/{name}/trigger — the same execution context builder, the same execute call, and the same response contract: a refused or failed run is classified into the same real status codes (404 / 409 FLOW_DISABLED / 422 FLOW_NO_START_NODE / 422 FLOW_INPUT_SCHEMA_INVALID / 400 FLOW_FAILED), from one shared definition all three flow doors read. Branch on the status and error.code, never on an inner success flag
authRequired: true (or omitted) + anonymous caller 401 UNAUTHENTICATED, the same envelope every seam answers
rateLimit armed and exhausted 429 + Retry-After, never with a cache directive
cacheTtlSeconds: 30 on a successful GET Cache-Control: private, max-age=30 — private is a security rule, not tuning: any response can be RLS-trimmed
cacheTtlSeconds: 0 Cache-Control: no-store
an error answer (401/429/5xx) never carries Cache-Control, and outputMapping is never applied to it

What an unmatched request answers

The endpoint seam writes nothing when it does not match, so it changes no existing answer. Both of these are the transport's own bare 404, byte for byte:

GET /api/v1/apps/acme/no-such-endpoint   →  404  {"error":"Not found"}
GET /api/v1/no-such-route                →  404  {"error":"Not found"}

A method mismatch on a declared path is also a 404, not 405 + Allow:

POST /api/v1/apps/acme/leads   →  404  {"error":"Not found"}

That is a consequence of the seam, not an inconsistency — nothing registered a route for that path, so there is no method set to report. A registered route still answers 405 when it exists and the verb does not fit — that contract is unchanged.

The five publish gates

A declaration this runtime cannot serve is rejected at publish, naming the endpoint, the key and the fix — never parsed into silence. objectstack validate (or os build) runs the same gates your publish path does:

Gate Rejects
Namespace (ADR-0121 D1/D2) a path outside /api/v1/apps/<manifest.namespace>/<subpath>, or a stack declaring apis: with no explicit manifest.namespace
Supported target type: 'script' / 'proxy' (neither executes in 17.x), an object_operation missing objectParams.object or .operation, a flow naming no target
Mapping a mapping transform (there is no transformation registry), an unusable dot path (empty segment, __proto__), two entries writing the same target path, or inputMapping on a find / get / delete operation that never reads a body
Policy authRequired: false without rateLimit.enabled: true (ADR-0121 D6), an unusable armed budget, a negative cacheTtlSeconds, or cacheTtlSeconds on a non-GET method
Uniqueness two endpoints in one stack claiming the same METHOD + path
**`authRequired: false` is the one answer that cannot be taken back.** It defaults to `true`, so omitting it is safe; an explicit `false` is the only thing that opens an unauthenticated execution entry point, and ADR-0121 D6 pairs it with an **armed** budget — `rateLimit.enabled` itself defaults to `false`, so writing only `windowMs` / `maxRequests` declares a budget that meters nothing. The gate checks `enabled === true`, not the key's presence.

inputMapping / outputMapping move and rename fields by dot path, and nothing more — inputMapping projects the request body before delegation (so it can never buy a caller past the policy chain), outputMapping projects a successful response body only.

Best Practices

Use Field Selection

❌ Bad: Fetch all fields when you only need a few

GET /api/data/account

✅ Good: Request only needed fields

GET /api/data/account?select=id,name,status

Use Expand for Relations

❌ Bad: N+1 queries

const res = await fetch('/api/data/task');
const { records } = await res.json();
for (const task of records) {
  task.assignee = await fetch(`/api/data/user/${task.assignee_id}`);
}

✅ Good: Single query with expand

const tasks = await fetch('/api/data/task?expand=assignee');

Respect Rate Limits

❌ Bad: Poll X-RateLimit-Remaining — that header is not emitted, so the check always reads null and the backoff never runs.

✅ Good: Handle the 429 and honour Retry-After

const response = await fetch('/api/data/task');

if (response.status === 429) {
  const retryAfter = Number(response.headers.get('Retry-After') ?? 1);
  await sleep(retryAfter * 1000);
  // …then retry once; the budget refills continuously, so a single wait is enough.
}

Handle Errors Gracefully

✅ Good: Parse error structure

const response = await fetch('/api/data/task', { method: 'POST', body: data });
const result = await response.json();

if (!response.ok) {
  if (result.code === 'VALIDATION_FAILED') {
    result.fields.forEach(field => {
      showFieldError(field.field, field.message);
    });
  }
}

Next Steps

} title="Real-Time Protocols" description="Learn WebSocket subscriptions and event streaming" href="/docs/protocol/kernel/realtime-protocol" /> } title="Error Handling" description="Master error codes and debugging strategies" href="/docs/protocol/kernel/error-handling" />