| name | objectstack-api | ||||||||
|---|---|---|---|---|---|---|---|---|---|
| description | Design the server-side API surface that an ObjectStack runtime exposes — REST endpoints, auth providers, realtime channels, error envelopes, batch contracts. Use when the user is adding `*.endpoint.ts`, configuring auth providers, defining custom routes, or extending the REST generator. Do not use for: consuming an ObjectStack API from a client (that is just standard HTTP — no skill needed); the auto-generated CRUD endpoints (those follow from objectstack-data); request-side query syntax (see objectstack-query). CEL expressions in route guards or auth predicates: load objectstack-formula alongside. | ||||||||
| license | Apache-2.0 | ||||||||
| compatibility | Requires @objectstack/spec 18.x (Zod v4 schemas) | ||||||||
| metadata |
|
| Need | Use instead |
|---|---|
| Model objects, fields, permissions, or datasources | objectstack-data |
| Filter, sort, paginate, or aggregate a request | objectstack-query |
| Register a kernel service, or read one from a plugin | objectstack-platform |
| CEL in a route guard or auth predicate | objectstack-formula |
Every ObjectStack object with apiEnabled: true (the default) automatically
gets a full REST API:
GET /api/v1/data/{object} # List records (with filter, sort, pagination)
GET /api/v1/data/{object}/:id # Get single record
POST /api/v1/data/{object} # Create record
PATCH /api/v1/data/{object}/:id # Update record
DELETE /api/v1/data/{object}/:id # Delete record (hard delete)
POST /api/v1/data/{object}/query # Complex queries + aggregation (QueryAST in body)
POST /api/v1/data/{object}/batch # Per-object batch operations
POST /api/v1/batch # Cross-object atomic batch
Data CRUD lives under the /data prefix. There is no /bulk route and no
GET .../aggregate route — batch writes go through the batch endpoints, and
aggregation goes through POST /api/v1/data/{object}/query with
groupBy/aggregations in the body.
Key rule: If your object defines
apiMethods, only those operations (and what derives from them) are exposed;[]means deny-all. The authorable values are the six primitives — see API Methods (Operations).
The metadata read surface lives under /api/v1/meta (separate from the data
CRUD routes above):
GET /api/v1/meta/:type # List metadata items of a type (object, view, flow, doc, …)
GET /api/v1/meta/:type/:name # Read a single metadata item
The read-side query params (?preview=draft, ?package=, ?include=content)
are client contracts — nothing in a stack declares them.
A FormView is served by the anonymous form endpoints only when its
sharing declares all three: enabled: true (the schema default is
false), allowAnonymous: true and a publicLink slug. Miss any one and
both endpoints answer 404 FORM_NOT_FOUND. The form is then mounted at:
GET /api/v1/forms/:slug # returns form spec + restricted objectSchema
POST /api/v1/forms/:slug/submit # whitelist-filtered INSERT, no auth header
These bypass enforceAuth (Web-to-Lead / Web-to-Case flows). The form's own
declaration authorizes the submit: a publicFormGrant on its target object
(create and read-back of its new row, nothing else), decided before any permission
set resolves. The submit context's guest_portal name admits nothing and needs
no set; a set so named only feeds the grant's result masker. The framework
strips fields outside the form's sections[].fields[] list; a
beforeInsert hook on the target object should stamp safe defaults
(status='new', lead_source='web', …) and delete privileged keys
(owner, internal_notes, …). On a walled posture (group/isolated), a
form on an object walled by an organization column is withheld too — both
endpoints 404, and save / publish warn public-form-intake-unavailable;
declare tenancy: { enabled: false } on an object whose rows belong to no
organization to offer it again. For the full contract, read
node_modules/@objectstack/spec/src/ui/view.zod.ts (FormViewSchema) and
node_modules/@objectstack/spec/src/ui/sharing.zod.ts (SharingConfigSchema
with enabled / allowAnonymous / publicLink).
A form open to anonymous intake declares no file field. A section field whose
object type is in FILE_REFERENCE_TYPES (image, file, avatar, video,
audio) is refused by the gating lint rule public-form-file-field — at
os validate / os build / os lint, and at the view's publish — because a
visitor has no upload door to mint a sys_file id. A form published before that
rule meets the same refusal at submit: a file value answers the flat 400 VALIDATION_FAILED with fields[].code: 'invalid_value'. Collect the file on a
signed-in form instead.
defineStack({ apis }) declares an HTTP endpoint as metadata. Declared
endpoints are live from protocol 17: the runtime matches
METHOD + path, runs the endpoint's policy keys, and delegates to the same
pipelines the built-in routes use — object_operation to the data pipeline
behind /api/v1/data/{object}, flow to the automation pipeline behind
POST /api/v1/automation/{name}/trigger. An endpoint is a stable URL plus a
policy layer over an existing pipeline, never a second execution dialect.
| Use | When |
|---|---|
defineStack({ apis }) |
The endpoint is a projection of something the platform already executes: query/return records, or trigger a flow. No code, no deploy artifact, publish-gated. Prefer this. |
| A flow that runs your code | The endpoint needs real handler CODE: point a type: 'flow' endpoint at a flow whose script node names a stack function (defineStack({ functions })), or whose connector_action node calls a declared connector (outbound). The URL stays declarative; the code runs where the automation surface already runs it. |
An inbound webhook from a sender you control is a type: 'api' flow
(objectstack-automation → Inbound webhook (api) triggers): it verifies
ObjectStack's own x-objectstack-signature, enqueues, and answers 202.
An app never mounts on http.server — that service is the platform
plugins' own door-building primitive, and a code-mounted route gets no caller
identity, auth gate, rate limit, namespace carve-out or OpenAPI entry. Three
cases have no app surface today; say so rather than invent one: a third-party
callback verified by its own signature over the raw body (the hook checks only
x-objectstack-signature), a streaming response (a declared endpoint answers
one JSON envelope), and a protocol the platform does not speak.
import type { ApiEndpoint } from '@objectstack/spec/api';
// The stack declares `manifest: { namespace: 'acme', … }` — required, see below.
export const leadFeed: ApiEndpoint = {
name: 'acme_lead_feed',
path: '/api/v1/apps/acme/leads', // /api/v1/apps/<namespace>/<subpath>
method: 'GET',
summary: 'Lead feed',
type: 'object_operation',
objectParams: { object: 'acme_lead', operation: 'find' },
// `authRequired` omitted → defaults to `true`. Omission is SAFE.
cacheTtlSeconds: 30, // GET-only; rides success answers only
};A declared path must be /api/v1/apps/<manifest.namespace>/<subpath>. Only the
subpath is yours to name, and the ordinary naming rules below apply inside it.
manifest.namespace must be declared explicitly — it is never derived from
manifest.id. A path outside the carve-out is rejected at publish.
A declaration this runtime cannot serve is rejected at publish, one gate at
a time. Do not memorise the gate texts; read the rejection, it carries the
fix. The five: namespace (the carve-out above), supported target
(script / proxy do not execute in 17.x; an object_operation needs both
objectParams.object and .operation; a flow needs a target),
mapping (below), policy (below), and uniqueness (one METHOD +
path claim per stack).
authRequired defaults to true, so omitting it is safe. An explicit
false is the only thing that opens an anonymous, unauthenticated execution
entry point — and ADR-0121 D6 pairs it with an armed budget:
authRequired: false,
rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 100 },The gate's predicate is rateLimit.enabled === true, not the key's presence:
RateLimitConfigSchema.enabled itself defaults to false, so writing only
windowMs / maxRequests declares a budget that meters nothing. Endpoint
budgets are metered independently of the server-level server.security.rateLimit.
inputMapping / outputMapping move and rename fields by dot path, and
nothing more. inputMapping maps the REQUEST BODY, applied after the policy
chain and before delegation (so a mapping can never buy a caller past
authRequired or the rate limiter); outputMapping is applied to a successful
response body only. Three consequences worth knowing before you author one:
transformis rejected — there is no transformation-function registry. Compute the value where it is produced (a flow, or a formula field).inputMappingis rejected on afind/get/deleteobject_operation, which never reads a request body.- Two entries cannot write the same target path, nor one inside the other
(
xandx.y).
An apis: block written against an older major changes meaning without
changing a byte — inert documentation becomes an execution entry point. Work
through the declarative-apis-endpoints-live entry of the protocol upgrade
guide before upgrading; it is a security review, not a rename. Its two
load-bearing steps: move every path into the carve-out, and grep every entry
for authRequired: false.
| Pattern | Use Case | Example |
|---|---|---|
/api/v1/data/{object} |
Auto-generated collection | /api/v1/data/accounts |
/api/v1/data/{object}/:id |
Auto-generated record | /api/v1/data/accounts/abc123 |
/api/v1/{object}/:id/{action} |
Custom action on record | /api/v1/cases/:id/close |
/api/v1/{domain}/{action} |
Domain-level action | /api/v1/ai/chat |
/api/v1/apps/{namespace}/{subpath} |
Declarative apis: endpoint — the carve-out, not a free choice |
/api/v1/apps/acme/leads |
Rules:
- Always use plural nouns for collection paths (
accounts, notaccount). - Use snake_case for multi-word paths (
project_tasks, notprojectTasks). - Use verbs only for actions, not for CRUD (
/close,/approve). - Always prefix with
/api/v1/for versioning.
The authorable ApiMethod enum is the SIX PRIMITIVES. The wider EFFECTIVE
vocabulary (ApiOperation, 14 values) is what gates and responses speak; its
eight extra verbs are never declared in apiMethods:
Authorable primitives:
| Method | HTTP surface today | Purpose |
|---|---|---|
get |
GET /data/{object}/:id |
Retrieve a single record |
list |
GET /data/{object} |
List records with filter/sort/pagination |
create |
POST /data/{object} |
Create a new record |
update |
PATCH /data/{object}/:id |
Update an existing record |
delete |
DELETE /data/{object}/:id |
Delete a record |
bulk |
POST /data/{object}/batch |
Batch create/update/delete (bulk ∧ child op) |
Derived operations (granted automatically, never authored):
| Operation | Derives from | HTTP surface today |
|---|---|---|
upsert |
create ∧ update |
none generated |
aggregate |
list |
POST /data/{object}/query with groupBy/aggregations |
search |
list ∧ searchable |
global GET /api/v1/search, not per-object |
import |
create ∨ update (writeMode-precise) |
POST /data/{object}/import |
export |
list |
GET /data/{object}/export |
history (from get ∧ trackHistory) gates only. restore / purge never
derive — the trash surface is retired. Declaring any derived verb in
apiMethods is stripped at parse with a FROM → TO warning.
POST /data/{object}/import takes mapping as { Column: field } or as
FieldMappingEntry[] (api/export.zod.ts); only the entry form carries
applyOn. 'always' (default) writes the column on every row the import
creates or updates; 'create' writes it only when the row is created, so a
re-run upsert refreshes the always columns and leaves a value an admin set
since (an owner, a stage) as it is. A create-only column is still read for
matching. Any other value is 400 INVALID_REQUEST.
GET /api/v1/health, GET /ready (200 running / 503 booting) and
GET /api/v1/discovery (per-service status) exist on every deployment.
All three are response surfaces — nothing to author.
The HttpDispatcher is the central request router; it answers:
| HTTP Status | Error Code | When |
|---|---|---|
| 404 | ROUTE_NOT_FOUND |
No route matches the path |
| 405 | METHOD_NOT_ALLOWED |
Route exists but method not supported |
| 501 | NOT_IMPLEMENTED |
Route declared but handler is a stub |
| 503 | SERVICE_UNAVAILABLE |
Service is registered but not ready |
Realtime contracts are pointer-style — read the spec source for exact shapes:
node_modules/@objectstack/spec/src/api/realtime.zod.ts—TransportProtocol(websocket|sse|polling),SubscriptionSchema(id,events[],transport, optionalchannel),RealtimeEventSchema, andRealtimeConfigSchema.node_modules/@objectstack/spec/src/api/websocket.zod.ts— the WebSocket message protocol: subscribe/unsubscribe messages, event delivery, presence, cursor and collaborative-edit messages, and ack/error/ping/pong frames.
RBAC (definePermissionSet) and RLS are objectstack-data's surface, not this
one; this section is the endpoint's own auth keys.
There is no nested auth block on an endpoint, and no name / request /
response field: auth is the flat public + permissions pair on
RestApiEndpointSchema, and declarative ApiEndpointSchema endpoints (and the
dispatcher) use authRequired: boolean (default true) — setting it false
obliges you to arm a rateLimit (ADR-0121 D6 — see Declarative Endpoints →
authRequired and the D6 pairing above). Rate-limit policies themselves are
shaped by RateLimitConfigSchema:
import { RateLimitConfigSchema, type RateLimitConfig } from '@objectstack/spec/shared';
const limit: RateLimitConfig = RateLimitConfigSchema.parse({
enabled: true,
windowMs: 60_000, // time window in milliseconds
maxRequests: 100, // max requests per window
});Provider and login contracts live in
node_modules/@objectstack/spec/src/api/auth.zod.ts: AuthProvider is
'local' | 'google' | 'github' | 'microsoft' | 'ldap' | 'saml', and
LoginRequestSchema carries type (login method), plus optional email,
username, password, provider, and redirectTo. Read that file for the
session and token response shapes before wiring an auth flow.
defineDatasource, schemaMode, the external write gate and
credentialsRef are objectstack-data → rules/datasources.md; driver packages
and the Turso Cloud/EE caveat are objectstack-platform → Driver Selection
Guide; the driver id vocabulary is data/driver/config-registry.zod.ts.
error.code is two-tier: the closed StandardErrorCode catalog plus the
codes registered per owning package in api/error-code-ledger.zod.ts.
ApiErrorSchema.code validates the union, so an unregistered code fails
parse → fails the envelope conformance suites → fails CI.
That ledger takes framework packages only. A downstream repo keeps its OWN
ledger and composes the two checks itself — envelopeViolations(body) for the
SHAPE, makeApiErrorSchema(yourCodes) for the VOCABULARY (both in
api/contract.zod.ts). What no repo may do is emit a code in no ledger at all.
A flow door — POST /api/v1/automation/{name}/trigger, resume, /actions, a
declared type: 'flow' endpoint — answers a failed run with 400 FLOW_FAILED;
its error.details is FlowFailureDetailsSchema (api/automation-api.zod.ts):
errorMessage? (the flow author's text), summary? (which node failed) and
innerRefusal?, the { code, message } a node's write was refused with, code
in its wire spelling (UNIQUE_VIOLATION, VALIDATION_FAILED,
PERMISSION_DENIED). Render it as a record form renders a refused save:
top-level userMessage, then details.innerRefusal, then message. Absent
keys mean the run produced none — never invent an empty.
- Use auto-generated APIs whenever possible. Only create custom endpoints for business logic that cannot be expressed through CRUD + triggers.
- Return consistent error shapes. The dispatcher envelope is
DispatcherErrorResponseSchema:{ success: false, error: { code, message, httpStatus?, route?, service?, hint? } }, wherecodeis the semantic string andcode/messageare required. General API errors useErrorResponseSchema(errors.zod.ts). Be aware the shipped data routes return flat{ error, code }bodies instead (e.g.CONCURRENT_UPDATE→ 409,VALIDATION_FAILED→ 400) — do not assume every error arrives in thesuccess: falseenvelope. - Apply least-privilege auth. Every endpoint should declare its required permissions explicitly.
- Design idempotent writes deliberately. No upsert route is generated, so
an external integration queries by its unique external ID and branches to
create or update — group those writes through
POST /api/v1/data/{object}/batch.
- Not handling 409 Conflict. The generated
PATCH /api/v1/data/{object}/:idroute does optimistic concurrency with two spellings: theIf-Matchheader or anexpectedVersionfield in the JSON body — the body wins when both are sent, and the token is typically theupdated_atvalue the client read. Sending neither skips the check; a mismatch answers 409CONCURRENT_UPDATE; the quoted-empty entity-tag ("") is refused 400VALIDATION_FAILED, not treated as omitted. - Assuming
DELETEis recoverable. ObjectStackDELETEis a hard delete — there is no recycle bin (the deadenable.trashflag was removed in 16.x). For recoverability, use per-fieldtrackHistory(audit trail) or alifecyclearchive policy instead of custom soft-delete logic.
After adding a *.endpoint.ts, a custom route, or an auth provider, run the
author-time gate before reporting done:
os validate # Zod schema + CEL predicate validation + bindings (no artifact)
# or: os build # the same gates, plus emits dist/Route-guard and auth predicates are CEL; the gate parses them and fails
non-zero with a located message instead of letting a malformed guard fall
through at runtime. In a scaffolded project the gate is npm run validate. See
objectstack-platform → Verify your work for the full gate list.
See references/_index.md for the full list of Zod
schemas (with one-line descriptions) — pointers into
node_modules/@objectstack/spec/src/. Always Read the source for exact field
shapes; do not rely on memory of property names.