From cd7e5773b8de129c6af4a81f453101d96162c9de Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 20 Aug 2026 15:51:08 +0000 Subject: [PATCH 1/2] docs(api): add a developer page for declarative `apis:` endpoints (ADR-0121) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `api` is an authorable metadata kind, live from protocol 17, whose only hand-written coverage was the normative implementer text in `protocol/kernel/http-protocol.mdx` — and the page a developer actually clicks, `api/plugin-endpoints.mdx`, is a catalog of built-in plugin routes with nothing about authoring. The naming collision sent the reader to the wrong page. New `content/docs/api/declarative-endpoints.mdx` leads with the channel decision (ADR-0121 D3: caller inside the platform → `actions`, caller outside → `apis:`), states the four publish gates in the terms of the errors publish/validate actually emit, gives `authRequired: false` its own section with the ADR-0121 D6 armed-`rateLimit` obligation, and covers `api` being code-only (403 `NOT_CREATABLE` on the runtime metadata route) plus what `/openapi.json` says. Both worked examples carry `{/* os:check */}`, so they are type-checked against the current spec by `check:skill-examples` — which caught one defect while this page was being written: `target` is required on every entry even for an `object_operation`, where nothing reads it. The page now says so. `api/index.mdx` gets a Card, a module-list entry and the sentence that disambiguates the two endpoint pages; `plugin-endpoints.mdx` gets one intro sentence saying what it is not and a "See also" link (no restructuring); `api/meta.json` gets one entry. Declared deviation: `.claude/workflows/docs-accuracy-audit.js` carries the one line written by `node scripts/docs-audit/check-audit-scope.mjs --write`, which a new hand-written page requires. It is committed exactly as generated, never hand edited. That makes `.claude/**` part of this diff, so the PR is human-merge-only. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GawRwpD44VwBDVy3hs77AX --- .claude/workflows/docs-accuracy-audit.js | 1 + content/docs/api/declarative-endpoints.mdx | 313 +++++++++++++++++++++ content/docs/api/index.mdx | 10 + content/docs/api/meta.json | 1 + content/docs/api/plugin-endpoints.mdx | 5 + 5 files changed, 330 insertions(+) create mode 100644 content/docs/api/declarative-endpoints.mdx diff --git a/.claude/workflows/docs-accuracy-audit.js b/.claude/workflows/docs-accuracy-audit.js index 849c878e9c..c5d0dcd064 100644 --- a/.claude/workflows/docs-accuracy-audit.js +++ b/.claude/workflows/docs-accuracy-audit.js @@ -44,6 +44,7 @@ const ALL_HANDWRITTEN = [ "content/docs/api/client-sdk.mdx", "content/docs/api/data-api.mdx", "content/docs/api/data-flow.mdx", + "content/docs/api/declarative-endpoints.mdx", "content/docs/api/environment-routing.mdx", "content/docs/api/error-catalog.mdx", "content/docs/api/error-handling-client.mdx", diff --git a/content/docs/api/declarative-endpoints.mdx b/content/docs/api/declarative-endpoints.mdx new file mode 100644 index 0000000000..4b7f1d6bc5 --- /dev/null +++ b/content/docs/api/declarative-endpoints.mdx @@ -0,0 +1,313 @@ +--- +title: Declarative Endpoints +description: Expose your app to systems outside the platform by declaring an apis: endpoint as metadata — which channel to pick, the four publish gates, and the obligation that comes with an anonymous endpoint. +--- + +# Declarative Endpoints + +A stack can publish an HTTP endpoint as **metadata** instead of writing a handler: a URL, +a method, a policy block, and the pipeline it delegates to. That is the `apis:` block of +`defineStack`, and its entries are **live from protocol 17** — an endpoint that passes its +publish gates serves real traffic as soon as the package is published. + + +**This is not [Plugin Endpoints](/docs/api/plugin-endpoints).** That page is a *catalog of +built-in routes* the platform serves when a plugin is installed (`/auth`, `/automation`, +`/graphql`, …) — you call those, you do not write them. This page is about the endpoints +**you declare**, which live under your own namespace and never overlap with that catalog. + + +## Which channel: `actions` or `apis`? + +This is the decision to make first, and it has a single criterion — **where the caller +is**, not what the operation does ([ADR-0121](https://github.com/objectstack-ai/objectstack/blob/main/docs/adr/0121-declarative-endpoint-routing-namespace-and-channel-split.md) D3): + +| The caller is | Channel | Why | +|:---|:---|:---| +| **Inside** the platform — a UI button, a session in the Console, an AI/MCP client, the client SDK | `actions` | It already holds a session and speaks the platform's dialect; a command surface fits it | +| **Outside** the platform — a partner system, an inbound webhook, someone else's integration | `apis:` | The payload shape is theirs, there is no platform session, and the URL itself is the contract you publish | + +"What does it do?" is not the criterion, because it degrades into taste ("is deleting a +record a command or a resource operation?"). "Where is the caller?" has exactly one answer +for any concrete endpoint. + +Picking the wrong one is a style problem, not a behaviour problem: a `type: 'flow'` +endpoint delegates to the same automation pipeline an action-triggered flow uses, with the +same identity envelope and the same RLS semantics (ADR-0121 D5). The cost of a +misclassification is the URL shape and the policy keys — never the execution semantics. + +## Declare one + +Endpoints are declared on the **stack**, not on an app shell — inline in +`defineStack({ apis })`, or in one of the files the `api` metadata type is registered for +(`*.api.ts`, `*.api.yml`, `*.api.json`). Either way the same gates judge them, at stack +compile and again at `publishPackage`. + +{/* os:check */} +```typescript +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { + id: 'acme-crm', + name: 'Acme CRM', + version: '1.0.0', + type: 'app', + // REQUIRED before you can declare `apis:` — your URL carve-out is derived + // from it, and there is deliberately no fallback that derives it from + // `manifest.id` (an outward URL contract must not move because a package + // id was rewritten). + namespace: 'acme', + }, + apis: [ + { + name: 'acme_lead_feed', + // `/api/v1/apps//` — only the subpath is yours. + path: '/api/v1/apps/acme/leads', + method: 'GET', + summary: 'Lead feed', + description: 'Open leads, for the partner portal.', + type: 'object_operation', + // `target` is required on every entry by the vocabulary. An + // object_operation is addressed by `objectParams`, so nothing reads this + // one — name the object it works on and keep the two in step. + target: 'acme_lead', + objectParams: { object: 'acme_lead', operation: 'find' }, + // Omitting `authRequired` is the safe spelling — it defaults to `true`. + cacheTtl: 30, + }, + { + name: 'acme_lead_intake', + path: '/api/v1/apps/acme/leads/intake', + method: 'POST', + summary: 'Lead intake', + type: 'flow', + target: 'acme_lead_intake', + }, + ], +}); +``` + +Publish it (`objectstack publish`), and the two URLs answer. `objectstack validate` — and +`os build` — run the same gates the publish path runs, so a declaration that would be +refused is refused before you deploy. + +### What a request does + +A declared endpoint is **not a registered route**. It is matched in the dispatcher's +unmatched-request seam, which is what makes it structurally impossible for your +declaration to shadow a built-in one. Match → policy chain (`rateLimit` → `authRequired` → +`cacheTtl`) → delegation to an existing pipeline: + +| `type` | Delegates to | Request shape | +|:---|:---|:---| +| `object_operation` | the same `callData` binding that serves `/api/v1/data/{object}` | `find` reads its criteria from the query string; `get` / `update` / `delete` take the record id from `query.id`; `create` / `update` take the body. `create` answers `201`, the rest `200` | +| `flow` | the same automation pipeline as `POST /api/v1/automation/{name}/trigger` | the request body is the flow input. A refused or failed run answers a real status: `404` unknown flow, `409` `FLOW_DISABLED`, `422` `FLOW_NO_START_NODE`, `400` `FLOW_FAILED` | + +The frozen vocabulary has **no path-template syntax**, so an endpoint cannot express +`/leads/{id}` — a record id travels as `?id=…`. A method that no declaration claims +answers the transport's own bare `404`, not `405`: nothing registered a route, so there is +no method set to report. The full request lifecycle, including the unmatched-request +contract, is in the [HTTP protocol reference](/docs/protocol/kernel/http-protocol). + +## The four publish gates + +Every entry must satisfy all four or publish and validate fail, **naming the endpoint, the +key and the fix** — never parsing the declaration into silence. The messages below are the +ones you will actually read, so you can match a failure to its gate. + +Per endpoint the gates stop at the **first** failure, in the order below, so one endpoint +reports one thing to fix. Across endpoints they do not stop: three bad entries produce +three rejections in one run. + +### 1. Namespace — the route has to be yours + +`path` must be `/api/v1/apps//`, with a non-empty subpath. +The namespace segment comes from your stack's `manifest.namespace` (2–20 characters, +`^[a-z][a-z0-9_]{1,19}$`) and is **never** written on the endpoint — a `namespace` key on +an endpoint is refused by name. + +> `Endpoint 'acme_lead_feed' (apis[0])` declares path `'/api/v1/leads'`, which is not +> inside this stack's endpoint carve-out. + +A stack that declares `apis:` without an explicit `manifest.namespace` is rejected once, +against `apis` itself, before any path is judged. + +The `apps//` segment is why route ownership is **structural** rather than a +list somebody maintains: no built-in domain lives under `apps/`, and two packages can +never collide because their namespaces differ. A path outside the carve-out would parse +today and match nothing at runtime — the endpoint seam only ever consults declarations +under that mount. + +### 2. Supported subset — only what 17.x actually executes + +| Declaration | Verdict | +|:---|:---| +| `type: 'object_operation'` with **both** `objectParams.object` and `objectParams.operation` (`find` / `get` / `create` / `update` / `delete`) | executes | +| `type: 'flow'` with a `target` naming the flow | executes | +| `type: 'object_operation'` missing either half of `objectParams` | rejected — the executor cannot infer either, and the endpoint would answer `501` on every request | +| `type: 'flow'` with no `target` | rejected | +| `type: 'script'`, `type: 'proxy'` | rejected at publish | + +`target` is required on **every** entry, whatever the `type`. A `flow` endpoint is executed +by it; an `object_operation` is addressed by `objectParams` instead, so nothing reads its +`target` — write the object name there and keep the two in step, because leaving the key +out is a type error before it is anything else. + +`script` and `proxy` are **not servable declarations**. Nothing in the platform verifies +that a script target is reachable, and forwarding to an arbitrary outbound URL is an +egress surface this runtime does not open. Write the logic as `type: 'flow'` instead: a +flow whose script node runs your registered function, or whose outbound call is made by a +declared connector. Both keys stay in the vocabulary and are refused rather than parsed +and ignored, so you find out at publish and not in production. + +> `Endpoint 'acme_proxy' (apis[2])` declares `type: 'proxy'`, which this runtime does not +> execute. + +**Mapping entries are gated with them.** `inputMapping` and `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. Four shapes are refused: a `transform` key (there is no +transformation registry anywhere in the platform — compute the value where it is produced, +in the flow or in a formula field), an unusable dot path (empty, an empty segment such as +`a..b`, or a JavaScript prototype key), two entries writing the same target path or one +writing inside the other, and `inputMapping` on a `find` / `get` / `delete` operation, +which never reads a body. + + +The [HTTP protocol reference](/docs/protocol/kernel/http-protocol) tabulates mapping as a +gate of its own, for five rows instead of four. Same checks, same messages — it splits the +mapping refusals out of this family rather than nesting them. + + +### 3. Policy — the keys that must be able to take effect + +- `authRequired: false` requires `rateLimit.enabled: true` — see [Anonymous + endpoints](#anonymous-endpoints-are-an-open-execution-entry-point) below. +- An **armed** budget must be usable: `maxRequests` above `0` and `windowMs` above `0`. A + zero-or-negative allowance rejects every request including your own health checks, and + the runtime fails closed on it rather than serving unmetered. +- `cacheTtl` is seconds and cannot be negative. +- `cacheTtl` is **GET-only**. It becomes a `Cache-Control` header on a successful answer, + and a non-GET answer is not a cacheable representation, so on any other method the key + could never take effect and is refused instead of ignored. + +`cacheTtl: 0` is not the same as omitting the key: `0` emits `Cache-Control: no-store` +(saying "never store this"), while omitting it sends no caching header at all. A positive +ttl emits `private, max-age=` — `private` is a security rule and not a tuning choice, +because any answer can be RLS-trimmed for its caller and a shared cache must never hand +one caller's answer to somebody else. It rides successful answers only; a `401` / `429` / +`5xx` never carries a cache directive. + +### 4. Uniqueness — one claim per METHOD and path + +Two endpoints in one stack cannot claim the same method and path. Paths are compared with +one trailing slash trimmed — the same rule the matcher applies — so `/x` and `/x/` are the +same claim, and the second one would be dead metadata that passed validation. + +> `Endpoint 'acme_lead_feed_v2' (apis[3])` claims `GET /api/v1/apps/acme/leads`, already +> claimed by endpoint `'acme_lead_feed' (apis[0])`. + +## Anonymous endpoints are an open execution entry point + +`authRequired` defaults to `true`. **Omitting it is the safe spelling**, and an explicit +`false` is the only thing that opens an endpoint — a visible line in a diff, deliberately. + +Read `authRequired: false` as what it is: an anonymous, internet-reachable execution entry +point. Anyone who can reach your deployment can call it, as often as they like, without +credentials. Because of that, ADR-0121 D6 makes an **armed rate limit its paired +obligation**, checked at publish: + +{/* os:check */} +```typescript +import type { ApiEndpoint } from '@objectstack/spec/api'; + +// A partner's webhook receiver: no session to present, so it must carry a budget. +export const partnerWebhook: ApiEndpoint = { + name: 'acme_partner_webhook', + path: '/api/v1/apps/acme/webhooks/partner', + method: 'POST', + type: 'flow', + target: 'acme_partner_intake', + authRequired: false, + // `enabled` DEFAULTS TO FALSE. Writing only `windowMs` / `maxRequests` + // declares a budget that meters nothing — publish checks `enabled === true`, + // not the presence of the key. + rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }, +}; +``` + +> `Endpoint 'acme_partner_webhook' (apis[1])` declares `authRequired: false` without an +> ARMED rate limit. + +What that budget buys you, and what it does not: + +- **Metering runs before the auth gate** (`rateLimit` → `authRequired` → `cacheTtl`). That + order is deliberate: 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. +- **The bucket is per endpoint, per caller**: keyed on the session principal when there is + one, otherwise on the client address. An exhausted budget answers `429` with a + `Retry-After` header. +- **Anonymity is not a permission grant.** The request still runs through the same + pipeline the built-in routes use, under the anonymous principal — so an anonymous + endpoint reaches exactly what your permission model grants anonymous callers, and + nothing more. Opening the door does not widen what is behind it. See + [Authentication](/docs/permissions/authentication) and [Record-Level + Security](/docs/permissions/rls). +- **Payload authenticity is not covered.** The vocabulary has no signature keys — no HMAC, + no timestamp, no replay window — and none is implied by `authRequired: false`. If your + partner signs its webhooks, verify the signature inside the flow the endpoint triggers. + + +An `apis:` block written against an older major changes meaning without changing a byte: +what used to be inert documentation is an execution entry point from protocol 17. Before +upgrading, work through the `declarative-apis-endpoints-live` entry of the protocol upgrade +guide — for an entry carrying `authRequired: false` that is a security review, not a +rename. + + +## `api` is code-only + +You declare endpoints in **source**, publish them, and redeploy. The runtime metadata API +does not create or update them, and it does not fail vaguely: + +```http +PUT /api/v1/meta/api/acme_lead_feed → 403 {"code":"NOT_CREATABLE"} +``` + +The `api` metadata type is registered with `allowRuntimeCreate: false` and +`allowOrgOverride: false`, and that refusal runs **before any body validation** — so a +perfectly valid endpoint body gets the same `403`, in `?mode=draft` as well as direct +writes. There is no per-organization overlay of an endpoint either. + +The reason is that the runtime metadata door was never the door that **serves**: an +endpoint reaches the matcher through the artifact route — stack compile, loader ingest, +`publishPackage` — and a row written through `PUT /meta/api/…` was enumerated but never +matched, published into `/openapi.json` as a live path while every request to it answered +`404`. Closing the write ends that split. (`OS_METADATA_WRITABLE=api` unlocks the write on +one deployment as a diagnostic; the endpoint still will not be served, which is why it is +not a workaround.) + +## Reading the result in `/openapi.json` + +`GET /api/v1/openapi.json` describes your declared endpoints alongside the generated ones: +the `path` and `method` read back verbatim, `name` as `operationId`, and `summary` / +`description` — the only two documentation fields in the vocabulary — as written. + +Nothing is invented: no guessed request or response schemas, no fabricated summaries. The +document is built from the set the **matcher confirmed it will serve**, not from the set +you declared, so an endpoint that appears there is one that answers. That is the fastest +way to check what a partner will see before you send them a URL. + +## See also + +- [Plugin Endpoints](/docs/api/plugin-endpoints) — the built-in routes an installed plugin + serves. Different thing, similar name +- [HTTP protocol reference](/docs/protocol/kernel/http-protocol) — the normative request + lifecycle, the unmatched-request contract, and the gate table +- [API endpoint schema](/docs/references/api/endpoint) — the generated field reference for + `ApiEndpointSchema` +- [Data API](/docs/api/data-api) — the pipeline an `object_operation` endpoint delegates to +- [Flows](/docs/automation/flows) — the pipeline a `flow` endpoint delegates to +- [API Overview](/docs/api) — discovery, error envelopes, and the rest of this module diff --git a/content/docs/api/index.mdx b/content/docs/api/index.mdx index 8bfba0d722..cd70fc3d0c 100644 --- a/content/docs/api/index.mdx +++ b/content/docs/api/index.mdx @@ -80,6 +80,7 @@ surface (OAuth flows, sessions, providers) and - [Data API](/docs/api/data-api) — CRUD, batch operations, record cloning, and analytics queries - [Metadata & Package API](/docs/api/metadata-api) — object schemas, metadata types, UI views, and package management - [Plugin Endpoints](/docs/api/plugin-endpoints) — auth, workflow, realtime, and other plugin-provided routes +- [Declarative Endpoints](/docs/api/declarative-endpoints) — publish an HTTP endpoint of your own as metadata (`apis:`) - [Client SDK](/docs/api/client-sdk) — the official TypeScript client - [Environment Routing](/docs/api/environment-routing) — routing requests across environments - [Data Flow](/docs/api/data-flow) — how a request travels through the stack @@ -88,6 +89,10 @@ surface (OAuth flows, sessions, providers) and - [Error Catalog](/docs/api/error-catalog) — the full list of error codes - [Wire Format](/docs/api/wire-format) — request/response envelopes on the wire +The two endpoint pages are easy to confuse: **Plugin Endpoints** is a catalog of routes the +platform serves for you once a plugin is installed, while **Declarative Endpoints** is how you +declare a route of your own, under your app's namespace, for callers outside the platform. + Spec: [HTTP API](/docs/protocol/kernel/http-protocol), [Real-Time Protocols](/docs/protocol/kernel/realtime-protocol) Schema reference: [API](/docs/references/api) @@ -249,6 +254,11 @@ See the [Protocol Reference](/docs/references/api/protocol) for the full list of title="Data API" description="CRUD, batch operations, and analytics endpoints" /> + @@ -191,3 +195,4 @@ instead of pointing at a path with nothing behind it. - [API Overview](/docs/api) — discovery, error handling, and protocol types - [Client SDK](/docs/api/client-sdk) — service-aware TypeScript client that adapts to installed plugins +- [Declarative Endpoints](/docs/api/declarative-endpoints) — declaring an endpoint of your own (`apis:`), the other half of this page's name From 8544d3e910cead8f934a287c8037b4f04861e788 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 00:58:45 +0000 Subject: [PATCH 2/2] docs(api): quote the declarative-endpoints frontmatter description MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Build Docs` failed deterministically on this page: the `description` was an unquoted YAML scalar containing `apis: `, and a colon-followed-by-space inside a plain scalar opens a nested mapping. YAMLParseError: Nested mappings are not allowed in compact mappings at line 2, column 14 Quoting the whole scalar is the fix; the text is unchanged. Verified with the same `yaml` 2.9.0 the docs build resolves — the pre-fix string reproduces that exact error and the committed one parses, and all 396 `content/docs/**/*.mdx` frontmatter blocks parse clean. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01GawRwpD44VwBDVy3hs77AX --- content/docs/api/declarative-endpoints.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/content/docs/api/declarative-endpoints.mdx b/content/docs/api/declarative-endpoints.mdx index 4b7f1d6bc5..7437754615 100644 --- a/content/docs/api/declarative-endpoints.mdx +++ b/content/docs/api/declarative-endpoints.mdx @@ -1,6 +1,6 @@ --- title: Declarative Endpoints -description: Expose your app to systems outside the platform by declaring an apis: endpoint as metadata — which channel to pick, the four publish gates, and the obligation that comes with an anonymous endpoint. +description: "Expose your app to systems outside the platform by declaring an apis: endpoint as metadata — which channel to pick, the four publish gates, and the obligation that comes with an anonymous endpoint." --- # Declarative Endpoints