diff --git a/.changeset/declarative-endpoints-docs-skill-catchup.md b/.changeset/declarative-endpoints-docs-skill-catchup.md new file mode 100644 index 0000000000..3c2acff05f --- /dev/null +++ b/.changeset/declarative-endpoints-docs-skill-catchup.md @@ -0,0 +1,61 @@ +--- +"@objectstack/spec": patch +--- + +docs(spec,skills): the docs and the `objectstack-api` skill catch up with the endpoint executor (#5238) + +The executor landed (#5040 E1–E8) and the blanket refusal of a non-empty `apis:` +became five per-endpoint publish gates — but three prose surfaces still told the +reader the opposite. That is worse than stale: each one is an *instruction* an +upgrading author (very often an AI maintainer with nothing but that text) would +follow away from a capability that now works. + +**Two prescription texts in `packages/spec` — the shipped half of this change.** + +- `App.apis`'s `retiredKey()` tombstone said the stack-level `defineStack({ apis })` + it redirects to "is ALSO not executable in v17 (#4936) … until the endpoint + executor ships". A tombstone that redirects somewhere has to be true about the + place it points at; this one sent the author to a surface it called dead in the + same sentence, so the natural next move was to keep serving the route from + handler code. It now says the surface EXECUTES from protocol 17, names the five + gates, and carries the two things to get right when moving a declaration up a + level: the `/api/v1/apps//` carve-out with an explicit + `manifest.namespace` (ADR-0121 D1/D2), and `authRequired` defaulting to `true` + with ADR-0121 D6's armed-`rateLimit` pairing on an explicit `false`. The removal + half is untouched — `App.apis` was never read and is still gone — and #4936 is + still named, because the history is why the redirect exists at all. +- The `defineStack({ server })` module header said #5040 "wires endpoint-level + `rateLimit` — still unwired today". It is wired. The header now also states the + relationship an author of the server-level budget actually needs: endpoint + buckets are keyed in their own namespace, so the two budgets meter + independently rather than sharing a counter. + +Both feed `content/docs/references/` through `gen:docs`; those two pages move with +them and nothing else does. + +**Hand-written docs.** `protocol/kernel/http-protocol.mdx` traded its "that surface +has no executor" callout for a real **Declarative Endpoints** section: the serving +chain (match → policy chain → delegation to the same pipelines the built-in routes +use), the five gates, the policy answers (401 / 429 + `Retry-After` / +`Cache-Control: private, max-age=` on successes only), and the identity that is +easy to get wrong — an unmatched path, and a **method mismatch on a declared path**, +both keep the transport's bare `404` byte for byte, because the seam is Hono's +`notFound` and not a registered route, so there is no method set to report a `405` +over. `getting-started/quick-reference.mdx` gains the compact lookup entry. + +**The `objectstack-api` skill** stopped describing `ApiEndpointSchema` as a +four-arm `type` union with a `target` and gained a section that teaches the current +capability: when `apis:` beats `contributes.routes` (and when it does not — real +handler code), the carve-out, the gates as *things to run `objectstack validate` +for* rather than texts to memorise, D6 with its `enabled === true` predicate spelled +out, and the mapping keys' minimal semantics (projection by dot path; no +`transform`; no `inputMapping` on a bodyless operation). It points at the +`declarative-apis-endpoints-live` upgrade entry rather than restating the review, +so there is one source of truth for it. + +Every claim above was measured against the built spec rather than believed: the +doc example publishes, the `authRequired`-omitted shape resolves to `true`, and the +`rateLimit` written without `enabled: true` beside `authRequired: false` is refused +with its prescription. The tombstone's new text is pinned two-sided (it must say +the new thing AND must not say the retired one, with `#4936` still present) so a +regression cannot pass by emptying the message. diff --git a/content/docs/getting-started/quick-reference.mdx b/content/docs/getting-started/quick-reference.mdx index 87d4232b24..708992f028 100644 --- a/content/docs/getting-started/quick-reference.mdx +++ b/content/docs/getting-started/quick-reference.mdx @@ -284,6 +284,38 @@ import type { Field, ServiceObject, QueryAST } from '@objectstack/spec/data'; import type { View } from '@objectstack/spec/ui'; ``` +### Declarative Endpoints (`apis:`) + +Declared on the **stack** (`defineStack({ apis })`), live from protocol 17 — each entry +is gated at publish and, once it passes, serves real traffic. Full contract: +[HTTP API → Declarative Endpoints](/docs/protocol/kernel/http-protocol). + +| Rule | Value | +|:---|:---| +| **Path shape** | `/api/v1/apps//` — only the subpath is yours (ADR-0121 D1) | +| **Namespace** | must be declared **explicitly** as `manifest.namespace`; never derived from `manifest.id` (D2) | +| **Types that execute** | `object_operation` (needs `objectParams.object` + `.operation`) and `flow` (needs `target`). `script` / `proxy` are rejected at publish | +| **`authRequired`** | defaults to `true` — **omitting it is safe**. An explicit `false` is the only thing that opens anonymous access | +| **`authRequired: false`** | REQUIRES an armed budget, `rateLimit: { enabled: true, windowMs, maxRequests }` (ADR-0121 D6) — `enabled` itself defaults to `false`, so a budget without it meters nothing | +| **`cacheTtl`** | seconds, GET-only, applied to successful answers only (`Cache-Control: private, max-age=`) | + +{/* os:check */} +```typescript +import type { ApiEndpointInput } from '@objectstack/spec/api'; + +// With `manifest: { namespace: 'acme', … }` on the same stack. +export const leadFeed: ApiEndpointInput = { + name: 'acme_lead_feed', + path: '/api/v1/apps/acme/leads', // /api/v1/apps// + method: 'GET', + type: 'object_operation', + target: 'acme_lead', + objectParams: { object: 'acme_lead', operation: 'find' }, + // `authRequired` omitted → defaults to true (a session is required). + cacheTtl: 30, +}; +``` + ## Search Tips - **By Feature**: "How do I model relationships?" → [Field Protocol](/docs/references/data/field) → `lookup` or `master_detail` types diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 3430e15735..6f970d4f83 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -1036,17 +1036,140 @@ 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. And the per-endpoint `rateLimit` key on -`ApiEndpointSchema` is **not wired to anything**; declaring it changes nothing today. -It travels with the declarative `apis:` surface as a whole: since -[#4936](https://github.com/objectstack-ai/objectstack/issues/4936) that surface has no -executor and a **non-empty `apis:` is rejected at publish/validate**, so the key cannot -be reached at all. Wiring it is part of the endpoint executor, tracked by -[#5040](https://github.com/objectstack-ai/objectstack/issues/5040). +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](https://github.com/objectstack-ai/objectstack/issues/4939).) +The **per-endpoint** `rateLimit` on `ApiEndpointSchema` is a second, independent budget. +Between [#4936](https://github.com/objectstack-ai/objectstack/issues/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](https://github.com/objectstack-ai/objectstack/issues/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 */} +```typescript +import { defineStack } from '@objectstack/spec'; + +export default defineStack({ + manifest: { + id: '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//` — only the subpath is yours. + path: '/api/v1/apps/acme/leads', + method: 'GET', + summary: 'Lead feed', + type: 'object_operation', + target: 'acme_lead', + objectParams: { object: 'acme_lead', operation: 'find' }, + // Defaults to `true`. Omitting it is safe; see the policy table below. + authRequired: true, + // Seconds. GET-only, and only ever on a successful answer. + cacheTtl: 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 `/apps/`, and `METHOD` + path + (one trailing slash trimmed) must hit exactly one declaration. +2. **Policy chain** — `rateLimit` → `authRequired` → `cacheTtl`, 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` | +| `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 | +| `cacheTtl: 30` on a successful GET | `Cache-Control: private, max-age=30` — `private` is a security rule, not tuning: any response can be RLS-trimmed | +| `cacheTtl: 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: + +```http +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`: + +```http +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//`, 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 `cacheTtl`, or `cacheTtl` 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 diff --git a/content/docs/references/system/stack-server.mdx b/content/docs/references/system/stack-server.mdx index a34798ba42..7b9687a695 100644 --- a/content/docs/references/system/stack-server.mdx +++ b/content/docs/references/system/stack-server.mdx @@ -65,13 +65,25 @@ baked into the artifact. Related: #4910 (this seam), #4937 (the limiter that documented an execution -chain it never had), #4936 (the declarative `apis:` surface: vocabulary kept, +chain it never had), #4936 (the declarative `apis:` surface as it stood while -a non-empty array rejected until an executor exists) and #5040 (that +nothing executed it: vocabulary kept, a non-empty array rejected outright) and -executor, which wires endpoint-level `rateLimit` — still unwired today), +#5040 — the executor that ended that state. From protocol 17 a declared -ADR-0069 D2 (shared counters), ADR-0049 (enforce or remove). +endpoint is LIVE behind five per-endpoint publish gates, and its own + +`rateLimit` is enforced by the endpoint policy chain against a bucket keyed in + +a separate namespace, so an endpoint budget and the server-level budget + +declared here meter INDEPENDENTLY rather than sharing a counter. The upgrade + +checklist for that flip is the `declarative-apis-endpoints-live` entry of the + +protocol upgrade guide. ADR-0069 D2 (shared counters), ADR-0049 (enforce or + +remove). **Source:** `packages/spec/src/system/stack-server.zod.ts` diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index 361616cbfc..ba3d7d03e4 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -86,7 +86,7 @@ const result = ActionNavItemSchema.parse(data); | **homePageId** | `any` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, #4709, ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources automatically. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | | **objects** | `any` | optional | [REMOVED] `App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read; the spec itself labelled it "config file convenience"). Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. Delete the key. | -| **apis** | `any` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Delete the key. Note the stack-level `defineStack({ apis })` this prescription used to redirect to is ALSO not executable in v17 (#4936): the vocabulary is kept but a non-empty array is rejected there too, until the endpoint executor ships (tracked by https://github.com/objectstack-ai/objectstack/issues/5040). Serve the route in code meanwhile — a plugin manifest `contributes.routes` entry or an `http.server` route. | +| **apis** | `any` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Delete the key and declare the endpoint one level up, on the STACK: `defineStack({ apis })`. That surface EXECUTES from protocol 17 (#5040). Between #4936 and the executor landing it was refused wholesale — nothing mounted a declared path, so every key including `authRequired` parsed and gated nothing — and that blanket refusal is now narrowed to five per-endpoint publish gates (namespace, supported target, mapping, policy, uniqueness): an endpoint that passes them is mounted and serves traffic as soon as the stack is published. Two things to get right when you move it: the path must sit inside your own carve-out, `/api/v1/apps//` with an explicit `manifest.namespace` (ADR-0121 D1/D2), and `authRequired` defaults to `true` — an explicit `false` is the only thing that opens anonymous access, and ADR-0121 D6 then requires an armed `rateLimit: { enabled: true, windowMs, maxRequests }`. Read the `declarative-apis-endpoints-live` entry of the protocol upgrade guide first; it is a security review, not a rename. A route that genuinely needs handler CODE still belongs in a plugin manifest `contributes.routes` entry. | | **sharing** | `any` | optional | [REMOVED] `App.sharing` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049 enforce-or-remove) — no public-app route ever read it, so it declared sharing that did not exist. Public access is granted per FORM VIEW (`FormView.sharing`, the public-data-collection surface). Delete the key. | | **embed** | `any` | optional | [REMOVED] `App.embed` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049) — no iframe route ever read it. Embedding is a per-form-view surface (`FormView.sharing`), not an app-level switch. Delete the key. | | **mobileNavigation** | `any` | optional | [REMOVED] `App.mobileNavigation` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — fully unimplemented; no renderer, including packages/mobile, ever read it). Delete the key; the block returns if/when a real mobile navigation ships. | diff --git a/packages/spec/src/system/stack-server.zod.ts b/packages/spec/src/system/stack-server.zod.ts index 7c5252b6fa..0aefc53452 100644 --- a/packages/spec/src/system/stack-server.zod.ts +++ b/packages/spec/src/system/stack-server.zod.ts @@ -39,10 +39,16 @@ * baked into the artifact. * * Related: #4910 (this seam), #4937 (the limiter that documented an execution - * chain it never had), #4936 (the declarative `apis:` surface: vocabulary kept, - * a non-empty array rejected until an executor exists) and #5040 (that - * executor, which wires endpoint-level `rateLimit` — still unwired today), - * ADR-0069 D2 (shared counters), ADR-0049 (enforce or remove). + * chain it never had), #4936 (the declarative `apis:` surface as it stood while + * nothing executed it: vocabulary kept, a non-empty array rejected outright) and + * #5040 — the executor that ended that state. From protocol 17 a declared + * endpoint is LIVE behind five per-endpoint publish gates, and its own + * `rateLimit` is enforced by the endpoint policy chain against a bucket keyed in + * a separate namespace, so an endpoint budget and the server-level budget + * declared here meter INDEPENDENTLY rather than sharing a counter. The upgrade + * checklist for that flip is the `declarative-apis-endpoints-live` entry of the + * protocol upgrade guide. ADR-0069 D2 (shared counters), ADR-0049 (enforce or + * remove). */ import { z } from 'zod'; diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index c415fa1b5b..53d5db66f5 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -1059,6 +1059,46 @@ describe('retired dead keys carry prescriptions (#4001)', () => { navigation: [{ id: 'nav_home', label: 'Home', type: 'object', objectName: 'account' }], })).not.toThrow(); }); + + // #5238 (#5040 E9) — the `apis` tombstone redirects to `defineStack({ apis })`, + // so it also has to be TRUE about what that surface does. It used to add "which + // is ALSO not executable in v17 (#4936) … until the endpoint executor ships", + // and the executor shipped: an author following the prescription today would be + // told to move a declaration to a place the same sentence calls dead, and would + // reasonably keep serving the route from handler code instead. + // + // The assertions below are deliberately two-sided. Asserting only the presence + // of the new words would keep passing if the retired claim were re-added + // underneath them, and asserting only the absence would pass on an empty + // string — the vacuity the surrounding pins exist to avoid. + describe('the `apis` prescription tracks the executor (#5238)', () => { + const prescription = (): string => { + const result = AppSchema.safeParse({ name: 'app_x', label: 'X', apis: [] }); + expect(result.success).toBe(false); + return result.error!.issues.map((i) => i.message).join('\n'); + }; + + it('states the stack-level surface EXECUTES, and where the endpoint may live', () => { + const messages = prescription(); + expect(messages).toContain('defineStack({ apis })'); + expect(messages).toContain('EXECUTES from protocol 17'); + // The one edit an author moving a declaration up must make (ADR-0121 D1/D2). + expect(messages).toContain('/api/v1/apps//'); + // The key whose wrong answer is unrecoverable, plus its D6 obligation. + expect(messages).toContain('`authRequired` defaults to `true`'); + expect(messages).toContain('enabled: true'); + // Where the full security review lives — one source of truth, not a copy. + expect(messages).toContain('declarative-apis-endpoints-live'); + }); + + it('no longer tells the author that stack-level `apis:` is rejected', () => { + const messages = prescription(); + expect(messages).not.toContain('not executable in v17'); + expect(messages).not.toContain('until the endpoint executor ships'); + // The historical fact stays: #4936 happened, and the prescription says so. + expect(messages).toContain('#4936'); + }); + }); }); // #4001 (app step, PR B) — the app shell is `.strict()`, and the nav-item diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index fb536ebea2..17f28c0566 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -1172,12 +1172,21 @@ export const AppSchema = lazySchema(() => z.object({ ), apis: retiredKey( '`App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — ' + - 'never read). Delete the key. Note the stack-level `defineStack({ apis })` this ' + - 'prescription used to redirect to is ALSO not executable in v17 (#4936): the ' + - 'vocabulary is kept but a non-empty array is rejected there too, until the endpoint ' + - 'executor ships (tracked by ' + - 'https://github.com/objectstack-ai/objectstack/issues/5040). Serve the route in code ' + - 'meanwhile — a plugin manifest `contributes.routes` entry or an `http.server` route.', + 'never read). Delete the key and declare the endpoint one level up, on the STACK: ' + + '`defineStack({ apis })`. That surface EXECUTES from protocol 17 (#5040). Between ' + + '#4936 and the executor landing it was refused wholesale — nothing mounted a declared ' + + 'path, so every key including `authRequired` parsed and gated nothing — and that ' + + 'blanket refusal is now narrowed to five per-endpoint publish gates (namespace, ' + + 'supported target, mapping, policy, uniqueness): an endpoint that passes them is ' + + 'mounted and serves traffic as soon as the stack is published. Two things to get ' + + 'right when you move it: the path must sit inside your own carve-out, ' + + '`/api/v1/apps//` with an explicit `manifest.namespace` ' + + '(ADR-0121 D1/D2), and `authRequired` defaults to `true` — an explicit `false` is the ' + + 'only thing that opens anonymous access, and ADR-0121 D6 then requires an armed ' + + '`rateLimit: { enabled: true, windowMs, maxRequests }`. Read the ' + + '`declarative-apis-endpoints-live` entry of the protocol upgrade guide first; it is a ' + + 'security review, not a rename. A route that genuinely needs handler CODE still ' + + 'belongs in a plugin manifest `contributes.routes` entry.', ), /** diff --git a/skills/objectstack-api/SKILL.md b/skills/objectstack-api/SKILL.md index 50a01fe23a..3216eb645d 100644 --- a/skills/objectstack-api/SKILL.md +++ b/skills/objectstack-api/SKILL.md @@ -14,7 +14,7 @@ license: Apache-2.0 compatibility: Requires @objectstack/spec 16.x (Zod v4 schemas) metadata: author: objectstack-ai - version: "1.2" + version: "1.3" domain: api tags: rest, graphql, endpoint, auth, realtime, server --- @@ -139,10 +139,120 @@ export const closeCase: RestApiEndpoint = RestApiEndpointSchema.parse({ There is no `name`, `request`, `response`, or `auth` field on this schema — request/response schemas are referenced **by name** (`requestSchema` / -`responseSchema`), and auth is the flat `public` + `permissions` pair. The -alternative declarative surface is `ApiEndpointSchema` (`endpoint.zod.ts`): -`type: 'flow' | 'script' | 'object_operation' | 'proxy'` plus a `target` -(Flow ID, script name, or proxy URL) and `authRequired: boolean`. +`responseSchema`), and auth is the flat `public` + `permissions` pair. + +The alternative — and usually the better one — is the **declarative** surface +`ApiEndpointSchema`, which needs no handler code at all. See the next section. + +--- + +## Declarative Endpoints (`apis:`) — no handler code + +`defineStack({ apis })` declares an HTTP endpoint as **metadata**. Declared +endpoints are **live from protocol 17** (#5040): 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. + +### Choosing between `apis:` and `contributes.routes` + +| 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.** | +| **`contributes.routes`** (plugin manifest) | The endpoint needs real handler CODE — a third-party callback with its own signature verification, a streaming response, a protocol the platform does not speak. This registers a prefix for a plugin-provided service. | + +If the logic is "a bit of computation, then a record write", express it as a +**flow** and point a `type: 'flow'` endpoint at it — that keeps the URL +declarative and the logic in the automation surface that already runs it. + + +```typescript +import type { ApiEndpointInput } from '@objectstack/spec/api'; + +// The stack declares `manifest: { namespace: 'acme', … }` — required, see below. +export const leadFeed: ApiEndpointInput = { + name: 'acme_lead_feed', + path: '/api/v1/apps/acme/leads', // /api/v1/apps// + method: 'GET', + summary: 'Lead feed', + type: 'object_operation', + target: 'acme_lead', + objectParams: { object: 'acme_lead', operation: 'find' }, + // `authRequired` omitted → defaults to `true`. Omission is SAFE. + cacheTtl: 30, // seconds; GET-only; success answers only +}; +``` + +### The path carve-out (ADR-0121 D1/D2) + +A declared path must be `/api/v1/apps//`. Only the +subpath is yours to name. `manifest.namespace` must be declared **explicitly** — +there is deliberately no derivation from `manifest.id`, because an outward URL +contract must not move because a package id was rewritten. This is what makes +route ownership structural: no built-in domain lives under `apps/`, and two +packages cannot collide because their namespaces differ. + +Note the ordinary naming rules above still apply *inside* the subpath, but the +prefix is not yours to choose — a path outside the carve-out is rejected at +publish, and would match nothing at runtime even if it were not. + +### Five publish gates, each with a prescription + +A declaration this runtime cannot serve is **rejected at publish**, one gate at a +time, each naming the endpoint, the key and the fix. Run the gate yourself: + +```bash +objectstack validate # or: os build — same gates +``` + +Do not memorise the gate texts; **read the rejection**, it carries the fix. +What the five gates cover: **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` and the D6 pairing + +`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: + +```typescript +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`. + +### Mapping keys: projection only + +`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: + +- `transform` is rejected — there is no transformation-function registry. + Compute the value where it is produced (a flow, or a formula field). +- `inputMapping` is rejected on a `find` / `get` / `delete` `object_operation`, + which never reads a request body. +- Two entries cannot write the same target path, nor one inside the other + (`x` and `x.y`). + +### Upgrading a pre-17 stack + +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`. --- @@ -154,6 +264,7 @@ alternative declarative surface is `ApiEndpointSchema` (`endpoint.zod.ts`): | `/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:** @@ -319,8 +430,10 @@ fields on the endpoint itself: ``` Declarative `ApiEndpointSchema` endpoints (and the dispatcher) instead use -`authRequired: boolean` (default `true`). Rate-limit policies themselves are -shaped by `RateLimitConfigSchema`: +`authRequired: boolean` (default `true`) — and setting it to `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`: ```typescript diff --git a/skills/objectstack-api/evals/README.md b/skills/objectstack-api/evals/README.md index c375c7124d..fc4a181361 100644 --- a/skills/objectstack-api/evals/README.md +++ b/skills/objectstack-api/evals/README.md @@ -21,7 +21,10 @@ evals/ │ └── test-data-route-errors.md # flat { error, code } bodies (CONCURRENT_UPDATE 409, VALIDATION_FAILED 400) ├── endpoints/ │ ├── test-rest-endpoint-shape.md # RestApiEndpointSchema fields (public/permissions, schema name refs) -│ └── test-api-endpoint-types.md # ApiEndpointSchema type/target/authRequired +│ ├── test-api-endpoint-types.md # only object_operation/flow execute; script/proxy rejected at publish +│ ├── test-endpoint-path-carveout.md # /api/v1/apps//, explicit namespace +│ ├── test-auth-required-default.md # defaults true — omission is safe, explicit false opens anonymous +│ └── test-d6-armed-rate-limit.md # authRequired:false REQUIRES rateLimit.enabled === true (ADR-0121 D6) └── ... ```