From 3ad7e13901fe772a2b7a8462542e8b3bbb49daf6 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 30 Jun 2026 12:21:20 -0300 Subject: [PATCH 01/12] feat(api): add experimental API instance Adds an `experimental` APIClass instance mounted (via createApi) at `/api/experimental/`, alongside the existing `v1` and `default` instances. Typed as APIClass<'/experimental'> so typed route methods resolve correctly. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/meteor/server/api/api.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/meteor/server/api/api.ts b/apps/meteor/server/api/api.ts index 7d59683f07f8c..d5ed68ca01c92 100644 --- a/apps/meteor/server/api/api.ts +++ b/apps/meteor/server/api/api.ts @@ -42,6 +42,7 @@ const createApi = function _createApi(options: { version?: string; useDefaultAut export const API: { api: Router<'/api', any, APIActionHandler>; v1: APIClass<'/v1'>; + experimental: APIClass<'/experimental'>; default: APIClass; ApiClass: typeof APIClass; channels?: { @@ -73,6 +74,10 @@ export const API: { version: 'v1', useDefaultAuth: true, }), + experimental: createApi({ + version: 'experimental', + useDefaultAuth: true, + }), default: createApi({}), }; From cb4c2629345d787088747a3869e043f4e1eb174b Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 30 Jun 2026 12:22:25 -0300 Subject: [PATCH 02/12] feat(api): mount experimental router and metrics Mounts API.experimental.router into the request pipeline, before the default catch-all router. Adds a dedicated metricsMiddleware block keyed on the experimental base path so experimental traffic is recorded in the REST API Prometheus metrics with a distinguishable `version=experimental` label. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/meteor/server/api/api.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/apps/meteor/server/api/api.ts b/apps/meteor/server/api/api.ts index d5ed68ca01c92..a3c94817dfa6b 100644 --- a/apps/meteor/server/api/api.ts +++ b/apps/meteor/server/api/api.ts @@ -118,11 +118,23 @@ export const startRestAPI = () => { activeRequestsGauge: metrics.rocketchatRestApiActiveRequests, }), ) + .use( + metricsMiddleware({ + basePathRegex: new RegExp(/^\/api\/experimental\//), + api: API.experimental, + settings, + endpointTimeSummary: metrics.rocketchatRestApi, + endpointTimeHistogram: metrics.rocketchatRestApiSeconds, + responseSizeHistogram: metrics.rocketchatRestApiResponseSizeBytes, + activeRequestsGauge: metrics.rocketchatRestApiActiveRequests, + }), + ) .use(tracerSpanMiddleware) .use(remoteAddressMiddleware) .use(cors(settings)) .use(loggerMiddleware(logger)) .use(API.v1.router) + .use(API.experimental.router) .use(API.default.router).router, ); }; From 413ffd4476a1323a3ed3b5d3f1a04c6d4f49ea79 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 30 Jun 2026 12:24:19 -0300 Subject: [PATCH 03/12] feat(api): add experimental unstable-signal middleware Adds a middleware that stamps every experimental response with `x-experimental: true` and a `Warning: 299 ...` header, mirroring the deprecation-header pattern. Registered on API.experimental only (at module load, before any route is added) so /api/v1/* and default responses are unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/meteor/server/api/api.ts | 6 +++++ .../server/api/v1/middlewares/experimental.ts | 22 +++++++++++++++++++ 2 files changed, 28 insertions(+) create mode 100644 apps/meteor/server/api/v1/middlewares/experimental.ts diff --git a/apps/meteor/server/api/api.ts b/apps/meteor/server/api/api.ts index a3c94817dfa6b..412eec4a67f83 100644 --- a/apps/meteor/server/api/api.ts +++ b/apps/meteor/server/api/api.ts @@ -9,6 +9,7 @@ import { type APIActionHandler, RocketChatAPIRouter } from './router'; import { metrics } from '../lib/metrics'; import { settings } from '../settings'; import { cors } from './v1/middlewares/cors'; +import { experimentalWarningMiddleware } from './v1/middlewares/experimental'; import { loggerMiddleware } from './v1/middlewares/logger'; import { metricsMiddleware } from './v1/middlewares/metrics'; import { remoteAddressMiddleware } from './v1/middlewares/remoteAddressMiddleware'; @@ -81,6 +82,11 @@ export const API: { default: createApi({}), }; +// Stamp the unstable-signal headers on every experimental response. Registered +// here, at module load, so it precedes any endpoint route registered later on +// API.experimental (Hono runs `.use` middleware in registration order). +API.experimental.router.use(experimentalWarningMiddleware()); + settings.watch('Accounts_CustomFields', (value) => { if (!value) { return API.v1?.setLimitedCustomFields([]); diff --git a/apps/meteor/server/api/v1/middlewares/experimental.ts b/apps/meteor/server/api/v1/middlewares/experimental.ts new file mode 100644 index 0000000000000..626239cf3e28d --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/experimental.ts @@ -0,0 +1,22 @@ +import type { MiddlewareHandler } from 'hono'; + +// RFC 7234 "miscellaneous persistent warning" code. Paired with the +// programmatic-friendly `x-experimental` header so clients can detect, in code, +// that they hit an unstable endpoint. +const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"'; + +/** + * Stamps every response from the experimental API instance with the unstable + * signal headers. Registered on `API.experimental` only — `/api/v1/*` and the + * default router never see these headers. + * + * Mirrors the header-writing pattern of `writeDeprecationHeader` in + * `deprecationWarningLogger.ts`: the headers are set on `c.res.headers` before + * the route handler runs so they are picked up when the handler builds the + * final response (see `Router.method` in `@rocket.chat/http-router`). + */ +export const experimentalWarningMiddleware = (): MiddlewareHandler => async (c, next) => { + c.res.headers.set('x-experimental', 'true'); + c.res.headers.set('Warning', WARNING_HEADER); + await next(); +}; From fdabf2b0390250b8a13161048fead99ccb581818 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 30 Jun 2026 12:28:30 -0300 Subject: [PATCH 04/12] feat(rest-typings): add opt-in ExperimentalEndpoints Declares an `ExperimentalEndpoints` type in a new experimental/ folder and re-exports it from the package root. It is intentionally kept out of the `Endpoints` union so PathPattern/Method/Path and the stable typed client surface stay free of experimental paths; consumers opt in by importing `ExperimentalEndpoints` explicitly. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../rest-typings/src/experimental/index.ts | 27 +++++++++++++++++++ packages/rest-typings/src/index.ts | 4 +++ 2 files changed, 31 insertions(+) create mode 100644 packages/rest-typings/src/experimental/index.ts diff --git a/packages/rest-typings/src/experimental/index.ts b/packages/rest-typings/src/experimental/index.ts new file mode 100644 index 0000000000000..6cca034a56677 --- /dev/null +++ b/packages/rest-typings/src/experimental/index.ts @@ -0,0 +1,27 @@ +/** + * Opt-in typings for experimental REST endpoints (`/api/experimental/...`). + * + * These are intentionally **not** merged into the `Endpoints` union exported + * from the package root, so the stable typed client surface (`PathPattern`, + * `Method`, `Path`, the SDK) stays free of unstable paths. Consumers who want + * typed experimental calls import `ExperimentalEndpoints` explicitly. + * + * Endpoints under this namespace carry **no semver promise**: they may change + * shape or be removed in any release without a deprecation cycle. See + * `docs/experimental-api-endpoints.md`. + * + * Declare new experimental endpoints here, following the per-resource style of + * the `/v1` endpoint types (e.g. `v1/calendar`). Every path key must begin with + * `/experimental/`. For example: + * + * ```ts + * export type ExperimentalEndpoints = { + * '/experimental/example.info': { + * GET: (params: { id: string }) => { id: string; value: number }; + * }; + * }; + * ``` + */ +export type ExperimentalEndpoints = { + // No experimental endpoints are currently declared. +}; diff --git a/packages/rest-typings/src/index.ts b/packages/rest-typings/src/index.ts index c5798ee9a7743..426d1c4c46ac5 100644 --- a/packages/rest-typings/src/index.ts +++ b/packages/rest-typings/src/index.ts @@ -271,5 +271,9 @@ export * from './v1/banners'; export * from './default'; export * from './v1/twoFactorChallenges'; +// Opt-in experimental endpoint typings. Deliberately NOT part of the `Endpoints` +// union above — see ./experimental for the rationale. +export type * from './experimental'; + // Export the ajv instance for use in other packages export * from './v1/Ajv'; From ea979f109eec9881789fbf2e721eb2b780ceb2d1 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Tue, 30 Jun 2026 12:31:25 -0300 Subject: [PATCH 05/12] chore(api): add experimental guardrails and docs - CI guard: a type-level assertion in rest-typings fails `yarn typecheck` if any path key is declared in both ExperimentalEndpoints and the stable Endpoints union, catching accidental promotion-by-copy-paste. - Docs: add the developer guide (contract, decision guide, lifecycle, promotion path) and the design/implementation plan, and document the guardrails plus the deliberate decision to keep experimental endpoints out of generated API docs. Co-Authored-By: Claude Opus 4.8 (1M context) --- docs/experimental-api-endpoints-plan.md | 183 ++++++++++++++++++ docs/experimental-api-endpoints.md | 116 +++++++++++ .../noOverlapWithStableEndpoints.ts | 23 +++ 3 files changed, 322 insertions(+) create mode 100644 docs/experimental-api-endpoints-plan.md create mode 100644 docs/experimental-api-endpoints.md create mode 100644 packages/rest-typings/src/experimental/noOverlapWithStableEndpoints.ts diff --git a/docs/experimental-api-endpoints-plan.md b/docs/experimental-api-endpoints-plan.md new file mode 100644 index 0000000000000..f5a79d4441b86 --- /dev/null +++ b/docs/experimental-api-endpoints-plan.md @@ -0,0 +1,183 @@ +# Plan: Experimental REST API endpoints + +## Goal + +Allow REST endpoints to ship to production but **change or be removed in any release +without a major-version bump**. The mechanism must be general-purpose (any team can +use it), not specific to one feature. + +## The contract + +> Endpoints under `/api/experimental/...` are unstable. They may change shape or be +> removed in any release, without notice and without a deprecation cycle. No semver +> promise attaches to this namespace. + +The namespace **is** the contract. A caller hitting `/api/experimental/*` has opted +into instability by the URL alone. `/v1` keeps its implicit semver-stability promise, +untouched. + +**Only the new typed API is allowed on experimental routes.** Endpoints must be +registered with `.get()` / `.post()` / `.put()` / `.delete()` (with AJV `body` / `query` +/ `response` validators). The deprecated `.addRoute()` is **not** available for +experimental endpoints — new surface area should not be born on the legacy registration +path. + +## Why this design (key findings from the current code) + +- `createApi({ version })` turns the `version` string into the URL path segment, so a + new instance with `version: 'experimental'` mounts at `/api/experimental/` + with zero router changes. See `apps/meteor/app/api/server/api.ts:33-77` and + `ApiClass.ts:199`. +- The typed route methods `.get()/.post()/.put()/.delete()` are generic over + `TSubPathPattern extends string` (`ApiClass.ts:669`) — they are **not** gated on + `keyof Endpoints`. Routes accumulate *outward* into the instance's `TOperations`, + read back by `ExtractApiClassEndpoints` (`ApiClass.ts:125-126`). So a separate + experimental instance works *with* the type system. +- `PathPattern`, `Method`, `Path`, and the typed client are all derived from the + `Endpoints` interface (`packages/rest-typings/src/index.ts:48-120`). Keeping + experimental paths **out** of that interface keeps the stable client surface clean + and forces explicit opt-in for experimental ones. +- The deprecation framework already writes `x-deprecation-*` response headers + (`apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts:13-19`). We mirror that + pattern for an `x-experimental` / `Warning` signal. +- Auth, permissions, rate limiting, CORS, AJV validation, and metrics all come from + `createApi` + the middleware chain in `startRestAPI` — experimental endpoints get + them for free. + +--- + +## Commit constraint + +**Each implementation step below ships as exactly one commit.** No step is split across +multiple commits, and no commit spans more than one step. This keeps the history +bisectable, makes each phase independently reviewable and revertable, and maps the PR +review 1:1 onto the plan. + +- A step's commit must leave the tree in a compiling, lint-clean state (`yarn lint + --quiet` passes) — partial work is squashed before committing. +- Commit message subject names the step, e.g. `feat(api): add experimental API instance + (step 1)`. +- If a step turns out to require a prerequisite not in the plan, add it to that step's + single commit rather than introducing an out-of-band commit. + +## Implementation steps + +### Step 1 — Add the `experimental` API instance + +**File:** `apps/meteor/app/api/server/api.ts` + +1. In the `API` object literal (around line 69-77), add: + ```ts + experimental: createApi({ version: 'experimental', useDefaultAuth: true }), + ``` + Place it between `v1` and `default`. +2. Add `experimental: APIClass<'/experimental'>;` to the `API` type annotation + (around line 42-68) so it is typed. +3. If any `settings.watch(...)` callbacks need to refresh experimental routes the way + they refresh `API.v1` (rate limiter reloads at lines 92-100, custom fields at + 79-90), add the matching `API.experimental?.…` calls. Optional for v1 of this work. + +**Acceptance:** `API.experimental.get('ping', { ... }, handler)` compiles and serves at +`GET /api/experimental/ping`. + +**Commit (1 of 5):** `feat(api): add experimental API instance` + +### Step 2 — Mount it in the request pipeline + +**File:** `apps/meteor/app/api/server/api.ts`, `startRestAPI` (lines 102-123) + +1. Insert `.use(API.experimental.router)` into the chain, **before** + `.use(API.default.router)` (line 121). Order matters: `default` is the catch-all. +2. Extend the metrics middleware `basePathRegex` (line 107) so experimental traffic is + measured. Either broaden the regex to `^\/api\/(v1|experimental)\//` or add a second + `metricsMiddleware` block pointed at `API.experimental`. Metrics are the canary used + later to decide whether an endpoint is ready for promotion to `/v1`. + +**Acceptance:** experimental requests appear in the REST API Prometheus metrics with a +distinguishable path/label. + +**Commit (2 of 5):** `feat(api): mount experimental router and metrics` + +### Step 3 — Runtime "unstable" signal (mirror deprecation headers) + +**New file:** `apps/meteor/app/api/server/middlewares/experimental.ts` (or colocate with +existing middlewares under `apps/meteor/app/api/server/middlewares/`). + +1. Write a middleware that sets, on every response from the experimental instance: + ``` + Warning: 299 - "experimental: endpoint is unstable and may change without notice" + x-experimental: true + ``` + `Warning: 299` is the RFC 7234 "miscellaneous persistent warning" code; `x-experimental` + is the easy programmatic check. Model the header-writing on + `writeDeprecationHeader` in `deprecationWarningLogger.ts:13-19`. +2. Register the middleware on `API.experimental` only (not on `v1`/`default`). + +**Acceptance:** every `/api/experimental/*` response carries both headers; `/api/v1/*` +responses do not. + +**Commit (3 of 5):** `feat(api): add experimental unstable-signal middleware` + +### Step 4 — Separate, opt-in SDK typings + +**File:** `packages/rest-typings/src/index.ts` (+ a new file for the declarations) + +1. Create `packages/rest-typings/src/experimental/index.ts` (new folder) and declare: + ```ts + export type ExperimentalEndpoints = { + '/experimental/': { + GET: (params: ...) => ...; + }; + // ... + }; + ``` + Follow the existing per-resource endpoint style (e.g. + `packages/rest-typings/src/v1/channels/channels.ts`). +2. Export `ExperimentalEndpoints` from the package root, but **do NOT** add it to the + `interface Endpoints extends ...` union (`index.ts:48-93`). This keeps `PathPattern`, + `Method`, `Path`, and the stable typed client free of experimental paths. +3. Consumers who want typed experimental calls import `ExperimentalEndpoints` + explicitly. + +**Acceptance:** `import type { Endpoints } from '@rocket.chat/rest-typings'` does NOT +include experimental paths; `import type { ExperimentalEndpoints }` does. + +**Commit (4 of 5):** `feat(rest-typings): add opt-in ExperimentalEndpoints` + +### Step 5 — Guardrails (because it is a general mechanism) + +1. **CI/lint guard:** add a check (script or eslint rule) asserting no path key present + in `ExperimentalEndpoints` is also present in `Endpoints`. This catches accidental + "promotion by copy-paste" that would silently create a semver obligation. +2. **Promotion path:** document that stabilizing an endpoint means copying it to `/v1` + (optionally keeping the experimental path forwarding for a transition window). + Removal needs no deprecation cycle — but log removals for courtesy. +3. **Docs/CONTRIBUTING note:** state the no-semver guarantee and how to add an + experimental endpoint, so the mechanism is discoverable. +4. **OpenAPI/doc generation:** decide deliberately whether generated API docs scan only + `Endpoints` (experimental endpoints hidden — probably desirable) or also + `ExperimentalEndpoints`. + +**Commit (5 of 5):** `chore(api): add experimental guardrails and docs` + +--- + +## Test checklist + +- [ ] `GET /api/experimental/` resolves and returns the `x-experimental` + `Warning` headers. +- [ ] `/api/v1/*` responses are unchanged (no experimental headers). +- [ ] Auth / permissions / rate limiting enforced on an experimental route exactly as on `/v1`. +- [ ] `Endpoints` type does not include experimental paths; `ExperimentalEndpoints` does. +- [ ] CI guard fails if a path appears in both unions. +- [ ] Experimental requests show up in REST API metrics. + +## Files touched (summary) + +| File | Change | +| ---- | ------ | +| `apps/meteor/app/api/server/api.ts` | Add `experimental` instance, type entry, mount in `startRestAPI`, metrics regex | +| `apps/meteor/app/api/server/middlewares/experimental.ts` (new) | `x-experimental` / `Warning` header middleware | +| `packages/rest-typings/src/experimental/index.ts` (new) | `ExperimentalEndpoints` type, NOT merged into `Endpoints` | +| `packages/rest-typings/src/index.ts` | Export `ExperimentalEndpoints` | +| CI/lint config | Guard: no path in both `Endpoints` and `ExperimentalEndpoints` | +| docs / CONTRIBUTING | Document the contract + promotion path | diff --git a/docs/experimental-api-endpoints.md b/docs/experimental-api-endpoints.md new file mode 100644 index 0000000000000..a7b1468851606 --- /dev/null +++ b/docs/experimental-api-endpoints.md @@ -0,0 +1,116 @@ +# Experimental REST API endpoints + +> Developer guide. For how the mechanism is built, see +> [experimental-api-endpoints-plan.md](experimental-api-endpoints-plan.md). + +## What they are + +Experimental endpoints live under `/api/experimental/...` and carry an explicit +stability contract: + +> Endpoints under `/api/experimental/...` are **unstable**. They may change shape or be +> removed in **any** release — without notice and without a deprecation cycle. No semver +> promise attaches to this namespace. + +Compare with `/api/v1/...`, which is the official, stable surface: its endpoints follow +semver, breaking changes require a major-version bump, and removals go through a +deprecation cycle. + +## Why they exist + +We sometimes need to ship something to production *before* its API shape has settled: + +- A new feature whose request/response contract is still being learned from real usage. +- An endpoint built for a specific client (e.g. our own UI) where we are not yet ready + to commit to it as a public, supported interface. +- Something we want behind a clear "use at your own risk" sign while it matures. + +Without an experimental lane, the only choices are bad ones: either freeze a design we +are not confident in onto `/v1` (and then carry it forever, or break it with a major +bump), or keep the feature out of production until the API is perfect. Experimental +endpoints give a third path — ship now, iterate freely, commit later. + +## Should I use one? — decision guide + +**Use an experimental endpoint when:** + +- The request/response shape is likely to change as the feature matures. +- You want production traffic / real feedback before committing to a contract. +- The consumer is internal or opted-in, and can tolerate breaking changes between + releases. +- You would otherwise be tempted to "just put it on `/v1` for now and fix it later." + +**Do NOT use an experimental endpoint when:** + +- The endpoint is meant for third-party integrators who expect stability. They should + not have to track breakage release-to-release. +- The contract is already well understood and unlikely to change — put it on `/v1`. +- You are tempted to use `experimental` as a permanent home to avoid the discipline of + a stable API. It is a staging area, not a dumping ground (see below). + +## Expectations if you publish one + +- **It is not forever.** Every experimental endpoint is expected to either be + **elevated to `/v1`** once its contract stabilizes, or be **removed** if it does not + pan out. An endpoint that sits in `experimental` indefinitely is a smell — it means a + decision is overdue. +- **Callers are warned at runtime.** Every experimental response carries + `x-experimental: true` and a `Warning: 299 ...` header. Clients can detect and surface + this. +- **Typed clients must opt in.** Experimental endpoints are declared in a separate + `ExperimentalEndpoints` type, not in the main `Endpoints` union, so the stable SDK + surface stays honest. Consumers import them deliberately. +- **Use the typed API only.** Register with `.get()` / `.post()` / `.put()` / + `.delete()` and AJV validators — `.addRoute()` is not available for experimental + routes. + +## Lifecycle: experimental → official + +``` + stabilizes + experimental ───────────────▶ v1 (official, semver-stable) + (/api/experimental/x) (/api/v1/x) + │ + │ does not pan out + ▼ + removed (no deprecation cycle needed) +``` + +**Elevating to `/v1`:** + +1. Confirm the contract is stable and you are ready to support it under semver. +2. Add the endpoint under `/v1`: register it on `API.v1` and declare its types in the + appropriate `*Endpoints` type that *is* part of the `Endpoints` union. +3. Optionally keep the experimental path forwarding to the new `/v1` path for a + transition window so existing callers are not broken on the day of promotion. +4. Remove the experimental declaration once the transition window closes. + +**Removing an experimental endpoint** needs no deprecation cycle — that freedom is the +whole point of the namespace. Still, log the removal and give a heads-up to any known +consumers as a courtesy. + +## Guardrails & tooling + +- **No path lives in both unions.** A type-level guard + (`packages/rest-typings/src/experimental/noOverlapWithStableEndpoints.ts`) + fails `yarn typecheck` in CI if any path key is declared in both + `ExperimentalEndpoints` and the stable `Endpoints` union. This catches + "promotion by copy-paste" — a duplicate key that would silently attach a + semver obligation to a path advertised as unstable. Promotion means *moving* + the declaration to a stable `*Endpoints` type, not leaving a copy behind. +- **Generated API docs intentionally skip experimental endpoints.** OpenAPI / + doc generation scans the `Endpoints` union, which experimental paths are + deliberately kept out of, so they do not appear in public API docs. This is + by design: an unstable surface should not be advertised as part of the + documented contract. The runtime `x-experimental` / `Warning` headers and + this guide are how the namespace is surfaced instead. +- **Metrics are the promotion signal.** Experimental traffic is recorded in the + REST API Prometheus metrics under `version=experimental`, so real usage can + inform whether an endpoint is ready to graduate to `/v1` or should be removed. + +## TL;DR + +Experimental endpoints let you ship an API to production while its shape is still in +flux, without locking yourself into semver. They are a **staging area, not a permanent +home**: every one is expected to graduate to `/v1` or be removed. If you need stability +guarantees, use `/v1`. If a third party will depend on it, use `/v1`. diff --git a/packages/rest-typings/src/experimental/noOverlapWithStableEndpoints.ts b/packages/rest-typings/src/experimental/noOverlapWithStableEndpoints.ts new file mode 100644 index 0000000000000..de0755ab0de19 --- /dev/null +++ b/packages/rest-typings/src/experimental/noOverlapWithStableEndpoints.ts @@ -0,0 +1,23 @@ +/** + * CI guard (type-level): asserts that no path declared in `ExperimentalEndpoints` + * is also declared in the stable `Endpoints` union. + * + * Promoting an experimental endpoint to `/v1` means *copying* it into a stable + * `*Endpoints` type — it must never be left declared in both unions, which + * would silently attach a semver obligation to a path advertised as unstable. + * If that ever happens, `tsc` (run by `yarn typecheck` in CI) fails to compile + * this file, naming the offending path key(s) in the constraint error. + * + * This file declares only types — it emits no runtime code. + */ +import type { Endpoints } from '../index'; +import type { ExperimentalEndpoints } from './index'; + +type PathsDeclaredInBothUnions = Extract; + +// `T extends never` is only satisfiable when T *is* never. If any path key is +// shared between the two unions, `PathsDeclaredInBothUnions` is that union of +// keys (not `never`) and this alias fails to type-check. +type AssertNoOverlap = T; + +export type ExperimentalEndpointsDoNotOverlapStableEndpoints = AssertNoOverlap; From b2984fc460633b99bbb46f91c21405fd3afb525e Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 19 Aug 2026 15:32:49 -0300 Subject: [PATCH 06/12] apply rate limiters --- apps/meteor/server/api/api.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/apps/meteor/server/api/api.ts b/apps/meteor/server/api/api.ts index 412eec4a67f83..4c5af3f7ca82b 100644 --- a/apps/meteor/server/api/api.ts +++ b/apps/meteor/server/api/api.ts @@ -100,14 +100,19 @@ settings.watch('Accounts_CustomFields', (value) => { } }); +const reloadRoutesToRefreshRateLimiter = () => { + API.v1.reloadRoutesToRefreshRateLimiter(); + API.experimental.reloadRoutesToRefreshRateLimiter(); +}; + settings.watch('API_Enable_Rate_Limiter_Limit_Time_Default', (value) => { defaultRateLimiterOptions.intervalTimeInMS = value; - API.v1.reloadRoutesToRefreshRateLimiter(); + reloadRoutesToRefreshRateLimiter(); }); settings.watch('API_Enable_Rate_Limiter_Limit_Calls_Default', (value) => { defaultRateLimiterOptions.numRequestsAllowed = value; - API.v1.reloadRoutesToRefreshRateLimiter(); + reloadRoutesToRefreshRateLimiter(); }); export const startRestAPI = () => { From 712cfc5f6042f83006b5ec752e7e4200c8a98cba Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 19 Aug 2026 15:41:58 -0300 Subject: [PATCH 07/12] fix metrics getting doubled --- .../server/api/v1/middlewares/metrics.spec.ts | 87 +++++++++++++++++++ .../server/api/v1/middlewares/metrics.ts | 6 ++ 2 files changed, 93 insertions(+) diff --git a/apps/meteor/server/api/v1/middlewares/metrics.spec.ts b/apps/meteor/server/api/v1/middlewares/metrics.spec.ts index 87ed45e325128..7cabdc83f506a 100644 --- a/apps/meteor/server/api/v1/middlewares/metrics.spec.ts +++ b/apps/meteor/server/api/v1/middlewares/metrics.spec.ts @@ -199,4 +199,91 @@ describe('Metrics middleware', () => { entrypoint: 'method.call/get:param', }); }); + + it('should only record requests matching its own base path', async () => { + const ajv = new Ajv(); + const app = express(); + const settings = new CachedSettings(); + + const makeMetrics = () => { + const endTimer = jest.fn(); + return { + endTimer, + summary: { startTimer: jest.fn().mockReturnValue(endTimer) }, + histogram: { startTimer: jest.fn().mockReturnValue(jest.fn()) }, + responseSizeHistogram: { observe: jest.fn() }, + activeRequestsGauge: { inc: jest.fn(), dec: jest.fn() }, + }; + }; + + const v1Metrics = makeMetrics(); + const experimentalMetrics = makeMetrics(); + + const route = (router: Router) => + router.get( + '/test', + { + response: { + 200: ajv.compile({ + type: 'object', + properties: { + message: { type: 'string' }, + }, + }), + }, + }, + async () => ({ + statusCode: 200, + body: { message: 'Metrics test successful' }, + }), + ); + + const api = new Router('/api'); + + api + .use( + metricsMiddleware({ + basePathRegex: new RegExp(/^\/api\/v1\//), + api: { version: 'v1' } as any, + settings, + endpointTimeSummary: v1Metrics.summary as any, + endpointTimeHistogram: v1Metrics.histogram as any, + responseSizeHistogram: v1Metrics.responseSizeHistogram as any, + activeRequestsGauge: v1Metrics.activeRequestsGauge as any, + }), + ) + .use( + metricsMiddleware({ + basePathRegex: new RegExp(/^\/api\/experimental\//), + api: { version: 'experimental' } as any, + settings, + endpointTimeSummary: experimentalMetrics.summary as any, + endpointTimeHistogram: experimentalMetrics.histogram as any, + responseSizeHistogram: experimentalMetrics.responseSizeHistogram as any, + activeRequestsGauge: experimentalMetrics.activeRequestsGauge as any, + }), + ) + .use(route(new Router('/v1'))) + .use(route(new Router('/experimental'))); + + app.use(api.router); + + expect((await request(app).get('/api/v1/test')).statusCode).toBe(200); + + expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1); + expect(v1Metrics.endTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 'v1', entrypoint: 'test' }); + expect(experimentalMetrics.summary.startTimer).not.toHaveBeenCalled(); + expect(experimentalMetrics.activeRequestsGauge.inc).not.toHaveBeenCalled(); + + expect((await request(app).get('/api/experimental/test')).statusCode).toBe(200); + + expect(experimentalMetrics.summary.startTimer).toHaveBeenCalledTimes(1); + expect(experimentalMetrics.endTimer).toHaveBeenCalledWith({ + status: 200, + method: 'get', + version: 'experimental', + entrypoint: 'test', + }); + expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1); + }); }); diff --git a/apps/meteor/server/api/v1/middlewares/metrics.ts b/apps/meteor/server/api/v1/middlewares/metrics.ts index 22b5bb98aa76b..11d6103deaf34 100644 --- a/apps/meteor/server/api/v1/middlewares/metrics.ts +++ b/apps/meteor/server/api/v1/middlewares/metrics.ts @@ -23,6 +23,12 @@ export const metricsMiddleware = activeRequestsGauge: Gauge; }): MiddlewareHandler => async (c, next) => { + // Several metrics middlewares share the same `/api` mount (v1, experimental, apps), so each + // one has to ignore the paths that belong to the others or a request gets sampled more than once. + if (basePathRegex && !basePathRegex.test(c.req.path)) { + return next(); + } + const rocketchatRestApiEnd = endpointTimeSummary.startTimer(); const rocketchatRestApiHistEnd = endpointTimeHistogram.startTimer(); From fb5fe826790b0244770b38044059b23bae5b86c3 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 19 Aug 2026 16:10:45 -0300 Subject: [PATCH 08/12] omit addRoute from experimental --- apps/meteor/server/api/api.ts | 10 ++++++++-- apps/meteor/server/api/v1/middlewares/metrics.ts | 3 +-- docs/experimental-api-endpoints-plan.md | 6 +++--- docs/experimental-api-endpoints.md | 4 ++-- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/apps/meteor/server/api/api.ts b/apps/meteor/server/api/api.ts index 4c5af3f7ca82b..f93da560724c9 100644 --- a/apps/meteor/server/api/api.ts +++ b/apps/meteor/server/api/api.ts @@ -40,10 +40,16 @@ const createApi = function _createApi(options: { version?: string; useDefaultAut }); }; +/** + * The experimental namespace is typed-API only: the deprecated `addRoute()` is omitted from the + * surface so new endpoints cannot be born on the legacy registration path. + */ +export type ExperimentalAPI = Omit, 'addRoute'>; + export const API: { api: Router<'/api', any, APIActionHandler>; v1: APIClass<'/v1'>; - experimental: APIClass<'/experimental'>; + experimental: ExperimentalAPI; default: APIClass; ApiClass: typeof APIClass; channels?: { @@ -78,7 +84,7 @@ export const API: { experimental: createApi({ version: 'experimental', useDefaultAuth: true, - }), + }) as ExperimentalAPI, default: createApi({}), }; diff --git a/apps/meteor/server/api/v1/middlewares/metrics.ts b/apps/meteor/server/api/v1/middlewares/metrics.ts index 11d6103deaf34..62171bb8565ec 100644 --- a/apps/meteor/server/api/v1/middlewares/metrics.ts +++ b/apps/meteor/server/api/v1/middlewares/metrics.ts @@ -2,7 +2,6 @@ import type { MiddlewareHandler } from 'hono'; import type { Gauge, Histogram, Summary } from 'prom-client'; import type { CachedSettings } from '../../../settings/CachedSettings'; -import type { APIClass } from '../../ApiClass'; export const metricsMiddleware = ({ @@ -15,7 +14,7 @@ export const metricsMiddleware = activeRequestsGauge, }: { basePathRegex?: RegExp; - api: APIClass; + api: { version?: string }; settings: CachedSettings; endpointTimeSummary: Summary; endpointTimeHistogram: Histogram; diff --git a/docs/experimental-api-endpoints-plan.md b/docs/experimental-api-endpoints-plan.md index f5a79d4441b86..0f6ee260d1d39 100644 --- a/docs/experimental-api-endpoints-plan.md +++ b/docs/experimental-api-endpoints-plan.md @@ -18,9 +18,9 @@ untouched. **Only the new typed API is allowed on experimental routes.** Endpoints must be registered with `.get()` / `.post()` / `.put()` / `.delete()` (with AJV `body` / `query` -/ `response` validators). The deprecated `.addRoute()` is **not** available for -experimental endpoints — new surface area should not be born on the legacy registration -path. +/ `response` validators). The deprecated `.addRoute()` is **not exposed** on +`API.experimental` (its type omits the method) — new surface area should not be born on +the legacy registration path. ## Why this design (key findings from the current code) diff --git a/docs/experimental-api-endpoints.md b/docs/experimental-api-endpoints.md index a7b1468851606..21b44358648af 100644 --- a/docs/experimental-api-endpoints.md +++ b/docs/experimental-api-endpoints.md @@ -61,8 +61,8 @@ endpoints give a third path — ship now, iterate freely, commit later. `ExperimentalEndpoints` type, not in the main `Endpoints` union, so the stable SDK surface stays honest. Consumers import them deliberately. - **Use the typed API only.** Register with `.get()` / `.post()` / `.put()` / - `.delete()` and AJV validators — `.addRoute()` is not available for experimental - routes. + `.delete()` and AJV validators — `.addRoute()` is not exposed on `API.experimental`, + its type omits the method. ## Lifecycle: experimental → official From 1001f4d2a5cfd4014d91937b9755ed6b38597224 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 19 Aug 2026 16:32:56 -0300 Subject: [PATCH 09/12] fix experimental headers --- apps/meteor/server/api/api.ts | 8 +- .../api/v1/middlewares/experimental.spec.ts | 86 +++++++++++++++++++ .../server/api/v1/middlewares/experimental.ts | 31 ++++--- docs/experimental-api-endpoints-plan.md | 12 ++- 4 files changed, 115 insertions(+), 22 deletions(-) create mode 100644 apps/meteor/server/api/v1/middlewares/experimental.spec.ts diff --git a/apps/meteor/server/api/api.ts b/apps/meteor/server/api/api.ts index f93da560724c9..6b9edf9a080a1 100644 --- a/apps/meteor/server/api/api.ts +++ b/apps/meteor/server/api/api.ts @@ -84,15 +84,10 @@ export const API: { experimental: createApi({ version: 'experimental', useDefaultAuth: true, - }) as ExperimentalAPI, + }), default: createApi({}), }; -// Stamp the unstable-signal headers on every experimental response. Registered -// here, at module load, so it precedes any endpoint route registered later on -// API.experimental (Hono runs `.use` middleware in registration order). -API.experimental.router.use(experimentalWarningMiddleware()); - settings.watch('Accounts_CustomFields', (value) => { if (!value) { return API.v1?.setLimitedCustomFields([]); @@ -148,6 +143,7 @@ export const startRestAPI = () => { ) .use(tracerSpanMiddleware) .use(remoteAddressMiddleware) + .use(experimentalWarningMiddleware({ basePathRegex: new RegExp(/^\/api\/experimental(\/|$)/) })) .use(cors(settings)) .use(loggerMiddleware(logger)) .use(API.v1.router) diff --git a/apps/meteor/server/api/v1/middlewares/experimental.spec.ts b/apps/meteor/server/api/v1/middlewares/experimental.spec.ts new file mode 100644 index 0000000000000..4d44f66bec432 --- /dev/null +++ b/apps/meteor/server/api/v1/middlewares/experimental.spec.ts @@ -0,0 +1,86 @@ +import { Router } from '@rocket.chat/http-router'; +import Ajv from 'ajv'; +import express from 'express'; +import request from 'supertest'; + +import { cors } from './cors'; +import { experimentalWarningMiddleware } from './experimental'; +import { CachedSettings } from '../../../settings/CachedSettings'; + +const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"'; + +const buildApp = ({ corsEnabled }: { corsEnabled: boolean }) => { + const ajv = new Ajv(); + const settings = new CachedSettings(); + settings.set({ _id: 'API_Enable_CORS', value: corsEnabled } as any); + settings.set({ _id: 'API_CORS_Origin', value: 'https://allowed.example' } as any); + + const route = (router: Router) => + router.get('/test', { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({ + statusCode: 200 as const, + body: {}, + })); + + const api = new Router('/api') + .use(experimentalWarningMiddleware({ basePathRegex: new RegExp(/^\/api\/experimental(\/|$)/) })) + .use(cors(settings)) + .use(route(new Router('/v1'))) + .use(route(new Router('/experimental'))); + + const app = express(); + app.use(api.router); + return app; +}; + +const preflight = (app: express.Express, path: string, origin: string) => + request(app).options(path).set('Origin', origin).set('Access-Control-Request-Method', 'GET'); + +describe('Experimental middleware', () => { + it('should stamp the unstable signal headers on experimental responses', async () => { + const res = await request(buildApp({ corsEnabled: true })).get('/api/experimental/test'); + + expect(res.statusCode).toBe(200); + expect(res.headers['x-experimental']).toBe('true'); + expect(res.headers.warning).toBe(WARNING_HEADER); + }); + + it('should not stamp responses from other versions', async () => { + const res = await request(buildApp({ corsEnabled: true })).get('/api/v1/test'); + + expect(res.statusCode).toBe(200); + expect(res.headers['x-experimental']).toBeUndefined(); + expect(res.headers.warning).toBeUndefined(); + }); + + it('should stamp 404s for unmatched experimental paths', async () => { + const res = await request(buildApp({ corsEnabled: true })).get('/api/experimental/nope'); + + expect(res.statusCode).toBe(404); + expect(res.headers['x-experimental']).toBe('true'); + }); + + // cors answers rejected preflights without calling next(), so these only carry the headers + // while the middleware stays registered ahead of it + it('should stamp preflight rejections when CORS is disabled', async () => { + const res = await preflight(buildApp({ corsEnabled: false }), '/api/experimental/test', 'https://allowed.example'); + + expect(res.statusCode).toBe(405); + expect(res.headers['x-experimental']).toBe('true'); + expect(res.headers.warning).toBe(WARNING_HEADER); + }); + + it('should stamp preflight rejections from disallowed origins', async () => { + const res = await preflight(buildApp({ corsEnabled: true }), '/api/experimental/test', 'https://evil.example'); + + expect(res.statusCode).toBe(403); + expect(res.headers['x-experimental']).toBe('true'); + expect(res.headers.warning).toBe(WARNING_HEADER); + }); + + it('should not stamp preflight rejections from other versions', async () => { + const res = await preflight(buildApp({ corsEnabled: true }), '/api/v1/test', 'https://evil.example'); + + expect(res.statusCode).toBe(403); + expect(res.headers['x-experimental']).toBeUndefined(); + }); +}); diff --git a/apps/meteor/server/api/v1/middlewares/experimental.ts b/apps/meteor/server/api/v1/middlewares/experimental.ts index 626239cf3e28d..05bef915b9e13 100644 --- a/apps/meteor/server/api/v1/middlewares/experimental.ts +++ b/apps/meteor/server/api/v1/middlewares/experimental.ts @@ -6,17 +6,24 @@ import type { MiddlewareHandler } from 'hono'; const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"'; /** - * Stamps every response from the experimental API instance with the unstable - * signal headers. Registered on `API.experimental` only — `/api/v1/*` and the - * default router never see these headers. + * Stamps every experimental response with the unstable signal headers. * - * Mirrors the header-writing pattern of `writeDeprecationHeader` in - * `deprecationWarningLogger.ts`: the headers are set on `c.res.headers` before - * the route handler runs so they are picked up when the handler builds the - * final response (see `Router.method` in `@rocket.chat/http-router`). + * Registered on the shared `/api` mount ahead of `cors`, and scoped by path rather than by + * router: `cors` answers rejected preflights with 403/405 without calling `next()`, so a + * middleware living on `API.experimental.router` would never run for those responses. + * + * The headers are set on `c.res.headers` before the downstream handlers run; Hono merges them + * into whatever response is produced later, so 404s and CORS rejections are covered too. */ -export const experimentalWarningMiddleware = (): MiddlewareHandler => async (c, next) => { - c.res.headers.set('x-experimental', 'true'); - c.res.headers.set('Warning', WARNING_HEADER); - await next(); -}; +export const experimentalWarningMiddleware = + ({ basePathRegex }: { basePathRegex: RegExp }): MiddlewareHandler => + async (c, next) => { + if (!basePathRegex.test(c.req.path)) { + return next(); + } + + c.res.headers.set('x-experimental', 'true'); + c.res.headers.set('Warning', WARNING_HEADER); + + await next(); + }; diff --git a/docs/experimental-api-endpoints-plan.md b/docs/experimental-api-endpoints-plan.md index 0f6ee260d1d39..6bdd1e46aadb2 100644 --- a/docs/experimental-api-endpoints-plan.md +++ b/docs/experimental-api-endpoints-plan.md @@ -111,10 +111,14 @@ existing middlewares under `apps/meteor/app/api/server/middlewares/`). `Warning: 299` is the RFC 7234 "miscellaneous persistent warning" code; `x-experimental` is the easy programmatic check. Model the header-writing on `writeDeprecationHeader` in `deprecationWarningLogger.ts:13-19`. -2. Register the middleware on `API.experimental` only (not on `v1`/`default`). - -**Acceptance:** every `/api/experimental/*` response carries both headers; `/api/v1/*` -responses do not. +2. Register the middleware on the shared `/api` mount in `startRestAPI`, **ahead of** + `cors`, scoped to `/api/experimental` by a `basePathRegex` (same shape as the metrics + middleware guard). It cannot live on `API.experimental.router`: `cors` answers rejected + preflights with 403/405 without calling `next()`, so a router-scoped middleware would + never run for those responses. + +**Acceptance:** every `/api/experimental/*` response carries both headers — including 404s +and CORS preflight rejections; `/api/v1/*` responses do not. **Commit (3 of 5):** `feat(api): add experimental unstable-signal middleware` From 728e639eb80fc828c088e2bb8f6a68132515f533 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 19 Aug 2026 17:11:38 -0300 Subject: [PATCH 10/12] fix metrics again --- apps/meteor/server/api/api.ts | 15 +++++ .../server/api/v1/middlewares/metrics.spec.ts | 60 +++++++++++++++++++ .../server/api/v1/middlewares/metrics.ts | 11 +++- docs/experimental-api-endpoints-plan.md | 11 ++-- 4 files changed, 91 insertions(+), 6 deletions(-) diff --git a/apps/meteor/server/api/api.ts b/apps/meteor/server/api/api.ts index 6b9edf9a080a1..deb2cd69363ce 100644 --- a/apps/meteor/server/api/api.ts +++ b/apps/meteor/server/api/api.ts @@ -141,6 +141,21 @@ export const startRestAPI = () => { activeRequestsGauge: metrics.rocketchatRestApiActiveRequests, }), ) + .use( + // Catch-all sampler for the default router (`/api/info`, `/api/docs/json`) and for + // unmatched `/api/*` paths, which belong to none of the versioned prefixes above. + // Add any new versioned namespace to `excludePathRegex` as well, or it gets counted twice. + metricsMiddleware({ + excludePathRegex: new RegExp(/^\/api\/(v1|experimental|apps)\//), + // `API.default` has no `version`; label it explicitly so the series is not blank. + api: { version: 'default' }, + settings, + endpointTimeSummary: metrics.rocketchatRestApi, + endpointTimeHistogram: metrics.rocketchatRestApiSeconds, + responseSizeHistogram: metrics.rocketchatRestApiResponseSizeBytes, + activeRequestsGauge: metrics.rocketchatRestApiActiveRequests, + }), + ) .use(tracerSpanMiddleware) .use(remoteAddressMiddleware) .use(experimentalWarningMiddleware({ basePathRegex: new RegExp(/^\/api\/experimental(\/|$)/) })) diff --git a/apps/meteor/server/api/v1/middlewares/metrics.spec.ts b/apps/meteor/server/api/v1/middlewares/metrics.spec.ts index 7cabdc83f506a..76d33e0ce5560 100644 --- a/apps/meteor/server/api/v1/middlewares/metrics.spec.ts +++ b/apps/meteor/server/api/v1/middlewares/metrics.spec.ts @@ -286,4 +286,64 @@ describe('Metrics middleware', () => { }); expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1); }); + + it('should sample the default router and unmatched paths exactly once', async () => { + const ajv = new Ajv(); + const app = express(); + const settings = new CachedSettings(); + + const makeMetrics = () => { + const endTimer = jest.fn(); + return { + endTimer, + summary: { startTimer: jest.fn().mockReturnValue(endTimer) }, + histogram: { startTimer: jest.fn().mockReturnValue(jest.fn()) }, + responseSizeHistogram: { observe: jest.fn() }, + activeRequestsGauge: { inc: jest.fn(), dec: jest.fn() }, + }; + }; + + const v1Metrics = makeMetrics(); + const defaultMetrics = makeMetrics(); + + const wire = (metrics: ReturnType, extra: { basePathRegex?: RegExp; excludePathRegex?: RegExp }, version: string) => + metricsMiddleware({ + ...extra, + api: { version }, + settings, + endpointTimeSummary: metrics.summary as any, + endpointTimeHistogram: metrics.histogram as any, + responseSizeHistogram: metrics.responseSizeHistogram as any, + activeRequestsGauge: metrics.activeRequestsGauge as any, + }); + + const api = new Router('/api') + .use(wire(v1Metrics, { basePathRegex: new RegExp(/^\/api\/v1\//) }, 'v1')) + .use(wire(defaultMetrics, { excludePathRegex: new RegExp(/^\/api\/(v1|experimental|apps)\//) }, 'default')); + + const route = (router: Router, subpath: string) => + router.get(subpath, { response: { 200: ajv.compile({ type: 'object' }) } }, async () => ({ statusCode: 200 as const, body: {} })); + + // the catch-all router is mounted first on purpose: the exclusion guard has to hold + // regardless of the order the versioned routers happen to be registered in + api.use(route(new Router(''), 'info')); + api.use(route(new Router('/v1'), '/test')); + + expect((await request(app.use(api.router)).get('/api/info')).statusCode).toBe(200); + + expect(v1Metrics.summary.startTimer).not.toHaveBeenCalled(); + expect(defaultMetrics.endTimer).toHaveBeenCalledWith({ status: 200, method: 'get', version: 'default', entrypoint: '/api/info' }); + + defaultMetrics.endTimer.mockClear(); + + expect((await request(app).get('/api/v1/test')).statusCode).toBe(200); + + expect(v1Metrics.summary.startTimer).toHaveBeenCalledTimes(1); + expect(defaultMetrics.summary.startTimer).toHaveBeenCalledTimes(1); // still just the /api/info call + expect(defaultMetrics.endTimer).not.toHaveBeenCalled(); + + expect((await request(app).get('/api/bogus')).statusCode).toBe(404); + + expect(defaultMetrics.endTimer).toHaveBeenCalledWith({ status: 404, method: 'get', version: 'default', entrypoint: '/api/*' }); + }); }); diff --git a/apps/meteor/server/api/v1/middlewares/metrics.ts b/apps/meteor/server/api/v1/middlewares/metrics.ts index 62171bb8565ec..613ee2d90b335 100644 --- a/apps/meteor/server/api/v1/middlewares/metrics.ts +++ b/apps/meteor/server/api/v1/middlewares/metrics.ts @@ -6,6 +6,7 @@ import type { CachedSettings } from '../../../settings/CachedSettings'; export const metricsMiddleware = ({ basePathRegex, + excludePathRegex, api, settings, endpointTimeSummary, @@ -14,6 +15,7 @@ export const metricsMiddleware = activeRequestsGauge, }: { basePathRegex?: RegExp; + excludePathRegex?: RegExp; api: { version?: string }; settings: CachedSettings; endpointTimeSummary: Summary; @@ -22,12 +24,17 @@ export const metricsMiddleware = activeRequestsGauge: Gauge; }): MiddlewareHandler => async (c, next) => { - // Several metrics middlewares share the same `/api` mount (v1, experimental, apps), so each - // one has to ignore the paths that belong to the others or a request gets sampled more than once. + // Several metrics middlewares share the same `/api` mount (v1, experimental, apps, default), so + // each one has to ignore the paths that belong to the others or a request gets sampled more than + // once. The versioned ones opt in by prefix; the catch-all opts out of the prefixes it does not own. if (basePathRegex && !basePathRegex.test(c.req.path)) { return next(); } + if (excludePathRegex?.test(c.req.path)) { + return next(); + } + const rocketchatRestApiEnd = endpointTimeSummary.startTimer(); const rocketchatRestApiHistEnd = endpointTimeHistogram.startTimer(); diff --git a/docs/experimental-api-endpoints-plan.md b/docs/experimental-api-endpoints-plan.md index 6bdd1e46aadb2..a7eab984d484c 100644 --- a/docs/experimental-api-endpoints-plan.md +++ b/docs/experimental-api-endpoints-plan.md @@ -88,10 +88,13 @@ review 1:1 onto the plan. 1. Insert `.use(API.experimental.router)` into the chain, **before** `.use(API.default.router)` (line 121). Order matters: `default` is the catch-all. -2. Extend the metrics middleware `basePathRegex` (line 107) so experimental traffic is - measured. Either broaden the regex to `^\/api\/(v1|experimental)\//` or add a second - `metricsMiddleware` block pointed at `API.experimental`. Metrics are the canary used - later to decide whether an endpoint is ready for promotion to `/v1`. +2. Add a second `metricsMiddleware` block pointed at `API.experimental` so experimental + traffic is measured. Metrics are the canary used later to decide whether an endpoint is + ready for promotion to `/v1`. Because every block shares the same `/api` mount, each one + needs a guard or a request is sampled more than once: the versioned blocks opt in via + `basePathRegex`, and a catch-all block for `API.default` (`/api/info`, `/api/docs/json`, + unmatched `/api/*`) opts out via `excludePathRegex`. Without that catch-all block the + guards silently drop default-router traffic that used to be sampled. **Acceptance:** experimental requests appear in the REST API Prometheus metrics with a distinguishable path/label. From 0796de1ec7abd7087c7e0e6980262510d4cac55c Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 19 Aug 2026 17:19:29 -0300 Subject: [PATCH 11/12] doc: fix doc items --- .../server/api/v1/middlewares/experimental.ts | 6 +- docs/experimental-api-endpoints-plan.md | 90 ++++++++++++------- docs/experimental-api-endpoints.md | 8 +- 3 files changed, 66 insertions(+), 38 deletions(-) diff --git a/apps/meteor/server/api/v1/middlewares/experimental.ts b/apps/meteor/server/api/v1/middlewares/experimental.ts index 05bef915b9e13..c8e80294c7431 100644 --- a/apps/meteor/server/api/v1/middlewares/experimental.ts +++ b/apps/meteor/server/api/v1/middlewares/experimental.ts @@ -1,8 +1,8 @@ import type { MiddlewareHandler } from 'hono'; -// RFC 7234 "miscellaneous persistent warning" code. Paired with the -// programmatic-friendly `x-experimental` header so clients can detect, in code, -// that they hit an unstable endpoint. +// `x-experimental` is the supported programmatic signal. `Warning: 299` is emitted for +// legacy tooling only — warn code 299 came from RFC 7234, which RFC 9111 has obsoleted +// along with the `Warning` header itself. const WARNING_HEADER = '299 - "experimental: endpoint is unstable and may change without notice"'; /** diff --git a/docs/experimental-api-endpoints-plan.md b/docs/experimental-api-endpoints-plan.md index a7eab984d484c..c13372664477d 100644 --- a/docs/experimental-api-endpoints-plan.md +++ b/docs/experimental-api-endpoints-plan.md @@ -26,20 +26,23 @@ the legacy registration path. - `createApi({ version })` turns the `version` string into the URL path segment, so a new instance with `version: 'experimental'` mounts at `/api/experimental/` - with zero router changes. See `apps/meteor/app/api/server/api.ts:33-77` and - `ApiClass.ts:199`. + without changing any router internals — the new router still has to be mounted in + `startRestAPI` (see Step 2). See `apps/meteor/server/api/api.ts` (the `API` object and + `createApi`) and `apps/meteor/server/api/ApiClass.ts` (`apiPath` composition in the + `APIClass` constructor). - The typed route methods `.get()/.post()/.put()/.delete()` are generic over - `TSubPathPattern extends string` (`ApiClass.ts:669`) — they are **not** gated on - `keyof Endpoints`. Routes accumulate *outward* into the instance's `TOperations`, - read back by `ExtractApiClassEndpoints` (`ApiClass.ts:125-126`). So a separate - experimental instance works *with* the type system. + `TSubPathPattern extends string` (`ApiClass.ts`, `APIClass.method()` and its + per-verb wrappers) — they are **not** gated on `keyof Endpoints`. Routes accumulate + *outward* into the instance's `TOperations`, read back by `ExtractApiClassEndpoints` + (`apps/meteor/server/api/api.ts`). So a separate experimental instance works *with* + the type system. - `PathPattern`, `Method`, `Path`, and the typed client are all derived from the - `Endpoints` interface (`packages/rest-typings/src/index.ts:48-120`). Keeping + `Endpoints` interface (`packages/rest-typings/src/index.ts`). Keeping experimental paths **out** of that interface keeps the stable client surface clean and forces explicit opt-in for experimental ones. - The deprecation framework already writes `x-deprecation-*` response headers - (`apps/meteor/app/lib/server/lib/deprecationWarningLogger.ts:13-19`). We mirror that - pattern for an `x-experimental` / `Warning` signal. + (`writeDeprecationHeader` in `apps/meteor/server/lib/deprecationWarningLogger.ts`). We + mirror that pattern for an `x-experimental` / `Warning` signal. - Auth, permissions, rate limiting, CORS, AJV validation, and metrics all come from `createApi` + the middleware chain in `startRestAPI` — experimental endpoints get them for free. @@ -64,18 +67,28 @@ review 1:1 onto the plan. ### Step 1 — Add the `experimental` API instance -**File:** `apps/meteor/app/api/server/api.ts` +**File:** `apps/meteor/server/api/api.ts` -1. In the `API` object literal (around line 69-77), add: +1. In the `API` object literal, add: ```ts experimental: createApi({ version: 'experimental', useDefaultAuth: true }), ``` Place it between `v1` and `default`. -2. Add `experimental: APIClass<'/experimental'>;` to the `API` type annotation - (around line 42-68) so it is typed. -3. If any `settings.watch(...)` callbacks need to refresh experimental routes the way - they refresh `API.v1` (rate limiter reloads at lines 92-100, custom fields at - 79-90), add the matching `API.experimental?.…` calls. Optional for v1 of this work. +2. Add an `experimental` entry to the `API` type annotation so it is typed. Use + `Omit, 'addRoute'>` rather than a plain `APIClass`, so the + typed-API-only rule above is enforced by the compiler and not just by convention. +3. Refreshing experimental routes when settings change is a **required** parity + condition, not an optional extra — the contract above promises experimental + endpoints get rate limiting "for free", which only holds if the refresh callbacks + cover them. The `settings.watch(...)` callbacks in this file that must also update + `API.experimental`: + - `API_Enable_Rate_Limiter_Limit_Time_Default` → `reloadRoutesToRefreshRateLimiter()` + - `API_Enable_Rate_Limiter_Limit_Calls_Default` → `reloadRoutesToRefreshRateLimiter()` + - `Accounts_CustomFields` → `setLimitedCustomFields()` + + **Known gap:** the rate-limiter watchers are at parity; the `Accounts_CustomFields` + watcher still updates `API.v1` only. That is currently harmless — no experimental + endpoint returns user objects — but it must be closed before one does. **Acceptance:** `API.experimental.get('ping', { ... }, handler)` compiles and serves at `GET /api/experimental/ping`. @@ -84,10 +97,10 @@ review 1:1 onto the plan. ### Step 2 — Mount it in the request pipeline -**File:** `apps/meteor/app/api/server/api.ts`, `startRestAPI` (lines 102-123) +**File:** `apps/meteor/server/api/api.ts`, `startRestAPI` 1. Insert `.use(API.experimental.router)` into the chain, **before** - `.use(API.default.router)` (line 121). Order matters: `default` is the catch-all. + `.use(API.default.router)`. Order matters: `default` is the catch-all. 2. Add a second `metricsMiddleware` block pointed at `API.experimental` so experimental traffic is measured. Metrics are the canary used later to decide whether an endpoint is ready for promotion to `/v1`. Because every block shares the same `/api` mount, each one @@ -96,24 +109,31 @@ review 1:1 onto the plan. unmatched `/api/*`) opts out via `excludePathRegex`. Without that catch-all block the guards silently drop default-router traffic that used to be sampled. -**Acceptance:** experimental requests appear in the REST API Prometheus metrics with a -distinguishable path/label. +**Acceptance:** experimental requests appear in the REST API Prometheus metrics labelled +`version=experimental` — specifically that label, not merely a distinguishable one. A +change that only adjusted the path regex while leaving experimental traffic under the +`v1` version label does not satisfy this. `/api/v1/*` and default-router traffic each +still record exactly one sample under their own label. **Commit (2 of 5):** `feat(api): mount experimental router and metrics` ### Step 3 — Runtime "unstable" signal (mirror deprecation headers) -**New file:** `apps/meteor/app/api/server/middlewares/experimental.ts` (or colocate with -existing middlewares under `apps/meteor/app/api/server/middlewares/`). +**New file:** `apps/meteor/server/api/v1/middlewares/experimental.ts`, colocated with the +existing middlewares. 1. Write a middleware that sets, on every response from the experimental instance: ``` Warning: 299 - "experimental: endpoint is unstable and may change without notice" x-experimental: true ``` - `Warning: 299` is the RFC 7234 "miscellaneous persistent warning" code; `x-experimental` - is the easy programmatic check. Model the header-writing on - `writeDeprecationHeader` in `deprecationWarningLogger.ts:13-19`. + `x-experimental: true` is the **supported programmatic signal** — clients should detect + experimental responses with it. `Warning: 299` is a legacy compatibility signal only: + warn code 299 came from RFC 7234, which RFC 9111 has since obsoleted along with the + `Warning` header itself, so modern clients are not expected to generate or interpret + it. It is emitted for the benefit of tooling that still surfaces it, and may be dropped + without it being a breaking change. Model the header-writing on `writeDeprecationHeader` + in `apps/meteor/server/lib/deprecationWarningLogger.ts`. 2. Register the middleware on the shared `/api` mount in `startRestAPI`, **ahead of** `cors`, scoped to `/api/experimental` by a `basePathRegex` (same shape as the metrics middleware guard). It cannot live on `API.experimental.router`: `cors` answers rejected @@ -141,7 +161,7 @@ and CORS preflight rejections; `/api/v1/*` responses do not. Follow the existing per-resource endpoint style (e.g. `packages/rest-typings/src/v1/channels/channels.ts`). 2. Export `ExperimentalEndpoints` from the package root, but **do NOT** add it to the - `interface Endpoints extends ...` union (`index.ts:48-93`). This keeps `PathPattern`, + `interface Endpoints extends ...` union. This keeps `PathPattern`, `Method`, `Path`, and the stable typed client free of experimental paths. 3. Consumers who want typed experimental calls import `ExperimentalEndpoints` explicitly. @@ -153,9 +173,14 @@ include experimental paths; `import type { ExperimentalEndpoints }` does. ### Step 5 — Guardrails (because it is a general mechanism) -1. **CI/lint guard:** add a check (script or eslint rule) asserting no path key present - in `ExperimentalEndpoints` is also present in `Endpoints`. This catches accidental - "promotion by copy-paste" that would silently create a semver obligation. +1. **CI guard (type-level):** add + `packages/rest-typings/src/experimental/noOverlapWithStableEndpoints.ts`, a + types-only module asserting that no path key present in `ExperimentalEndpoints` is + also present in `Endpoints`. It resolves `Extract` against a `T extends never` constraint, so `tsc` — run by `yarn typecheck` + in CI — fails and names the offending key(s). No script or ESLint rule is involved. + This catches accidental "promotion by copy-paste" that would silently create a semver + obligation. 2. **Promotion path:** document that stabilizing an endpoint means copying it to `/v1` (optionally keeping the experimental path forwarding for a transition window). Removal needs no deprecation cycle — but log removals for courtesy. @@ -182,9 +207,10 @@ include experimental paths; `import type { ExperimentalEndpoints }` does. | File | Change | | ---- | ------ | -| `apps/meteor/app/api/server/api.ts` | Add `experimental` instance, type entry, mount in `startRestAPI`, metrics regex | -| `apps/meteor/app/api/server/middlewares/experimental.ts` (new) | `x-experimental` / `Warning` header middleware | +| `apps/meteor/server/api/api.ts` | Add `experimental` instance, type entry, mount in `startRestAPI`, metrics blocks | +| `apps/meteor/server/api/v1/middlewares/experimental.ts` (new) | `x-experimental` / `Warning` header middleware | +| `apps/meteor/server/api/v1/middlewares/metrics.ts` | `basePathRegex` / `excludePathRegex` sampling guards | | `packages/rest-typings/src/experimental/index.ts` (new) | `ExperimentalEndpoints` type, NOT merged into `Endpoints` | | `packages/rest-typings/src/index.ts` | Export `ExperimentalEndpoints` | -| CI/lint config | Guard: no path in both `Endpoints` and `ExperimentalEndpoints` | +| `packages/rest-typings/src/experimental/noOverlapWithStableEndpoints.ts` (new) | Type-level guard: no path in both `Endpoints` and `ExperimentalEndpoints` | | docs / CONTRIBUTING | Document the contract + promotion path | diff --git a/docs/experimental-api-endpoints.md b/docs/experimental-api-endpoints.md index 21b44358648af..29a262a937b8d 100644 --- a/docs/experimental-api-endpoints.md +++ b/docs/experimental-api-endpoints.md @@ -55,8 +55,10 @@ endpoints give a third path — ship now, iterate freely, commit later. pan out. An endpoint that sits in `experimental` indefinitely is a smell — it means a decision is overdue. - **Callers are warned at runtime.** Every experimental response carries - `x-experimental: true` and a `Warning: 299 ...` header. Clients can detect and surface - this. + `x-experimental: true` — that is the supported signal to detect and surface in client + code. Responses also carry a `Warning: 299 ...` header, kept only for legacy tooling + that still reads it: RFC 9111 obsoletes the `Warning` header and its warn codes, so do + not build new client logic on it. - **Typed clients must opt in.** Experimental endpoints are declared in a separate `ExperimentalEndpoints` type, not in the main `Endpoints` union, so the stable SDK surface stays honest. Consumers import them deliberately. @@ -66,7 +68,7 @@ endpoints give a third path — ship now, iterate freely, commit later. ## Lifecycle: experimental → official -``` +```text stabilizes experimental ───────────────▶ v1 (official, semver-stable) (/api/experimental/x) (/api/v1/x) From 1e133708ece6e4645ab11e76c306a083957b1061 Mon Sep 17 00:00:00 2001 From: Diego Sampaio Date: Wed, 19 Aug 2026 18:32:20 -0300 Subject: [PATCH 12/12] remove enforcement for experimental api --- apps/meteor/server/api/api.ts | 8 +---- docs/experimental-api-endpoints-plan.md | 31 +++++++++---------- docs/experimental-api-endpoints.md | 16 +++++----- .../noOverlapWithStableEndpoints.ts | 23 -------------- 4 files changed, 23 insertions(+), 55 deletions(-) delete mode 100644 packages/rest-typings/src/experimental/noOverlapWithStableEndpoints.ts diff --git a/apps/meteor/server/api/api.ts b/apps/meteor/server/api/api.ts index deb2cd69363ce..56772f2159f76 100644 --- a/apps/meteor/server/api/api.ts +++ b/apps/meteor/server/api/api.ts @@ -40,16 +40,10 @@ const createApi = function _createApi(options: { version?: string; useDefaultAut }); }; -/** - * The experimental namespace is typed-API only: the deprecated `addRoute()` is omitted from the - * surface so new endpoints cannot be born on the legacy registration path. - */ -export type ExperimentalAPI = Omit, 'addRoute'>; - export const API: { api: Router<'/api', any, APIActionHandler>; v1: APIClass<'/v1'>; - experimental: ExperimentalAPI; + experimental: APIClass<'/experimental'>; default: APIClass; ApiClass: typeof APIClass; channels?: { diff --git a/docs/experimental-api-endpoints-plan.md b/docs/experimental-api-endpoints-plan.md index c13372664477d..b3f7a86c38985 100644 --- a/docs/experimental-api-endpoints-plan.md +++ b/docs/experimental-api-endpoints-plan.md @@ -18,9 +18,10 @@ untouched. **Only the new typed API is allowed on experimental routes.** Endpoints must be registered with `.get()` / `.post()` / `.put()` / `.delete()` (with AJV `body` / `query` -/ `response` validators). The deprecated `.addRoute()` is **not exposed** on -`API.experimental` (its type omits the method) — new surface area should not be born on -the legacy registration path. +/ `response` validators). The deprecated `.addRoute()` must not be used — new surface +area should not be born on the legacy registration path. This is a documented rule, not a +compiler-enforced one: `API.experimental` is a plain `APIClass`, and `.addRoute()` already +carries `@deprecated` everywhere it is reachable. ## Why this design (key findings from the current code) @@ -74,9 +75,12 @@ review 1:1 onto the plan. experimental: createApi({ version: 'experimental', useDefaultAuth: true }), ``` Place it between `v1` and `default`. -2. Add an `experimental` entry to the `API` type annotation so it is typed. Use - `Omit, 'addRoute'>` rather than a plain `APIClass`, so the - typed-API-only rule above is enforced by the compiler and not just by convention. +2. Add an `experimental` entry to the `API` type annotation so it is typed: + `APIClass<'/experimental'>`. Hiding `addRoute()` from that type was considered and + dropped: the typed methods return `this`, so a restricted surface only holds until the + first chained registration, and closing that hole means duplicating every typed + signature and widening the route-extraction types that pattern-match `APIClass`. Not + worth it for a rule `@deprecated` already signals. 3. Refreshing experimental routes when settings change is a **required** parity condition, not an optional extra — the contract above promises experimental endpoints get rate limiting "for free", which only holds if the refresh callbacks @@ -173,14 +177,11 @@ include experimental paths; `import type { ExperimentalEndpoints }` does. ### Step 5 — Guardrails (because it is a general mechanism) -1. **CI guard (type-level):** add - `packages/rest-typings/src/experimental/noOverlapWithStableEndpoints.ts`, a - types-only module asserting that no path key present in `ExperimentalEndpoints` is - also present in `Endpoints`. It resolves `Extract` against a `T extends never` constraint, so `tsc` — run by `yarn typecheck` - in CI — fails and names the offending key(s). No script or ESLint rule is involved. - This catches accidental "promotion by copy-paste" that would silently create a semver - obligation. +1. **No path in both unions.** A type-level CI guard for this was considered and dropped: + union keys are full paths, so `/experimental/x` and `/v1/x` never collide, and the + transition window described below does not produce a collision either. Keep the rule in + the docs instead — promotion means *moving* the declaration to a stable `*Endpoints` + type, not leaving a copy behind. 2. **Promotion path:** document that stabilizing an endpoint means copying it to `/v1` (optionally keeping the experimental path forwarding for a transition window). Removal needs no deprecation cycle — but log removals for courtesy. @@ -200,7 +201,6 @@ include experimental paths; `import type { ExperimentalEndpoints }` does. - [ ] `/api/v1/*` responses are unchanged (no experimental headers). - [ ] Auth / permissions / rate limiting enforced on an experimental route exactly as on `/v1`. - [ ] `Endpoints` type does not include experimental paths; `ExperimentalEndpoints` does. -- [ ] CI guard fails if a path appears in both unions. - [ ] Experimental requests show up in REST API metrics. ## Files touched (summary) @@ -212,5 +212,4 @@ include experimental paths; `import type { ExperimentalEndpoints }` does. | `apps/meteor/server/api/v1/middlewares/metrics.ts` | `basePathRegex` / `excludePathRegex` sampling guards | | `packages/rest-typings/src/experimental/index.ts` (new) | `ExperimentalEndpoints` type, NOT merged into `Endpoints` | | `packages/rest-typings/src/index.ts` | Export `ExperimentalEndpoints` | -| `packages/rest-typings/src/experimental/noOverlapWithStableEndpoints.ts` (new) | Type-level guard: no path in both `Endpoints` and `ExperimentalEndpoints` | | docs / CONTRIBUTING | Document the contract + promotion path | diff --git a/docs/experimental-api-endpoints.md b/docs/experimental-api-endpoints.md index 29a262a937b8d..c049e34471edf 100644 --- a/docs/experimental-api-endpoints.md +++ b/docs/experimental-api-endpoints.md @@ -63,8 +63,9 @@ endpoints give a third path — ship now, iterate freely, commit later. `ExperimentalEndpoints` type, not in the main `Endpoints` union, so the stable SDK surface stays honest. Consumers import them deliberately. - **Use the typed API only.** Register with `.get()` / `.post()` / `.put()` / - `.delete()` and AJV validators — `.addRoute()` is not exposed on `API.experimental`, - its type omits the method. + `.delete()` and AJV validators. Do not use `.addRoute()`: it is already deprecated + across the whole API, and a namespace created to iterate on new contracts is the last + place that should add to the legacy path. ## Lifecycle: experimental → official @@ -93,13 +94,10 @@ consumers as a courtesy. ## Guardrails & tooling -- **No path lives in both unions.** A type-level guard - (`packages/rest-typings/src/experimental/noOverlapWithStableEndpoints.ts`) - fails `yarn typecheck` in CI if any path key is declared in both - `ExperimentalEndpoints` and the stable `Endpoints` union. This catches - "promotion by copy-paste" — a duplicate key that would silently attach a - semver obligation to a path advertised as unstable. Promotion means *moving* - the declaration to a stable `*Endpoints` type, not leaving a copy behind. +- **No path lives in both unions.** A duplicate key would silently attach a semver + obligation to a path advertised as unstable, so promotion means *moving* the declaration + to a stable `*Endpoints` type, not leaving a copy behind. Nothing enforces this — the + `/experimental/` path prefix keeps the two unions from overlapping in practice. - **Generated API docs intentionally skip experimental endpoints.** OpenAPI / doc generation scans the `Endpoints` union, which experimental paths are deliberately kept out of, so they do not appear in public API docs. This is diff --git a/packages/rest-typings/src/experimental/noOverlapWithStableEndpoints.ts b/packages/rest-typings/src/experimental/noOverlapWithStableEndpoints.ts deleted file mode 100644 index de0755ab0de19..0000000000000 --- a/packages/rest-typings/src/experimental/noOverlapWithStableEndpoints.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * CI guard (type-level): asserts that no path declared in `ExperimentalEndpoints` - * is also declared in the stable `Endpoints` union. - * - * Promoting an experimental endpoint to `/v1` means *copying* it into a stable - * `*Endpoints` type — it must never be left declared in both unions, which - * would silently attach a semver obligation to a path advertised as unstable. - * If that ever happens, `tsc` (run by `yarn typecheck` in CI) fails to compile - * this file, naming the offending path key(s) in the constraint error. - * - * This file declares only types — it emits no runtime code. - */ -import type { Endpoints } from '../index'; -import type { ExperimentalEndpoints } from './index'; - -type PathsDeclaredInBothUnions = Extract; - -// `T extends never` is only satisfiable when T *is* never. If any path key is -// shared between the two unions, `PathsDeclaredInBothUnions` is that union of -// keys (not `never`) and this alias fails to type-check. -type AssertNoOverlap = T; - -export type ExperimentalEndpointsDoNotOverlapStableEndpoints = AssertNoOverlap;