From 2f52074389f87b787dc5460f23ddee7f6264d169 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 08:17:35 +0000 Subject: [PATCH] feat(spec)!: narrow the `apis:` hard reject to per-endpoint publish gates (#5111, #5040 E7) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit THE FLIP. #4936 refused a non-empty `apis:` wholesale because the declarative endpoint surface executed nothing — no route mounted, no matcher, every key including `authRequired` parsed green and gated nothing. The #5040 E-series built the executor, so that premise is gone; keeping the refusal would be the lie in the other direction. This replaces the blanket `.max(0)` with a per-endpoint gate on `ObjectStackDefinitionSchema`, and an endpoint that passes it is MOUNTED and serves traffic on publish. Gates, each rejecting with a prescription naming the endpoint and the key: - namespace (ADR-0121 D1/D2): `path` must be `/api/v1/apps//`; `manifest.namespace` must be declared explicitly (#5040 Q1 = A — no `deriveNamespaceFromPackageId` fallback for an outward URL contract); - supported subset (mirrors `planEndpointTarget`): `script` / `proxy`, an `object_operation` missing `objectParams.object|operation`, a `flow` with an empty `target`; - mapping (mirrors `mappingDeclarationRejection`): any `transform`, an unusable `source`/`target` path (empty, empty segment, prototype keys), colliding targets — plus `inputMapping` on `find`/`get`/`delete`, which never read a body (PM ruling: same category as `cacheTtl` on a non-GET); - policy (ADR-0121 D6 + the E4 refusals): `authRequired: false` requires `rateLimit.enabled === true` (presence is NOT armed — `enabled` defaults to `false`), an armed budget must be usable, `cacheTtl` non-negative and GET-only; - uniqueness: one claim per METHOD + normalized path inside a stack. The gate lives on the schema, not in `defineStack`, so every publish/validate seam runs it: `defineStack`, `os validate`, the lint scorer, the metadata plugin's artifact ingestion and `EnvironmentArtifactSchema.metadata`. `normalizeEndpointPath` moves to `@objectstack/spec/api` and the endpoint matcher re-exports it, so the uniqueness gate and the matcher's index key can never disagree about the canonical path form. Upgrade documentation is the security deliverable (maintainer ruling: no activation switch): a `declarative-apis-endpoints-live` semantic migration entry plus a step-17 rationale paragraph instruct the upgrading (AI) maintainer to review every historical `apis:` block before upgrading and to pay particular attention to explicit `authRequired: false`. Both reach `docs/protocol-upgrade-guide.md` and `spec-changes.json` through the ADR-0087 generators. Vocabulary frozen: no key added, removed or renamed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- .changeset/apis-publish-gates-flip.md | 60 ++ docs/protocol-upgrade-guide.md | 5 + packages/metadata/src/endpoint-matcher.ts | 14 +- packages/spec/api-surface.json | 1 + packages/spec/spec-changes.json | 14 + .../spec/src/api/apis-no-executor.test.ts | 175 ------ .../spec/src/api/apis-publish-gates.test.ts | 499 ++++++++++++++++ .../spec/src/api/endpoint-publish-gate.ts | 559 ++++++++++++++++++ packages/spec/src/api/endpoint.zod.ts | 28 + packages/spec/src/migrations/registry.ts | 66 ++- packages/spec/src/stack.zod.ts | 101 ++-- 11 files changed, 1297 insertions(+), 225 deletions(-) create mode 100644 .changeset/apis-publish-gates-flip.md delete mode 100644 packages/spec/src/api/apis-no-executor.test.ts create mode 100644 packages/spec/src/api/apis-publish-gates.test.ts create mode 100644 packages/spec/src/api/endpoint-publish-gate.ts diff --git a/.changeset/apis-publish-gates-flip.md b/.changeset/apis-publish-gates-flip.md new file mode 100644 index 0000000000..348d18428d --- /dev/null +++ b/.changeset/apis-publish-gates-flip.md @@ -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//`, 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//thing'` → +`path: '/api/v1/apps//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. diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 270e9c45a6..634151110e 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -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//` (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 | @@ -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//`, 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//…`), 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//` 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. --- diff --git a/packages/metadata/src/endpoint-matcher.ts b/packages/metadata/src/endpoint-matcher.ts index f25e772314..a1431fb95d 100644 --- a/packages/metadata/src/endpoint-matcher.ts +++ b/packages/metadata/src/endpoint-matcher.ts @@ -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'; @@ -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 { diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index 5622d95d24..46a7a6781c 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -3124,6 +3124,7 @@ "envelopeViolations (function)", "getAuthEndpointUrl (function)", "getDefaultRouteRegistrations (function)", + "normalizeEndpointPath (function)", "readServiceSelfInfo (function)", "standardErrorCodeForHttpStatus (function)" ], diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index a31c063975..aa95b41496 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -533,6 +533,13 @@ "migrationId": "plugin-runtime-family-retired", "toMajor": 17, "rationale": "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." + }, + { + "surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)", + "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`", + "migrationId": "declarative-apis-endpoints-live", + "toMajor": 17, + "rationale": "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//…`), 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." } ], "removed": [] @@ -1125,6 +1132,13 @@ "migrationId": "plugin-runtime-family-retired", "toMajor": 17, "rationale": "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." + }, + { + "surface": "stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)", + "replacement": "the same declarations, re-read as LIVE HTTP routes: `path` moved under `/api/v1/apps//`, and every entry that declares `authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying `rateLimit: { enabled: true, … }`", + "migrationId": "declarative-apis-endpoints-live", + "toMajor": 17, + "rationale": "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//…`), 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." } ], "removed": [] diff --git a/packages/spec/src/api/apis-no-executor.test.ts b/packages/spec/src/api/apis-no-executor.test.ts deleted file mode 100644 index f6d1238314..0000000000 --- a/packages/spec/src/api/apis-no-executor.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * [#4936] A non-empty `apis:` is REJECTED; the `ApiEndpoint` vocabulary is KEPT. - * - * ## What this pins, and why it is a rejection rather than a retirement - * - * The declarative `apis:` surface was zero-execution end to end. Metadata - * loading worked perfectly — `GET /api/v1/meta/api` returned the showcase's two - * endpoints with every key intact — while the execution side never fired once: - * no route was mounted for a declared `path` (so the request died at Hono's - * `notFound`, not even reaching the dispatcher), and the dispatcher branch that - * would have run it called a `matchEndpoint` method that NO implementation in - * the repo provided. Every key on `ApiEndpointSchema` was therefore - * declared ≠ enforced, `authRequired: true` included — a security semantic that - * parsed green and gated nothing. - * - * The maintainer verdict (2026-08-04) chose the third route: keep the - * vocabulary, refuse the authoring. Endpoint shapes are an industry-stable - * form, so retiring `ApiEndpointSchema` would only mean re-introducing the same - * schema later; refusing loudly kills the lie just as dead while preserving the - * vocabulary and the metadata investment. The executor (#5040) replaces this - * rejection with real execution, and every definition stays valid across that - * change. - * - * ## Why the assertions below live on the SCHEMA, not on `defineStack` - * - * `ObjectStackDefinitionSchema` is the single choke point every publish and - * validate path runs through — `defineStack` (spec), the metadata plugin's - * artifact ingestion (`ObjectStackDefinitionSchema.parse`), `os validate`, the - * lint scorer, and `EnvironmentArtifactSchema.metadata`. Putting the refusal - * there rather than in one caller is what makes it impossible to reach the - * runtime through a path that forgot to check (Prime Directive #12: reject at - * authoring/publish, never tolerate in a consumer). - */ - -import { describe, it, expect } from 'vitest'; - -import { ObjectStackDefinitionSchema, defineStack } from '../stack.zod'; -import { ApiEndpointSchema } from './endpoint.zod'; - -const manifest = { - id: 'com.example.apis', - name: 'apis-test', - version: '1.0.0', - type: 'app' as const, -}; - -/** The endpoint the showcase used to ship — a realistic, fully valid one. */ -const validEndpoint = { - name: 'showcase_task_feed', - path: '/api/v1/showcase/tasks', - method: 'GET' as const, - summary: 'Task feed', - type: 'object_operation' as const, - target: 'showcase_task', - objectParams: { object: 'showcase_task', operation: 'find' as const }, - authRequired: true, - cacheTtl: 30, -}; - -describe('[#4936] non-empty `apis:` is rejected at publish/validate', () => { - it('rejects a non-empty `apis:` through the schema — the shared publish/validate seam', () => { - const result = ObjectStackDefinitionSchema.safeParse({ manifest, apis: [validEndpoint] }); - expect(result.success, 'a declared endpoint must NOT parse clean').toBe(false); - }); - - it('rejects it through `defineStack` too — the build path an author actually calls', () => { - expect(() => defineStack({ manifest, apis: [validEndpoint] })).toThrow(/apis:/); - }); - - it('the rejection carries the prescription, not a bare "too big"', () => { - const result = ObjectStackDefinitionSchema.safeParse({ manifest, apis: [validEndpoint] }); - expect(result.success).toBe(false); - const message = result.success ? '' : JSON.stringify(result.error.issues); - - // The fact: declared, but nothing executes it. - expect(message).toMatch(/DECLARED BUT NOT EXECUTABLE/); - // The fix the author must apply. - expect(message).toMatch(/delete the `apis:` entries/i); - // The honest alternative that works today. - expect(message).toMatch(/contributes\.routes|http\.server/); - // The LIVE tracking pointer. #4936 closes with this change, so the - // prescription must name #5040 — the executor card that is still open — - // or it sends an upgrading author to a closed issue. - expect(message).toMatch(/issues\/5040/); - // And it must promise the vocabulary survives, so nobody "migrates away" - // from endpoint definitions that are about to start working. - expect(message).toMatch(/vocabulary is deliberately KEPT/); - }); - - it('points at exactly ONE issue URL, and it is the OPEN one', () => { - // #4936 closes with this change, so it may appear only as the decision's - // provenance ("(#4936)"), never as a link an upgrading author is invited to - // follow for status. #5040 — the executor card — is the live tracker, and - // it must be the only URL in the string. This is the assertion the test - // above cannot make: "contains 5040" stays true even if a stale - // `issues/4936` link were sitting next to it. - const result = ObjectStackDefinitionSchema.safeParse({ manifest, apis: [validEndpoint] }); - const message = result.success ? '' : JSON.stringify(result.error.issues); - const urls = [...message.matchAll(/issues\/(\d+)/g)].map((m) => m[1]); - expect(urls.length, 'the prescription must carry a tracking link').toBeGreaterThan(0); - expect([...new Set(urls)]).toEqual(['5040']); - }); - - it('accepts an EMPTY `apis:` — the key stays declared and parseable', () => { - expect(ObjectStackDefinitionSchema.safeParse({ manifest, apis: [] }).success).toBe(true); - expect(() => defineStack({ manifest, apis: [] })).not.toThrow(); - }); - - it('accepts an ABSENT `apis:`', () => { - expect(ObjectStackDefinitionSchema.safeParse({ manifest }).success).toBe(true); - expect(() => defineStack({ manifest })).not.toThrow(); - }); - - it('rejects on COUNT, not on content — a second valid endpoint is refused the same way', () => { - const second = { - name: 'showcase_inquiry_purge_api', - path: '/api/v1/showcase/inquiries/purge', - method: 'POST' as const, - type: 'flow' as const, - target: 'showcase_inquiry_purge', - authRequired: true, - }; - expect(ObjectStackDefinitionSchema.safeParse({ manifest, apis: [second] }).success).toBe(false); - expect( - ObjectStackDefinitionSchema.safeParse({ manifest, apis: [validEndpoint, second] }).success, - ).toBe(false); - }); -}); - -describe('[#4936] the `ApiEndpoint` vocabulary itself is untouched', () => { - // Anti-vacuity for the whole file: if the schema had been retired instead of - // the authoring refused, every assertion above would still pass while the - // verdict ("词表零折腾" — zero churn to the vocabulary) had been violated. - it('still parses a full endpoint on its own, every key intact', () => { - const parsed = ApiEndpointSchema.parse(validEndpoint); - expect(parsed.name).toBe('showcase_task_feed'); - expect(parsed.path).toBe('/api/v1/showcase/tasks'); - expect(parsed.method).toBe('GET'); - expect(parsed.type).toBe('object_operation'); - expect(parsed.objectParams).toEqual({ object: 'showcase_task', operation: 'find' }); - expect(parsed.authRequired).toBe(true); - expect(parsed.cacheTtl).toBe(30); - }); - - it('keeps `authRequired` defaulting to true — the key #4936 called out by name', () => { - const parsed = ApiEndpointSchema.parse({ - name: 'x_endpoint', - path: '/api/v1/x', - method: 'GET', - type: 'flow', - target: 'x_flow', - }); - expect(parsed.authRequired).toBe(true); - }); - - it('keeps endpoint-level `rateLimit` in the vocabulary (#4910-Q2 routed it here)', () => { - const parsed = ApiEndpointSchema.parse({ - name: 'x_endpoint', - path: '/api/v1/x', - method: 'GET', - type: 'flow', - target: 'x_flow', - rateLimit: { requests: 10, window: 60 }, - }); - expect(parsed.rateLimit).toBeDefined(); - }); - - it('still rejects a malformed endpoint on its own terms', () => { - // Guards the guard: the vocabulary must still be a real schema, not an - // `any` that would make the assertions above meaningless. - expect(() => ApiEndpointSchema.parse({ name: 'Bad Name', path: 'no-slash' })).toThrow(); - }); -}); diff --git a/packages/spec/src/api/apis-publish-gates.test.ts b/packages/spec/src/api/apis-publish-gates.test.ts new file mode 100644 index 0000000000..71e64c7351 --- /dev/null +++ b/packages/spec/src/api/apis-publish-gates.test.ts @@ -0,0 +1,499 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#5111 / #5040 E7] THE FLIP — a non-empty `apis:` publishes; each endpoint is gated. + * + * ## What this file replaces + * + * It was `apis-no-executor.test.ts`, and it pinned the opposite assertion: + * #4936 refused every non-empty `apis:` because the surface was zero-execution + * end to end — nothing mounted a declared `path`, no matcher existed, and every + * key was declared ≠ enforced, `authRequired` included. The #5040 E-series + * built the executor, so that premise is gone. The refusal narrows to a set of + * per-endpoint gates, and the first `it` below is the flip's positive + * assertion: a well-formed endpoint PASSES validation, for the first time since + * #4936. + * + * ## Why the assertions live on the SCHEMA, not on `defineStack` + * + * Unchanged from the file this replaces, and the reason is the same: + * `ObjectStackDefinitionSchema` is the single choke point every publish and + * validate path runs through — `defineStack` (spec), the metadata plugin's + * artifact ingestion (`ObjectStackDefinitionSchema.parse`), `os validate`, the + * lint scorer, and `EnvironmentArtifactSchema.metadata`. A gate placed in one + * caller would be a gate the other four forget. Both seams are asserted below. + * + * ## Read every rejection as a security assertion + * + * These endpoints are LIVE once published. A gate that stops working does not + * fail loudly — it publishes an endpoint the runtime then serves, which is why + * each gate is pinned by its message content (the prescription the author + * reads) and not merely by `success: false`. + */ + +import { describe, it, expect } from 'vitest'; + +import { ObjectStackDefinitionSchema, defineStack } from '../stack.zod'; +import { ApiEndpointSchema, normalizeEndpointPath } from './endpoint.zod'; + +const manifest = { + id: 'com.example.apis', + name: 'apis-test', + version: '1.0.0', + type: 'app' as const, + namespace: 'showcase', +}; + +/** A fully valid `object_operation` endpoint under the stack's own namespace. */ +const validObjectEndpoint = { + name: 'showcase_task_feed', + path: '/api/v1/apps/showcase/tasks', + method: 'GET' as const, + summary: 'Task feed', + type: 'object_operation' as const, + target: 'showcase_task', + objectParams: { object: 'showcase_task', operation: 'find' as const }, + authRequired: true, + cacheTtl: 30, +}; + +/** A fully valid `flow` endpoint under the same namespace. */ +const validFlowEndpoint = { + name: 'showcase_inquiry_purge_api', + path: '/api/v1/apps/showcase/inquiries/purge', + method: 'POST' as const, + type: 'flow' as const, + target: 'showcase_inquiry_purge', + authRequired: true, +}; + +/** Parse through the real seam and return the issue messages, joined. */ +function reject(stack: Record): string { + const result = ObjectStackDefinitionSchema.safeParse(stack); + expect(result.success, 'the declaration must NOT parse clean').toBe(false); + return result.success ? '' : result.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('\n'); +} + +/** Parse a stack that must be accepted; returns the parsed endpoints. */ +function accept(stack: Record) { + const result = ObjectStackDefinitionSchema.safeParse(stack); + expect( + result.success ? '' : JSON.stringify(result.error.issues), + 'the declaration must parse clean', + ).toBe(''); + return result.success ? result.data.apis : undefined; +} + +describe('[#5111] the flip — a well-formed `apis:` publishes', () => { + it('accepts an object_operation endpoint under the stack\'s own namespace', () => { + const apis = accept({ manifest, apis: [validObjectEndpoint] }); + expect(apis).toHaveLength(1); + // Anti-vacuity: the endpoint is not merely tolerated, it survives parsing + // whole — including the schema default that makes omission SAFE. + expect(apis?.[0]?.authRequired).toBe(true); + expect(apis?.[0]?.objectParams).toEqual({ object: 'showcase_task', operation: 'find' }); + }); + + it('accepts a flow endpoint alongside it, through `defineStack` too', () => { + expect(() => defineStack({ manifest, apis: [validObjectEndpoint, validFlowEndpoint] })).not.toThrow(); + }); + + it('accepts an anonymous endpoint that arms its rate limit (ADR-0121 D6 satisfied)', () => { + const apis = accept({ + manifest, + apis: [ + { + ...validFlowEndpoint, + name: 'showcase_partner_webhook', + path: '/api/v1/apps/showcase/hooks/partner', + authRequired: false, + rateLimit: { enabled: true, windowMs: 60_000, maxRequests: 100 }, + }, + ], + }); + expect(apis?.[0]?.authRequired).toBe(false); + expect(apis?.[0]?.rateLimit?.enabled).toBe(true); + }); + + it('accepts mapping keys on a body-carrying operation', () => { + accept({ + manifest, + apis: [ + { + ...validObjectEndpoint, + name: 'showcase_task_create', + path: '/api/v1/apps/showcase/tasks', + method: 'POST', + objectParams: { object: 'showcase_task', operation: 'create' }, + cacheTtl: undefined, + inputMapping: [{ source: 'title', target: 'name' }, { source: 'meta.owner', target: 'owner_id' }], + outputMapping: [{ source: 'id', target: 'task_id' }], + }, + ], + }); + }); + + it('still accepts an EMPTY and an ABSENT `apis:` — the #4936 regression pin', () => { + expect(ObjectStackDefinitionSchema.safeParse({ manifest, apis: [] }).success).toBe(true); + expect(ObjectStackDefinitionSchema.safeParse({ manifest }).success).toBe(true); + expect(() => defineStack({ manifest, apis: [] })).not.toThrow(); + expect(() => defineStack({ manifest })).not.toThrow(); + // A stack with NO namespace and no endpoints must stay publishable: the + // namespace requirement is triggered by declaring `apis:`, never by + // existing. + const { namespace: _dropped, ...noNamespace } = manifest; + expect(ObjectStackDefinitionSchema.safeParse({ manifest: noNamespace, apis: [] }).success).toBe(true); + }); +}); + +describe('[#5111] gate (c) — namespace carve-out (ADR-0121 D1/D2)', () => { + it('rejects a path outside the `apps//` mount, naming the endpoint and the shape', () => { + const message = reject({ + manifest, + apis: [{ ...validObjectEndpoint, path: '/api/v1/showcase/tasks' }], + }); + expect(message).toMatch(/showcase_task_feed/); + expect(message).toMatch(/apis\.0\.path/); + expect(message).toMatch(/\/api\/v1\/apps\/showcase\//); + }); + + it("rejects a path under ANOTHER package's namespace", () => { + const message = reject({ + manifest, + apis: [{ ...validObjectEndpoint, path: '/api/v1/apps/crm/tasks' }], + }); + expect(message).toMatch(/not inside this stack's endpoint carve-out/); + }); + + it('rejects the mount with an empty subpath — the namespace root is not claimable', () => { + for (const path of ['/api/v1/apps/showcase', '/api/v1/apps/showcase/', '/api/v1/apps/showcase//']) { + const message = reject({ manifest, apis: [{ ...validObjectEndpoint, path }] }); + expect(message, path).toMatch(/non-empty subpath/); + } + }); + + it('rejects a path that merely LOOKS like the mount (`/apps/showcasex/…`)', () => { + const message = reject({ + manifest, + apis: [{ ...validObjectEndpoint, path: '/api/v1/apps/showcasex/tasks' }], + }); + expect(message).toMatch(/carve-out/); + }); + + it('rejects `apis:` on a stack with no explicit `manifest.namespace` (Q1 = A, no derivation)', () => { + const { namespace: _dropped, ...noNamespace } = manifest; + const message = reject({ manifest: noNamespace, apis: [validObjectEndpoint] }); + expect(message).toMatch(/MUST declare an explicit `manifest.namespace`/); + // The ruling is that the fallback does NOT apply: `com.example.apis` would + // derive `apis` under `deriveNamespaceFromPackageId`, and the rejection + // must not hint that it did. + expect(message).toMatch(/NOT derived from/); + // Reported ONCE, against `apis` — not once per endpoint. + const result = ObjectStackDefinitionSchema.safeParse({ + manifest: noNamespace, + apis: [validObjectEndpoint, validFlowEndpoint], + }); + const custom = result.success ? [] : result.error.issues.filter((i) => i.code === 'custom'); + expect(custom).toHaveLength(1); + }); +}); + +describe('[#5111] gate (a) — the supported subset (mirrors `planEndpointTarget`)', () => { + it("rejects `type: 'script'` with the flow prescription", () => { + const message = reject({ + manifest, + apis: [{ ...validObjectEndpoint, type: 'script', target: 'do_thing', objectParams: undefined }], + }); + expect(message).toMatch(/does not execute/); + expect(message).toMatch(/type: 'flow'/); + }); + + it("rejects `type: 'proxy'` and says why (outbound/SSRF ruling pending)", () => { + const message = reject({ + manifest, + apis: [{ ...validObjectEndpoint, type: 'proxy', target: 'https://example.com', objectParams: undefined }], + }); + expect(message).toMatch(/SSRF|egress/); + }); + + it('rejects an object_operation missing `objectParams.object`', () => { + const message = reject({ + manifest, + apis: [{ ...validObjectEndpoint, objectParams: { operation: 'find' } }], + }); + expect(message).toMatch(/objectParams\.object` is missing/); + }); + + it('rejects an object_operation missing `objectParams.operation`', () => { + const message = reject({ + manifest, + apis: [{ ...validObjectEndpoint, objectParams: { object: 'showcase_task' } }], + }); + expect(message).toMatch(/objectParams\.operation` is missing/); + }); + + it('rejects a flow endpoint with an empty `target`', () => { + const message = reject({ manifest, apis: [{ ...validFlowEndpoint, target: '' }] }); + expect(message).toMatch(/names no target flow/); + }); +}); + +describe('[#5111] gate (b) — mapping declarations (mirrors `mappingDeclarationRejection`)', () => { + const bodyEndpoint = { + ...validObjectEndpoint, + name: 'showcase_task_create', + method: 'POST' as const, + objectParams: { object: 'showcase_task', operation: 'create' as const }, + cacheTtl: undefined, + }; + + it('rejects `transform` on either mapping key', () => { + for (const key of ['inputMapping', 'outputMapping'] as const) { + const message = reject({ + manifest, + apis: [{ ...bodyEndpoint, [key]: [{ source: 'a', target: 'b', transform: 'upper' }] }], + }); + expect(message, key).toMatch(/transformation-function registry/); + expect(message, key).toMatch(new RegExp(`apis\\.0\\.${key}\\.0\\.transform`)); + } + }); + + it('rejects unusable `source` / `target` paths — empty, empty segment, prototype keys', () => { + const unusable = ['', 'a..b', '.a', 'a.', '__proto__', 'a.prototype.b', 'constructor']; + for (const bad of unusable) { + expect( + reject({ manifest, apis: [{ ...bodyEndpoint, inputMapping: [{ source: bad, target: 'ok' }] }] }), + `source '${bad}'`, + ).toMatch(/not a usable field path/); + expect( + reject({ manifest, apis: [{ ...bodyEndpoint, inputMapping: [{ source: 'ok', target: bad }] }] }), + `target '${bad}'`, + ).toMatch(/not a usable field path/); + } + }); + + it('rejects colliding targets — the same path, and one inside another', () => { + const same = reject({ + manifest, + apis: [{ ...bodyEndpoint, outputMapping: [{ source: 'a', target: 'x' }, { source: 'b', target: 'x' }] }], + }); + expect(same).toMatch(/collides with outputMapping\[0\]\.target 'x'/); + + const nested = reject({ + manifest, + apis: [{ ...bodyEndpoint, outputMapping: [{ source: 'a', target: 'x' }, { source: 'b', target: 'x.y' }] }], + }); + expect(nested).toMatch(/silently discard/); + }); + + it('rejects `inputMapping` on find / get / delete — declared but provably inert (PM ruling)', () => { + for (const operation of ['find', 'get', 'delete'] as const) { + const message = reject({ + manifest, + apis: [ + { + ...validObjectEndpoint, + method: operation === 'delete' ? 'DELETE' : 'GET', + cacheTtl: undefined, + objectParams: { object: 'showcase_task', operation }, + inputMapping: [{ source: 'a', target: 'b' }], + }, + ], + }); + expect(message, operation).toMatch(/never reads a request body/); + expect(message, operation).toMatch(/create` \/ `update/); + } + }); + + it('does NOT reject `outputMapping` on those operations — a find still has a result to project', () => { + accept({ + manifest, + apis: [{ ...validObjectEndpoint, outputMapping: [{ source: 'records', target: 'items' }] }], + }); + }); +}); + +describe('[#5111] gate (e) — policy keys (ADR-0121 D6 + the E4 refusals)', () => { + it('rejects `authRequired: false` with NO rateLimit', () => { + const message = reject({ manifest, apis: [{ ...validFlowEndpoint, authRequired: false }] }); + expect(message).toMatch(/ADR-0121 D6|armed rate limit/i); + expect(message).toMatch(/No `rateLimit` is declared at all/); + }); + + it('rejects `authRequired: false` with an UNARMED rateLimit — the `enabled` default trap', () => { + // `RateLimitConfigSchema.enabled` defaults to FALSE, so this declaration + // satisfies "carries a rateLimit" while metering nothing. A presence check + // would publish an anonymous, unmetered endpoint. + const message = reject({ + manifest, + apis: [{ ...validFlowEndpoint, authRequired: false, rateLimit: { windowMs: 60_000, maxRequests: 100 } }], + }); + expect(message).toMatch(/DEFAULTS to `false`/); + expect(message).toMatch(/enabled: true/); + }); + + it('rejects `authRequired: false` with an explicitly disabled rateLimit', () => { + const message = reject({ + manifest, + apis: [ + { ...validFlowEndpoint, authRequired: false, rateLimit: { enabled: false, maxRequests: 5 } }, + ], + }); + expect(message).toMatch(/anonymous AND unmetered/); + }); + + it('rejects an armed budget that cannot be honoured (maxRequests / windowMs <= 0)', () => { + const zeroBudget = reject({ + manifest, + apis: [{ ...validFlowEndpoint, rateLimit: { enabled: true, maxRequests: 0 } }], + }); + expect(zeroBudget).toMatch(/rejects every request/); + expect(zeroBudget).toMatch(/apis\.0\.rateLimit\.maxRequests/); + + const badWindow = reject({ + manifest, + apis: [{ ...validFlowEndpoint, rateLimit: { enabled: true, windowMs: -1 } }], + }); + expect(badWindow).toMatch(/MILLISECONDS/); + }); + + it('accepts an armed budget with defaults materialized (`enabled: true` alone)', () => { + const apis = accept({ manifest, apis: [{ ...validFlowEndpoint, rateLimit: { enabled: true } }] }); + expect(apis?.[0]?.rateLimit).toEqual({ enabled: true, windowMs: 60_000, maxRequests: 100 }); + }); + + it('rejects a negative `cacheTtl`, and accepts 0 (an explicit no-store)', () => { + const message = reject({ manifest, apis: [{ ...validObjectEndpoint, cacheTtl: -5 }] }); + expect(message).toMatch(/cannot be negative/); + accept({ manifest, apis: [{ ...validObjectEndpoint, cacheTtl: 0 }] }); + }); + + it('rejects `cacheTtl` on a non-GET endpoint', () => { + const message = reject({ + manifest, + apis: [{ ...validFlowEndpoint, cacheTtl: 30 }], + }); + expect(message).toMatch(/GET-only/); + expect(message).toMatch(/apis\.0\.cacheTtl/); + }); +}); + +describe('[#5111] gate (d) — one claim per METHOD + path inside a stack', () => { + it('rejects two endpoints claiming the same method and path, naming BOTH', () => { + const message = reject({ + manifest, + apis: [validObjectEndpoint, { ...validObjectEndpoint, name: 'showcase_task_feed_copy' }], + }); + expect(message).toMatch(/showcase_task_feed_copy/); + expect(message).toMatch(/already claimed by endpoint 'showcase_task_feed' \(apis\[0\]\)/); + }); + + it('applies the matcher\'s normalization — one trailing slash is not a different route', () => { + const message = reject({ + manifest, + apis: [ + validObjectEndpoint, + { ...validObjectEndpoint, name: 'showcase_task_feed_slash', path: '/api/v1/apps/showcase/tasks/' }, + ], + }); + expect(message).toMatch(/same claim/); + // The rule the gate applies is the exported one the matcher indexes with, + // not a second copy: if this ever diverges the gate publishes duplicates + // the matcher silently resolves to one winner. + expect(normalizeEndpointPath('/api/v1/apps/showcase/tasks/')).toBe('/api/v1/apps/showcase/tasks'); + expect(normalizeEndpointPath('/')).toBe('/'); + expect(normalizeEndpointPath('/x//')).toBe('/x/'); + }); + + it('allows the same path under different methods', () => { + accept({ + manifest, + apis: [ + validObjectEndpoint, + { + ...validObjectEndpoint, + name: 'showcase_task_create', + method: 'POST', + cacheTtl: undefined, + objectParams: { object: 'showcase_task', operation: 'create' }, + }, + ], + }); + }); +}); + +describe('[#5111] every rejection is actionable, and reaches every publish seam', () => { + it('reports one issue per offending endpoint — three bad endpoints, three rejections', () => { + const result = ObjectStackDefinitionSchema.safeParse({ + manifest, + apis: [ + { ...validObjectEndpoint, name: 'a_bad', path: '/api/v1/nope' }, + { ...validFlowEndpoint, name: 'b_bad', target: '' }, + { ...validObjectEndpoint, name: 'c_bad', path: '/api/v1/apps/showcase/other', cacheTtl: -1 }, + ], + }); + const custom = result.success ? [] : result.error.issues.filter((i) => i.code === 'custom'); + expect(custom).toHaveLength(3); + expect(custom.map((i) => i.path.join('.'))).toEqual(['apis.0.path', 'apis.1.target', 'apis.2.cacheTtl']); + }); + + it('`defineStack` throws the same prescription an artifact parse reports', () => { + expect(() => defineStack({ manifest, apis: [{ ...validObjectEndpoint, type: 'proxy', objectParams: undefined }] })) + .toThrow(/does not execute/); + }); + + it('no rejection message tells the author to delete `apis:` any more', () => { + // The #4936 prescription ("delete the `apis:` entries") is retired with the + // blanket refusal. A gate that still said it would send an author to + // remove a capability that now works. + const message = reject({ manifest, apis: [{ ...validObjectEndpoint, path: '/api/v1/nope' }] }); + expect(message).not.toMatch(/delete the `apis:` entries/i); + expect(message).not.toMatch(/DECLARED BUT NOT EXECUTABLE/); + }); +}); + +describe('[#5111] the `ApiEndpoint` vocabulary itself is untouched', () => { + // Anti-vacuity for the whole file: the flip is validation logic on the + // EXISTING keys. If a key had been added, removed or renamed, every + // assertion above could still pass while the frozen-vocabulary constraint + // (#5040) had been violated. + it('still parses a full endpoint on its own, every key intact', () => { + const parsed = ApiEndpointSchema.parse(validObjectEndpoint); + expect(parsed.name).toBe('showcase_task_feed'); + expect(parsed.path).toBe('/api/v1/apps/showcase/tasks'); + expect(parsed.method).toBe('GET'); + expect(parsed.type).toBe('object_operation'); + expect(parsed.objectParams).toEqual({ object: 'showcase_task', operation: 'find' }); + expect(parsed.authRequired).toBe(true); + expect(parsed.cacheTtl).toBe(30); + }); + + it('keeps `authRequired` defaulting to true — omission is the SAFE state', () => { + const parsed = ApiEndpointSchema.parse({ + name: 'x_endpoint', + path: '/api/v1/apps/showcase/x', + method: 'GET', + type: 'flow', + target: 'x_flow', + }); + expect(parsed.authRequired).toBe(true); + }); + + it('keeps endpoint-level `rateLimit` in the vocabulary (#4910-Q2 routed it here)', () => { + const parsed = ApiEndpointSchema.parse({ + name: 'x_endpoint', + path: '/api/v1/apps/showcase/x', + method: 'GET', + type: 'flow', + target: 'x_flow', + rateLimit: { enabled: true }, + }); + expect(parsed.rateLimit).toBeDefined(); + }); + + it('still rejects a malformed endpoint on its own terms', () => { + // Guards the guard: the vocabulary must still be a real schema, not an + // `any` that would make the assertions above meaningless. + expect(() => ApiEndpointSchema.parse({ name: 'Bad Name', path: 'no-slash' })).toThrow(); + }); +}); diff --git a/packages/spec/src/api/endpoint-publish-gate.ts b/packages/spec/src/api/endpoint-publish-gate.ts new file mode 100644 index 0000000000..12e9ac9eb9 --- /dev/null +++ b/packages/spec/src/api/endpoint-publish-gate.ts @@ -0,0 +1,559 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The PUBLISH GATES for declarative `apis:` endpoints (#5040 E7, ADR-0121). + * + * ## What changed here, and why it is a narrowing rather than a reversal + * + * #4936 refused a non-empty `apis:` WHOLESALE: nothing mounted a declared + * `path`, no matcher existed, and every key — `authRequired` included — parsed + * green while gating nothing. That refusal was the honest state for a surface + * with zero executor. #5040's E-series built the executor (mount seam, matcher, + * policy keys, execution targets, mapping keys, OpenAPI enrichment), so the + * refusal's premise is gone and keeping it would now be the lie in the other + * direction: a working capability refused at the door. + * + * This module is that flip. A declared endpoint is no longer rejected for + * EXISTING; it is rejected for being a shape 17.x cannot serve, one gate at a + * time, each naming the endpoint, the key and the fix. The set of legal + * declarations is exactly the set the runtime executes — the `declared = + * enforced` state the whole program exists to reach. + * + * ## Every gate has a runtime counterpart, and the runtime is the source + * + * Nothing here is invented. Each gate mirrors a refusal the runtime already + * makes, so publish and execution can never disagree about what is servable: + * + * | gate | runtime counterpart | + * |---|---| + * | unsupported target (`script` / `proxy` / incomplete `object_operation` / empty flow `target`) | `planEndpointTarget` — `packages/runtime/src/endpoint-executor.ts` | + * | mapping (`transform`, unusable path, colliding `target`) | `mappingDeclarationRejection` — `packages/runtime/src/api-mapping.ts` | + * | policy (armed-but-unusable `rateLimit`, negative `cacheTtl`, `cacheTtl` off GET) | `endpointRateLimiterRegistry` / `cacheControlHeader` — `packages/runtime/src/endpoint-policy.ts` | + * | namespace + uniqueness | ADR-0121 D1/D2, and `normalizeEndpointPath` (`packages/metadata/src/endpoint-matcher.ts`) for the path form | + * + * The runtime keeps its refusals: a declaration can still reach the store + * through a direct `metadata.register()` that never passed publish, and a + * silent skip there would be the same lie. This gate is the FIRST door, not + * the only one. + * + * ## Why the messages are this long + * + * The author is very often an AI maintainer (ADR-0033) reading nothing but the + * rejection. Each message therefore carries the fact, the exact offending + * value, and the fix — enough to act on without opening this file, the ADR, or + * the issue. That is the same posture as a `retiredKey()` tombstone. + * + * ## Two rulings this module implements verbatim + * + * - **ADR-0121 D2 / #5040 Q1 = A** — the namespace segment comes from an + * EXPLICIT `manifest.namespace`. There is no `deriveNamespaceFromPackageId` + * fallback here on purpose: an outward URL contract must not shift because + * somebody rewrote a package id. + * - **ADR-0121 D6** — `authRequired: false` requires an ARMED rate limit, and + * "armed" means `rateLimit.enabled === true`. A presence check would be + * vacuous: `RateLimitConfigSchema.enabled` defaults to `false`, so + * `rateLimit: { windowMs, maxRequests }` parses into a budget that meters + * nothing while satisfying the letter of "declares a rateLimit". + */ + +import type { ApiEndpoint } from './endpoint.zod'; +import { normalizeEndpointPath } from './endpoint.zod'; + +/** + * The platform's single reserved carve-out segment for app-declared endpoints + * (ADR-0121 D1): a declared path is `/apps//`. + * + * Deliberately a SECOND spelling of `APP_ENDPOINT_SEGMENT` + * (`packages/runtime/src/api-endpoint-step.ts`), which cannot be imported here + * — `packages/spec` is below `packages/runtime`. ADR-0121 makes the RULE the + * contract, so the two sides agree on a rule rather than on a shared list. + */ +const APP_ENDPOINT_SEGMENT = 'apps'; + +/** + * The dispatcher prefix a declaration is written against. + * + * A declared `path` is matched against the request path VERBATIM + * (`endpointIndexKey`), so it carries the deployment's dispatcher prefix as a + * literal — and publish, which runs long before any deployment exists, can only + * hold declarations to the default (`DispatcherPluginConfig.prefix`, default + * `/api/v1`). A deployment that re-prefixes its dispatcher is #5040 §7-8, an + * open vocabulary question (prefix-relative declarations), NOT something to + * guess at here. + */ +const DEFAULT_RUNTIME_PREFIX = '/api/v1'; + +/** The mount prefix every endpoint of `namespace` must be declared under. */ +export function appEndpointMountPrefix(namespace: string): string { + return `${DEFAULT_RUNTIME_PREFIX}/${APP_ENDPOINT_SEGMENT}/${namespace}/`; +} + +/** Path segments no mapping may walk or write — prototype pollution vectors. */ +const UNSAFE_PATH_SEGMENTS = new Set(['__proto__', 'prototype', 'constructor']); + +/** The object operations that never read a request body (#5111, PM ruling). */ +const BODYLESS_OPERATIONS = new Set(['find', 'get', 'delete']); + +/** The two mapping keys, spelled as the vocabulary spells them. */ +const MAPPING_KEYS = ['inputMapping', 'outputMapping'] as const; +type MappingKey = (typeof MAPPING_KEYS)[number]; + +/** + * One gate failure: where it happened (a Zod issue path, relative to the stack + * root) and what the author must read. + */ +export interface EndpointGateIssue { + /** Zod issue path — e.g. `['apis', 2, 'cacheTtl']`. */ + path: (string | number)[]; + message: string; +} + +/** The stack identity the namespace gate reads (ADR-0121 D2). */ +export interface EndpointGateIdentity { + namespace?: string | undefined; +} + +const NAMESPACE_RE = /^[a-z][a-z0-9_]{1,19}$/; + +/** + * Split a declared mapping path, or `undefined` when it is not a usable one. + * + * Character-for-character the runtime's `splitPath` + * (`packages/runtime/src/api-mapping.ts`): a path that this accepts and that + * one refuses would be a declaration published and then refused at 501. + */ +function splitMappingPath(path: unknown): string[] | undefined { + if (typeof path !== 'string' || path === '') return undefined; + const segments = path.split('.'); + for (const segment of segments) { + if (segment === '' || UNSAFE_PATH_SEGMENTS.has(segment)) return undefined; + } + return segments; +} + +/** Whether `a` is `b` or an ancestor of it (`['a']` vs `['a','b']`). */ +function isPathPrefix(a: string[], b: string[]): boolean { + if (a.length > b.length) return false; + return a.every((segment, i) => b[i] === segment); +} + +const MAPPING_PATH_HINT = + "`source` and `target` are dot-separated field paths ('user.profile.email'). An empty path, an " + + "empty segment ('a..b') and the JavaScript prototype keys (__proto__, prototype, constructor) " + + 'are refused — the runtime refuses the identical set, so accepting one here would publish a ' + + 'declaration that answers 501 on every request.'; + +/** + * Every gate, over one stack's `apis:`. + * + * Returns one issue per violation (never throws, never short-circuits the + * array): an author fixing a stack with three bad endpoints should see three + * rejections, not one per publish attempt. Per endpoint the gates DO stop at + * the first failure, so a single endpoint reports the one thing to fix rather + * than a cascade derived from it. + */ +export function validateApiEndpointDeclarations( + endpoints: readonly ApiEndpoint[] | undefined, + identity: EndpointGateIdentity, +): EndpointGateIssue[] { + if (!endpoints || endpoints.length === 0) return []; + + const issues: EndpointGateIssue[] = []; + const namespace = typeof identity.namespace === 'string' ? identity.namespace : undefined; + + // ── The namespace gate's precondition (ADR-0121 D2, #5040 Q1 = A) ─────── + // + // Without an explicit namespace there is no legal path to hold anything to, + // so this is reported once against `apis` itself and the per-endpoint path + // gate is skipped — repeating "you have no namespace" per endpoint would + // bury the one fix under N copies. + const namespaceUsable = namespace !== undefined && NAMESPACE_RE.test(namespace); + if (!namespaceUsable) { + issues.push({ + path: ['apis'], + message: + 'A stack that declares `apis:` MUST declare an explicit `manifest.namespace` ' + + (namespace === undefined + ? '(none is declared).' + : `(got '${namespace}', which is not a legal namespace).`) + + ' The endpoint URL carve-out is derived from it (ADR-0121 D2): every declared `path` ' + + `must be \`${DEFAULT_RUNTIME_PREFIX}/${APP_ENDPOINT_SEGMENT}//\`. ` + + 'The namespace is 2-20 chars matching /^[a-z][a-z0-9_]{1,19}$/ and is the same identity ' + + 'that prefixes every object name (`_`). It is NOT derived from ' + + '`manifest.id`: an outward URL contract must not move because a package id was rewritten.', + }); + } + + const mount = namespaceUsable ? appEndpointMountPrefix(namespace!) : undefined; + /** normalized "METHOD path" → the first endpoint that claimed it. */ + const claims = new Map(); + + for (let index = 0; index < endpoints.length; index++) { + const endpoint = endpoints[index]!; + const at = (...rest: (string | number)[]): (string | number)[] => ['apis', index, ...rest]; + const named = `Endpoint '${endpoint.name}' (apis[${index}])`; + + const failure = firstFailure(endpoint, named, at, mount, claims); + if (failure) { + issues.push(failure); + continue; + } + + // Only a fully legal endpoint claims its route: a rejected declaration + // must not also generate a duplicate report against the endpoint that + // is fine. + claims.set(claimKey(endpoint), { index, name: endpoint.name }); + } + + return issues; +} + +/** The normalized claim key — the matcher's own index key (`endpointIndexKey`). */ +function claimKey(endpoint: ApiEndpoint): string { + return `${endpoint.method.toUpperCase()} ${normalizeEndpointPath(endpoint.path)}`; +} + +/** + * The gates for ONE endpoint, in order, stopping at the first failure. + * + * Order is deliberate: namespace (is this route even yours?) → target (can this + * shape run at all?) → mapping → policy → uniqueness. Reporting "your rate + * limit is unusable" on an endpoint whose `type: 'proxy'` cannot run at all + * would send the author to fix the wrong line. + */ +function firstFailure( + endpoint: ApiEndpoint, + named: string, + at: (...rest: (string | number)[]) => (string | number)[], + mount: string | undefined, + claims: Map, +): EndpointGateIssue | undefined { + return ( + namespaceGate(endpoint, named, at, mount) + ?? targetGate(endpoint, named, at) + ?? mappingGate(endpoint, named, at) + ?? policyGate(endpoint, named, at) + ?? uniquenessGate(endpoint, named, at, claims) + ); +} + +// ============================================================================ +// (c) Namespace — ADR-0121 D1 / D2 +// ============================================================================ + +function namespaceGate( + endpoint: ApiEndpoint, + named: string, + at: (...rest: (string | number)[]) => (string | number)[], + mount: string | undefined, +): EndpointGateIssue | undefined { + if (mount === undefined) return undefined; // already reported once, against `apis` + + const path = endpoint.path; + // Judged on the NORMALIZED path — the form the matcher indexes. Otherwise + // `/` (a bare namespace root with a trailing slash) would pass here + // and then be unreachable: it normalizes back to the mount itself, which + // the endpoint step does not even consider a candidate path. + const normalized = normalizeEndpointPath(path); + const subpath = normalized.startsWith(mount) ? normalized.slice(mount.length) : undefined; + if (subpath !== undefined && subpath !== '' && !subpath.startsWith('/')) return undefined; + + return { + path: at('path'), + message: + `${named} declares path '${path}', which is not inside this stack's endpoint carve-out. ` + + `A declared path must be \`${mount}\` with a non-empty subpath — for example ` + + `'${mount}things' (ADR-0121 D1). ` + + 'The `apps//` segment is what makes route ownership structural rather than a ' + + 'list to maintain: no built-in domain lives under `apps/`, and two packages can never ' + + 'collide because their namespaces differ. A path outside it would parse today and match ' + + 'NOTHING at runtime — the endpoint step only ever consults declarations under that mount. ' + + 'Only the subpath is yours to name; the prefix and namespace segments are derived from ' + + 'the deployment and from `manifest.namespace`.', + }; +} + +// ============================================================================ +// (a) Supported subset — mirrors `planEndpointTarget` +// ============================================================================ + +function targetGate( + endpoint: ApiEndpoint, + named: string, + at: (...rest: (string | number)[]) => (string | number)[], +): EndpointGateIssue | undefined { + if (endpoint.type === 'object_operation') { + const object = endpoint.objectParams?.object; + const operation = endpoint.objectParams?.operation; + if (!object || !operation) { + const missing = !object ? 'object' : 'operation'; + return { + path: at('objectParams'), + message: + `${named} declares \`type: 'object_operation'\` but \`objectParams.${missing}\` is missing. ` + + 'An object_operation endpoint must declare BOTH `objectParams.object` (the object name, ' + + "e.g. 'crm_lead') and `objectParams.operation` (one of find / get / create / update / " + + 'delete). The executor delegates to the same data pipeline `/data` uses and cannot ' + + 'infer either half; an incomplete declaration would answer 501 on every request.', + }; + } + return undefined; + } + + if (endpoint.type === 'flow') { + if (!endpoint.target) { + return { + path: at('target'), + message: + `${named} declares \`type: 'flow'\` but names no target flow in \`target\`. ` + + 'Set `target` to the flow name (snake_case) this endpoint triggers — the endpoint is a ' + + 'stable URL over that flow, executed through the same automation pipeline as ' + + '`POST /automation//trigger`.', + }; + } + return undefined; + } + + return { + path: at('type'), + message: + `${named} declares \`type: '${endpoint.type}'\`, which this runtime does not execute. ` + + "Only 'object_operation' and 'flow' endpoints execute in 17.x. `script` is refused because " + + 'no path in this repo verifies that a script target is reachable through the automation ' + + 'service — express the logic as a flow (`type: \'flow\'`) whose script node runs your ' + + 'registered function. `proxy` is refused because forwarding to an arbitrary outbound URL is ' + + 'a new egress/SSRF surface that needs its own security ruling before it can be served ' + + '(#5040 §7-3); call the third-party system from a flow instead, where the outbound call is ' + + 'made by a declared connector. Both stay in the vocabulary and are rejected here rather ' + + 'than parsed and ignored.', + }; +} + +// ============================================================================ +// (b) Mapping — mirrors `mappingDeclarationRejection`, plus the inert-declaration ruling +// ============================================================================ + +function mappingGate( + endpoint: ApiEndpoint, + named: string, + at: (...rest: (string | number)[]) => (string | number)[], +): EndpointGateIssue | undefined { + for (const key of MAPPING_KEYS) { + const failure = mappingKeyGate(endpoint, key, named, at); + if (failure) return failure; + } + return inertInputMappingGate(endpoint, named, at); +} + +function mappingKeyGate( + endpoint: ApiEndpoint, + key: MappingKey, + named: string, + at: (...rest: (string | number)[]) => (string | number)[], +): EndpointGateIssue | undefined { + const entries = endpoint[key]; + if (!Array.isArray(entries) || entries.length === 0) return undefined; + + const seen: Array<{ index: number; path: string; segments: string[] }> = []; + + for (let i = 0; i < entries.length; i++) { + const entry = entries[i]!; + const where = `${key}[${i}]`; + + if (entry.transform !== undefined) { + return { + path: at(key, i, 'transform'), + message: + `${named} declares ${where}.transform ('${String(entry.transform)}'), which this runtime ` + + 'does not execute. There is no transformation-function registry anywhere in the ' + + 'platform, so the key would be parsed and ignored — the declared-but-inert state this ' + + 'gate exists to end. A mapping entry MOVES and RENAMES fields by dot path and nothing ' + + 'more: drop `transform`, or shape the value where it is produced (a flow endpoint whose ' + + 'flow computes the value, or a formula field on the object). A function-valued ' + + 'transform needs a registry and a sandbox ruling of its own before it can be declared.', + }; + } + + const sourceSegments = splitMappingPath(entry.source); + if (!sourceSegments) { + return { + path: at(key, i, 'source'), + message: + `${named} declares ${where}.source '${String(entry.source)}', which is not a usable field ` + + `path. ${MAPPING_PATH_HINT}`, + }; + } + + const targetSegments = splitMappingPath(entry.target); + if (!targetSegments) { + return { + path: at(key, i, 'target'), + message: + `${named} declares ${where}.target '${String(entry.target)}', which is not a usable field ` + + `path. ${MAPPING_PATH_HINT}`, + }; + } + + const collision = seen.find( + (other) => isPathPrefix(other.segments, targetSegments) || isPathPrefix(targetSegments, other.segments), + ); + if (collision) { + return { + path: at(key, i, 'target'), + message: + `${named} declares ${where}.target '${entry.target}', which collides with ` + + `${key}[${collision.index}].target '${collision.path}'. Two mapping entries cannot write ` + + "the same target path, and neither can write INSIDE the other's ('x' and 'x.y'): one " + + 'would silently discard the other, so the declaration you read is not the projection ' + + 'you would get. Give each entry a distinct target.', + }; + } + + seen.push({ index: i, path: entry.target, segments: targetSegments }); + } + + return undefined; +} + +/** + * `inputMapping` on an operation that never reads a request body. + * + * PM ruling on #5111 (2026-08-04), same category as `cacheTtl` on a non-GET + * method: the declaration is legal to parse and provably inert, because + * `inputMapping` maps the REQUEST BODY (its own `.describe()`) and `find` / + * `get` / `delete` are served from `query` alone. "Declared, parsed, does + * nothing" is exactly the state this program removes. + */ +function inertInputMappingGate( + endpoint: ApiEndpoint, + named: string, + at: (...rest: (string | number)[]) => (string | number)[], +): EndpointGateIssue | undefined { + const entries = endpoint.inputMapping; + if (!Array.isArray(entries) || entries.length === 0) return undefined; + if (endpoint.type !== 'object_operation') return undefined; + + const operation = endpoint.objectParams?.operation; + if (!operation || !BODYLESS_OPERATIONS.has(operation)) return undefined; + + return { + path: at('inputMapping'), + message: + `${named} declares \`inputMapping\` on a '${operation}' object_operation, which never reads a ` + + 'request body — so every entry would be parsed and then do nothing. `inputMapping` maps the ' + + 'REQUEST BODY to internal params (its own vocabulary text); `find` takes its criteria from ' + + 'the query string and `get` / `delete` take the record id from `query.id`. Remove the key, ' + + 'or move the endpoint to an operation that carries a body (`create` / `update`). ' + + 'Same rule, same reason as `cacheTtl` on a non-GET endpoint: a declaration that cannot ' + + 'take effect is rejected instead of silently ignored.', + }; +} + +// ============================================================================ +// (e) Policy — ADR-0121 D6 + the E4 refusals +// ============================================================================ + +function policyGate( + endpoint: ApiEndpoint, + named: string, + at: (...rest: (string | number)[]) => (string | number)[], +): EndpointGateIssue | undefined { + const rateLimit = endpoint.rateLimit; + const armed = rateLimit?.enabled === true; + + // D6 — anonymous access requires an ARMED budget. `enabled` defaults to + // `false`, so a presence check would pass a budget that meters nothing. + if (endpoint.authRequired === false && !armed) { + return { + path: at('rateLimit'), + message: + `${named} declares \`authRequired: false\` without an ARMED rate limit. An anonymous ` + + 'endpoint is a free, unauthenticated execution entry point, so ADR-0121 D6 requires it to ' + + 'carry its own budget: declare `rateLimit: { enabled: true, windowMs: 60000, maxRequests: ' + + '100 }`. ' + + (rateLimit === undefined + ? 'No `rateLimit` is declared at all.' + : '`enabled` is not `true` — and it DEFAULTS to `false`, so writing only `windowMs` / ' + + '`maxRequests` declares a budget that meters nothing and the endpoint would be ' + + 'anonymous AND unmetered.') + + ' `authRequired` defaults to `true`; setting it to `false` is the only way to open an ' + + 'endpoint, and arming the budget is the paired obligation.', + }; + } + + if (armed) { + const { maxRequests, windowMs } = rateLimit; + if (typeof maxRequests === 'number' && maxRequests <= 0) { + return { + path: at('rateLimit', 'maxRequests'), + message: + `${named} declares \`rateLimit.enabled: true\` with \`maxRequests: ${maxRequests}\`, a budget ` + + 'that can never be honoured: a zero-or-negative allowance rejects every request, ' + + 'including your own health checks. Set `maxRequests` above 0, or turn metering off with ' + + '`enabled: false`. (The runtime fails closed on this shape — every request to the ' + + 'endpoint would error — because "the author asked for metering and got none" is the ' + + 'worse outcome.)', + }; + } + if (typeof windowMs === 'number' && windowMs <= 0) { + return { + path: at('rateLimit', 'windowMs'), + message: + `${named} declares \`rateLimit.enabled: true\` with \`windowMs: ${windowMs}\`, which is not a ` + + 'usable window. `windowMs` is the budget window in MILLISECONDS — 60000 is one minute. ' + + 'Set it above 0, or turn metering off with `enabled: false`.', + }; + } + } + + const cacheTtl = endpoint.cacheTtl; + if (typeof cacheTtl === 'number' && cacheTtl < 0) { + return { + path: at('cacheTtl'), + message: + `${named} declares \`cacheTtl: ${cacheTtl}\`. A response cache lifetime cannot be negative — ` + + 'seconds only, 0 or more. Use a positive number of seconds for a cacheable answer, or ' + + '`cacheTtl: 0` to say explicitly "never store this response" (it emits ' + + '`Cache-Control: no-store`); omit the key to send no caching header at all.', + }; + } + + if (cacheTtl !== undefined && endpoint.method !== 'GET') { + return { + path: at('cacheTtl'), + message: + `${named} declares \`cacheTtl\` on a ${endpoint.method} endpoint. \`cacheTtl\` is GET-only ` + + '(#5040 §3.3): it becomes a `Cache-Control` header on a successful response, and a ' + + 'non-GET answer is not a cacheable representation, so the key would be parsed and never ' + + 'take effect. Remove `cacheTtl`, or declare the endpoint as GET if it really is a read.', + }; + } + + return undefined; +} + +// ============================================================================ +// (d) Uniqueness — one stack, one claim per METHOD + normalized path +// ============================================================================ + +function uniquenessGate( + endpoint: ApiEndpoint, + named: string, + at: (...rest: (string | number)[]) => (string | number)[], + claims: Map, +): EndpointGateIssue | undefined { + const key = claimKey(endpoint); + const first = claims.get(key); + if (!first) return undefined; + + return { + path: at('path'), + message: + `${named} claims ${endpoint.method} ${endpoint.path}, already claimed by endpoint ` + + `'${first.name}' (apis[${first.index}]). One stack cannot declare the same METHOD and path ` + + 'twice: the matcher indexes exactly one endpoint per claim, so only the lexicographically ' + + 'first name would ever serve and the other would be dead metadata. Paths are compared with ' + + 'one trailing slash trimmed, the same rule the matcher applies — \'/x\' and \'/x/\' are the ' + + 'same claim. Give one of them a different path or method, or delete the one you do not want.', + }; +} diff --git a/packages/spec/src/api/endpoint.zod.ts b/packages/spec/src/api/endpoint.zod.ts index cabd4f7c96..a3a5087336 100644 --- a/packages/spec/src/api/endpoint.zod.ts +++ b/packages/spec/src/api/endpoint.zod.ts @@ -48,6 +48,34 @@ export const ApiEndpointSchema = z.object({ cacheTtl: z.number().optional().describe('Response cache TTL in seconds'), }); +/** + * The canonical form of an endpoint `path` — exactly ONE trailing slash + * trimmed, never from a lone `/`. + * + * Declared here, with the vocabulary, because two independent consumers must + * agree on it byte for byte or a declaration passes one and fails the other: + * + * - the **matcher** (`packages/metadata/src/endpoint-matcher.ts`) normalizes + * both the stored declaration and the request path with it, so a request for + * `/x/` reaches an endpoint declared as `/x`; + * - the **publish gate** (#5040 E7) compares declarations with it to reject + * two endpoints in one stack claiming the same METHOD + path. + * + * Were the gate to normalize differently, a stack could publish two endpoints + * the matcher then treats as one — and the loser would be dead metadata that + * passed validation. Trimming ONE (not all) keeps `/x//` and `/x/` distinct, + * matching how every router in this stack treats an empty path segment; keeping + * a lone `/` whole means the normalized form is still a legal + * `ApiEndpointSchema.path`. Nothing else happens: no percent-decoding, no + * Unicode normalization, no case folding (an open vocabulary question, #5040 + * §7-5 — not something for an implementation to settle). + */ +export function normalizeEndpointPath(path: string): string { + const raw = String(path ?? ''); + if (raw.length > 1 && raw.endsWith('/')) return raw.slice(0, -1); + return raw; +} + export const ApiEndpoint = Object.assign(ApiEndpointSchema, { create: >(config: T) => config, }); diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index fbe4d74668..4f7257ac84 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -859,7 +859,22 @@ const step17: MigrationStep = { + '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.', + + 'guidance inside a union arm never reaches the author at all.\n\n' + + '⚠️ 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//' + + '` (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.', conversionIds: [ 'action-execute-to-target', 'field-conditionalRequired-to-requiredWhen', @@ -1528,6 +1543,55 @@ const step17: MigrationStep = { + 'already only building an object. Boot-time composition through `defineStack` is ' + 'unchanged.', }, + { + id: 'declarative-apis-endpoints-live', + surface: 'stack.apis[] (every declared ApiEndpoint — REVIEW REQUIRED BEFORE UPGRADING)', + replacement: + 'the same declarations, re-read as LIVE HTTP routes: `path` moved under ' + + '`/api/v1/apps//`, and every entry that declares ' + + '`authRequired: false` re-confirmed as an intentionally anonymous endpoint carrying ' + + '`rateLimit: { enabled: true, … }`', + reason: + '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//…`), 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.', + acceptanceCriteria: + '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//` 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.', + }, ], }; diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index fddd0040b5..a65cb33b84 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -40,7 +40,8 @@ import { PermissionSetSchema } from './security/permission.zod'; import { CapabilityDeclarationSchema } from './security/capabilities'; import { SharingRuleSchema } from './security/sharing.zod'; -import { ApiEndpointSchema } from './api/endpoint.zod'; +import { ApiEndpointSchema, type ApiEndpoint } from './api/endpoint.zod'; +import { validateApiEndpointDeclarations } from './api/endpoint-publish-gate'; import { retiredKey } from './shared/retired-key'; // AI Protocol @@ -125,33 +126,35 @@ export const DatasourceMappingRuleSchema = lazySchema(() => z.object({ export type DatasourceMappingRule = z.infer; /** - * The prescription raised when a stack declares a non-empty `apis:` (#4936). + * Raise every `apis:` publish-gate failure as a Zod issue (#5040 E7). * - * This string IS the migration doc for whoever hits it — very often an AI - * (ADR-0033) — so it states the fact, the fix, and the tracking issue that - * will make the key writable again. Same posture as a `retiredKey()` - * tombstone, except the key is NOT retired: the vocabulary stays, only - * authoring it is refused while no executor exists. + * The #4936 blanket refusal that used to live here — a `.max(0)` on `apis` + * whose error message told the author to delete their endpoints — is GONE, and + * this is what replaced it. Its premise was that nothing executed a declared + * endpoint; the #5040 E-series built the executor (mount seam, matcher, policy + * keys, execution targets, mapping keys), so the refusal would now be the lie + * in the other direction. What survives is the part that was always right: + * a declaration this runtime cannot serve is REFUSED, loudly and with a + * prescription, never parsed into silence. * - * Deliberately module-local: a rejection that a later release deletes should - * not first become a public export other packages can start depending on. + * It hangs off the whole stack object rather than the `apis` field because two + * of the gates are cross-field: the namespace carve-out is derived from + * `manifest.namespace` (ADR-0121 D2), and uniqueness is a property of the set. + * Placing it on the SCHEMA — not inside `defineStack` — is what keeps it + * unavoidable: `defineStack`, `os validate`, the lint scorer, the metadata + * plugin's artifact ingestion and `EnvironmentArtifactSchema.metadata` all run + * through this one parse, so no publish path can forget to check. */ -const APIS_NO_EXECUTOR_GUIDANCE = - '`apis:` (declarative ApiEndpoint) is DECLARED BUT NOT EXECUTABLE in this runtime, so a ' - + 'non-empty array is rejected instead of silently accepted (#4936). Nothing mounts the ' - + 'declared `path`, no endpoint matcher exists, and therefore NO key on the endpoint takes ' - + 'effect — `authRequired` included, which would parse green while gating nothing. ' - + 'Fix: delete the `apis:` entries (an empty array or an absent key is fine). To serve the ' - + 'route today, mount it in code — a plugin manifest `contributes.routes` entry or an ' - + '`http.server` route — which is the path the showcase now uses. ' - + 'The `ApiEndpoint` vocabulary is deliberately KEPT: the executor (mounting + endpoint ' - + 'matching + per-key wiring for authRequired/cacheTtl/inputMapping/outputMapping/rateLimit) ' - + 'is tracked by https://github.com/objectstack-ai/objectstack/issues/5040, and this ' - + 'rejection is replaced by real execution there — so keep your definitions, do not ' - + 'redesign around the refusal. One thing WILL change when they come back: ADR-0121 D1 ' - + 'namespaces endpoint paths as `/apps//`, so a path ' - + 'like `/api/v1/my/thing` becomes `/api/v1/apps//thing`. Everything ' - + 'else about the endpoint is unchanged.'; +function applyApiEndpointGates( + config: { manifest?: { namespace?: string | undefined } | undefined; apis?: ApiEndpoint[] | undefined }, + ctx: z.RefinementCtx, +): void { + for (const issue of validateApiEndpointDeclarations(config.apis, { + namespace: config.manifest?.namespace, + })) { + ctx.addIssue({ code: 'custom', path: issue.path, message: issue.message }); + } +} /** * ObjectStack Ecosystem Definition @@ -291,27 +294,41 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ sharingRules: z.array(SharingRuleSchema).optional().describe('Record Sharing Rules'), /** - * ObjectAPI: API Layer + * ObjectAPI: API Layer — the platform's OUTWARD integration face. + * + * ⚠️ **Declared endpoints are LIVE from protocol 17** (#5040). Between #4936 + * and the executor landing, a non-empty `apis:` was refused wholesale because + * nothing executed it; that refusal is now narrowed to a per-endpoint gate, + * and an endpoint that passes it **serves requests as soon as it is + * published**. Read `authRequired: false` on any entry as what it is: an + * anonymous, internet-reachable execution entry point (ADR-0121 D6 makes an + * armed `rateLimit` its paired obligation). * - * ⚠️ **A non-empty `apis:` is REJECTED in v17** (#4936, maintainer verdict - * 2026-08-04). The vocabulary below is deliberately KEPT — endpoint shapes - * are an industry-stable form and retiring one only to re-introduce the same - * thing later is churn — but this runtime has no executor for it, so - * declaring an endpoint is refused rather than parsed into silence. + * Each entry must satisfy every gate below or publish/validate fails naming + * that endpoint and that key — the runtime executes exactly the set that + * passes, so `declared = enforced` holds in both directions: * - * Why refusing beats accepting: the whole surface was zero-execution end to - * end. Nothing mounted the declared `path`, `matchEndpoint` had no - * implementation anywhere in the repo, and every key was therefore - * declared ≠ enforced — `authRequired: true` included, which is a SECURITY - * semantic that parsed green and gated nothing. Accepting that metadata is - * the false-compliance failure ADR-0049 exists to stop; refusing it is the - * only honest state until {@link https://github.com/objectstack-ai/objectstack/issues/5040 #5040} - * lands the executor and turns this rejection back into execution. + * 1. **Namespace** (ADR-0121 D1/D2) — `path` must be + * `/api/v1/apps//`. No built-in domain lives + * under `apps/`, and two packages cannot collide because their namespaces + * differ, so route ownership is structural rather than a maintained list. + * 2. **Supported subset** — only `object_operation` (with both + * `objectParams.object` and `.operation`) and `flow` (with a `target`) + * execute in 17.x; `script` and `proxy` are refused pending their own + * rulings, and mapping `transform` is refused because no transformation + * registry exists. + * 3. **Policy** — `authRequired: false` requires `rateLimit.enabled: true`; + * an armed budget must be usable; `cacheTtl` must be non-negative and + * GET-only. + * 4. **Uniqueness** — one claim per METHOD + path inside a stack. + * + * Which channel: a caller INSIDE the platform (a session, the UI, AI/MCP, the + * SDK) invokes an `action`; a caller OUTSIDE it (a partner system, an + * inbound webhook) reaches an `apis:` endpoint (ADR-0121 D3). */ apis: z.array(ApiEndpointSchema) - .max(0, { error: () => APIS_NO_EXECUTOR_GUIDANCE }) .optional() - .describe('API Endpoints — vocabulary retained, but a non-empty array is REJECTED until the executor ships (#4936 → #5040)'), + .describe('API Endpoints — declared endpoints are live from protocol 17; each is gated at publish (ADR-0121, #5040)'), webhooks: z.array(WebhookSchema).optional().describe('Outbound Webhooks'), /** @@ -580,7 +597,7 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ * @example "./objectstack-runtime.7a70cd6576d17ff6.mjs" */ runtimeModule: z.string().optional().describe('Path (relative to the artifact JSON) of the compiled runtime ESM bundle. Set by `objectstack build`; do not author by hand.'), -})); +}).superRefine(applyApiEndpointGates)); export type ObjectStackDefinition = z.infer;