Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions .changeset/apis-publish-gates-flip.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
---
"@objectstack/spec": major
"@objectstack/metadata": patch
---

feat(spec)!: declarative `apis:` publishes again — the blanket refusal narrows to per-endpoint publish gates, and declared endpoints go LIVE (#5111, #5040 E7)

⚠️ **Read this as a security note, not a schema note.** Declarative endpoints
**execute** from protocol 17. Before this release the surface was inert end to
end — nothing mounted a declared `path`, no matcher existed, and every key
including `authRequired` parsed green and gated nothing — which is why #4936
refused a non-empty `apis:` outright. The #5040 E-series built the executor
(mount seam, endpoint matcher, policy keys, execution targets, mapping keys,
OpenAPI enrichment), so the refusal's premise is gone and keeping it would be
the lie in the other direction.

## BREAKING — the refusal narrows, and what passes it is served

`apis: [ …endpoints… ]` no longer fails wholesale. Each entry is now gated
individually, and **an endpoint that passes the gate is mounted and answers
real requests as soon as the stack is published.**

**Before you upgrade, review every historical `apis:` block** — including any
you restored, generated from an older doc, or left in place because it was
known to do nothing. Pay particular attention to any entry that explicitly
declares **`authRequired: false`**: the schema default is `true`, so an
*omission* is safe and needs no review, while an explicit `false` is the only
thing that opens **anonymous** access to that endpoint. ADR-0121 D6 now pairs
it with a mandatory armed rate limit — and "armed" means
`rateLimit: { enabled: true, … }`, because `enabled` defaults to `false`, so a
budget written without it meters nothing.

## The gates, each rejecting with its own prescription

| gate | rejected shape |
|---|---|
| **namespace** (ADR-0121 D1/D2) | a `path` that is not `/api/v1/apps/<manifest.namespace>/<subpath>`, or a stack that declares `apis:` without an explicit `manifest.namespace` (no derivation from `manifest.id`) |
| **supported subset** | `type: 'script'` / `'proxy'`; an `object_operation` missing `objectParams.object` or `.operation`; a `flow` with an empty `target` |
| **mapping** | any `transform`; an unusable `source`/`target` path (empty, empty segment `a..b`, `__proto__`/`prototype`/`constructor`); two entries whose `target`s collide (same path, or one inside another); `inputMapping` on a `find`/`get`/`delete` operation, which never reads a body |
| **policy** | `authRequired: false` without `rateLimit.enabled === true`; an armed budget with `maxRequests`/`windowMs` ≤ 0; a negative `cacheTtl`; `cacheTtl` on a non-GET method |
| **uniqueness** | two endpoints in one stack claiming the same METHOD + path (one trailing slash trimmed, the matcher's own rule) |

**FROM → TO.** `path: '/api/v1/<anything>/thing'` →
`path: '/api/v1/apps/<manifest.namespace>/thing'`, with `manifest.namespace`
declared explicitly. `authRequired: false` → either delete the key (the safe
default `true` applies) or keep it **and** add
`rateLimit: { enabled: true, windowMs: 60000, maxRequests: 100 }`. Every other
key is unchanged: the `ApiEndpoint` vocabulary is frozen — this release adds,
removes and renames nothing on it. The gates are validation logic over the keys
that already existed.

The runtime keeps its own refusals for a declaration that reached the store
without passing publish (a direct `metadata.register()`), so the two ends agree:
what publish accepts is exactly what the executor serves.

`normalizeEndpointPath` is now exported from `@objectstack/spec/api` and is the
one canonical form of a declared path — the publish gate and the endpoint
matcher (`@objectstack/metadata`) read the same rule instead of each carrying a
copy, so a stack can never publish a duplicate the matcher would silently
resolve to a single winner.
5 changes: 5 additions & 0 deletions docs/protocol-upgrade-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ Last, it removes `dashboard.widgets[].responsive` (#4876) — the straggler of t

Finally it CONVERGES `dashboard.widgets[].compareTo` (#5011) — the one entry in this step that is not a removal but a vocabulary merge, and the one whose defect was worst-shaped. The widget declared three arms with confident TSDoc; the analytics executor implements one contract, `DatasetSelection.compareTo` = `{ kind, dimension? }`, which has no `offset` in it. On the ADR-0021 dataset path the two string arms were DROPPED by the renderer (a comparison silently absent from a widget whose author asked for one) and `{ offset }` was forwarded into that contract with no dimension, so the executor threw `compareTo requires a timeDimension "undefined"` and errored the whole widget. All three arms worked on the legacy inline chart path. Same key, two fates — and the failing one was the path the spec itself calls canonical, which is why this ranks above an ordinary declared-but-unread key: the documentation was actively teaching a shape that crashes. The widget now declares the executor's own words, so `declared = enforced` holds by construction with no second vocabulary left to drift. `dimension` is optional and resolved by the EXECUTOR (one dated time dimension → that one; zero or several → a loud error naming the candidates), which is a producer-side resolution rule, not the consumer-side tolerance PD #12 forbids. The bare strings and `{ offset: '1y' }` replay mechanically; every other `{ offset }` duration is a semantic TODO below, because `previousPeriod` shifts by the resolved window's own length and rewriting `7d` into it would change which rows the comparison counts. The converged slot is also union-free, which is not cosmetic: zod collapses a failed union into one bare `Invalid input` and #5014 showed that curated guidance inside a union arm never reaches the author at all.

⚠️ One protocol-17 change turns metadata ON rather than off, and it is the one to read first: declarative `apis:` endpoints EXECUTE from 17 (#5040). The surface used to be inert end to end — no route mounted, no matcher, every key including `authRequired` parsed and enforced nothing — which is why #4936 refused a non-empty `apis:` outright. 17 ships the executor and narrows that refusal to a per-endpoint publish gate, so an endpoint that passes the gate is MOUNTED and serves traffic the moment it is published. Any historical `apis:` block therefore changes meaning without changing a byte. Review every entry before upgrading, and pay particular attention to an explicit `authRequired: false`: the schema default is `true`, so an omission is safe, and only that explicit `false` opens anonymous access — which ADR-0121 D6 now pairs with a mandatory armed `rateLimit` (`enabled: true`; the key defaults to `false`, so a budget written without it meters nothing). Paths also move under the namespace carve-out `/api/v1/apps/<manifest.namespace>/<subpath>` (ADR-0121 D1/D2). The full checklist is the `declarative-apis-endpoints-live` semantic entry below; it is a security review, not a rename, so nothing about it is applied for you.

### Mechanical (applied for you)

| Conversion | Surface | Change | Load window |
Expand Down Expand Up @@ -309,6 +311,9 @@ Finally it CONVERGES `dashboard.widgets[].compareTo` (#5011) — the one entry i
- **`plugin-runtime-family-retired`** — `kernel.dynamicLoadRequest / kernel.dynamicUnloadRequest / kernel.dynamicPluginResult / kernel.pluginSource / kernel.dynamicPluginOperation` → (removed — there is no replacement shape, because there is no operation to describe. Plugins are composed at boot: `defineStack` registers them and the kernel runs register → init → start; the set is fixed until the process restarts. Delete the import and the value. Runtime plugin loading, if it is ever built, returns via the enforce route of ADR-0049 through a new ADR — loader first, vocabulary second)
- Why not automatic: The five schemas declared the "Dynamic Loading" capability — runtime load / unload / reload of plugins without a kernel restart, with sandboxing, integrity hashes, drain strategies and dependent-cascade policy — and NOTHING implemented it. A bare-name scan of objectstack, cloud and objectui found zero references outside this package's own declaration, its unit tests and the generated artifacts: no runtime ever received a `DynamicLoadRequest`, performed a load/unload, or produced a `DynamicPluginResult`. That is the ADR-0049 false-compliance shape at its most inviting to an AI author (ADR-0033), who reads `DynamicLoadRequestSchema` in the published IDE bundle as proof the platform hot-loads plugins and constructs a request that parses clean and is received by nobody (#3950: an exported schema with no consumer is read as a capability). The #3896 follow-up removed this module's discovery/sandbox config island and left these five in place explicitly — "operation contracts, not security promises; the enforce-or-remove call on them is a design decision rather than a correction" — but that suspension lived only in a changeset paragraph with no issue carrying it. #4834 is that decision, answered REMOVE. `experimental` was considered and rejected: it is only `.describe()` prose and cannot stop an import, the weakest of the three ADR-0049 channels. None of the five is stored metadata — they are root request/result payload shapes embedded in no parent schema and parsed against no metadata document — so no `sys_metadata` row can carry one and there is no source for the D2 chain to rewrite; this entry is the D3 record. The removal also subsumes the kernel half of `plugin-activation-events-retired` (#4657): that tombstone goes with the shape that carried it. ADR-0049, #4834.
- Done when: No code imports `DynamicLoadRequestSchema`, `DynamicUnloadRequestSchema`, `DynamicPluginResultSchema`, `PluginSourceSchema`, `DynamicPluginOperationSchema` or any of their type aliases (`DynamicLoadRequest`, `DynamicUnloadRequest`, `DynamicPluginResult`, `PluginSource`, `DynamicPluginOperation`, `DynamicLoadRequestInput`, `DynamicUnloadRequestInput`) from `@objectstack/spec` or `@objectstack/spec/kernel` — every one is TS2305 after upgrade, on every public entry (pinned by symbol identity in `plugin-runtime-retirement.test.ts`). Nothing regresses at runtime, because nothing called anything: a caller that believed it was hot-loading a plugin was already only building an object. Boot-time composition through `defineStack` is unchanged.
- **`declarative-apis-endpoints-live`** — `stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)` → the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps/<manifest.namespace>/<subpath>`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`
- Why not automatic: This is the one protocol-17 entry that turns metadata ON rather than off, so read it as a SECURITY review item and not as a rename. Before 17 the declarative endpoint surface executed NOTHING: no route was mounted for a declared `path`, no matcher existed, and every key — `authRequired` included — parsed green and gated nothing (#4936, which refused a non-empty `apis:` outright for exactly that reason). Protocol 17 ships the executor (#5040) and narrows that refusal to a per-endpoint publish gate: an endpoint that PASSES the gate is mounted and serves real traffic as soon as the stack is published. So an `apis:` block written against an older major — or one restored from a pre-#4936 source, or authored from a doc that predates the refusal — changes meaning without changing a byte: what used to be inert documentation becomes an execution entry point into the data and automation pipelines. Nothing about that transition can be applied mechanically, because the judgment it needs is "did the author of this endpoint mean for the internet to reach it?" — and the one key where a wrong answer is unrecoverable is `authRequired`. Its schema default is `true`, so an omission is SAFE and needs no review; an EXPLICIT `authRequired: false` is the only thing that opens anonymous access, and under ADR-0121 D6 it now also requires an armed `rateLimit` (`enabled: true` — the key defaults to `false`, so a budget written without it meters nothing) or the stack refuses to publish. Grep every `apis:` entry for `authRequired: false` before you upgrade, delete the ones that were never meant to be public, and arm a budget on the ones that were. The path move is the mechanical-looking half and is still yours: ADR-0121 D1/D2 confine a declared path to your own namespace carve-out (`/api/v1/apps/<namespace>/…`), the namespace comes from an explicit `manifest.namespace` with no derivation fallback, and the subpath is the only part you name — rewriting it for you would silently change a URL third parties call.
- Done when: You have READ every entry of every `apis:` block, not just the ones that fail to publish. Concretely: (1) each declared `path` is `/api/v1/apps/<your manifest.namespace>/<subpath>` and the stack declares that `manifest.namespace` explicitly; (2) every entry declaring `authRequired: false` is one you INTEND to be reachable without a session, and each carries `rateLimit: { enabled: true, windowMs, maxRequests }` — entries that were not intended to be anonymous have the key removed so the safe default (`true`) applies; (3) `objectstack validate` passes, which also proves no endpoint declares a shape 17.x cannot execute (`type: script` / `proxy`, mapping `transform`, an `object_operation` missing `objectParams`, `cacheTtl` on a non-GET method, `inputMapping` on find/get/delete, or two endpoints claiming one METHOD + path); and (4) after publishing, each endpoint answers as you expect — an anonymous request to a session-only endpoint returns 401 rather than data.

---

Expand Down
14 changes: 7 additions & 7 deletions packages/metadata/src/endpoint-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@
* have recovered.
*/

import { ApiEndpointSchema, type ApiEndpoint } from '@objectstack/spec/api';
import { ApiEndpointSchema, normalizeEndpointPath, type ApiEndpoint } from '@objectstack/spec/api';
import type { ApiEndpointMatch } from '@objectstack/spec/contracts';
import type { Logger } from '@objectstack/spec/contracts';

Expand All @@ -87,13 +87,13 @@ export function normalizeEndpointMethod(method: string): string {
* Trim exactly one trailing slash, never from a lone `/`.
*
* Applied to BOTH the stored declaration and the query, so the two sides can
* never disagree about which form is canonical.
* never disagree about which form is canonical — and re-exported from
* `@objectstack/spec/api`, which OWNS the rule (#5040 E7), rather than
* re-implemented here. The publish gate that rejects two endpoints claiming the
* same METHOD + path must normalize exactly as this matcher does, or a stack
* could publish a duplicate the index then silently resolves to one winner.
*/
export function normalizeEndpointPath(path: string): string {
const raw = String(path ?? '');
if (raw.length > 1 && raw.endsWith('/')) return raw.slice(0, -1);
return raw;
}
export { normalizeEndpointPath };

/** The index key for a normalized method+path pair. */
export function endpointIndexKey(method: string, path: string): string {
Expand Down
1 change: 1 addition & 0 deletions packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -3124,6 +3124,7 @@
"envelopeViolations (function)",
"getAuthEndpointUrl (function)",
"getDefaultRouteRegistrations (function)",
"normalizeEndpointPath (function)",
"readServiceSelfInfo (function)",
"standardErrorCodeForHttpStatus (function)"
],
Expand Down
Loading
Loading