From 1ee9709551c9e194800a062230124214f4a86da4 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 01:21:35 +0000 Subject: [PATCH 1/5] feat(spec,core,runtime)!: reject non-empty `apis:` loudly; retire the ApiRegistry family (#4936, #4939) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The declarative `apis:` surface was zero-execution end to end while reporting perfect health. Metadata loaded fine — `GET /api/v1/meta/api` returned every declared endpoint with every key — but no route was ever mounted for a declared path, so a request died at Hono's `notFound` (a bare 404, not the dispatcher's semantic one), and the `handleApiEndpoint` branch behind it called a `matchEndpoint` method no implementation in this repo has ever provided. Every key on `ApiEndpointSchema` was therefore declared != enforced, `authRequired` included — a security semantic that parsed green and gated nothing. Per the maintainer verdict (2026-08-04, #4936), this takes the third route: keep the vocabulary, refuse the authoring. - spec: a non-empty `apis:` is rejected on `ObjectStackDefinitionSchema` — the one choke point `defineStack`, metadata artifact ingestion, `os validate`, the lint scorer and `EnvironmentArtifactSchema` all run through, so no path can forget to check. The rejection carries its own prescription and names #5040 (the executor) as the live tracker. Empty/absent still pass. `ApiEndpointSchema` itself is untouched: retiring an industry-stable endpoint shape would only mean re-introducing it identically later. - runtime: `handleApiEndpoint`, its now-orphaned private `callData` delegate (tsc TS6133 found it) and the `/__api-endpoint` ledger + legacy-prefix entries are deleted, so the absence is loud instead of grep-able dead code. - spec/core (#4939): the second, unrelated endpoint declaration shape retires whole — `ApiEndpointRegistration`/`ApiRegistry`/`ApiRegistryEntry` and their value schemas (12 JSON-Schema defs, 67 authorable keys), the ~500-line `ApiRegistry` service, `createApiRegistryPlugin`, and hono's unread `useApiRegistry` option. It was composed only in `packages/core/examples/`, so `requiredPermissions` promised gateway enforcement no gateway performed. `ConflictResolutionStrategy` survives, moved to `api/router.zod` — two independent ratchets (spec sync-retirement, objectui parity) pin it. - showcase: declares no endpoints (both definitions preserved, commented, with the rationale); the coverage manifest's "demonstrated ... executed by the runtime dispatcher" claim is corrected to a waiver — it was the exact advertise-what-you-do-not-deliver claim Prime Directive #10 forbids. - the #4910/#5006 endpoint-level `rateLimit` tracking pointers now name #5040, since #4936 closes here. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- ...-loud-reject-and-apiregistry-retirement.md | 100 ++ content/docs/kernel/services.mdx | 3 +- .../docs/protocol/kernel/http-protocol.mdx | 11 +- content/docs/references/api/index.mdx | 1 - content/docs/references/api/meta.json | 1 - content/docs/references/api/registry.mdx | 315 ----- content/docs/references/api/router.mdx | 18 +- .../docs/references/system/stack-server.mdx | 8 +- content/docs/references/ui/app.mdx | 2 +- ...-metadata-kind-admission-and-retirement.md | 2 +- examples/app-showcase/objectstack.config.ts | 9 +- examples/app-showcase/src/coverage.ts | 8 +- .../app-showcase/src/system/apis/index.ts | 103 +- examples/app-showcase/test/gap-fill.test.ts | 56 +- .../client/src/client-url-conformance.test.ts | 13 +- packages/core/API_REGISTRY.md | 392 ------ packages/core/README.md | 1 - .../core/examples/api-registry-example.ts | 549 --------- packages/core/src/api-registry-plugin.test.ts | 393 ------ packages/core/src/api-registry-plugin.ts | 94 -- packages/core/src/api-registry.test.ts | 1089 ----------------- packages/core/src/api-registry.ts | 739 ----------- packages/core/src/index.ts | 10 +- packages/metadata-protocol/src/seed-loader.ts | 5 +- .../plugin-hono-server/src/hono-plugin.ts | 12 +- packages/runtime/src/dispatcher-plugin.ts | 15 +- packages/runtime/src/http-dispatcher.ts | 120 +- packages/runtime/src/route-ledger.ts | 13 +- .../src/security/inbound-rate-limit.ts | 5 +- packages/spec/PROTOCOL_MAP.md | 3 +- packages/spec/api-surface.json | 25 - packages/spec/authorable-surface.json | 67 - packages/spec/json-schema.manifest.json | 14 +- .../spec/src/api/apis-no-executor.test.ts | 169 +++ packages/spec/src/api/index.ts | 12 +- .../spec/src/api/registry-retirement.test.ts | 116 ++ packages/spec/src/api/registry.example.ts | 532 -------- packages/spec/src/api/registry.test.ts | 988 --------------- packages/spec/src/api/registry.zod.ts | 863 ------------- packages/spec/src/api/router.zod.ts | 30 + packages/spec/src/stack.zod.ts | 47 +- packages/spec/src/system/stack-server.zod.ts | 6 +- packages/spec/src/ui/app.zod.ts | 8 +- 43 files changed, 708 insertions(+), 6259 deletions(-) create mode 100644 .changeset/apis-loud-reject-and-apiregistry-retirement.md delete mode 100644 content/docs/references/api/registry.mdx delete mode 100644 packages/core/API_REGISTRY.md delete mode 100644 packages/core/examples/api-registry-example.ts delete mode 100644 packages/core/src/api-registry-plugin.test.ts delete mode 100644 packages/core/src/api-registry-plugin.ts delete mode 100644 packages/core/src/api-registry.test.ts delete mode 100644 packages/core/src/api-registry.ts create mode 100644 packages/spec/src/api/apis-no-executor.test.ts create mode 100644 packages/spec/src/api/registry-retirement.test.ts delete mode 100644 packages/spec/src/api/registry.example.ts delete mode 100644 packages/spec/src/api/registry.test.ts delete mode 100644 packages/spec/src/api/registry.zod.ts diff --git a/.changeset/apis-loud-reject-and-apiregistry-retirement.md b/.changeset/apis-loud-reject-and-apiregistry-retirement.md new file mode 100644 index 0000000000..7d4f7867c4 --- /dev/null +++ b/.changeset/apis-loud-reject-and-apiregistry-retirement.md @@ -0,0 +1,100 @@ +--- +"@objectstack/spec": major +"@objectstack/core": major +"@objectstack/plugin-hono-server": major +"@objectstack/runtime": minor +"@objectstack/metadata-protocol": patch +"@objectstack/client": patch +--- + +feat(spec,core,runtime)!: declarative `apis:` refuses loudly instead of parsing into silence; the `ApiRegistry` family retires (#4936, #4939) + +The declarative API-endpoint surface was **zero-execution end to end**, and said nothing +about it. Metadata loading worked perfectly — a stack declared `apis:`, `defineStack` +accepted it, and `GET /api/v1/meta/api` returned every endpoint with every key intact. +The execution side never fired once. On a real boot (showcase, 47 plugins) both declared +paths answered a bare `404 {"error":"Not found"}` — not even the dispatcher's semantic +404, because **no route was ever mounted** for a declared path, so the request died at +Hono's `notFound`. Behind that, the dispatcher's `handleApiEndpoint` branch resolved the +metadata service and called `matchEndpoint` on it — a method **no implementation in the +repo has ever provided**. The branch returned "not handled" on every request ever served. + +So every key on `ApiEndpointSchema` was declared ≠ enforced: `path`/`method` (never +mounted), `type`/`target`/`objectParams` (never executed), `cacheTtl`, +`inputMapping`/`outputMapping`, `rateLimit`, `summary`/`description` — and +**`authRequired`**, a security semantic that parsed green and gated nothing at all. That +is false compliance, the failure ADR-0049 exists to stop, not debt. + +## BREAKING — a non-empty `apis:` is now rejected + +Metadata that parsed cleanly before is now **refused at publish/validate**, with the +prescription in the rejection itself: + +``` +apis: `apis:` (declarative ApiEndpoint) is DECLARED BUT NOT EXECUTABLE in this runtime, +so a non-empty array is rejected instead of silently accepted (#4936). … +``` + +**FROM → TO.** `apis: [ …endpoints… ]` → `apis: []` (or delete the key; both are still +accepted, and an empty array is not a special case). To actually serve the route today, +mount it **in code** — a plugin manifest `contributes.routes` entry, or an `http.server` +route. That is now the only honest path, and the one `examples/app-showcase` uses +(`src/system/server/recalc-endpoint.ts`). + +The refusal lives on `ObjectStackDefinitionSchema` itself, which is the single choke +point every path runs through — `defineStack`, the metadata plugin's artifact ingestion, +`os validate`, the lint scorer and `EnvironmentArtifactSchema`. There is no path that +forgot to check. + +**The `ApiEndpoint` vocabulary is deliberately KEPT.** Retiring it was considered and +rejected: endpoint shapes are an industry-stable form, so a retirement would only mean +re-introducing the identical schema later. Your endpoint definitions stay valid TypeScript +and stay in the spec; only *authoring them into a stack* is refused, and only until the +executor lands. Keep them commented next to your stack — that is what the showcase does. +The executor (route mounting + endpoint matching + per-key wiring for +`authRequired`/`cacheTtl`/`inputMapping`/`outputMapping`/`rateLimit`) is tracked by +**#5040**, which replaces this rejection with real execution. + +## BREAKING — the `ApiRegistry` / `ApiEndpointRegistration` family is removed (#4939) + +The repo carried a **second**, unrelated declaration shape for "an API endpoint": +`ApiEndpointRegistrationSchema` and the ~500-line `ApiRegistry` service that +`createApiRegistryPlugin()` registered under `api-registry`. Nothing composed it — every +assembly site lived in `packages/core/examples/`, with no registration in +`packages/runtime`, `packages/cli` or any `examples/app-*`, and a real boot carried no +such service. The whole family was therefore inert, including +`ApiEndpointRegistration.requiredPermissions`, whose docs promised **in the present tense** +that "the gateway layer automatically validates these permissions" while no gateway read +it. Two declaration shapes, both dead; this retirement converges them on one. + +Removed from `@objectstack/spec/api`: `ApiEndpointRegistration(Schema)`, +`ApiRegistry(Schema)`, `ApiRegistryEntry(Schema)`, `ApiMetadataSchema`, +`ApiParameterSchema`, `ApiResponseSchema`, `ApiDiscoveryQuerySchema`, +`ApiDiscoveryResponseSchema`, `ApiProtocolType`, `HttpStatusCode`, +`ObjectQLReferenceSchema`, `SchemaDefinition` (12 JSON-Schema defs, 67 authorable keys). +Removed from `@objectstack/core`: `ApiRegistry`, `createApiRegistryPlugin`. +Removed from `@objectstack/plugin-hono-server`: the `useApiRegistry` option — it was +defaulted to `true` and read by nothing, configuring a service that was never composed. + +**FROM → TO.** There is no replacement shape to migrate to, because nothing executed the +old one: delete the registration objects. If you were assembling an `ApiRegistryEntry`, +you were building a value only your own code read — keep it as your own type. Declarative +endpoints have one vocabulary now, `ApiEndpointSchema`. + +`ConflictResolutionStrategy` **survives** the removal and moved to +`@objectstack/spec/api`'s `router.zod` — same name, same four values +(`error`/`priority`/`first-wins`/`last-wins`), same import path. It is pinned there by two +independent ratchets and is not part of the retired surface. + +## Also in this change + +- `handleApiEndpoint` and its private `callData` delegate are deleted from + `http-dispatcher.ts`, and `/__api-endpoint` leaves `LEGACY_CHAIN_PREFIXES` and the route + ledger. Absence is now loud (ADR-0076): the surface is refused at authoring rather than + 404ing at runtime with dead code behind it. +- `examples/app-showcase` no longer declares endpoints, and its coverage manifest no + longer claims the capability is `demonstrated` — that entry read "executed by the runtime + dispatcher (handleApiEndpoint)", which was exactly the advertise-what-you-don't-deliver + claim Prime Directive #10 forbids. +- The endpoint-level `rateLimit` tracking pointers left by #4910/#5006 now name **#5040**, + the live executor card, instead of #4936, which closes with this change. diff --git a/content/docs/kernel/services.mdx b/content/docs/kernel/services.mdx index 60efcd826f..2e566d2daf 100644 --- a/content/docs/kernel/services.mdx +++ b/content/docs/kernel/services.mdx @@ -33,7 +33,7 @@ export const myPlugin: Plugin = { ``` Take init-time configuration from the plugin's own options (as -`CacheServicePlugin` and `createApiRegistryPlugin()` do) rather than from the +`CacheServicePlugin` does) rather than from the `settings` service: that service is an async, namespaced resolver — `await settings.get(namespace, key)` returns a `{ value, source, locked, … }` envelope, not a synchronous config bag keyed by dotted paths — and it is only @@ -128,7 +128,6 @@ The core ecosystem defines several standard service contracts: | `http-server` | `IHttpServer` | `plugin-hono-server` | | `data` | `IDataEngine` | `@objectstack/objectql` (drivers implement `IDataDriver`) | | `auth` | `IAuthService` | `plugin-auth` | -| `api-registry` | `ApiRegistry` | `@objectstack/core` | | `cache` | `ICacheService` | `@objectstack/service-cache` (memory adapter; its Redis adapter is still a skeleton that throws) — otherwise the kernel's in-memory fallback | | `lifecycle` | `LifecycleService` (`@objectstack/objectql`) | Registered by `ObjectQLPlugin` — enforces object `lifecycle` declarations (ADR-0057 retention/rotation/archival); call `sweep()` for an on-demand pass | diff --git a/content/docs/protocol/kernel/http-protocol.mdx b/content/docs/protocol/kernel/http-protocol.mdx index 3130b9e810..3430e15735 100644 --- a/content/docs/protocol/kernel/http-protocol.mdx +++ b/content/docs/protocol/kernel/http-protocol.mdx @@ -1037,9 +1037,14 @@ is the declared budget multiplied by the number of nodes. **Not implemented, deliberately named rather than implied.** ObjectStack does **not** emit `X-RateLimit-Limit` / `-Remaining` / `-Reset` headers on successful responses — only `Retry-After` on a 429. And the per-endpoint `rateLimit` key on -`ApiEndpointSchema` / `ApiEndpointRegistrationSchema` is **not wired to anything**; -declaring it changes nothing today. Its fate travels with the declarative `apis:` -surface as a whole, tracked by [#4936](https://github.com/objectstack-ai/objectstack/issues/4936). +`ApiEndpointSchema` is **not wired to anything**; declaring it changes nothing today. +It travels with the declarative `apis:` surface as a whole: since +[#4936](https://github.com/objectstack-ai/objectstack/issues/4936) that surface has no +executor and a **non-empty `apis:` is rejected at publish/validate**, so the key cannot +be reached at all. Wiring it is part of the endpoint executor, tracked by +[#5040](https://github.com/objectstack-ai/objectstack/issues/5040). +(The second spelling this callout used to name, `ApiEndpointRegistrationSchema`, was +retired outright in [#4939](https://github.com/objectstack-ai/objectstack/issues/4939).) ## Best Practices diff --git a/content/docs/references/api/index.mdx b/content/docs/references/api/index.mdx index 78eb5a15b7..8ed3b4062a 100644 --- a/content/docs/references/api/index.mdx +++ b/content/docs/references/api/index.mdx @@ -29,7 +29,6 @@ This section contains all protocol schemas for the api layer of ObjectStack. - diff --git a/content/docs/references/api/meta.json b/content/docs/references/api/meta.json index fc9ef0dc22..a99c71d5e4 100644 --- a/content/docs/references/api/meta.json +++ b/content/docs/references/api/meta.json @@ -9,7 +9,6 @@ "endpoint", "errors", "protocol", - "registry", "router", "versioning", "---Transport & Realtime---", diff --git a/content/docs/references/api/registry.mdx b/content/docs/references/api/registry.mdx deleted file mode 100644 index b3b296e095..0000000000 --- a/content/docs/references/api/registry.mdx +++ /dev/null @@ -1,315 +0,0 @@ ---- -title: Registry -description: Registry protocol schemas ---- - -{/* ⚠️ AUTO-GENERATED — DO NOT EDIT. Run build-docs.ts to regenerate. Hand-written docs live in the module folders under content/docs/. */} - -Unified API Registry Protocol - -Provides a centralized registry for managing all API endpoints across different - -API types (REST, OData, WebSocket, Auth, File, Plugin-registered). - -This enables: - -- Unified API discovery and documentation (similar to Swagger/OpenAPI) - -- API testing interfaces - -- API governance and monitoring - -- Plugin API registration - -- Multi-protocol support - -Architecture Alignment: - -- Kubernetes: Service Discovery & API Server - -- AWS API Gateway: Unified API Management - -- Kong Gateway: Plugin-based API Management - -@example API Registry Entry - -```typescript - -const apiEntry: ApiRegistryEntry = \{ - -id: 'customer_crud', - -name: 'Customer CRUD API', - -type: 'rest', - -version: 'v1', - -basePath: '/api/v1/data/customer', - -endpoints: [...], - -metadata: \{ - -owner: 'sales_team', - -tags: ['customer', 'crm'] - -\} - -\} - -``` - - -**Source:** `packages/spec/src/api/registry.zod.ts` - - -## TypeScript Usage - -```typescript -import { ApiDiscoveryQuerySchema, ApiDiscoveryResponseSchema, ApiEndpointRegistrationSchema, ApiMetadataSchema, ApiParameterSchema, ApiProtocolType, ApiRegistrySchema, ApiRegistryEntrySchema, ApiResponseSchema, ConflictResolutionStrategy, HttpStatusCode, ObjectQLReferenceSchema, SchemaDefinition } from '@objectstack/spec/api'; -import type { ApiDiscoveryQuery, ApiDiscoveryResponse, ApiEndpointRegistration, ApiMetadata, ApiParameter, ApiProtocolType, ApiRegistry, ApiRegistryEntry, ApiResponse, ConflictResolutionStrategy, HttpStatusCode, ObjectQLReference, SchemaDefinition } from '@objectstack/spec/api'; - -// Validate data -const result = ApiDiscoveryQuerySchema.parse(data); -``` - ---- - -## ApiDiscoveryQuery - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **type** | `Enum<'rest' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>` | optional | Filter by API protocol type | -| **tags** | `string[]` | optional | Filter by tags (ANY match) | -| **status** | `Enum<'active' \| 'deprecated' \| 'experimental' \| 'beta'>` | optional | Filter by lifecycle status | -| **pluginSource** | `string` | optional | Filter by plugin name | -| **search** | `string` | optional | Full-text search in name/description | -| **version** | `string` | optional | Filter by specific version | - - ---- - -## ApiDiscoveryResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **apis** | `{ id: string; name: string; type: Enum<'rest' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>; version: string; … }[]` | ✅ | Matching API entries | -| **total** | `integer` | ✅ | Total matching APIs | -| **filters** | `{ type?: Enum<'rest' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>; tags?: string[]; status?: Enum<'active' \| 'deprecated' \| 'experimental' \| 'beta'>; pluginSource?: string; … }` | optional | Applied query filters | - - ---- - -## ApiEndpointRegistration - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique endpoint identifier | -| **method** | `Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>` | optional | HTTP method | -| **path** | `string` | ✅ | URL path pattern | -| **summary** | `string` | optional | Short endpoint summary | -| **description** | `string` | optional | Detailed endpoint description | -| **operationId** | `string` | optional | Unique operation identifier | -| **tags** | `string[]` | ✅ | Tags for categorization | -| **parameters** | `{ name: string; in: Enum<'path' \| 'query' \| 'header' \| 'body' \| 'cookie'>; description?: string; required: boolean; … }[]` | ✅ | Endpoint parameters | -| **requestBody** | `{ description?: string; required: boolean; contentType: string; schema?: any; … }` | optional | Request body specification | -| **responses** | `{ statusCode: integer \| Enum<'2xx' \| '3xx' \| '4xx' \| '5xx'>; description: string; contentType: string; schema?: any \| { $ref: object }; … }[]` | ✅ | Possible responses | -| **rateLimit** | `{ enabled: boolean; windowMs: integer; maxRequests: integer }` | optional | Endpoint specific rate limiting | -| **security** | `Record[]` | optional | Security requirements (e.g. [`{"bearerAuth": []}`]) | -| **requiredPermissions** | `string[]` | ✅ | Required RBAC permissions (e.g., "customer.read", "manage_users") | -| **priority** | `integer` | ✅ | Route priority for conflict resolution (0-1000, higher = more important) | -| **protocolConfig** | `Record` | optional | Protocol-specific configuration for custom protocols (gRPC, tRPC, etc.) | -| **deprecated** | `boolean` | ✅ | Whether endpoint is deprecated | -| **externalDocs** | `{ description?: string; url: string }` | optional | External documentation link | - - ---- - -## ApiMetadata - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **owner** | `string` | optional | Owner team or person | -| **status** | `Enum<'active' \| 'deprecated' \| 'experimental' \| 'beta'>` | ✅ | API lifecycle status | -| **tags** | `string[]` | ✅ | Classification tags | -| **pluginSource** | `string` | optional | Source plugin name | -| **custom** | `Record` | optional | Custom metadata fields | - - ---- - -## ApiParameter - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **name** | `string` | ✅ | Parameter name | -| **in** | `Enum<'path' \| 'query' \| 'header' \| 'body' \| 'cookie'>` | ✅ | Parameter location | -| **description** | `string` | optional | Parameter description | -| **required** | `boolean` | ✅ | Whether parameter is required | -| **schema** | `{ type: Enum<'string' \| 'number' \| 'integer' \| 'boolean' \| 'array' \| 'object'>; format?: string; enum?: any[]; default?: any; … } \| { $ref: object }` | ✅ | Parameter schema definition | -| **example** | `any` | optional | Example value | - - ---- - -## ApiProtocolType - -### Allowed Values - -* `rest` -* `odata` -* `websocket` -* `file` -* `auth` -* `metadata` -* `plugin` -* `webhook` -* `rpc` - - ---- - -## ApiRegistry - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **version** | `string` | ✅ | Registry version | -| **conflictResolution** | `Enum<'error' \| 'priority' \| 'first-wins' \| 'last-wins'>` | ✅ | Strategy for handling route conflicts | -| **apis** | `{ id: string; name: string; type: Enum<'rest' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>; version: string; … }[]` | ✅ | All registered APIs | -| **totalApis** | `integer` | ✅ | Total number of registered APIs | -| **totalEndpoints** | `integer` | ✅ | Total number of endpoints | -| **byType** | `Record; version: string; … }[]>` | optional | APIs grouped by protocol type | -| **byStatus** | `Record; version: string; … }[]>` | optional | APIs grouped by status | -| **updatedAt** | `string` | optional | Last registry update time | - - ---- - -## ApiRegistryEntry - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **id** | `string` | ✅ | Unique API identifier (snake_case) | -| **name** | `string` | ✅ | API display name | -| **type** | `Enum<'rest' \| 'odata' \| 'websocket' \| 'file' \| 'auth' \| 'metadata' \| 'plugin' \| 'webhook' \| 'rpc'>` | ✅ | API protocol type | -| **version** | `string` | ✅ | API version (e.g., v1, 2024-01) | -| **basePath** | `string` | ✅ | Base URL path for this API | -| **description** | `string` | optional | API description | -| **endpoints** | `{ id: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'DELETE' \| 'PATCH' \| 'HEAD' \| 'OPTIONS'>; path: string; summary?: string; … }[]` | ✅ | Registered endpoints | -| **config** | `Record` | optional | Protocol-specific configuration | -| **metadata** | `{ owner?: string; status: Enum<'active' \| 'deprecated' \| 'experimental' \| 'beta'>; tags: string[]; pluginSource?: string; … }` | optional | Additional metadata | -| **termsOfService** | `string` | optional | Terms of service URL | -| **contact** | `{ name?: string; url?: string; email?: string }` | optional | Contact information | -| **license** | `{ name: string; url?: string }` | optional | License information | - - ---- - -## ApiResponse - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **statusCode** | `integer \| Enum<'2xx' \| '3xx' \| '4xx' \| '5xx'>` | ✅ | HTTP status code | -| **description** | `string` | ✅ | Response description | -| **contentType** | `string` | ✅ | Response content type | -| **schema** | `any \| { $ref: object }` | optional | Response body schema | -| **headers** | `Record` | optional | Response headers | -| **example** | `any` | optional | Example response | - - ---- - -## ConflictResolutionStrategy - -### Allowed Values - -* `error` -* `priority` -* `first-wins` -* `last-wins` - - ---- - -## HttpStatusCode - -### Union Options - -This schema accepts one of the following structures: - -#### Option 1 - -Type: `integer` - ---- - -#### Option 2 - -Allowed Values: `2xx`, `3xx`, `4xx`, `5xx` - ---- - - ---- - -## ObjectQLReference - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **objectId** | `string` | ✅ | Object name to reference | -| **includeFields** | `string[]` | optional | Include only these fields in the schema | -| **excludeFields** | `string[]` | optional | Exclude these fields from the schema | -| **includeRelated** | `string[]` | optional | Include related objects via lookup fields | - - ---- - -## SchemaDefinition - -### Union Options - -This schema accepts one of the following structures: - -#### Option 1 - -Static JSON Schema definition - -Type: `any` - ---- - -#### Option 2 - -Dynamic ObjectQL reference - -### Properties - -| Property | Type | Required | Description | -| :--- | :--- | :--- | :--- | -| **$ref** | `{ objectId: string; includeFields?: string[]; excludeFields?: string[]; includeRelated?: string[] }` | ✅ | Dynamic reference to ObjectQL object | - ---- - - ---- - diff --git a/content/docs/references/api/router.mdx b/content/docs/references/api/router.mdx index d3e955413b..93403dff6a 100644 --- a/content/docs/references/api/router.mdx +++ b/content/docs/references/api/router.mdx @@ -16,13 +16,25 @@ Classifies routes for middleware application and security policies. ## TypeScript Usage ```typescript -import { RouteCategory, RouteDefinitionSchema, RouterConfigSchema } from '@objectstack/spec/api'; -import type { RouteCategory, RouteDefinition, RouterConfig } from '@objectstack/spec/api'; +import { ConflictResolutionStrategy, RouteCategory, RouteDefinitionSchema, RouterConfigSchema } from '@objectstack/spec/api'; +import type { ConflictResolutionStrategy, RouteCategory, RouteDefinition, RouterConfig } from '@objectstack/spec/api'; // Validate data -const result = RouteCategory.parse(data); +const result = ConflictResolutionStrategy.parse(data); ``` +--- + +## ConflictResolutionStrategy + +### Allowed Values + +* `error` +* `priority` +* `first-wins` +* `last-wins` + + --- ## RouteCategory diff --git a/content/docs/references/system/stack-server.mdx b/content/docs/references/system/stack-server.mdx index 63fbc62910..a34798ba42 100644 --- a/content/docs/references/system/stack-server.mdx +++ b/content/docs/references/system/stack-server.mdx @@ -65,9 +65,13 @@ baked into the artifact. Related: #4910 (this seam), #4937 (the limiter that documented an execution -chain it never had), #4936 (`apis:` endpoint-level `rateLimit`, still +chain it never had), #4936 (the declarative `apis:` surface: vocabulary kept, -unwired), ADR-0069 D2 (shared counters), ADR-0049 (enforce or remove). +a non-empty array rejected until an executor exists) and #5040 (that + +executor, which wires endpoint-level `rateLimit` — still unwired today), + +ADR-0069 D2 (shared counters), ADR-0049 (enforce or remove). **Source:** `packages/spec/src/system/stack-server.zod.ts` diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index 12b9b47ca5..361616cbfc 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -86,7 +86,7 @@ const result = ActionNavItemSchema.parse(data); | **homePageId** | `any` | optional | [REMOVED] `app.homePageId` was removed in @objectstack/spec 17.0.0 (#4667, #4709, ADR-0049). objectui's console did read it before v17 (`resolveLandingRoute`), so this key had a consumer — it was retired because the capability is better expressed on the navigation item itself than as an ID cross-reference that silently falls back when it dangles. An app's landing page IS its first navigation item (by `order`), and the root landing follows `isDefault` routing. Delete the key; to change where an app opens, reorder `navigation` so the intended entry is first, and set `isDefault` on the app that should own the root landing. Run `os migrate meta --from 16` to rewrite existing sources automatically. | | **requiredPermissions** | `string[]` | optional | Permissions required to access this app | | **objects** | `any` | optional | [REMOVED] `App.objects` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read; the spec itself labelled it "config file convenience"). Objects belong to the stack (`defineStack({ objects })`); an app reaches them through its navigation items. Delete the key. | -| **apis** | `any` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Declarative endpoints belong to the stack (`defineStack({ apis })`), not the app shell. Delete the key. | +| **apis** | `any` | optional | [REMOVED] `App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — never read). Delete the key. Note the stack-level `defineStack({ apis })` this prescription used to redirect to is ALSO not executable in v17 (#4936): the vocabulary is kept but a non-empty array is rejected there too, until the endpoint executor ships (tracked by https://github.com/objectstack-ai/objectstack/issues/5040). Serve the route in code meanwhile — a plugin manifest `contributes.routes` entry or an `http.server` route. | | **sharing** | `any` | optional | [REMOVED] `App.sharing` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049 enforce-or-remove) — no public-app route ever read it, so it declared sharing that did not exist. Public access is granted per FORM VIEW (`FormView.sharing`, the public-data-collection surface). Delete the key. | | **embed** | `any` | optional | [REMOVED] `App.embed` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit / ADR-0049) — no iframe route ever read it. Embedding is a per-form-view surface (`FormView.sharing`), not an app-level switch. Delete the key. | | **mobileNavigation** | `any` | optional | [REMOVED] `App.mobileNavigation` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — fully unimplemented; no renderer, including packages/mobile, ever read it). Delete the key; the block returns if/when a real mobile navigation ships. | diff --git a/docs/adr/0088-metadata-kind-admission-and-retirement.md b/docs/adr/0088-metadata-kind-admission-and-retirement.md index cd60c1901c..a14d7fbcc4 100644 --- a/docs/adr/0088-metadata-kind-admission-and-retirement.md +++ b/docs/adr/0088-metadata-kind-admission-and-retirement.md @@ -37,7 +37,7 @@ Their real, consumed forms already live elsewhere, and all are **code contributi | Retired kind | Delivered form | |---|---| -| `router` | plugin manifest `contributes.routes` (HttpDispatcher prefix routing) + app-authored declarative `apis:` (`ApiEndpoint`, executed by `handleApiEndpoint`) + imperative `http.server` mounts | +| `router` | plugin manifest `contributes.routes` (HttpDispatcher prefix routing) + imperative `http.server` mounts. ⚠️ **Correction (#4936, 2026-08-04):** this row originally also credited app-authored declarative `apis:` as "executed by `handleApiEndpoint`". That was never true — no route was mounted for a declared path and `matchEndpoint` had no implementation anywhere, so the branch was dead code; it has been deleted and a non-empty `apis:` is now rejected at publish/validate. The retirement decision for the `router` KIND is unaffected (its delivered forms are the two above, both code contributions); the endpoint executor is being built under #5040, after which declarative `apis:` becomes a third, real delivered form. | | `function` | `defineStack({ functions })` code values (hook-binder & flow `script` body runners) + plugin `contributes.functions` (QL query functions) | | `service` | the plugin/service registry itself (`registerService`) | diff --git a/examples/app-showcase/objectstack.config.ts b/examples/app-showcase/objectstack.config.ts index c2a0e600d5..90de4b659d 100644 --- a/examples/app-showcase/objectstack.config.ts +++ b/examples/app-showcase/objectstack.config.ts @@ -234,8 +234,13 @@ export default defineStack({ }, jobs: allJobs, emailTemplates: allEmails, - // Declarative REST endpoints (object_operation + flow) — the metadata - // counterpart of the code-mounted recalc endpoint (see src/system/apis/). + // Declarative REST endpoints — SUSPENDED and therefore EMPTY (#4936). The + // two definitions this used to carry are preserved, commented, in + // src/system/apis/index.ts with the full rationale: the surface parsed + // perfectly and executed nothing, so a non-empty `apis:` is now rejected at + // publish/validate. The code-mounted recalc endpoint + // (src/system/server/recalc-endpoint.ts) is the working path meanwhile; the + // executor that makes this key usable again is tracked by #5040. apis: allApis, // Declarative `connectors:` — both kinds (ADR-0097): provider-bound // INSTANCES (StatusApiConnector via `rest`; StatusOpenApiConnector via diff --git a/examples/app-showcase/src/coverage.ts b/examples/app-showcase/src/coverage.ts index 7c455b858d..c20f6b8bfd 100644 --- a/examples/app-showcase/src/coverage.ts +++ b/examples/app-showcase/src/coverage.ts @@ -180,10 +180,10 @@ export const STACK_COLLECTION_COVERAGE: Record = { notes: 'Merged into showcase_account by the ObjectQL engine at registerApp (priority overlay).', }, apis: { - status: 'demonstrated', - files: ['src/system/apis/index.ts'], - notes: - 'Declarative ApiEndpoint metadata (object_operation + flow targets), executed by the runtime dispatcher (handleApiEndpoint). Complements the code-mounted endpoint in src/system/server/ (router kind stays waived: code-only).', + status: 'waived', + reason: + 'NOT demonstrable: the runtime has no executor for declarative `apis:`. This entry read "demonstrated … executed by the runtime dispatcher (handleApiEndpoint)" until #4936 measured it on a real boot — the two declared endpoints returned a bare 404 (no route was ever mounted for them) while a control request on the same cookie and prefix returned 200, and the dispatcher branch named here called a `matchEndpoint` that no implementation in the repo provided. That made this the exact false-coverage claim Prime Directive #10 forbids, on a surface whose keys include `authRequired`. Per the 2026-08-04 verdict the vocabulary is kept but a non-empty `apis:` is now rejected at publish/validate, so the showcase declares none; src/system/apis/index.ts keeps both definitions commented, ready to restore. HTTP endpoints are still demonstrated the honest way, in code, by src/system/server/recalc-endpoint.ts (the router kind stays waived: code-only). Flip this back to `demonstrated` in the same PR that lands the executor.', + issue: 'https://github.com/objectstack-ai/objectstack/issues/5040', }, connectors: { status: 'demonstrated', diff --git a/examples/app-showcase/src/system/apis/index.ts b/examples/app-showcase/src/system/apis/index.ts index 5c7ffd5239..68090014a7 100644 --- a/examples/app-showcase/src/system/apis/index.ts +++ b/examples/app-showcase/src/system/apis/index.ts @@ -3,41 +3,74 @@ import type { ApiEndpoint } from '@objectstack/spec/api'; /** - * Declarative API endpoints (`apis:`) — the metadata-authored counterpart of - * the code-mounted endpoint in src/system/server/recalc-endpoint.ts. The - * runtime dispatcher matches these by path+method and executes the target - * (`object_operation` → a data read; `flow` → a flow run) with no handler - * code. Migrated here from app-crm when that example was slimmed back to a - * pure loading-pipeline smoke fixture. + * Declarative API endpoints (`apis:`) — SUSPENDED in v17 (#4936). + * + * ## Why this file is empty + * + * The showcase used to declare the two endpoints preserved below, and the + * metadata side worked perfectly: `defineStack({ apis })` loaded them, and + * `GET /api/v1/meta/api` returned both with every key intact. The EXECUTION + * side was zero-hit. On a real boot (showcase, 47 plugins) the declared paths + * answered a bare `404 {"error":"Not found"}` — not even the dispatcher's + * semantic 404, because no route was ever mounted for them and the request + * died at Hono's `notFound`. Behind that, the dispatcher's `handleApiEndpoint` + * branch called a `matchEndpoint` method that no implementation in the repo + * ever provided, so it could not have executed anything even if reached. + * + * Every key was therefore declared ≠ enforced — `authRequired: true` included, + * which parsed green while gating nothing at all. Per the maintainer verdict + * (2026-08-04, #4936) a non-empty `apis:` is now REJECTED at publish/validate + * with a prescription, so these definitions are commented out rather than + * shipped: an example must never demo a capability the runtime does not + * deliver (Prime Directive #10). + * + * ## What replaces it today + * + * A code-mounted endpoint — see `src/system/server/recalc-endpoint.ts`. That + * is the honest path until the executor lands. + * + * ## Restoring these + * + * The `ApiEndpoint` vocabulary is deliberately KEPT: the verdict rejected + * retiring it, because endpoint shapes are an industry-stable form that would + * only be re-introduced identically later. When the executor ships (#5040 — + * mounting + endpoint matching + per-key wiring), the rejection is replaced by + * real execution and the two definitions below can be uncommented as-is. They + * are kept verbatim for exactly that reason. */ -/** Read-only data projection: GET a filtered task list through a stable URL. */ -export const TaskFeedEndpoint: ApiEndpoint = { - name: 'showcase_task_feed', - path: '/api/v1/showcase/tasks', - method: 'GET', - summary: 'Task feed', - description: 'Returns tasks via a declarative object_operation endpoint — no handler code.', - type: 'object_operation', - target: 'showcase_task', - objectParams: { - object: 'showcase_task', - operation: 'find', - }, - authRequired: true, - cacheTtl: 30, -}; +// /** Read-only data projection: GET a filtered task list through a stable URL. */ +// export const TaskFeedEndpoint: ApiEndpoint = { +// name: 'showcase_task_feed', +// path: '/api/v1/showcase/tasks', +// method: 'GET', +// summary: 'Task feed', +// description: 'Returns tasks via a declarative object_operation endpoint — no handler code.', +// type: 'object_operation', +// target: 'showcase_task', +// objectParams: { +// object: 'showcase_task', +// operation: 'find', +// }, +// authRequired: true, +// cacheTtl: 30, +// }; +// +// /** Flow-typed endpoint: POST triggers the janitor flow (get+delete demo). */ +// export const InquiryPurgeEndpoint: ApiEndpoint = { +// name: 'showcase_inquiry_purge_api', +// path: '/api/v1/showcase/inquiries/purge', +// method: 'POST', +// summary: 'Purge closed inquiries', +// description: 'Invokes the showcase_inquiry_purge flow (get_record + delete_record janitor) over HTTP.', +// type: 'flow', +// target: 'showcase_inquiry_purge', +// authRequired: true, +// }; -/** Flow-typed endpoint: POST triggers the janitor flow (get+delete demo). */ -export const InquiryPurgeEndpoint: ApiEndpoint = { - name: 'showcase_inquiry_purge_api', - path: '/api/v1/showcase/inquiries/purge', - method: 'POST', - summary: 'Purge closed inquiries', - description: 'Invokes the showcase_inquiry_purge flow (get_record + delete_record janitor) over HTTP.', - type: 'flow', - target: 'showcase_inquiry_purge', - authRequired: true, -}; - -export const allApis = [TaskFeedEndpoint, InquiryPurgeEndpoint]; +/** + * Empty on purpose (#4936). An empty array and an absent key are both still + * accepted — only a NON-EMPTY `apis:` is rejected — so the stack keeps + * exercising the accepted shape rather than dropping the wiring entirely. + */ +export const allApis: ApiEndpoint[] = []; diff --git a/examples/app-showcase/test/gap-fill.test.ts b/examples/app-showcase/test/gap-fill.test.ts index 708cfd8545..edbdcf09d6 100644 --- a/examples/app-showcase/test/gap-fill.test.ts +++ b/examples/app-showcase/test/gap-fill.test.ts @@ -1,5 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +import { existsSync } from 'node:fs'; + import { describe, it, expect } from 'vitest'; import { SchemaRegistry } from '@objectstack/objectql'; @@ -60,28 +62,44 @@ describe('showcase gap fill — named import mapping (#2611)', () => { }); }); -describe('showcase gap fill — declarative api endpoints', () => { - it('is wired into the stack definition', () => { +/** + * [#4936] Declarative api endpoints — the showcase declares NONE, on purpose. + * + * This block used to assert the opposite: that `showcase_task_feed` and + * `showcase_inquiry_purge_api` were wired into the stack. They were — and that + * was the problem. The metadata loaded perfectly (`GET /api/v1/meta/api` + * returned both) while a real boot answered a bare 404 on each declared path, + * because no route was ever mounted and the dispatcher branch behind them + * called a `matchEndpoint` that no implementation provided. Every key was + * declared ≠ enforced, `authRequired: true` included. + * + * So the assertion is inverted rather than deleted. A test that merely stopped + * checking would let the endpoints drift back in silently; this one fails if + * they do — which matters because re-adding them is no longer just inert, it + * now breaks `objectstack validate` for the whole example. + */ +describe('[#4936] showcase declares no executable-less api endpoints', () => { + it('ships an EMPTY `apis:` — the shape publish/validate still accepts', () => { const apis = (stack as { apis?: Array<{ name: string }> }).apis ?? []; - expect(apis.map((a) => a.name)).toEqual( - expect.arrayContaining(['showcase_task_feed', 'showcase_inquiry_purge_api']), - ); + expect( + apis, + 'A non-empty `apis:` is rejected at publish/validate (#4936): the runtime has no ' + + 'executor for declarative endpoints, so declaring one would fail `objectstack ' + + 'validate` AND advertise a capability that does not exist (Prime Directive #10). ' + + 'The two definitions this example used to ship are preserved, commented, in ' + + 'src/system/apis/index.ts — restore them in the same PR that lands the executor (#5040).', + ).toEqual([]); }); - it('flow-typed endpoints target flows that actually exist (no 500 at dispatch)', () => { - const apis = (stack as { apis?: Array<{ type: string; target: string }> }).apis ?? []; - const flowNames = ((stack as { flows?: Array<{ name: string }> }).flows ?? []).map((f) => f.name); - for (const api of apis.filter((a) => a.type === 'flow')) { - expect(flowNames, `api endpoint targets missing flow '${api.target}'`).toContain(api.target); - } - }); - - it('object_operation endpoints target objects that exist', () => { - const apis = (stack as { apis?: Array<{ type: string; target: string }> }).apis ?? []; - const objectNames = ((stack as { objects?: Array<{ name: string }> }).objects ?? []).map((o) => o.name); - for (const api of apis.filter((a) => a.type === 'object_operation')) { - expect(objectNames, `api endpoint targets missing object '${api.target}'`).toContain(api.target); - } + it('still demonstrates HTTP endpoints the honest way — in code', () => { + // The replacement path the coverage waiver points at. If this file ever + // disappears the waiver is lying too, so pin it here rather than trusting + // the note alone. (vitest runs with cwd = the package root, as + // test/coverage.test.ts also relies on.) + expect( + existsSync(`${process.cwd()}/src/system/server/recalc-endpoint.ts`), + "the code-mounted endpoint is the showcase's live HTTP proof", + ).toBe(true); }); }); diff --git a/packages/client/src/client-url-conformance.test.ts b/packages/client/src/client-url-conformance.test.ts index 358b919aec..ac618fb5f6 100644 --- a/packages/client/src/client-url-conformance.test.ts +++ b/packages/client/src/client-url-conformance.test.ts @@ -60,10 +60,15 @@ const BASE = 'http://localhost:9'; interface Pattern { verb: string; source: string; route: string; re: RegExp } /** - * `(unmatched)` is the `__api-endpoint` catch-all: metadata-declared custom - * endpoints, whose route set exists only at runtime. Treating it as a pattern - * would match every URL and make this whole suite vacuous, so it is excluded - * — the one ledger row this guard deliberately cannot use. + * A `(unmatched)` row would match every URL and make this whole suite vacuous, + * so it is excluded here and re-asserted absent below ("guard the guard"). + * + * The one row that ever had this shape — the `__api-endpoint` catch-all for + * metadata-declared `apis:` — was REMOVED in #4936, because nothing served it: + * the branch behind it called a `matchEndpoint` no implementation provided. + * The exclusion stays as a standing rule, not as a description of a live row: + * the endpoint executor is being built (#5040), and if it ever re-enters the + * ledger it must arrive as enumerated routes, never as a catch-all. */ const UNUSABLE_ROWS = new Set(['* (unmatched)']); diff --git a/packages/core/API_REGISTRY.md b/packages/core/API_REGISTRY.md deleted file mode 100644 index 5ff464a786..0000000000 --- a/packages/core/API_REGISTRY.md +++ /dev/null @@ -1,392 +0,0 @@ -# API Registry Implementation - -## Overview - -The API Registry is a centralized service in the ObjectStack kernel that manages API endpoint registration, discovery, and conflict resolution across different protocols and plugins. - -## Features - -✅ **Multi-Protocol Support** - REST, GraphQL, OData, WebSocket, Plugin APIs, and more -✅ **Route Conflict Detection** - Configurable strategies (error, priority, first-wins, last-wins) -✅ **RBAC Integration** - Endpoints can specify required permissions -✅ **Dynamic Schema Linking** - Reference ObjectQL objects for auto-updating schemas -✅ **Protocol Extensions** - Support for gRPC, tRPC, and custom protocols -✅ **API Discovery** - Filter and search APIs by type, status, tags, and more - -## Architecture - -The API Registry follows the ObjectStack microkernel pattern: - -``` -┌─────────────────────────────────────────────────────┐ -│ ObjectKernel (Core) │ -│ ┌───────────────────────────────────────────────┐ │ -│ │ Service Registry (DI Container) │ │ -│ │ ┌─────────────────────────────────────────┐ │ │ -│ │ │ API Registry Service │ │ │ -│ │ │ • registerApi() │ │ │ -│ │ │ • unregisterApi() │ │ │ -│ │ │ • findApis() │ │ │ -│ │ │ • getRegistry() │ │ │ -│ │ └─────────────────────────────────────────┘ │ │ -│ └───────────────────────────────────────────────┘ │ -└─────────────────────────────────────────────────────┘ - │ - ┌─────────┴─────────┬──────────┬──────────┐ - │ │ │ │ -┌───▼────┐ ┌───────▼──┐ ┌──▼───┐ ┌───▼────┐ -│ REST │ │ GraphQL │ │WebSkt│ │ Plugin │ -│ Plugin │ │ Plugin │ │Plugin│ │ APIs │ -└────────┘ └──────────┘ └──────┘ └────────┘ -``` - -## Usage - -### 1. Register the API Registry Plugin - -```typescript -import { ObjectKernel, createApiRegistryPlugin } from '@objectstack/core'; - -const kernel = new ObjectKernel(); - -// Register with default settings (error on conflicts) -kernel.use(createApiRegistryPlugin()); - -// Or with custom configuration -kernel.use( - createApiRegistryPlugin({ - conflictResolution: 'priority', // priority, first-wins, last-wins - version: '1.0.0', - }) -); - -await kernel.bootstrap(); -``` - -### 2. Register APIs in Plugins - -```typescript -import type { Plugin } from '@objectstack/core'; -import type { ApiRegistry } from '@objectstack/core'; -import type { ApiRegistryEntry } from '@objectstack/spec/api'; - -const myPlugin: Plugin = { - name: 'my-plugin', - version: '1.0.0', - - init: async (ctx) => { - // Get the API Registry service - const registry = ctx.getService('api-registry'); - - // Register your API - const api: ApiRegistryEntry = { - id: 'customer_api', - name: 'Customer API', - type: 'rest', - version: 'v1', - basePath: '/api/v1/customers', - endpoints: [ - { - id: 'get_customer', - method: 'GET', - path: '/api/v1/customers/:id', - summary: 'Get customer by ID', - requiredPermissions: ['customer.read'], // RBAC - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string', format: 'uuid' }, - }, - ], - responses: [ - { - statusCode: 200, - description: 'Customer found', - schema: { - $ref: { - objectId: 'customer', // Dynamic ObjectQL reference - excludeFields: ['password_hash'], - }, - }, - }, - ], - }, - ], - metadata: { - status: 'active', - tags: ['customer', 'crm'], - }, - }; - - registry.registerApi(api); - }, -}; -``` - -### 3. Discover APIs - -```typescript -const registry = kernel.getService('api-registry'); - -// Get all APIs -const allApis = registry.getAllApis(); - -// Find REST APIs -const restApis = registry.findApis({ type: 'rest' }); - -// Find active APIs with specific tags -const crmApis = registry.findApis({ - status: 'active', - tags: ['crm'], -}); - -// Search by name -const searchResults = registry.findApis({ - search: 'customer', -}); - -// Get endpoint by route -const endpoint = registry.findEndpointByRoute('GET', '/api/v1/customers/:id'); -console.log(endpoint?.api.name); // "Customer API" -console.log(endpoint?.endpoint.summary); // "Get customer by ID" -``` - -### 4. Get Registry Snapshot - -```typescript -const registry = kernel.getService('api-registry'); -const snapshot = registry.getRegistry(); - -console.log(`Total APIs: ${snapshot.totalApis}`); -console.log(`Total Endpoints: ${snapshot.totalEndpoints}`); -console.log(`Conflict Resolution: ${snapshot.conflictResolution}`); - -// APIs grouped by type -snapshot.byType?.rest.forEach((api) => { - console.log(`REST API: ${api.name}`); -}); - -// APIs grouped by status -snapshot.byStatus?.active.forEach((api) => { - console.log(`Active API: ${api.name}`); -}); -``` - -## Conflict Resolution Strategies - -### 1. Error (Default) - -Throws an error when a route conflict is detected. - -```typescript -kernel.use(createApiRegistryPlugin({ conflictResolution: 'error' })); -``` - -**Best for:** Production environments where conflicts should be caught early. - -### 2. Priority - -Uses the `priority` field on endpoints to resolve conflicts. Higher priority wins. - -```typescript -kernel.use(createApiRegistryPlugin({ conflictResolution: 'priority' })); - -// In your plugin -registry.registerApi({ - endpoints: [ - { - path: '/api/data/:object', - priority: 900, // Core API (high priority) - }, - ], -}); -``` - -**Priority Ranges:** -- **900-1000**: Core system endpoints -- **500-900**: Custom/override endpoints -- **100-500**: Plugin endpoints -- **0-100**: Fallback routes - -### 3. First-Wins - -First registered endpoint wins. Subsequent registrations are ignored. - -```typescript -kernel.use(createApiRegistryPlugin({ conflictResolution: 'first-wins' })); -``` - -**Best for:** Stable, predictable routing where load order matters. - -### 4. Last-Wins - -Last registered endpoint wins. Previous registrations are overwritten. - -```typescript -kernel.use(createApiRegistryPlugin({ conflictResolution: 'last-wins' })); -``` - -**Best for:** Development/testing where you want to override defaults. - -## RBAC Integration - -Endpoints can specify required permissions that are automatically validated at the gateway level: - -```typescript -{ - id: 'delete_customer', - method: 'DELETE', - path: '/api/v1/customers/:id', - requiredPermissions: [ - 'customer.delete', - 'api_enabled', - ], - responses: [], -} -``` - -**Permission Format:** -- **Object Permissions:** `.` (e.g., `customer.read`, `order.delete`) -- **System Permissions:** `` (e.g., `manage_users`, `api_enabled`) - -## Dynamic Schema Linking - -Reference ObjectQL objects instead of static schemas: - -```typescript -{ - statusCode: 200, - description: 'Customer retrieved', - schema: { - $ref: { - objectId: 'customer', // ObjectQL object name - excludeFields: ['password_hash'], // Exclude sensitive fields - includeFields: ['id', 'name'], // Or whitelist specific fields - includeRelated: ['account'], // Include related objects - }, - }, -} -``` - -**Benefits:** -- API documentation auto-updates when object schemas change -- No schema duplication between API and data model -- Consistent type definitions across API and database - -## Protocol-Specific Configuration - -Support custom protocols with `protocolConfig`: - -### WebSocket - -```typescript -{ - id: 'customer_updates', - path: '/ws/customers', - protocolConfig: { - subProtocol: 'websocket', - eventName: 'customer.updated', - direction: 'server-to-client', - }, -} -``` - -### gRPC - -```typescript -{ - id: 'grpc_method', - path: '/grpc/CustomerService/GetCustomer', - protocolConfig: { - subProtocol: 'grpc', - serviceName: 'CustomerService', - methodName: 'GetCustomer', - streaming: false, - }, -} -``` - -### tRPC - -```typescript -{ - id: 'trpc_query', - path: '/trpc/customer.get', - protocolConfig: { - subProtocol: 'trpc', - procedureType: 'query', - router: 'customer', - }, -} -``` - -## API Registry Methods - -### Registration - -- `registerApi(api: ApiRegistryEntry): void` - Register an API -- `unregisterApi(apiId: string): void` - Unregister an API - -### Discovery - -- `getApi(apiId: string): ApiRegistryEntry | undefined` - Get API by ID -- `getAllApis(): ApiRegistryEntry[]` - Get all registered APIs -- `findApis(query: ApiDiscoveryQuery): ApiDiscoveryResponse` - Search/filter APIs -- `getEndpoint(apiId: string, endpointId: string): ApiEndpointRegistration | undefined` - Get specific endpoint -- `findEndpointByRoute(method: string, path: string): { api, endpoint } | undefined` - Find endpoint by route - -### Registry Info - -- `getRegistry(): ApiRegistry` - Get complete registry snapshot -- `getStats(): RegistryStats` - Get registry statistics -- `clear(): void` - Clear all registered APIs (for testing) - -## Examples - -See [api-registry-example.ts](./examples/api-registry-example.ts) for comprehensive examples: - -1. **Basic API Registration** - Simple REST API with CRUD endpoints -2. **Multi-Plugin Discovery** - Multiple plugins registering different API types -3. **Route Conflict Resolution** - Priority-based conflict handling -4. **Custom Protocol Support** - WebSocket API with protocol config -5. **Dynamic Schema Linking** - ObjectQL reference in API responses - -## Testing - -Run the API Registry tests: - -```bash -pnpm --filter @objectstack/core test api-registry.test.ts -pnpm --filter @objectstack/core test api-registry-plugin.test.ts -``` - -**Test Coverage:** -- ✅ 32 tests for ApiRegistry service -- ✅ 9 tests for API Registry plugin -- ✅ All conflict resolution strategies -- ✅ Multi-protocol support -- ✅ API discovery and filtering -- ✅ Integration with kernel lifecycle - -## Next Steps - -Based on [API_REGISTRY_ENHANCEMENTS.md](../../API_REGISTRY_ENHANCEMENTS.md), recommended next implementations: - -1. **API Explorer Plugin** - UI to visualize the registry -2. **Gateway Integration** - Implement permission checking in API gateway -3. **Schema Resolution** - Build engine to resolve ObjectQL references to JSON schemas -4. **Conflict Detection UI** - Visualization of route conflicts and priorities -5. **Plugin Examples** - Reference implementations for gRPC and tRPC plugins - -## Related Documentation - -- [API Registry Schema](../spec/src/api/registry.zod.ts) - Zod schema definitions -- [API Registry Tests](./src/api-registry.test.ts) - Comprehensive test suite -- [Plugin System](./README.md) - ObjectStack plugin architecture -- [Microkernel Design](../../ARCHITECTURE.md) - Overall architecture - -## License - -MIT diff --git a/packages/core/README.md b/packages/core/README.md index 35c58351ee..2490e0b165 100644 --- a/packages/core/README.md +++ b/packages/core/README.md @@ -63,7 +63,6 @@ await kernel.bootstrap(); | `Plugin` | interface | Plugin contract (`init`, `start`, `stop`, lifecycle hooks). | | `PluginContext` | interface | `{ logger, registerService, getService, eventBus, kernel }`. | | `ObjectLogger` | class | Structured logger over pino; swappable backends. | -| `ApiRegistry` | class | Runtime route registry (consumed by `@objectstack/rest`). | | `QA` | namespace | Built-in kernel self-tests. | | `PackageManager` | class | Per-package DI namespace resolver. | diff --git a/packages/core/examples/api-registry-example.ts b/packages/core/examples/api-registry-example.ts deleted file mode 100644 index 83baa95561..0000000000 --- a/packages/core/examples/api-registry-example.ts +++ /dev/null @@ -1,549 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * API Registry Example - * - * Demonstrates how to use the API Registry in the ObjectStack kernel - * to register and discover API endpoints across plugins. - */ - -import { ObjectKernel, createApiRegistryPlugin, ApiRegistry } from '@objectstack/core'; -import type { Plugin } from '@objectstack/core'; -import type { ApiRegistryEntry } from '@objectstack/spec/api'; - -// Example 1: Basic API Registration -async function example1_BasicApiRegistration() { - console.log('\n=== Example 1: Basic API Registration ===\n'); - - const kernel = new ObjectKernel(); - - // Register API Registry plugin with default settings - kernel.use(createApiRegistryPlugin()); - - // Create a plugin that registers a simple REST API - const customerPlugin: Plugin = { - name: 'customer-plugin', - version: '1.0.0', - // init() resolves `api-registry` synchronously with no fallback, so the - // ordering requirement is DECLARED rather than left to the `kernel.use()` - // order above (ADR-0116). Registration order is not a contract — the - // kernel orders from this graph. - dependencies: ['com.objectstack.core.api-registry'], - requiresServices: ['api-registry'], - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - - const customerApi: ApiRegistryEntry = { - id: 'customer_api', - name: 'Customer Management API', - type: 'rest', - version: 'v1', - basePath: '/api/v1/customers', - description: 'CRUD operations for customer records', - endpoints: [ - { - id: 'list_customers', - method: 'GET', - path: '/api/v1/customers', - summary: 'List all customers', - parameters: [ - { - name: 'limit', - in: 'query', - schema: { type: 'number' }, - description: 'Maximum number of results', - }, - { - name: 'offset', - in: 'query', - schema: { type: 'number' }, - description: 'Offset for pagination', - }, - ], - responses: [ - { - statusCode: 200, - description: 'Customers retrieved successfully', - schema: { - type: 'array', - items: { - type: 'object', - properties: { - id: { type: 'string' }, - name: { type: 'string' }, - email: { type: 'string' }, - }, - }, - }, - }, - ], - }, - { - id: 'get_customer', - method: 'GET', - path: '/api/v1/customers/:id', - summary: 'Get customer by ID', - requiredPermissions: ['customer.read'], // RBAC integration - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string', format: 'uuid' }, - }, - ], - responses: [ - { - statusCode: 200, - description: 'Customer found', - }, - { - statusCode: 404, - description: 'Customer not found', - }, - ], - }, - { - id: 'create_customer', - method: 'POST', - path: '/api/v1/customers', - summary: 'Create new customer', - requiredPermissions: ['customer.create'], - requestBody: { - required: true, - contentType: 'application/json', - schema: { - type: 'object', - properties: { - name: { type: 'string' }, - email: { type: 'string', format: 'email' }, - }, - }, - }, - responses: [ - { - statusCode: 201, - description: 'Customer created', - }, - ], - }, - ], - metadata: { - status: 'active', - tags: ['customer', 'crm', 'data'], - owner: 'sales_team', - }, - }; - - registry.registerApi(customerApi); - ctx.logger.info('Customer API registered', { - endpointCount: customerApi.endpoints.length, - }); - }, - }; - - kernel.use(customerPlugin); - await kernel.bootstrap(); - - // Access the registry - const registry = kernel.getService('api-registry'); - const snapshot = registry.getRegistry(); - - console.log(`Total APIs: ${snapshot.totalApis}`); - console.log(`Total Endpoints: ${snapshot.totalEndpoints}`); - console.log('\nRegistered APIs:'); - snapshot.apis.forEach((api) => { - console.log(` - ${api.name} (${api.type}) - ${api.endpoints.length} endpoints`); - }); - - await kernel.shutdown(); -} - -// Example 2: Multi-Plugin API Discovery -async function example2_MultiPluginDiscovery() { - console.log('\n=== Example 2: Multi-Plugin API Discovery ===\n'); - - const kernel = new ObjectKernel(); - kernel.use(createApiRegistryPlugin()); - - // Data Plugin - REST APIs - const dataPlugin: Plugin = { - name: 'data-plugin', - dependencies: ['com.objectstack.core.api-registry'], - requiresServices: ['api-registry'], - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - - registry.registerApi({ - id: 'customer_api', - name: 'Customer API', - type: 'rest', - version: 'v1', - basePath: '/api/v1/customers', - endpoints: [ - { - id: 'get_customers', - method: 'GET', - path: '/api/v1/customers', - responses: [], - }, - ], - metadata: { - status: 'active', - tags: ['data', 'crm'], - }, - }); - - registry.registerApi({ - id: 'product_api', - name: 'Product API', - type: 'rest', - version: 'v1', - basePath: '/api/v1/products', - endpoints: [ - { - id: 'get_products', - method: 'GET', - path: '/api/v1/products', - responses: [], - }, - ], - metadata: { - status: 'active', - tags: ['data', 'inventory'], - }, - }); - }, - }; - - - // Analytics Plugin - Beta API - const analyticsPlugin: Plugin = { - name: 'analytics-plugin', - dependencies: ['com.objectstack.core.api-registry'], - requiresServices: ['api-registry'], - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - - registry.registerApi({ - id: 'analytics_api', - name: 'Analytics API', - type: 'rest', - version: 'v1', - basePath: '/api/v1/analytics', - endpoints: [ - { - id: 'get_reports', - method: 'GET', - path: '/api/v1/analytics/reports', - responses: [], - }, - ], - metadata: { - status: 'beta', - tags: ['analytics', 'reporting'], - }, - }); - }, - }; - - kernel.use(dataPlugin); - kernel.use(analyticsPlugin); - await kernel.bootstrap(); - - const registry = kernel.getService('api-registry'); - - // Discovery 1: Find all REST APIs - console.log('All REST APIs:'); - const restApis = registry.findApis({ type: 'rest' }); - restApis.apis.forEach((api) => console.log(` - ${api.name}`)); - - // Discovery 2: Find active APIs - console.log('\nActive APIs:'); - const activeApis = registry.findApis({ status: 'active' }); - console.log(` Total: ${activeApis.total}`); - - // Discovery 3: Find data-related APIs - console.log('\nData-related APIs:'); - const dataApis = registry.findApis({ tags: ['data'] }); - dataApis.apis.forEach((api) => console.log(` - ${api.name}`)); - - // Discovery 4: Search by name - console.log('\nSearch for "analytics":'); - const analyticsApis = registry.findApis({ search: 'analytics' }); - analyticsApis.apis.forEach((api) => console.log(` - ${api.name} (${api.metadata?.status})`)); - - await kernel.shutdown(); -} - -// Example 3: Route Conflict Resolution -async function example3_ConflictResolution() { - console.log('\n=== Example 3: Route Conflict Resolution ===\n'); - - const kernel = new ObjectKernel(); - - // Use priority-based conflict resolution - kernel.use( - createApiRegistryPlugin({ - conflictResolution: 'priority', - }) - ); - - // Core Plugin - High priority - const corePlugin: Plugin = { - name: 'core-plugin', - dependencies: ['com.objectstack.core.api-registry'], - requiresServices: ['api-registry'], - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - - registry.registerApi({ - id: 'core_data_api', - name: 'Core Data API', - type: 'rest', - version: 'v1', - basePath: '/api', - endpoints: [ - { - id: 'core_data', - method: 'GET', - path: '/api/data/:object', - priority: 900, // High priority - summary: 'Core data endpoint (generic)', - responses: [], - }, - ], - }); - - ctx.logger.info('Core API registered with priority 900'); - }, - }; - - // Custom Plugin - Medium priority - const customPlugin: Plugin = { - name: 'custom-plugin', - dependencies: ['com.objectstack.core.api-registry'], - requiresServices: ['api-registry'], - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - - registry.registerApi({ - id: 'custom_data_api', - name: 'Custom Data API', - type: 'rest', - version: 'v1', - basePath: '/api', - endpoints: [ - { - id: 'custom_data', - method: 'GET', - path: '/api/data/:object', - priority: 300, // Lower priority - summary: 'Custom data endpoint (specialized)', - responses: [], - }, - ], - }); - - ctx.logger.info('Custom API registered with priority 300'); - }, - }; - - kernel.use(corePlugin); - kernel.use(customPlugin); - await kernel.bootstrap(); - - const registry = kernel.getService('api-registry'); - - // Check which endpoint won - const winner = registry.findEndpointByRoute('GET', '/api/data/:object'); - console.log('\nConflict Resolution Result:'); - console.log(` Route: GET /api/data/:object`); - console.log(` Winner: ${winner?.api.name}`); - console.log(` Endpoint: ${winner?.endpoint.summary}`); - console.log(` Priority: ${winner?.endpoint.priority}`); - - await kernel.shutdown(); -} - -// Example 4: Plugin-specific APIs with Custom Protocol -async function example4_CustomProtocol() { - console.log('\n=== Example 4: Custom Protocol Support ===\n'); - - const kernel = new ObjectKernel(); - kernel.use(createApiRegistryPlugin()); - - const websocketPlugin: Plugin = { - name: 'websocket-plugin', - dependencies: ['com.objectstack.core.api-registry'], - requiresServices: ['api-registry'], - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - - registry.registerApi({ - id: 'realtime_api', - name: 'Real-time WebSocket API', - type: 'websocket', - version: 'v1', - basePath: '/ws', - endpoints: [ - { - id: 'customer_updates', - path: '/ws/customers', - summary: 'Customer update notifications', - protocolConfig: { - subProtocol: 'websocket', - eventName: 'customer.updated', - direction: 'server-to-client', - }, - responses: [], - }, - { - id: 'order_updates', - path: '/ws/orders', - summary: 'Order update notifications', - protocolConfig: { - subProtocol: 'websocket', - eventName: 'order.updated', - direction: 'bidirectional', - }, - responses: [], - }, - ], - metadata: { - status: 'active', - tags: ['realtime', 'websocket'], - pluginSource: 'websocket-plugin', - }, - }); - }, - }; - - kernel.use(websocketPlugin); - await kernel.bootstrap(); - - const registry = kernel.getService('api-registry'); - const wsApis = registry.findApis({ type: 'websocket' }); - - console.log('WebSocket APIs:'); - wsApis.apis.forEach((api) => { - console.log(`\n${api.name}:`); - api.endpoints.forEach((endpoint) => { - console.log(` - ${endpoint.summary}`); - console.log(` Event: ${endpoint.protocolConfig?.eventName}`); - console.log(` Direction: ${endpoint.protocolConfig?.direction}`); - }); - }); - - await kernel.shutdown(); -} - -// Example 5: Dynamic Schema Linking with ObjectQL -async function example5_DynamicSchemas() { - console.log('\n=== Example 5: Dynamic Schema Linking ===\n'); - - const kernel = new ObjectKernel(); - kernel.use(createApiRegistryPlugin()); - - const dynamicPlugin: Plugin = { - name: 'dynamic-plugin', - dependencies: ['com.objectstack.core.api-registry'], - requiresServices: ['api-registry'], - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - - registry.registerApi({ - id: 'dynamic_customer_api', - name: 'Dynamic Customer API', - type: 'rest', - version: 'v1', - basePath: '/api/v1/customers', - endpoints: [ - { - id: 'get_customer_dynamic', - method: 'GET', - path: '/api/v1/customers/:id', - summary: 'Get customer (with dynamic schema)', - responses: [ - { - statusCode: 200, - description: 'Customer retrieved', - // Dynamic schema linked to ObjectQL - // - // IMPORTANT: The API Registry stores this ObjectQL reference as-is. - // The actual schema resolution (expanding the reference into a full JSON Schema) - // is performed by downstream tools: - // - API Gateway: For runtime request/response validation - // - OpenAPI/Swagger Generator: For API documentation generation - // - GraphQL Schema Builder: For GraphQL type generation - // - // The Registry's responsibility is to STORE the reference metadata, - // not to resolve or transform it. - schema: { - $ref: { - objectId: 'customer', // References ObjectQL object - excludeFields: ['password_hash', 'internal_notes'], // Exclude sensitive fields - includeRelated: ['account', 'primary_contact'], // Include related objects - }, - }, - }, - ], - }, - ], - }); - - ctx.logger.info('Dynamic Customer API registered with ObjectQL schema references'); - }, - }; - - kernel.use(dynamicPlugin); - await kernel.bootstrap(); - - const registry = kernel.getService('api-registry'); - const endpoint = registry.getEndpoint('dynamic_customer_api', 'get_customer_dynamic'); - - console.log('Dynamic Endpoint:'); - console.log(` Path: ${endpoint?.path}`); - console.log(` Summary: ${endpoint?.summary}`); - - if (endpoint?.responses?.[0]?.schema && '$ref' in endpoint.responses[0].schema) { - const ref = endpoint.responses[0].schema.$ref; - console.log('\n Schema Reference (stored as metadata):'); - console.log(` Object: ${ref.objectId}`); - console.log(` Excluded Fields: ${ref.excludeFields?.join(', ')}`); - console.log(` Included Related: ${ref.includeRelated?.join(', ')}`); - console.log('\n ℹ️ Note: Schema resolution is handled by gateway/documentation tools,'); - console.log(' not by the API Registry itself.'); - } - - await kernel.shutdown(); -} - -// Run all examples -async function main() { - try { - await example1_BasicApiRegistration(); - await example2_MultiPluginDiscovery(); - await example3_ConflictResolution(); - await example4_CustomProtocol(); - await example5_DynamicSchemas(); - - console.log('\n=== All examples completed successfully! ===\n'); - } catch (error) { - console.error('Example failed:', error); - process.exit(1); - } -} - -// Only run if this file is executed directly -if (import.meta.url === `file://${process.argv[1]}`) { - main(); -} - -export { - example1_BasicApiRegistration, - example2_MultiPluginDiscovery, - example3_ConflictResolution, - example4_CustomProtocol, - example5_DynamicSchemas, -}; diff --git a/packages/core/src/api-registry-plugin.test.ts b/packages/core/src/api-registry-plugin.test.ts deleted file mode 100644 index 95aac65d98..0000000000 --- a/packages/core/src/api-registry-plugin.test.ts +++ /dev/null @@ -1,393 +0,0 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { ObjectKernel } from './kernel'; -import { createApiRegistryPlugin } from './api-registry-plugin'; -import { ApiRegistry } from './api-registry'; -import type { Plugin } from './types'; -import type { ApiRegistryEntryInput } from '@objectstack/spec/api'; - -describe('API Registry Plugin', () => { - let kernel: ObjectKernel; - - beforeEach(() => { - kernel = new ObjectKernel({ - skipSystemValidation: true - }); - }); - - describe('Plugin Registration', () => { - it('should register API Registry as a service', async () => { - await kernel.use(createApiRegistryPlugin()); - await kernel.bootstrap(); - - const registry = kernel.getService('api-registry'); - expect(registry).toBeDefined(); - expect(registry).toBeInstanceOf(ApiRegistry); - - await kernel.shutdown(); - }); - - it('should register with custom conflict resolution', async () => { - await kernel.use(createApiRegistryPlugin({ - conflictResolution: 'priority', - version: '2.0.0', - })); - await kernel.bootstrap(); - - const registry = kernel.getService('api-registry'); - const snapshot = registry.getRegistry(); - expect(snapshot.conflictResolution).toBe('priority'); - expect(snapshot.version).toBe('2.0.0'); - - await kernel.shutdown(); - }); - }); - - describe('Integration with Plugins', () => { - it('should allow plugins to register APIs', async () => { - await kernel.use(createApiRegistryPlugin()); - - const testPlugin: Plugin = { - name: 'test-plugin', - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - - const api: ApiRegistryEntryInput = { - id: 'test_api', - name: 'Test API', - type: 'rest', - version: 'v1', - basePath: '/api/test', - endpoints: [ - { - id: 'get_test', - method: 'GET', - path: '/api/test/hello', - summary: 'Test endpoint', - responses: [ - { - statusCode: 200, - description: 'Success', - }, - ], - }, - ], - }; - - registry.registerApi(api); - }, - }; - - await kernel.use(testPlugin); - await kernel.bootstrap(); - - const registry = kernel.getService('api-registry'); - const api = registry.getApi('test_api'); - expect(api).toBeDefined(); - expect(api?.name).toBe('Test API'); - expect(api?.endpoints.length).toBe(1); - - await kernel.shutdown(); - }); - - it('should allow multiple plugins to register APIs', async () => { - await kernel.use(createApiRegistryPlugin()); - - const plugin1: Plugin = { - name: 'plugin-1', - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - registry.registerApi({ - id: 'api1', - name: 'API 1', - type: 'rest', - version: 'v1', - basePath: '/api/plugin1', - endpoints: [ - { - id: 'endpoint1', - method: 'GET', - path: '/api/plugin1/data', - responses: [], - }, - ], - }); - }, - }; - - const plugin2: Plugin = { - name: 'plugin-2', - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - registry.registerApi({ - id: 'api2', - name: 'API 2', - type: 'odata', - version: 'v1', - basePath: '/odata', - endpoints: [ - { - id: 'query', - path: '/odata', - responses: [], - }, - ], - }); - }, - }; - - await kernel.use(plugin1); - await kernel.use(plugin2); - await kernel.bootstrap(); - - const registry = kernel.getService('api-registry'); - const stats = registry.getStats(); - expect(stats.totalApis).toBe(2); - expect(stats.apisByType.rest).toBe(1); - expect(stats.apisByType.odata).toBe(1); - - await kernel.shutdown(); - }); - - it('should support API discovery across plugins', async () => { - await kernel.use(createApiRegistryPlugin()); - - const dataPlugin: Plugin = { - name: 'data-plugin', - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - registry.registerApi({ - id: 'customer_api', - name: 'Customer API', - type: 'rest', - version: 'v1', - basePath: '/api/v1/customers', - endpoints: [], - metadata: { - status: 'active', - tags: ['crm', 'data'], - }, - }); - - registry.registerApi({ - id: 'product_api', - name: 'Product API', - type: 'rest', - version: 'v1', - basePath: '/api/v1/products', - endpoints: [], - metadata: { - status: 'active', - tags: ['inventory', 'data'], - }, - }); - }, - }; - - const analyticsPlugin: Plugin = { - name: 'analytics-plugin', - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - registry.registerApi({ - id: 'analytics_api', - name: 'Analytics API', - type: 'rest', - version: 'v1', - basePath: '/api/v1/analytics', - endpoints: [], - metadata: { - status: 'beta', - tags: ['analytics', 'reporting'], - }, - }); - }, - }; - - await kernel.use(dataPlugin); - await kernel.use(analyticsPlugin); - await kernel.bootstrap(); - - const registry = kernel.getService('api-registry'); - - // Find all data APIs - const dataApis = registry.findApis({ tags: ['data'] }); - expect(dataApis.total).toBe(2); - - // Find active APIs - const activeApis = registry.findApis({ status: 'active' }); - expect(activeApis.total).toBe(2); - - // Find CRM APIs - const crmApis = registry.findApis({ tags: ['crm'] }); - expect(crmApis.total).toBe(1); - expect(crmApis.apis[0].id).toBe('customer_api'); - - await kernel.shutdown(); - }); - - it('should handle route conflicts based on strategy', async () => { - await kernel.use(createApiRegistryPlugin({ - conflictResolution: 'priority', - })); - - const corePlugin: Plugin = { - name: 'core-plugin', - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - registry.registerApi({ - id: 'core_api', - name: 'Core API', - type: 'rest', - version: 'v1', - basePath: '/api', - endpoints: [ - { - id: 'core_endpoint', - method: 'GET', - path: '/api/data/:object', - priority: 900, // High priority - summary: 'Core data endpoint', - responses: [], - }, - ], - }); - }, - }; - - const pluginOverride: Plugin = { - name: 'plugin-override', - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - registry.registerApi({ - id: 'plugin_api', - name: 'Plugin API', - type: 'rest', - version: 'v1', - basePath: '/api', - endpoints: [ - { - id: 'plugin_endpoint', - method: 'GET', - path: '/api/data/:object', - priority: 300, // Lower priority - summary: 'Plugin data endpoint', - responses: [], - }, - ], - }); - }, - }; - - await kernel.use(corePlugin); - await kernel.use(pluginOverride); - await kernel.bootstrap(); - - const registry = kernel.getService('api-registry'); - const result = registry.findEndpointByRoute('GET', '/api/data/:object'); - - // Core API should win due to higher priority - expect(result?.api.id).toBe('core_api'); - expect(result?.endpoint.id).toBe('core_endpoint'); - - await kernel.shutdown(); - }); - - it('should support cleanup on plugin unload', async () => { - await kernel.use(createApiRegistryPlugin()); - - const dynamicPlugin: Plugin = { - name: 'dynamic-plugin', - init: async (ctx) => { - const registry = ctx.getService('api-registry'); - registry.registerApi({ - id: 'dynamic_api', - name: 'Dynamic API', - type: 'rest', - version: 'v1', - basePath: '/api/dynamic', - endpoints: [ - { - id: 'test', - method: 'GET', - path: '/api/dynamic/test', - responses: [], - }, - ], - }); - }, - destroy: async () => { - // In a real scenario, this would use ctx to access registry - // For now, we'll test the registry's unregister capability - }, - }; - - await kernel.use(dynamicPlugin); - await kernel.bootstrap(); - - const registry = kernel.getService('api-registry'); - expect(registry.getApi('dynamic_api')).toBeDefined(); - - // Unregister the API - registry.unregisterApi('dynamic_api'); - expect(registry.getApi('dynamic_api')).toBeUndefined(); - - await kernel.shutdown(); - }); - }); - - describe('API Registry Lifecycle', () => { - it('should be available during plugin start phase', async () => { - await kernel.use(createApiRegistryPlugin()); - - let registryAvailable = false; - - const testPlugin: Plugin = { - name: 'test-plugin', - init: async () => { - // Init phase - }, - start: async (ctx) => { - // Start phase - registry should be available - const registry = ctx.getService('api-registry'); - registryAvailable = registry !== undefined; - }, - }; - - await kernel.use(testPlugin); - await kernel.bootstrap(); - - expect(registryAvailable).toBe(true); - - await kernel.shutdown(); - }); - - it('should provide consistent registry across all plugins', async () => { - await kernel.use(createApiRegistryPlugin()); - - let registry1: ApiRegistry | undefined; - let registry2: ApiRegistry | undefined; - - const plugin1: Plugin = { - name: 'plugin-1', - init: async (ctx) => { - registry1 = ctx.getService('api-registry'); - }, - }; - - const plugin2: Plugin = { - name: 'plugin-2', - init: async (ctx) => { - registry2 = ctx.getService('api-registry'); - }, - }; - - await kernel.use(plugin1); - await kernel.use(plugin2); - await kernel.bootstrap(); - - // Same registry instance should be shared - expect(registry1).toBe(registry2); - - await kernel.shutdown(); - }); - }); -}); diff --git a/packages/core/src/api-registry-plugin.ts b/packages/core/src/api-registry-plugin.ts deleted file mode 100644 index 58e097740d..0000000000 --- a/packages/core/src/api-registry-plugin.ts +++ /dev/null @@ -1,94 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { Plugin, PluginContext } from './types.js'; -import { ApiRegistry } from './api-registry.js'; -import type { ConflictResolutionStrategy } from '@objectstack/spec/api'; - -/** - * API Registry Plugin Configuration - */ -export interface ApiRegistryPluginConfig { - /** - * Conflict resolution strategy for route conflicts - * @default 'error' - */ - conflictResolution?: ConflictResolutionStrategy; - - /** - * Registry version - * @default '1.0.0' - */ - version?: string; -} - -/** - * API Registry Plugin - * - * Registers the API Registry service in the kernel, making it available - * to all plugins for endpoint registration and discovery. - * - * **Usage:** - * ```typescript - * const kernel = new ObjectKernel(); - * - * // Register API Registry Plugin - * kernel.use(createApiRegistryPlugin({ conflictResolution: 'priority' })); - * - * // In other plugins, access the API Registry - * const plugin: Plugin = { - * name: 'my-plugin', - * init: async (ctx) => { - * const registry = ctx.getService('api-registry'); - * - * // Register plugin APIs - * registry.registerApi({ - * id: 'my_plugin_api', - * name: 'My Plugin API', - * type: 'rest', - * version: 'v1', - * basePath: '/api/v1/my-plugin', - * endpoints: [...] - * }); - * } - * }; - * ``` - * - * @param config - Plugin configuration - * @returns Plugin instance - */ -export function createApiRegistryPlugin( - config: ApiRegistryPluginConfig = {} -): Plugin { - const { - conflictResolution = 'error', - version = '1.0.0', - } = config; - - return { - name: 'com.objectstack.core.api-registry', - /** - * Services init() registers on every path (ADR-0116, #4131) — lets the - * kernel name this plugin when a consumer requires one before it inits. - */ - providesServices: ['api-registry'], - type: 'standard', - version: '1.0.0', - - init: async (ctx: PluginContext) => { - // Create API Registry instance - const registry = new ApiRegistry( - ctx.logger, - conflictResolution, - version - ); - - // Register as a service - ctx.registerService('api-registry', registry); - - ctx.logger.info('API Registry plugin initialized', { - conflictResolution, - version, - }); - }, - }; -} diff --git a/packages/core/src/api-registry.test.ts b/packages/core/src/api-registry.test.ts deleted file mode 100644 index 1818bbc720..0000000000 --- a/packages/core/src/api-registry.test.ts +++ /dev/null @@ -1,1089 +0,0 @@ -import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { ApiRegistry } from './api-registry'; -import type { - ApiRegistryEntryInput, -} from '@objectstack/spec/api'; -import type { Logger } from '@objectstack/spec/contracts'; - -// Mock logger -const createMockLogger = (): Logger => ({ - debug: vi.fn(), - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), -}); - -describe('ApiRegistry', () => { - let registry: ApiRegistry; - let logger: Logger; - - beforeEach(() => { - logger = createMockLogger(); - registry = new ApiRegistry(logger, 'error', '1.0.0'); - }); - - describe('Constructor', () => { - it('should create registry with default conflict resolution', () => { - const reg = new ApiRegistry(logger); - const snapshot = reg.getRegistry(); - expect(snapshot.conflictResolution).toBe('error'); - expect(snapshot.version).toBe('1.0.0'); - }); - - it('should create registry with custom conflict resolution', () => { - const reg = new ApiRegistry(logger, 'priority', '2.0.0'); - const snapshot = reg.getRegistry(); - expect(snapshot.conflictResolution).toBe('priority'); - expect(snapshot.version).toBe('2.0.0'); - }); - }); - - describe('registerApi', () => { - it('should register a simple REST API', () => { - const api: ApiRegistryEntryInput = { - id: 'customer_api', - name: 'Customer API', - type: 'rest', - version: 'v1', - basePath: '/api/v1/customers', - endpoints: [ - { - id: 'get_customer', - method: 'GET', - path: '/api/v1/customers/:id', - summary: 'Get customer by ID', - responses: [ - { - statusCode: 200, - description: 'Success', - }, - ], - }, - ], - }; - - registry.registerApi(api); - - const retrieved = registry.getApi('customer_api'); - expect(retrieved).toBeDefined(); - expect(retrieved?.name).toBe('Customer API'); - expect(retrieved?.endpoints.length).toBe(1); - }); - - it('should throw error when registering duplicate API', () => { - const api: ApiRegistryEntryInput = { - id: 'test_api', - name: 'Test API', - type: 'rest', - version: 'v1', - basePath: '/api/test', - endpoints: [], - }; - - registry.registerApi(api); - - expect(() => registry.registerApi(api)).toThrow( - "API 'test_api' already registered" - ); - }); - - it('should register API with multiple endpoints', () => { - const api: ApiRegistryEntryInput = { - id: 'crud_api', - name: 'CRUD API', - type: 'rest', - version: 'v1', - basePath: '/api/v1/data', - endpoints: [ - { - id: 'create', - method: 'POST', - path: '/api/v1/data', - summary: 'Create record', - responses: [], - }, - { - id: 'read', - method: 'GET', - path: '/api/v1/data/:id', - summary: 'Read record', - responses: [], - }, - { - id: 'update', - method: 'PUT', - path: '/api/v1/data/:id', - summary: 'Update record', - responses: [], - }, - { - id: 'delete', - method: 'DELETE', - path: '/api/v1/data/:id', - summary: 'Delete record', - responses: [], - }, - ], - }; - - registry.registerApi(api); - - const stats = registry.getStats(); - expect(stats.totalApis).toBe(1); - expect(stats.totalEndpoints).toBe(4); - expect(stats.totalRoutes).toBe(4); - }); - - it('should register API with RBAC permissions', () => { - const api: ApiRegistryEntryInput = { - id: 'protected_api', - name: 'Protected API', - type: 'rest', - version: 'v1', - basePath: '/api/protected', - endpoints: [ - { - id: 'admin_only', - method: 'POST', - path: '/api/protected/admin', - summary: 'Admin endpoint', - requiredPermissions: ['admin.access', 'api_enabled'], - responses: [], - }, - ], - }; - - registry.registerApi(api); - - const endpoint = registry.getEndpoint('protected_api', 'admin_only'); - expect(endpoint?.requiredPermissions).toEqual(['admin.access', 'api_enabled']); - }); - }); - - describe('unregisterApi', () => { - it('should unregister an API', () => { - const api: ApiRegistryEntryInput = { - id: 'temp_api', - name: 'Temporary API', - type: 'rest', - version: 'v1', - basePath: '/api/temp', - endpoints: [ - { - id: 'test', - method: 'GET', - path: '/api/temp/test', - responses: [], - }, - ], - }; - - registry.registerApi(api); - expect(registry.getApi('temp_api')).toBeDefined(); - - registry.unregisterApi('temp_api'); - expect(registry.getApi('temp_api')).toBeUndefined(); - }); - - it('should throw error when unregistering non-existent API', () => { - expect(() => registry.unregisterApi('nonexistent')).toThrow( - "API 'nonexistent' not found" - ); - }); - }); - - describe('Route Conflict Detection', () => { - describe('error strategy', () => { - it('should throw error on route conflict', () => { - const api1: ApiRegistryEntryInput = { - id: 'api1', - name: 'API 1', - type: 'rest', - version: 'v1', - basePath: '/api/v1', - endpoints: [ - { - id: 'endpoint1', - method: 'GET', - path: '/api/v1/test', - responses: [], - }, - ], - }; - - const api2: ApiRegistryEntryInput = { - id: 'api2', - name: 'API 2', - type: 'rest', - version: 'v1', - basePath: '/api/v1', - endpoints: [ - { - id: 'endpoint2', - method: 'GET', - path: '/api/v1/test', // Same route! - responses: [], - }, - ], - }; - - registry.registerApi(api1); - expect(() => registry.registerApi(api2)).toThrow(/Route conflict detected/); - }); - - it('should allow same path with different methods', () => { - const api: ApiRegistryEntryInput = { - id: 'multi_method', - name: 'Multi Method API', - type: 'rest', - version: 'v1', - basePath: '/api/v1', - endpoints: [ - { - id: 'get', - method: 'GET', - path: '/api/v1/resource', - responses: [], - }, - { - id: 'post', - method: 'POST', - path: '/api/v1/resource', - responses: [], - }, - { - id: 'put', - method: 'PUT', - path: '/api/v1/resource', - responses: [], - }, - ], - }; - - expect(() => registry.registerApi(api)).not.toThrow(); - expect(registry.getStats().totalRoutes).toBe(3); - }); - }); - - describe('priority strategy', () => { - beforeEach(() => { - registry = new ApiRegistry(logger, 'priority'); - }); - - it('should prefer higher priority endpoint', () => { - const api1: ApiRegistryEntryInput = { - id: 'low_priority', - name: 'Low Priority API', - type: 'rest', - version: 'v1', - basePath: '/api', - endpoints: [ - { - id: 'low', - method: 'GET', - path: '/api/test', - priority: 100, - responses: [], - }, - ], - }; - - const api2: ApiRegistryEntryInput = { - id: 'high_priority', - name: 'High Priority API', - type: 'rest', - version: 'v1', - basePath: '/api', - endpoints: [ - { - id: 'high', - method: 'GET', - path: '/api/test', - priority: 500, - responses: [], - }, - ], - }; - - registry.registerApi(api1); - registry.registerApi(api2); // Should replace low priority - - const result = registry.findEndpointByRoute('GET', '/api/test'); - expect(result?.api.id).toBe('high_priority'); - expect(result?.endpoint.id).toBe('high'); - }); - - it('should keep higher priority when registering lower priority', () => { - const api1: ApiRegistryEntryInput = { - id: 'high_priority', - name: 'High Priority API', - type: 'rest', - version: 'v1', - basePath: '/api', - endpoints: [ - { - id: 'high', - method: 'GET', - path: '/api/test', - priority: 900, - responses: [], - }, - ], - }; - - const api2: ApiRegistryEntryInput = { - id: 'low_priority', - name: 'Low Priority API', - type: 'rest', - version: 'v1', - basePath: '/api', - endpoints: [ - { - id: 'low', - method: 'GET', - path: '/api/test', - priority: 100, - responses: [], - }, - ], - }; - - registry.registerApi(api1); - registry.registerApi(api2); // Should NOT replace - - const result = registry.findEndpointByRoute('GET', '/api/test'); - expect(result?.api.id).toBe('high_priority'); - expect(result?.endpoint.id).toBe('high'); - }); - }); - - describe('first-wins strategy', () => { - beforeEach(() => { - registry = new ApiRegistry(logger, 'first-wins'); - }); - - it('should keep first registered endpoint', () => { - const api1: ApiRegistryEntryInput = { - id: 'first', - name: 'First API', - type: 'rest', - version: 'v1', - basePath: '/api', - endpoints: [ - { - id: 'first_endpoint', - method: 'GET', - path: '/api/test', - responses: [], - }, - ], - }; - - const api2: ApiRegistryEntryInput = { - id: 'second', - name: 'Second API', - type: 'rest', - version: 'v1', - basePath: '/api', - endpoints: [ - { - id: 'second_endpoint', - method: 'GET', - path: '/api/test', - responses: [], - }, - ], - }; - - registry.registerApi(api1); - registry.registerApi(api2); - - const result = registry.findEndpointByRoute('GET', '/api/test'); - expect(result?.api.id).toBe('first'); - expect(result?.endpoint.id).toBe('first_endpoint'); - }); - }); - - describe('last-wins strategy', () => { - beforeEach(() => { - registry = new ApiRegistry(logger, 'last-wins'); - }); - - it('should use last registered endpoint', () => { - const api1: ApiRegistryEntryInput = { - id: 'first', - name: 'First API', - type: 'rest', - version: 'v1', - basePath: '/api', - endpoints: [ - { - id: 'first_endpoint', - method: 'GET', - path: '/api/test', - responses: [], - }, - ], - }; - - const api2: ApiRegistryEntryInput = { - id: 'second', - name: 'Second API', - type: 'rest', - version: 'v1', - basePath: '/api', - endpoints: [ - { - id: 'second_endpoint', - method: 'GET', - path: '/api/test', - responses: [], - }, - ], - }; - - registry.registerApi(api1); - registry.registerApi(api2); - - const result = registry.findEndpointByRoute('GET', '/api/test'); - expect(result?.api.id).toBe('second'); - expect(result?.endpoint.id).toBe('second_endpoint'); - }); - }); - }); - - describe('findApis', () => { - beforeEach(() => { - // Register multiple APIs for testing - registry.registerApi({ - id: 'rest_api', - name: 'REST API', - type: 'rest', - version: 'v1', - basePath: '/api/v1/rest', - endpoints: [], - metadata: { - status: 'active', - tags: ['data', 'crud'], - }, - }); - - registry.registerApi({ - id: 'odata_api', - name: 'OData API', - type: 'odata', - version: 'v1', - basePath: '/odata', - endpoints: [], - metadata: { - status: 'active', - tags: ['query', 'data'], - }, - }); - - registry.registerApi({ - id: 'deprecated_api', - name: 'Deprecated API', - type: 'rest', - version: 'v0', - basePath: '/api/v0/old', - endpoints: [], - metadata: { - status: 'deprecated', - tags: ['legacy'], - }, - }); - }); - - it('should find all APIs with empty query', () => { - const result = registry.findApis({}); - expect(result.total).toBe(3); - expect(result.apis.length).toBe(3); - }); - - it('should filter by type', () => { - const result = registry.findApis({ type: 'rest' }); - expect(result.total).toBe(2); - expect(result.apis.every((api) => api.type === 'rest')).toBe(true); - }); - - it('should filter by status', () => { - const result = registry.findApis({ status: 'active' }); - expect(result.total).toBe(2); - expect(result.apis.every((api) => api.metadata?.status === 'active')).toBe(true); - }); - - it('should filter by version', () => { - const result = registry.findApis({ version: 'v1' }); - expect(result.total).toBe(2); - expect(result.apis.every((api) => api.version === 'v1')).toBe(true); - }); - - it('should filter by tags (ANY match)', () => { - const result = registry.findApis({ tags: ['data'] }); - expect(result.total).toBe(2); - }); - - it('should search in name and description', () => { - const result = registry.findApis({ search: 'odata' }); - expect(result.total).toBe(1); - expect(result.apis[0].id).toBe('odata_api'); - }); - - it('should combine multiple filters', () => { - const result = registry.findApis({ - type: 'rest', - status: 'active', - tags: ['crud'], - }); - expect(result.total).toBe(1); - expect(result.apis[0].id).toBe('rest_api'); - }); - }); - - describe('getEndpoint', () => { - it('should get endpoint by API and endpoint ID', () => { - const api: ApiRegistryEntryInput = { - id: 'test_api', - name: 'Test API', - type: 'rest', - version: 'v1', - basePath: '/api/test', - endpoints: [ - { - id: 'test_endpoint', - method: 'GET', - path: '/api/test/hello', - summary: 'Test endpoint', - responses: [], - }, - ], - }; - - registry.registerApi(api); - - const endpoint = registry.getEndpoint('test_api', 'test_endpoint'); - expect(endpoint).toBeDefined(); - expect(endpoint?.summary).toBe('Test endpoint'); - }); - - it('should return undefined for non-existent endpoint', () => { - const endpoint = registry.getEndpoint('nonexistent', 'also_nonexistent'); - expect(endpoint).toBeUndefined(); - }); - }); - - describe('findEndpointByRoute', () => { - it('should find endpoint by method and path', () => { - const api: ApiRegistryEntryInput = { - id: 'route_api', - name: 'Route API', - type: 'rest', - version: 'v1', - basePath: '/api', - endpoints: [ - { - id: 'get_users', - method: 'GET', - path: '/api/users', - responses: [], - }, - ], - }; - - registry.registerApi(api); - - const result = registry.findEndpointByRoute('GET', '/api/users'); - expect(result).toBeDefined(); - expect(result?.api.id).toBe('route_api'); - expect(result?.endpoint.id).toBe('get_users'); - }); - - it('should return undefined for non-existent route', () => { - const result = registry.findEndpointByRoute('POST', '/nonexistent'); - expect(result).toBeUndefined(); - }); - }); - - describe('getRegistry', () => { - it('should return complete registry snapshot', () => { - registry.registerApi({ - id: 'api1', - name: 'API 1', - type: 'rest', - version: 'v1', - basePath: '/api/v1', - endpoints: [ - { id: 'e1', path: '/api/v1/test', responses: [] }, - ], - }); - - const snapshot = registry.getRegistry(); - expect(snapshot.version).toBe('1.0.0'); - expect(snapshot.conflictResolution).toBe('error'); - expect(snapshot.totalApis).toBe(1); - expect(snapshot.totalEndpoints).toBe(1); - expect(snapshot.byType).toBeDefined(); - expect(snapshot.byStatus).toBeDefined(); - expect(snapshot.updatedAt).toBeDefined(); - }); - - it('should group APIs by type', () => { - registry.registerApi({ - id: 'rest1', - name: 'REST 1', - type: 'rest', - version: 'v1', - basePath: '/api/rest1', - endpoints: [], - }); - - registry.registerApi({ - id: 'rest2', - name: 'REST 2', - type: 'rest', - version: 'v1', - basePath: '/api/rest2', - endpoints: [], - }); - - registry.registerApi({ - id: 'graphql1', - name: 'GraphQL 1', - type: 'odata', - version: 'v1', - basePath: '/odata', - endpoints: [], - }); - - const snapshot = registry.getRegistry(); - expect(snapshot.byType?.rest?.length).toBe(2); - expect(snapshot.byType?.odata?.length).toBe(1); - }); - }); - - describe('clear', () => { - it('should clear all registered APIs', () => { - registry.registerApi({ - id: 'test', - name: 'Test', - type: 'rest', - version: 'v1', - basePath: '/test', - endpoints: [{ id: 'e1', path: '/test', responses: [] }], - }); - - expect(registry.getStats().totalApis).toBe(1); - - registry.clear(); - - expect(registry.getStats().totalApis).toBe(0); - expect(registry.getStats().totalEndpoints).toBe(0); - expect(registry.getStats().totalRoutes).toBe(0); - }); - }); - - describe('getStats', () => { - it('should return accurate statistics', () => { - registry.registerApi({ - id: 'api1', - name: 'API 1', - type: 'rest', - version: 'v1', - basePath: '/api1', - endpoints: [ - { id: 'e1', path: '/api1/e1', responses: [] }, - { id: 'e2', path: '/api1/e2', responses: [] }, - ], - }); - - registry.registerApi({ - id: 'api2', - name: 'API 2', - type: 'odata', - version: 'v1', - basePath: '/odata', - endpoints: [ - { id: 'query', path: '/odata', responses: [] }, - ], - }); - - const stats = registry.getStats(); - expect(stats.totalApis).toBe(2); - expect(stats.totalEndpoints).toBe(3); - expect(stats.totalRoutes).toBe(3); - expect(stats.apisByType.rest).toBe(1); - expect(stats.apisByType.odata).toBe(1); - expect(stats.endpointsByApi.api1).toBe(2); - expect(stats.endpointsByApi.api2).toBe(1); - }); - }); - - describe('Multi-protocol Support', () => { - it('should register OData API', () => { - const api: ApiRegistryEntryInput = { - id: 'odata', - name: 'OData API', - type: 'odata', - version: 'v1', - basePath: '/odata', - endpoints: [ - { - id: 'query', - path: '/odata', - summary: 'GraphQL Query', - responses: [], - }, - ], - }; - - registry.registerApi(api); - expect(registry.getApi('odata')?.type).toBe('odata'); - }); - - it('should register WebSocket API', () => { - const api: ApiRegistryEntryInput = { - id: 'websocket', - name: 'WebSocket API', - type: 'websocket', - version: 'v1', - basePath: '/ws', - endpoints: [ - { - id: 'subscribe', - path: '/ws/events', - summary: 'Subscribe to events', - protocolConfig: { - subProtocol: 'websocket', - eventName: 'data.updated', - direction: 'server-to-client', - }, - responses: [], - }, - ], - }; - - registry.registerApi(api); - const endpoint = registry.getEndpoint('websocket', 'subscribe'); - expect(endpoint?.protocolConfig?.subProtocol).toBe('websocket'); - }); - - it('should register Plugin API', () => { - const api: ApiRegistryEntryInput = { - id: 'custom_plugin', - name: 'Custom Plugin API', - type: 'plugin', - version: '1.0.0', - basePath: '/plugins/custom', - endpoints: [ - { - id: 'custom_action', - method: 'POST', - path: '/plugins/custom/action', - summary: 'Custom plugin action', - responses: [], - }, - ], - metadata: { - pluginSource: 'custom_plugin_package', - status: 'active', - }, - }; - - registry.registerApi(api); - const result = registry.findApis({ pluginSource: 'custom_plugin_package' }); - expect(result.total).toBe(1); - }); - }); - - describe('Performance Optimizations', () => { - it('should use indices for fast type-based lookups', () => { - // Register multiple APIs with different types - registry.registerApi({ - id: 'rest_api_1', - name: 'REST API 1', - type: 'rest', - version: 'v1', - basePath: '/api/rest1', - endpoints: [{ id: 'e1', path: '/api/rest1', responses: [] }], - }); - - registry.registerApi({ - id: 'rest_api_2', - name: 'REST API 2', - type: 'rest', - version: 'v1', - basePath: '/api/rest2', - endpoints: [{ id: 'e2', path: '/api/rest2', responses: [] }], - }); - - registry.registerApi({ - id: 'odata_api', - name: 'OData API', - type: 'odata', - version: 'v1', - basePath: '/odata', - endpoints: [{ id: 'e3', path: '/odata', responses: [] }], - }); - - // Should efficiently find all REST APIs - const restApis = registry.findApis({ type: 'rest' }); - expect(restApis.total).toBe(2); - expect(restApis.apis.every(api => api.type === 'rest')).toBe(true); - - // Should efficiently find OData APIs - const graphqlApis = registry.findApis({ type: 'odata' }); - expect(graphqlApis.total).toBe(1); - expect(graphqlApis.apis[0].id).toBe('odata_api'); - }); - - it('should use indices for fast tag-based lookups', () => { - registry.registerApi({ - id: 'api_1', - name: 'API 1', - type: 'rest', - version: 'v1', - basePath: '/api1', - endpoints: [{ id: 'e1', path: '/api1', responses: [] }], - metadata: { tags: ['customer', 'crm'] }, - }); - - registry.registerApi({ - id: 'api_2', - name: 'API 2', - type: 'rest', - version: 'v1', - basePath: '/api2', - endpoints: [{ id: 'e2', path: '/api2', responses: [] }], - metadata: { tags: ['order', 'sales'] }, - }); - - registry.registerApi({ - id: 'api_3', - name: 'API 3', - type: 'rest', - version: 'v1', - basePath: '/api3', - endpoints: [{ id: 'e3', path: '/api3', responses: [] }], - metadata: { tags: ['customer', 'analytics'] }, - }); - - // Should efficiently find APIs by tag - const customerApis = registry.findApis({ tags: ['customer'] }); - expect(customerApis.total).toBe(2); - expect(customerApis.apis.map(a => a.id).sort()).toEqual(['api_1', 'api_3']); - - // Should support multiple tags (ANY match) - const multiTagApis = registry.findApis({ tags: ['crm', 'sales'] }); - expect(multiTagApis.total).toBe(2); - }); - - it('should use indices for fast status-based lookups', () => { - registry.registerApi({ - id: 'active_api', - name: 'Active API', - type: 'rest', - version: 'v1', - basePath: '/active', - endpoints: [{ id: 'e1', path: '/active', responses: [] }], - metadata: { status: 'active' }, - }); - - registry.registerApi({ - id: 'beta_api', - name: 'Beta API', - type: 'rest', - version: 'v1', - basePath: '/beta', - endpoints: [{ id: 'e2', path: '/beta', responses: [] }], - metadata: { status: 'beta' }, - }); - - registry.registerApi({ - id: 'deprecated_api', - name: 'Deprecated API', - type: 'rest', - version: 'v1', - basePath: '/deprecated', - endpoints: [{ id: 'e3', path: '/deprecated', responses: [] }], - metadata: { status: 'deprecated' }, - }); - - // Should efficiently find by status - const activeApis = registry.findApis({ status: 'active' }); - expect(activeApis.total).toBe(1); - expect(activeApis.apis[0].id).toBe('active_api'); - - const betaApis = registry.findApis({ status: 'beta' }); - expect(betaApis.total).toBe(1); - }); - - it('should combine multiple indexed filters efficiently', () => { - registry.registerApi({ - id: 'rest_crm_active', - name: 'REST CRM Active', - type: 'rest', - version: 'v1', - basePath: '/crm', - endpoints: [{ id: 'e1', path: '/crm', responses: [] }], - metadata: { status: 'active', tags: ['crm', 'customer'] }, - }); - - registry.registerApi({ - id: 'rest_crm_beta', - name: 'REST CRM Beta', - type: 'rest', - version: 'v1', - basePath: '/crm-beta', - endpoints: [{ id: 'e2', path: '/crm-beta', responses: [] }], - metadata: { status: 'beta', tags: ['crm'] }, - }); - - registry.registerApi({ - id: 'graphql_crm_active', - name: 'GraphQL CRM Active', - type: 'odata', - version: 'v1', - basePath: '/odata', - endpoints: [{ id: 'e3', path: '/odata', responses: [] }], - metadata: { status: 'active', tags: ['crm'] }, - }); - - // Combine type + status + tags filters - const result = registry.findApis({ - type: 'rest', - status: 'active', - tags: ['crm'], - }); - - expect(result.total).toBe(1); - expect(result.apis[0].id).toBe('rest_crm_active'); - }); - - it('should maintain indices when APIs are unregistered', () => { - registry.registerApi({ - id: 'temp_api', - name: 'Temporary API', - type: 'rest', - version: 'v1', - basePath: '/temp', - endpoints: [{ id: 'e1', path: '/temp', responses: [] }], - metadata: { status: 'beta', tags: ['temp', 'test'] }, - }); - - // Verify it's in indices - expect(registry.findApis({ type: 'rest' }).total).toBe(1); - expect(registry.findApis({ status: 'beta' }).total).toBe(1); - expect(registry.findApis({ tags: ['temp'] }).total).toBe(1); - - // Unregister - registry.unregisterApi('temp_api'); - - // Verify removed from indices - expect(registry.findApis({ type: 'rest' }).total).toBe(0); - expect(registry.findApis({ status: 'beta' }).total).toBe(0); - expect(registry.findApis({ tags: ['temp'] }).total).toBe(0); - }); - }); - - describe('Safety Guards', () => { - it('should allow clear() in non-production environment', () => { - const originalEnv = process.env.NODE_ENV; - try { - process.env.NODE_ENV = 'test'; - - registry.registerApi({ - id: 'test_api', - name: 'Test API', - type: 'rest', - version: 'v1', - basePath: '/test', - endpoints: [{ id: 'e1', path: '/test', responses: [] }], - }); - - expect(registry.getStats().totalApis).toBe(1); - - // Should work without force flag in non-production - registry.clear(); - expect(registry.getStats().totalApis).toBe(0); - } finally { - process.env.NODE_ENV = originalEnv; - } - }); - - it('should prevent clear() in production without force flag', () => { - const originalEnv = process.env.NODE_ENV; - try { - process.env.NODE_ENV = 'production'; - - registry.registerApi({ - id: 'prod_api', - name: 'Production API', - type: 'rest', - version: 'v1', - basePath: '/prod', - endpoints: [{ id: 'e1', path: '/prod', responses: [] }], - }); - - // Should throw error in production without force flag - expect(() => registry.clear()).toThrow( - 'Cannot clear registry in production environment without force flag' - ); - - // API should still exist - expect(registry.getStats().totalApis).toBe(1); - } finally { - process.env.NODE_ENV = originalEnv; - } - }); - - it('should allow clear() in production with force flag', () => { - const originalEnv = process.env.NODE_ENV; - try { - process.env.NODE_ENV = 'production'; - - registry.registerApi({ - id: 'prod_api', - name: 'Production API', - type: 'rest', - version: 'v1', - basePath: '/prod', - endpoints: [{ id: 'e1', path: '/prod', responses: [] }], - }); - - expect(registry.getStats().totalApis).toBe(1); - - // Should work with force flag - registry.clear({ force: true }); - expect(registry.getStats().totalApis).toBe(0); - - // Verify logger warned about forced clear - expect(logger.warn).toHaveBeenCalledWith( - 'API registry forcefully cleared in production', - { force: true } - ); - } finally { - process.env.NODE_ENV = originalEnv; - } - }); - - it('should clear all indices when clear() is called', () => { - registry.registerApi({ - id: 'api_1', - name: 'API 1', - type: 'rest', - version: 'v1', - basePath: '/api1', - endpoints: [{ id: 'e1', path: '/api1', responses: [] }], - metadata: { status: 'active', tags: ['test'] }, - }); - - registry.clear(); - - // All lookups should return empty - expect(registry.findApis({ type: 'rest' }).total).toBe(0); - expect(registry.findApis({ status: 'active' }).total).toBe(0); - expect(registry.findApis({ tags: ['test'] }).total).toBe(0); - }); - }); -}); diff --git a/packages/core/src/api-registry.ts b/packages/core/src/api-registry.ts deleted file mode 100644 index 08a5b708e4..0000000000 --- a/packages/core/src/api-registry.ts +++ /dev/null @@ -1,739 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import type { - ApiRegistry as ApiRegistryType, - ApiRegistryEntry, - ApiRegistryEntryInput, - ApiEndpointRegistration, - ConflictResolutionStrategy, - ApiDiscoveryQuery, - ApiDiscoveryResponse, -} from '@objectstack/spec/api'; -import { ApiRegistryEntrySchema } from '@objectstack/spec/api'; -import type { Logger } from '@objectstack/spec/contracts'; -import { getEnv } from './utils/env.js'; - -/** - * API Registry Service - * - * Central registry for managing API endpoints across different protocols. - * Provides endpoint registration, discovery, and conflict resolution. - * - * **Features:** - * - Multi-protocol support (REST, GraphQL, OData, WebSocket, etc.) - * - Route conflict detection with configurable resolution strategies - * - RBAC permission integration - * - Dynamic schema linking with ObjectQL references - * - Plugin API registration - * - * **Architecture Alignment:** - * - Kubernetes: Service Discovery & API Server - * - AWS API Gateway: Unified API Management - * - Kong Gateway: Plugin-based API Management - * - * @example - * ```typescript - * const registry = new ApiRegistry(logger, 'priority'); - * - * // Register an API - * registry.registerApi({ - * id: 'customer_api', - * name: 'Customer API', - * type: 'rest', - * version: 'v1', - * basePath: '/api/v1/customers', - * endpoints: [...] - * }); - * - * // Discover APIs - * const apis = registry.findApis({ type: 'rest', status: 'active' }); - * - * // Get registry snapshot - * const snapshot = registry.getRegistry(); - * ``` - */ -export class ApiRegistry { - private apis: Map = new Map(); - private endpoints: Map = new Map(); - private routes: Map = new Map(); - - // Performance optimization: Auxiliary indices for O(1) lookups - private apisByType: Map> = new Map(); - private apisByTag: Map> = new Map(); - private apisByStatus: Map> = new Map(); - - private conflictResolution: ConflictResolutionStrategy; - private logger: Logger; - private version: string; - private updatedAt: string; - - constructor( - logger: Logger, - conflictResolution: ConflictResolutionStrategy = 'error', - version: string = '1.0.0' - ) { - this.logger = logger; - this.conflictResolution = conflictResolution; - this.version = version; - this.updatedAt = new Date().toISOString(); - } - - /** - * Register an API with its endpoints - * - * @param api - API registry entry - * @throws Error if API already registered or route conflicts detected - */ - registerApi(api: ApiRegistryEntryInput): void { - // Check if API already exists - if (this.apis.has(api.id)) { - throw new Error(`[ApiRegistry] API '${api.id}' already registered`); - } - - // Parse and validate the input using Zod schema - const fullApi = ApiRegistryEntrySchema.parse(api); - - // Validate and register endpoints - for (const endpoint of fullApi.endpoints) { - this.validateEndpoint(endpoint, fullApi.id); - } - - // Register the API - this.apis.set(fullApi.id, fullApi); - - // Register endpoints - for (const endpoint of fullApi.endpoints) { - this.registerEndpoint(fullApi.id, endpoint); - } - - // Update auxiliary indices for performance optimization - this.updateIndices(fullApi); - - this.updatedAt = new Date().toISOString(); - this.logger.info(`API registered: ${fullApi.id}`, { - api: fullApi.id, - type: fullApi.type, - endpointCount: fullApi.endpoints.length, - }); - } - - /** - * Unregister an API and all its endpoints - * - * @param apiId - API identifier - */ - unregisterApi(apiId: string): void { - const api = this.apis.get(apiId); - if (!api) { - throw new Error(`[ApiRegistry] API '${apiId}' not found`); - } - - // Remove all endpoints - for (const endpoint of api.endpoints) { - this.unregisterEndpoint(apiId, endpoint.id); - } - - // Remove from auxiliary indices - this.removeFromIndices(api); - - // Remove the API - this.apis.delete(apiId); - this.updatedAt = new Date().toISOString(); - - this.logger.info(`API unregistered: ${apiId}`); - } - - /** - * Register a single endpoint - * - * @param apiId - API identifier - * @param endpoint - Endpoint registration - * @throws Error if route conflict detected - */ - private registerEndpoint(apiId: string, endpoint: ApiEndpointRegistration): void { - const endpointKey = `${apiId}:${endpoint.id}`; - - // Check if endpoint already registered - if (this.endpoints.has(endpointKey)) { - throw new Error(`[ApiRegistry] Endpoint '${endpoint.id}' already registered for API '${apiId}'`); - } - - // Register endpoint - this.endpoints.set(endpointKey, { api: apiId, endpoint }); - - // Register route if path is defined - if (endpoint.path) { - this.registerRoute(apiId, endpoint); - } - } - - /** - * Unregister a single endpoint - * - * @param apiId - API identifier - * @param endpointId - Endpoint identifier - */ - private unregisterEndpoint(apiId: string, endpointId: string): void { - const endpointKey = `${apiId}:${endpointId}`; - const entry = this.endpoints.get(endpointKey); - - if (!entry) { - return; // Already unregistered - } - - // Unregister route - if (entry.endpoint.path) { - const routeKey = this.getRouteKey(entry.endpoint); - this.routes.delete(routeKey); - } - - // Unregister endpoint - this.endpoints.delete(endpointKey); - } - - /** - * Register a route with conflict detection - * - * @param apiId - API identifier - * @param endpoint - Endpoint registration - * @throws Error if route conflict detected (based on strategy) - */ - private registerRoute(apiId: string, endpoint: ApiEndpointRegistration): void { - const routeKey = this.getRouteKey(endpoint); - const priority = endpoint.priority ?? 100; - const existingRoute = this.routes.get(routeKey); - - if (existingRoute) { - // Route conflict detected - this.handleRouteConflict(routeKey, apiId, endpoint, existingRoute, priority); - return; - } - - // Register route - this.routes.set(routeKey, { - api: apiId, - endpointId: endpoint.id, - priority, - }); - } - - /** - * Handle route conflict based on resolution strategy - * - * @param routeKey - Route key - * @param apiId - New API identifier - * @param endpoint - New endpoint - * @param existingRoute - Existing route registration - * @param newPriority - New endpoint priority - * @throws Error if strategy is 'error' - */ - private handleRouteConflict( - routeKey: string, - apiId: string, - endpoint: ApiEndpointRegistration, - existingRoute: { api: string; endpointId: string; priority: number }, - newPriority: number - ): void { - const strategy = this.conflictResolution; - - switch (strategy) { - case 'error': - throw new Error( - `[ApiRegistry] Route conflict detected: '${routeKey}' is already registered by API '${existingRoute.api}' endpoint '${existingRoute.endpointId}'` - ); - - case 'priority': - if (newPriority > existingRoute.priority) { - // New endpoint has higher priority, replace - this.logger.warn( - `Route conflict: replacing '${routeKey}' (priority ${existingRoute.priority} -> ${newPriority})`, - { - oldApi: existingRoute.api, - oldEndpoint: existingRoute.endpointId, - newApi: apiId, - newEndpoint: endpoint.id, - } - ); - this.routes.set(routeKey, { - api: apiId, - endpointId: endpoint.id, - priority: newPriority, - }); - } else { - // Existing endpoint has higher priority, keep it - this.logger.warn( - `Route conflict: keeping existing '${routeKey}' (priority ${existingRoute.priority} >= ${newPriority})`, - { - existingApi: existingRoute.api, - existingEndpoint: existingRoute.endpointId, - newApi: apiId, - newEndpoint: endpoint.id, - } - ); - } - break; - - case 'first-wins': - // Keep existing route - this.logger.warn( - `Route conflict: keeping first registered '${routeKey}'`, - { - existingApi: existingRoute.api, - newApi: apiId, - } - ); - break; - - case 'last-wins': - // Replace with new route - this.logger.warn( - `Route conflict: replacing with last registered '${routeKey}'`, - { - oldApi: existingRoute.api, - newApi: apiId, - } - ); - this.routes.set(routeKey, { - api: apiId, - endpointId: endpoint.id, - priority: newPriority, - }); - break; - - default: - throw new Error(`[ApiRegistry] Unknown conflict resolution strategy: ${strategy}`); - } - } - - /** - * Generate a unique route key for conflict detection - * - * NOTE: This implementation uses exact string matching for route conflict detection. - * It works well for static paths but has limitations with parameterized routes. - * For example, `/api/users/:id` and `/api/users/:userId` will NOT be detected as conflicts - * even though they are semantically identical parameterized patterns. Similarly, - * `/api/:resource/list` and `/api/:entity/list` would also not be detected as conflicting. - * - * For more advanced conflict detection (e.g., path-to-regexp pattern matching), - * consider integrating with your routing library's conflict detection mechanism. - * - * @param endpoint - Endpoint registration - * @returns Route key (e.g., "GET:/api/v1/customers/:id") - */ - private getRouteKey(endpoint: ApiEndpointRegistration): string { - const method = endpoint.method || 'ANY'; - return `${method}:${endpoint.path}`; - } - - /** - * Validate endpoint registration - * - * @param endpoint - Endpoint to validate - * @param apiId - API identifier (for error messages) - * @throws Error if endpoint is invalid - */ - private validateEndpoint(endpoint: ApiEndpointRegistration, apiId: string): void { - if (!endpoint.id) { - throw new Error(`[ApiRegistry] Endpoint in API '${apiId}' missing 'id' field`); - } - - if (!endpoint.path) { - throw new Error(`[ApiRegistry] Endpoint '${endpoint.id}' in API '${apiId}' missing 'path' field`); - } - } - - /** - * Get an API by ID - * - * @param apiId - API identifier - * @returns API registry entry or undefined - */ - getApi(apiId: string): ApiRegistryEntry | undefined { - return this.apis.get(apiId); - } - - /** - * Get all registered APIs - * - * @returns Array of all APIs - */ - getAllApis(): ApiRegistryEntry[] { - return Array.from(this.apis.values()); - } - - /** - * Find APIs matching query criteria - * - * Performance optimized with auxiliary indices for O(1) lookups on type, tags, and status. - * - * @param query - Discovery query parameters - * @returns Matching APIs - */ - findApis(query: ApiDiscoveryQuery): ApiDiscoveryResponse { - let resultIds: Set | undefined; - - // Use indices for performance-optimized filtering - // Start with the most restrictive filter to minimize subsequent filtering - - // Filter by type (using index for O(1) lookup) - if (query.type) { - const typeIds = this.apisByType.get(query.type); - if (!typeIds || typeIds.size === 0) { - return { apis: [], total: 0, filters: query }; - } - resultIds = new Set(typeIds); - } - - // Filter by status (using index for O(1) lookup) - if (query.status) { - const statusIds = this.apisByStatus.get(query.status); - if (!statusIds || statusIds.size === 0) { - return { apis: [], total: 0, filters: query }; - } - - if (resultIds) { - // Intersect with previous results - resultIds = new Set([...resultIds].filter(id => statusIds.has(id))); - } else { - resultIds = new Set(statusIds); - } - - if (resultIds.size === 0) { - return { apis: [], total: 0, filters: query }; - } - } - - // Filter by tags (using index for O(M) lookup where M is number of tags) - if (query.tags && query.tags.length > 0) { - const tagMatches = new Set(); - - for (const tag of query.tags) { - const tagIds = this.apisByTag.get(tag); - if (tagIds) { - tagIds.forEach(id => tagMatches.add(id)); - } - } - - if (tagMatches.size === 0) { - return { apis: [], total: 0, filters: query }; - } - - if (resultIds) { - // Intersect with previous results - resultIds = new Set([...resultIds].filter(id => tagMatches.has(id))); - } else { - resultIds = tagMatches; - } - - if (resultIds.size === 0) { - return { apis: [], total: 0, filters: query }; - } - } - - // Get the actual API objects - let results: ApiRegistryEntry[]; - if (resultIds) { - results = Array.from(resultIds) - .map(id => this.apis.get(id)) - .filter((api): api is ApiRegistryEntry => api !== undefined); - } else { - results = Array.from(this.apis.values()); - } - - // Apply remaining filters that don't have indices (less common filters) - - // Filter by plugin source - if (query.pluginSource) { - results = results.filter( - (api) => api.metadata?.pluginSource === query.pluginSource - ); - } - - // Filter by version - if (query.version) { - results = results.filter((api) => api.version === query.version); - } - - // Search in name/description - if (query.search) { - const searchLower = query.search.toLowerCase(); - results = results.filter( - (api) => - api.name.toLowerCase().includes(searchLower) || - (api.description && api.description.toLowerCase().includes(searchLower)) - ); - } - - return { - apis: results, - total: results.length, - filters: query, - }; - } - - /** - * Get endpoint by API ID and endpoint ID - * - * @param apiId - API identifier - * @param endpointId - Endpoint identifier - * @returns Endpoint registration or undefined - */ - getEndpoint(apiId: string, endpointId: string): ApiEndpointRegistration | undefined { - const key = `${apiId}:${endpointId}`; - return this.endpoints.get(key)?.endpoint; - } - - /** - * Find endpoint by route (method + path) - * - * @param method - HTTP method - * @param path - URL path - * @returns Endpoint registration or undefined - */ - findEndpointByRoute(method: string, path: string): { - api: ApiRegistryEntry; - endpoint: ApiEndpointRegistration; - } | undefined { - const routeKey = `${method}:${path}`; - const route = this.routes.get(routeKey); - - if (!route) { - return undefined; - } - - const api = this.apis.get(route.api); - const endpoint = this.getEndpoint(route.api, route.endpointId); - - if (!api || !endpoint) { - return undefined; - } - - return { api, endpoint }; - } - - /** - * Get complete registry snapshot - * - * @returns Current registry state - */ - getRegistry(): ApiRegistryType { - const apis = Array.from(this.apis.values()); - - // Group by type - const byType: Record = {}; - for (const api of apis) { - if (!byType[api.type]) { - byType[api.type] = []; - } - byType[api.type].push(api); - } - - // Group by status - const byStatus: Record = {}; - for (const api of apis) { - const status = api.metadata?.status || 'active'; - if (!byStatus[status]) { - byStatus[status] = []; - } - byStatus[status].push(api); - } - - // Count total endpoints - const totalEndpoints = apis.reduce( - (sum, api) => sum + api.endpoints.length, - 0 - ); - - return { - version: this.version, - conflictResolution: this.conflictResolution, - apis, - totalApis: apis.length, - totalEndpoints, - byType, - byStatus, - updatedAt: this.updatedAt, - }; - } - - /** - * Clear all registered APIs - * - * **⚠️ SAFETY WARNING:** - * This method clears all registered APIs and should be used with caution. - * - * **Usage Restrictions:** - * - In production environments (NODE_ENV=production), a `force: true` parameter is required - * - Primarily intended for testing and development hot-reload scenarios - * - * @param options - Clear options - * @param options.force - Force clear in production environment (default: false) - * @throws Error if called in production without force flag - * - * @example Safe usage in tests - * ```typescript - * beforeEach(() => { - * registry.clear(); // OK in test environment - * }); - * ``` - * - * @example Usage in production (requires explicit force) - * ```typescript - * // In production, explicit force is required - * registry.clear({ force: true }); - * ``` - */ - clear(options: { force?: boolean } = {}): void { - const isProduction = this.isProductionEnvironment(); - - if (isProduction && !options.force) { - throw new Error( - '[ApiRegistry] Cannot clear registry in production environment without force flag. ' + - 'Use clear({ force: true }) if you really want to clear the registry.' - ); - } - - this.apis.clear(); - this.endpoints.clear(); - this.routes.clear(); - - // Clear auxiliary indices - this.apisByType.clear(); - this.apisByTag.clear(); - this.apisByStatus.clear(); - - this.updatedAt = new Date().toISOString(); - - if (isProduction) { - this.logger.warn('API registry forcefully cleared in production', { force: options.force }); - } else { - this.logger.info('API registry cleared'); - } - } - - /** - * Get registry statistics - * - * @returns Registry statistics - */ - getStats(): { - totalApis: number; - totalEndpoints: number; - totalRoutes: number; - apisByType: Record; - endpointsByApi: Record; - } { - const apis = Array.from(this.apis.values()); - - const apisByType: Record = {}; - for (const api of apis) { - apisByType[api.type] = (apisByType[api.type] || 0) + 1; - } - - const endpointsByApi: Record = {}; - for (const api of apis) { - endpointsByApi[api.id] = api.endpoints.length; - } - - return { - totalApis: this.apis.size, - totalEndpoints: this.endpoints.size, - totalRoutes: this.routes.size, - apisByType, - endpointsByApi, - }; - } - - /** - * Update auxiliary indices when an API is registered - * - * @param api - API entry to index - * @private - * @internal - */ - private updateIndices(api: ApiRegistryEntry): void { - // Index by type - this.ensureIndexSet(this.apisByType, api.type).add(api.id); - - // Index by status - const status = api.metadata?.status || 'active'; - this.ensureIndexSet(this.apisByStatus, status).add(api.id); - - // Index by tags - const tags = api.metadata?.tags || []; - for (const tag of tags) { - this.ensureIndexSet(this.apisByTag, tag).add(api.id); - } - } - - /** - * Remove API from auxiliary indices when unregistered - * - * @param api - API entry to remove from indices - * @private - * @internal - */ - private removeFromIndices(api: ApiRegistryEntry): void { - // Remove from type index - this.removeFromIndexSet(this.apisByType, api.type, api.id); - - // Remove from status index - const status = api.metadata?.status || 'active'; - this.removeFromIndexSet(this.apisByStatus, status, api.id); - - // Remove from tag indices - const tags = api.metadata?.tags || []; - for (const tag of tags) { - this.removeFromIndexSet(this.apisByTag, tag, api.id); - } - } - - /** - * Helper to ensure an index set exists and return it - * - * @param map - Index map - * @param key - Index key - * @returns The Set for this key (created if needed) - * @private - * @internal - */ - private ensureIndexSet(map: Map>, key: string): Set { - let set = map.get(key); - if (!set) { - set = new Set(); - map.set(key, set); - } - return set; - } - - /** - * Helper to remove an ID from an index set and clean up empty sets - * - * @param map - Index map - * @param key - Index key - * @param id - API ID to remove - * @private - * @internal - */ - private removeFromIndexSet(map: Map>, key: string, id: string): void { - const set = map.get(key); - if (set) { - set.delete(id); - // Clean up empty sets to avoid memory leaks - if (set.size === 0) { - map.delete(key); - } - } - } - - /** - * Check if running in production environment - * - * @returns true if NODE_ENV is 'production' - * @private - * @internal - */ - private isProductionEnvironment(): boolean { - return getEnv('NODE_ENV') === 'production'; - } -} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 90318db1cd..72f071a1d5 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -14,8 +14,14 @@ export * from './lite-kernel.js'; export * from './types.js'; export * from './logger.js'; export * from './plugin-loader.js'; -export * from './api-registry.js'; -export * from './api-registry-plugin.js'; +// `./api-registry.js` + `./api-registry-plugin.js` were RETIRED in #4939 +// (ADR-0049 enforce-or-remove). `createApiRegistryPlugin()` registered an +// `api-registry` service that only `packages/core/examples/` ever composed — +// no runtime, CLI or example app mounted it, and a real 47-plugin boot had no +// such service — so the ~500-line `ApiRegistry` and the whole +// `ApiEndpointRegistration` schema family it served were zero-execution. +// Declarative endpoints have ONE shape now: `ApiEndpointSchema` +// (`@objectstack/spec/api`), whose executor is tracked by #5040. export * as QA from './qa/index.js'; // Export security utilities diff --git a/packages/metadata-protocol/src/seed-loader.ts b/packages/metadata-protocol/src/seed-loader.ts index b88e248975..55c96273c7 100644 --- a/packages/metadata-protocol/src/seed-loader.ts +++ b/packages/metadata-protocol/src/seed-loader.ts @@ -39,8 +39,9 @@ const ALL_SEED_ENVS: readonly SeedEnv[] = ['prod', 'dev', 'test']; * `NODE_ENV` is this repo's ONE established environment source — `os start` * defaults it to `production`, `os dev` / `serve --dev` set `development`, * vitest sets `test`, and every other environment-sensitive behaviour here - * (auto-DDL, the api-registry production guard, the sqlite step-down, the - * hot-reload seeder) already branches on it. Seeds reuse it rather than + * (auto-DDL, the sqlite step-down, the hot-reload seeder) already branches on + * it — the api-registry production guard this list used to cite went with the + * ApiRegistry retirement (#4939). Seeds reuse it rather than * minting an `OS_SEED_ENV`, which would only trade one declared-but-unset key * for another. * diff --git a/packages/plugins/plugin-hono-server/src/hono-plugin.ts b/packages/plugins/plugin-hono-server/src/hono-plugin.ts index 0dc86a076a..3021570a13 100644 --- a/packages/plugins/plugin-hono-server/src/hono-plugin.ts +++ b/packages/plugins/plugin-hono-server/src/hono-plugin.ts @@ -43,11 +43,12 @@ export interface HonoPluginOptions { * Controls automatic endpoint generation and API behavior */ restConfig?: RestServerConfig; - /** - * Whether to load endpoints from API Registry - * @default true - */ - useApiRegistry?: boolean; + // `useApiRegistry` REMOVED in #4939. It advertised "load endpoints from + // API Registry" and was defaulted to `true` in the constructor, but no + // code path ever read it — and the `api-registry` service it named was + // itself composed only in `packages/core/examples/`, so there were never + // endpoints to load. The ApiRegistry family is retired; the option went + // with it rather than outliving the thing it configured. /** * Whether to enable SPA fallback @@ -216,7 +217,6 @@ export class HonoServerPlugin implements Plugin { constructor(options: HonoPluginOptions = {}) { this.options = { port: 3000, - useApiRegistry: true, spaFallback: false, ...options }; diff --git a/packages/runtime/src/dispatcher-plugin.ts b/packages/runtime/src/dispatcher-plugin.ts index 924cba48fb..1948247a64 100644 --- a/packages/runtime/src/dispatcher-plugin.ts +++ b/packages/runtime/src/dispatcher-plugin.ts @@ -123,12 +123,15 @@ export interface DispatcherPluginConfig { * `init()` — a `use()` at any later point still gates every route, so the * gate does not have to win a race with route registration to be complete. * - * Endpoint-level `ApiEndpointSchema.rateLimit` / - * `ApiEndpointRegistrationSchema.rateLimit` are NOT read here. They remain - * KNOWN-UNWIRED and are tracked by #4936, which owns the fate of the whole - * declarative `apis:` face — wiring one key of a surface whose existence is - * still undecided would have to be undone if that decision goes the other - * way. + * Endpoint-level `ApiEndpointSchema.rateLimit` is NOT read here. It remains + * KNOWN-UNWIRED and is now tracked by #5040, the endpoint-executor build. + * (`ApiEndpointRegistrationSchema.rateLimit`, the second spelling this note + * used to name, no longer exists — that whole registry family was retired + * in #4939.) #4936 settled the fate this note called undecided: the + * `ApiEndpoint` vocabulary is KEPT, a non-empty `apis:` is rejected at + * publish/validate until the executor exists, and every endpoint-level key + * — this one included — gets wired there, reusing the server-level seam + * below as its pattern. */ rateLimit?: { /** The authored `server.security.rateLimit` budget. */ diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 23887a75d9..ded84cb6de 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -11,7 +11,10 @@ import { readServiceSelfInfo, DispatcherErrorCode } from '@objectstack/spec/api' import { apiErrorResponse } from './error-envelope.js'; import type { ExecutionContext } from '@objectstack/spec/kernel'; import { DomainHandlerRegistry, type DomainRoute, type DomainHandlerDeps } from './domain-handler-registry.js'; -import * as actionExec from './action-execution.js'; +// `import * as actionExec from './action-execution.js'` was dropped in #4936: +// the dispatcher's own `callData` delegate was its last consumer here, and the +// delegate died with the `handleApiEndpoint` branch. The domain modules import +// `action-execution` for themselves. import { createAnalyticsDomain, handleAnalyticsRequest } from './domains/analytics.js'; import { isServiceServeable } from './service-serveable.js'; import { createI18nDomain, handleI18nRequest } from './domains/i18n.js'; @@ -568,16 +571,13 @@ export class HttpDispatcher { }); } - /** Thin delegate — body extracted to `./action-execution.ts` (D11③ PR-8). */ - private async callData( - action: string, - params: any, - dataDriver?: any, - scopeId?: string, - executionContext?: ExecutionContext, - ): Promise { - return actionExec.callData(this.domainDeps, action, params, dataDriver, scopeId, executionContext); - } + // The private `callData` delegate that stood here was removed with + // `handleApiEndpoint` in #4936 — that dead branch was its ONLY caller, and + // `tsc` said so (TS6133) the moment the branch went. Every live data path + // calls `actionExec.callData(deps, …)` directly from its domain module + // (`domains/data.ts`, `domains/mcp.ts`, `domains/actions.ts`), which is the + // D11③ shape anyway; the wrapper was a leftover of the pre-extraction + // dispatcher. /** Thin delegate — body extracted to `./domains/mcp.ts` (D11③ PR-9). */ async handleMcp(body: any, context: HttpProtocolContext): Promise { @@ -1619,10 +1619,23 @@ export class HttpDispatcher { } } - // 2. Custom API Endpoints (Registry lookup) - // Check if there is a custom endpoint defined for this path - const result = await this.handleApiEndpoint(cleanPath, method, body, query, context); - if (result.handled) return result; + // 2. Metadata-declared custom endpoints (`apis:`) — REMOVED in #4936. + // + // A `handleApiEndpoint` branch used to sit here. It resolved the + // metadata service and called `matchEndpoint` on it — a method NO + // implementation in this repo has ever provided (`MetadataManager` / + // `NodeMetadataManager` and every plugin alike), so the branch was + // `{ handled: false }` on every request ever served. It could not even + // be reached: the declared paths were never mounted, so a request for + // one died at Hono's `notFound` long before `dispatch()` saw it. + // + // That is precisely the input ADR-0076 "one route, one owner" warns + // about — code `grep` finds and the runtime never runs, which an agent + // (or a human) then reasons confidently from. Deleted rather than + // repaired so the absence is LOUD: a non-empty `apis:` is now rejected + // at publish/validate with a prescription (`stack.zod.ts`), instead of + // parsing clean and 404ing at runtime. The executor is being built + // under #5040 and will re-mount this surface for real. // 3. Fallback — return semantic 404 with diagnostic info return { @@ -1639,81 +1652,4 @@ export class HttpDispatcher { throw e; } } - - /** - * Handles Custom API Endpoints defined in metadata - */ - async handleApiEndpoint(path: string, method: string, body: any, query: any, context: HttpProtocolContext): Promise { - try { - // Attempt to find a matching endpoint in the registry - const metaSvc = await this.resolveService('metadata', context.environmentId); - if (!metaSvc || typeof (metaSvc as any).matchEndpoint !== 'function') { - return { handled: false }; - } - const endpoint = await (metaSvc as any).matchEndpoint({ path, method }); - - if (endpoint) { - // Execute the endpoint target logic - if (endpoint.type === 'flow') { - const automationSvc = await this.resolveService('automation'); - if (!automationSvc || typeof (automationSvc as any).runFlow !== 'function') { - return { handled: true, response: this.error('Automation service not available', 503) }; - } - const result = await (automationSvc as any).runFlow({ - flowId: endpoint.target, - inputs: { ...query, ...body, _request: context.request } - }); - return { handled: true, response: this.success(result) }; - } - - if (endpoint.type === 'script') { - const automationSvc = await this.resolveService('automation'); - if (!automationSvc || typeof (automationSvc as any).runScript !== 'function') { - return { handled: true, response: this.error('Automation service not available', 503) }; - } - const result = await (automationSvc as any).runScript({ - scriptName: endpoint.target, - context: { ...query, ...body, request: context.request } - }); - return { handled: true, response: this.success(result) }; - } - - if (endpoint.type === 'object_operation') { - // e.g. Proxy to an object action - if (endpoint.objectParams) { - const { object, operation } = endpoint.objectParams; - // Map standard CRUD operations - if (operation === 'find') { - const result = await this.callData('query', { object, query }); - // Spec: FindDataResponse = { object, records, total?, hasMore? } - return { handled: true, response: this.success(result.records, { total: result.total }) }; - } - if (operation === 'get' && query.id) { - const result = await this.callData('get', { object, id: query.id }); - return { handled: true, response: this.success(result) }; - } - if (operation === 'create') { - const result = await this.callData('create', { object, data: body }); - return { handled: true, response: this.success(result) }; - } - } - } - - if (endpoint.type === 'proxy') { - return { - handled: true, - response: { - status: 200, - body: { proxy: true, target: endpoint.target, note: 'Proxy execution requires http-client service' } - } - }; - } - } - } catch (e) { - // If matchEndpoint fails (e.g. not found), we just return not handled - // so we can fallback to 404 or other handlers - } - - return { handled: false }; - } } diff --git a/packages/runtime/src/route-ledger.ts b/packages/runtime/src/route-ledger.ts index bcd6d33b86..98d65510c4 100644 --- a/packages/runtime/src/route-ledger.ts +++ b/packages/runtime/src/route-ledger.ts @@ -97,7 +97,14 @@ export const LEGACY_CHAIN_PREFIXES = [ '/mcp', '/actions', '/openapi.json', - '/__api-endpoint', // handleApiEndpoint catch-all (metadata-declared endpoints) + // `/__api-endpoint` (the `handleApiEndpoint` catch-all for metadata-declared + // `apis:`) was REMOVED in #4936. It never named a mounted route: the branch + // it stood for resolved a `matchEndpoint` method no implementation in this + // repo ever provided, so it returned "not handled" on every request, and the + // declared paths were never mounted for it to see in the first place. A + // non-empty `apis:` is now rejected at publish/validate instead. When the + // executor lands (#5040) it re-enters this file as a REAL mount, not a + // catch-all placeholder. ] as const; export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ @@ -243,5 +250,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [ // ── misc legacy ─────────────────────────────────────────────────────────── { route: 'GET /openapi.json', domain: '/openapi.json', disposition: 'server-only', note: 'docs tooling; falls through when metadata service lacks a generator' }, - { route: '* (unmatched)', domain: '/__api-endpoint', disposition: 'dynamic', note: 'metadata-declared custom endpoints (flow/script/object_operation/proxy)' }, + // `* (unmatched)` / `/__api-endpoint` removed in #4936 — see LEGACY_CHAIN_PREFIXES + // above. It was the ledger's only row for a surface nothing served; an + // unmatched path now falls to the semantic 404 with no pretence otherwise. ]; diff --git a/packages/runtime/src/security/inbound-rate-limit.ts b/packages/runtime/src/security/inbound-rate-limit.ts index 64af1b12b6..6fc0564f41 100644 --- a/packages/runtime/src/security/inbound-rate-limit.ts +++ b/packages/runtime/src/security/inbound-rate-limit.ts @@ -15,8 +15,9 @@ * * This module is the connective tissue the maintainer adjudicated on * 2026-08-03: a NARROW authorable `server:` key (Q1=B), server-level only - * (Q2=B — endpoint-level `rateLimit` stays knowingly unwired, tracked by - * #4936), keyed principal-first with IP fallback and forwarded headers believed + * (Q2=B — endpoint-level `rateLimit` stays knowingly unwired, now tracked by + * #5040, the endpoint-executor build that #4936 chartered), keyed + * principal-first with IP fallback and forwarded headers believed * only under an explicit `trustProxy` (Q3=C), counting in the kernel cache with * an announced per-process fallback (Q4=B / ADR-0069 D2). * diff --git a/packages/spec/PROTOCOL_MAP.md b/packages/spec/PROTOCOL_MAP.md index 5c40193877..d7d34d6e60 100644 --- a/packages/spec/PROTOCOL_MAP.md +++ b/packages/spec/PROTOCOL_MAP.md @@ -155,7 +155,7 @@ This document serves as the **Grand Map** of the ObjectStack specification. It l | [`protocol.zod.ts`](src/api/protocol.zod.ts) | ⭐ | **Stack Protocol**. valid requests and responses for the platform. | | [`dispatcher.zod.ts`](src/api/dispatcher.zod.ts) | ⭐ | **HttpDispatcher**. Route-to-service mapping for API routing. | | [`discovery.zod.ts`](src/api/discovery.zod.ts) | ⭐ | **Service Discovery**. Service registration and API routes discovery. | -| [`endpoint.zod.ts`](src/api/endpoint.zod.ts) | | **API Endpoints**. REST API route definitions. | +| [`endpoint.zod.ts`](src/api/endpoint.zod.ts) | | **API Endpoints**. REST API route definitions. ⚠️ Vocabulary only in v17: no executor exists, so a non-empty `apis:` is rejected at publish/validate (#4936); the executor is tracked by #5040. | | [`rest-server.zod.ts`](src/api/rest-server.zod.ts) | | **REST Server**. REST-specific server settings. | | [`auth.zod.ts`](src/api/auth.zod.ts) | | **API Auth**. Authentication schemes for APIs. | | [`analytics.zod.ts`](src/api/analytics.zod.ts) | | **API Analytics**. Usage tracking for APIs. | @@ -170,7 +170,6 @@ This document serves as the **Grand Map** of the ObjectStack specification. It l | [`batch.zod.ts`](src/api/batch.zod.ts) | | **Batch API**. Bulk request processing. | | [`contract.zod.ts`](src/api/contract.zod.ts) | | **API Contracts**. Versioned API signatures. | | [`storage.zod.ts`](src/api/storage.zod.ts) | | **Storage API**. File upload/download endpoints. | -| [`registry.zod.ts`](src/api/registry.zod.ts) | | **Registry API**. Package registry interface. | | [`package-api.zod.ts`](src/api/package-api.zod.ts) | | **Package API**. Package lifecycle endpoints (`/api/v1/packages`). | --- diff --git a/packages/spec/api-surface.json b/packages/spec/api-surface.json index f7384b5ec8..9afa0e13d7 100644 --- a/packages/spec/api-surface.json +++ b/packages/spec/api-surface.json @@ -2318,35 +2318,14 @@ "AnalyticsSqlResponseSchema (const)", "ApiChangelogEntry (type)", "ApiChangelogEntrySchema (const)", - "ApiDiscoveryQuery (type)", - "ApiDiscoveryQuerySchema (const)", - "ApiDiscoveryResponse (type)", - "ApiDiscoveryResponseSchema (const)", "ApiDocumentationConfig (type)", "ApiDocumentationConfigSchema (const)", "ApiEndpoint (type)", "ApiEndpointInput (type)", - "ApiEndpointRegistration (type)", - "ApiEndpointRegistrationInput (type)", - "ApiEndpointRegistrationSchema (const)", "ApiEndpointSchema (const)", "ApiError (type)", "ApiErrorSchema (const)", "ApiMappingSchema (const)", - "ApiMetadata (type)", - "ApiMetadataInput (type)", - "ApiMetadataSchema (const)", - "ApiParameter (type)", - "ApiParameterSchema (const)", - "ApiProtocolType (type)", - "ApiRegistry (type)", - "ApiRegistryEntry (type)", - "ApiRegistryEntryInput (type)", - "ApiRegistryEntrySchema (const)", - "ApiRegistrySchema (const)", - "ApiResponse (type)", - "ApiResponseInput (type)", - "ApiResponseSchema (const)", "ApiRoutes (type)", "ApiRoutesSchema (const)", "ApiTestCollection (type)", @@ -2705,7 +2684,6 @@ "HandlerStatusSchema (const)", "HttpFindQueryParamsSchema (const)", "HttpMethod (type)", - "HttpStatusCode (type)", "HttpStatusErrorCodeMap (const)", "I18nProtocol (interface)", "IMPORT_JOB_MAX_ROWS (const)", @@ -2877,8 +2855,6 @@ "ODataResponseSchema (const)", "ObjectDefinitionResponse (type)", "ObjectDefinitionResponseSchema (const)", - "ObjectQLReference (type)", - "ObjectQLReferenceSchema (const)", "OpenApiGenerationConfig (type)", "OpenApiGenerationConfigInput (type)", "OpenApiGenerationConfigSchema (const)", @@ -3025,7 +3001,6 @@ "ScheduleExportResponseSchema (const)", "ScheduledExport (type)", "ScheduledExportSchema (const)", - "SchemaDefinition (type)", "ServiceInfo (type)", "ServiceInfoSchema (const)", "ServiceSelfInfo (type)", diff --git a/packages/spec/authorable-surface.json b/packages/spec/authorable-surface.json index d88c4db1dd..7554a1aba3 100644 --- a/packages/spec/authorable-surface.json +++ b/packages/spec/authorable-surface.json @@ -474,15 +474,6 @@ "api/ApiChangelogEntry:date", "api/ApiChangelogEntry:migrationGuide", "api/ApiChangelogEntry:version", - "api/ApiDiscoveryQuery:pluginSource", - "api/ApiDiscoveryQuery:search", - "api/ApiDiscoveryQuery:status", - "api/ApiDiscoveryQuery:tags", - "api/ApiDiscoveryQuery:type", - "api/ApiDiscoveryQuery:version", - "api/ApiDiscoveryResponse:apis", - "api/ApiDiscoveryResponse:filters", - "api/ApiDiscoveryResponse:total", "api/ApiDocumentationConfig:changelog", "api/ApiDocumentationConfig:codeTemplates", "api/ApiDocumentationConfig:contact", @@ -513,23 +504,6 @@ "api/ApiEndpoint:summary", "api/ApiEndpoint:target", "api/ApiEndpoint:type", - "api/ApiEndpointRegistration:deprecated", - "api/ApiEndpointRegistration:description", - "api/ApiEndpointRegistration:externalDocs", - "api/ApiEndpointRegistration:id", - "api/ApiEndpointRegistration:method", - "api/ApiEndpointRegistration:operationId", - "api/ApiEndpointRegistration:parameters", - "api/ApiEndpointRegistration:path", - "api/ApiEndpointRegistration:priority", - "api/ApiEndpointRegistration:protocolConfig", - "api/ApiEndpointRegistration:rateLimit", - "api/ApiEndpointRegistration:requestBody", - "api/ApiEndpointRegistration:requiredPermissions", - "api/ApiEndpointRegistration:responses", - "api/ApiEndpointRegistration:security", - "api/ApiEndpointRegistration:summary", - "api/ApiEndpointRegistration:tags", "api/ApiError:category", "api/ApiError:code", "api/ApiError:details", @@ -539,43 +513,6 @@ "api/ApiMapping:source", "api/ApiMapping:target", "api/ApiMapping:transform", - "api/ApiMetadata:custom", - "api/ApiMetadata:owner", - "api/ApiMetadata:pluginSource", - "api/ApiMetadata:status", - "api/ApiMetadata:tags", - "api/ApiParameter:description", - "api/ApiParameter:example", - "api/ApiParameter:in", - "api/ApiParameter:name", - "api/ApiParameter:required", - "api/ApiParameter:schema", - "api/ApiRegistry:apis", - "api/ApiRegistry:byStatus", - "api/ApiRegistry:byType", - "api/ApiRegistry:conflictResolution", - "api/ApiRegistry:totalApis", - "api/ApiRegistry:totalEndpoints", - "api/ApiRegistry:updatedAt", - "api/ApiRegistry:version", - "api/ApiRegistryEntry:basePath", - "api/ApiRegistryEntry:config", - "api/ApiRegistryEntry:contact", - "api/ApiRegistryEntry:description", - "api/ApiRegistryEntry:endpoints", - "api/ApiRegistryEntry:id", - "api/ApiRegistryEntry:license", - "api/ApiRegistryEntry:metadata", - "api/ApiRegistryEntry:name", - "api/ApiRegistryEntry:termsOfService", - "api/ApiRegistryEntry:type", - "api/ApiRegistryEntry:version", - "api/ApiResponse:contentType", - "api/ApiResponse:description", - "api/ApiResponse:example", - "api/ApiResponse:headers", - "api/ApiResponse:schema", - "api/ApiResponse:statusCode", "api/ApiRoutes:ai", "api/ApiRoutes:analytics", "api/ApiRoutes:approvals", @@ -1586,10 +1523,6 @@ "api/ObjectDefinitionResponse:error", "api/ObjectDefinitionResponse:meta", "api/ObjectDefinitionResponse:success", - "api/ObjectQLReference:excludeFields", - "api/ObjectQLReference:includeFields", - "api/ObjectQLReference:includeRelated", - "api/ObjectQLReference:objectId", "api/OpenApiGenerationConfig:apiVersion", "api/OpenApiGenerationConfig:contact", "api/OpenApiGenerationConfig:description", diff --git a/packages/spec/json-schema.manifest.json b/packages/spec/json-schema.manifest.json index 43cd34e443..68ac65360f 100644 --- a/packages/spec/json-schema.manifest.json +++ b/packages/spec/json-schema.manifest.json @@ -1,5 +1,5 @@ { - "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema — remove a key ONLY for a deliberate retirement. See #2978.", + "description": "Ratchet manifest of every JSON Schema emitted by scripts/build-schemas.ts. Auto-appended when new schemas are added (commit the change). A listed schema that a build no longer emits fails gen:schema \u2014 remove a key ONLY for a deliberate retirement. See #2978.", "schemas": [ "ai/AIModelConfig", "ai/AIUsageRecord", @@ -87,19 +87,10 @@ "api/AnalyticsResultResponse", "api/AnalyticsSqlResponse", "api/ApiChangelogEntry", - "api/ApiDiscoveryQuery", - "api/ApiDiscoveryResponse", "api/ApiDocumentationConfig", "api/ApiEndpoint", - "api/ApiEndpointRegistration", "api/ApiError", "api/ApiMapping", - "api/ApiMetadata", - "api/ApiParameter", - "api/ApiProtocolType", - "api/ApiRegistry", - "api/ApiRegistryEntry", - "api/ApiResponse", "api/ApiRoutes", "api/ApiTestCollection", "api/ApiTestRequest", @@ -276,7 +267,6 @@ "api/HandlerStatus", "api/HttpFindQueryParams", "api/HttpMethod", - "api/HttpStatusCode", "api/IdRequest", "api/ImportJobProgress", "api/ImportJobResults", @@ -361,7 +351,6 @@ "api/ODataQueryAdapter", "api/ODataResponse", "api/ObjectDefinitionResponse", - "api/ObjectQLReference", "api/OpenApiGenerationConfig", "api/OpenApiSecurityScheme", "api/OpenApiServer", @@ -430,7 +419,6 @@ "api/ScheduleExportRequest", "api/ScheduleExportResponse", "api/ScheduledExport", - "api/SchemaDefinition", "api/ServiceInfo", "api/ServiceSelfInfo", "api/ServiceStatus", diff --git a/packages/spec/src/api/apis-no-executor.test.ts b/packages/spec/src/api/apis-no-executor.test.ts new file mode 100644 index 0000000000..18dae01076 --- /dev/null +++ b/packages/spec/src/api/apis-no-executor.test.ts @@ -0,0 +1,169 @@ +// 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('does NOT name #4936 as the tracking pointer (it closes with this change)', () => { + const result = ObjectStackDefinitionSchema.safeParse({ manifest, apis: [validEndpoint] }); + const message = result.success ? '' : JSON.stringify(result.error.issues); + // #4936 may appear as the DECISION's provenance; what must not happen is + // it standing in as the live tracker with no #5040 alongside it. + expect(message).toMatch(/issues\/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/index.ts b/packages/spec/src/api/index.ts index 2ccb590ad4..18af2e4aeb 100644 --- a/packages/spec/src/api/index.ts +++ b/packages/spec/src/api/index.ts @@ -28,7 +28,17 @@ export * from './errors.zod'; export * from './error-code-ledger.zod'; export * from './protocol.zod'; export * from './rest-server.zod'; -export * from './registry.zod'; +// `./registry.zod` (the `ApiRegistry` / `ApiEndpointRegistration` family) was +// RETIRED in #4939 — ADR-0049 enforce-or-remove. It was assembled only in +// `packages/core/examples/`, never by `packages/runtime`, `packages/cli` or any +// `examples/app-*`, so a real 47-plugin boot carried no `api-registry` service +// and the whole schema family was zero-execution. That included +// `ApiEndpointRegistration.requiredPermissions`, documented in the present +// tense as gateway-enforced while no gateway ever read it — false compliance, +// not debt. It also left the repo with TWO declaration shapes for one concept; +// this retirement converges them on `ApiEndpointSchema` (`./endpoint.zod`), +// whose executor is tracked by #5040. The one survivor, +// `ConflictResolutionStrategy`, moved to `./router.zod` — see the note there. export * from './documentation.zod'; export * from './analytics.zod'; export * from './versioning.zod'; diff --git a/packages/spec/src/api/registry-retirement.test.ts b/packages/spec/src/api/registry-retirement.test.ts new file mode 100644 index 0000000000..1a42a58c8a --- /dev/null +++ b/packages/spec/src/api/registry-retirement.test.ts @@ -0,0 +1,116 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#4939] The `ApiRegistry` / `ApiEndpointRegistration` family is RETIRED. + * + * ## What was wrong + * + * `packages/spec/src/api/registry.zod.ts` declared a whole second shape for + * "an API endpoint", served by a ~500-line `ApiRegistry` in `packages/core` + * that `createApiRegistryPlugin()` registered as the `api-registry` service. + * Nothing composed it: every assembly site lived in + * `packages/core/examples/api-registry-example.ts`, with no registration in + * `packages/runtime`, `packages/cli` or any `examples/app-*`, and a real + * 47-plugin boot carried no `api-registry` service at all. + * + * So the entire schema family was zero-execution — `parameters`, `requestBody`, + * `responses`, `security`, `operationId`, `tags`, and worst of all + * `requiredPermissions`, whose TSDoc promised in the PRESENT TENSE that "the + * gateway layer automatically validates these permissions" while no gateway + * anywhere read it. That is false compliance in the ADR-0049 sense, not debt. + * + * It also left the repo carrying TWO declaration shapes for one concept, both + * inert. The 2026-08-04 verdict retired this one unconditionally and kept + * `ApiEndpointSchema` (`./endpoint.zod`), whose executor is tracked by #5040 — + * converging on one shape rather than choosing between two dead ones. + * + * ## Why these assertions are runtime namespace probes + * + * A removed export cannot be imported by name — that would not compile, so the + * pin has to ask the namespace object instead. `#4642` established that a + * compile-time conditional-type pin is a no-op in this package (tsconfig + * excludes `**\/*.test.ts`, and vitest never enables `typecheck`), so the + * load-bearing check must be a runtime one, with anti-vacuity guards. + */ + +import { describe, it, expect } from 'vitest'; + +/** Every name the retirement removed from the `./api` surface. */ +const RETIRED_NAMES = [ + // The #4939 subject and its factory. + 'ApiEndpointRegistrationSchema', + 'ApiEndpointRegistration', + // The registry container family. + 'ApiRegistrySchema', + 'ApiRegistry', + 'ApiRegistryEntrySchema', + 'ApiRegistryEntry', + 'ApiMetadataSchema', + // Value schemas that existed only to serve the above (#3950: an exported + // schema with no consumer is read as a capability by whoever finds it). + 'ApiParameterSchema', + 'ApiResponseSchema', + 'ApiProtocolType', + 'HttpStatusCode', + 'ObjectQLReferenceSchema', + 'SchemaDefinition', + 'ApiDiscoveryQuerySchema', + 'ApiDiscoveryResponseSchema', +] as const; + +describe('[#4939] ApiRegistry family retired from `@objectstack/spec/api`', () => { + it('exports none of the retired names', async () => { + const api = await import('./index'); + + // Anti-vacuity FIRST: the namespace we are about to probe must be real and + // non-trivial, or every `toBe(false)` below passes for the wrong reason. + const names = Object.keys(api); + expect(names.length, './api must export a non-trivial surface').toBeGreaterThan(100); + expect(names).toContain('ApiEndpointSchema'); + + for (const retired of RETIRED_NAMES) { + expect(retired in api, `./api must not export ${retired}`).toBe(false); + } + }); + + it('keeps `ApiEndpointSchema` — the ONE surviving endpoint shape', async () => { + const api = await import('./index'); + expect('ApiEndpointSchema' in api).toBe(true); + expect('ApiEndpoint' in api).toBe(true); + // `ApiMappingSchema` is its input/output mapping type and survives with it. + expect('ApiMappingSchema' in api).toBe(true); + }); + + it('keeps `ConflictResolutionStrategy`, moved to router.zod (NOT re-introducing the registry)', async () => { + const api = await import('./index'); + expect( + 'ConflictResolutionStrategy' in api, + 'two independent ratchets pin this as a ./api export: spec/src/automation/' + + 'sync-retirement.test.ts (#4738, the fourth ConflictResolution relative) and, ' + + 'cross-repo, objectui offline-nav-performance-spec-parity.test.ts', + ).toBe(true); + + // Its VALUE domain must survive the move byte-for-byte — a moved symbol + // that quietly changed vocabulary would be the #4738 trap, not a move. + const strategy = (api as Record).ConflictResolutionStrategy as { + parse: (v: unknown) => unknown; + }; + for (const ok of ['error', 'priority', 'first-wins', 'last-wins']) { + expect(() => strategy.parse(ok), `${ok} must stay legal`).not.toThrow(); + } + // And it must still be the ROUTE-conflict vocabulary, not ui's offline one. + for (const notOurs of ['client_wins', 'server_wins', 'last_write_wins', 'target_wins']) { + expect(() => strategy.parse(notOurs), `${notOurs} belongs to another enum`).toThrow(); + } + }); + + it('declares it exactly once — the retirement must not leave a second owner', async () => { + const api = await import('./index'); + const router = await import('./router.zod'); + expect('ConflictResolutionStrategy' in router).toBe(true); + expect( + (api as Record).ConflictResolutionStrategy, + './api must re-export the router declaration itself, not a copy', + ).toBe((router as Record).ConflictResolutionStrategy); + }); +}); diff --git a/packages/spec/src/api/registry.example.ts b/packages/spec/src/api/registry.example.ts deleted file mode 100644 index 07fd7d35db..0000000000 --- a/packages/spec/src/api/registry.example.ts +++ /dev/null @@ -1,532 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -/** - * API Registry Enhancement Examples - * - * This file demonstrates all the enhancements made to the Unified API Registry: - * 1. RBAC Integration - * 2. Dynamic Schema Linking (ObjectQL References) - * 3. Protocol Extensibility - * 4. Route Conflict Detection - */ - -import { - ApiEndpointRegistration, - ApiRegistryEntry, - ApiRegistry, - type ConflictResolutionStrategy, -} from './registry.zod'; - -// ========================================== -// Example 1: RBAC Integration -// ========================================== - -/** - * Example: Endpoint with RBAC Permission Requirements - * - * The gateway automatically validates these permissions before - * allowing the request to proceed. - */ -const endpointWithRBAC = ApiEndpointRegistration.create({ - id: 'get_customer_by_id', - method: 'GET', - path: '/api/v1/customers/:id', - summary: 'Get customer by ID', - description: 'Retrieves a customer record with RBAC protection', - - // RBAC Integration: Permissions checked at gateway level - requiredPermissions: ['customer.read'], - - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { - type: 'string', - format: 'uuid', - }, - }, - ], - responses: [ - { - statusCode: 200, - description: 'Customer found', - }, - { - statusCode: 403, - description: 'Permission denied - customer.read required', - }, - ], -}); - -/** - * Example: Admin Endpoint with Multiple Permissions - * - * All listed permissions must be satisfied (AND logic). - */ -const adminEndpoint = ApiEndpointRegistration.create({ - id: 'bulk_update_customers', - method: 'POST', - path: '/api/v1/admin/customers/bulk-update', - summary: 'Bulk update customers', - - // Multiple permissions required - requiredPermissions: [ - 'customer.modifyAll', // Can modify all customer records - 'api_enabled', // API access enabled - ], - - responses: [], -}); - -// ========================================== -// Example 2: Dynamic Schema Linking -// ========================================== - -/** - * Example: Response with ObjectQL Reference - * - * Instead of duplicating the customer schema, we reference - * the ObjectQL object definition. When the object schema changes, - * the API documentation automatically updates. - */ -const endpointWithDynamicSchema = ApiEndpointRegistration.create({ - id: 'get_customer_dynamic', - method: 'GET', - path: '/api/v1/customers/:id', - summary: 'Get customer (with dynamic schema)', - - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { - type: 'string', - }, - }, - ], - - responses: [ - { - statusCode: 200, - description: 'Customer retrieved successfully', - // Dynamic schema reference - auto-updates when object changes - schema: { - $ref: { - objectId: 'customer', - // Exclude sensitive fields from API response - excludeFields: ['password_hash', 'internal_notes'], - }, - }, - }, - ], -}); - -/** - * Example: Request Body with ObjectQL Reference - * - * The request body schema references the customer object, - * but only includes specific fields allowed for creation. - */ -const createEndpointWithDynamicSchema = ApiEndpointRegistration.create({ - id: 'create_customer_dynamic', - method: 'POST', - path: '/api/v1/customers', - summary: 'Create customer (with dynamic schema)', - - requestBody: { - description: 'Customer data', - required: true, - schema: { - $ref: { - objectId: 'customer', - // Only allow these fields in creation - includeFields: ['name', 'email', 'phone', 'company'], - }, - }, - }, - - responses: [ - { - statusCode: 201, - description: 'Customer created', - schema: { - $ref: { - objectId: 'customer', - excludeFields: ['password_hash'], - }, - }, - }, - ], -}); - -/** - * Example: Complex Schema with Related Objects - * - * Include related objects via lookup fields for a complete response. - */ -const orderWithRelations = ApiEndpointRegistration.create({ - id: 'get_order_with_relations', - method: 'GET', - path: '/api/v1/orders/:id', - summary: 'Get order with customer and items', - - parameters: [ - { - name: 'id', - in: 'path', - required: true, - schema: { type: 'string' }, - }, - ], - - responses: [ - { - statusCode: 200, - description: 'Order with related objects', - schema: { - $ref: { - objectId: 'order', - // Include related customer and order items - includeRelated: ['customer', 'items'], - }, - }, - }, - ], -}); - -// ========================================== -// Example 3: Protocol Extensibility -// ========================================== - -/** - * Example: gRPC Service Endpoint - * - * Plugin-registered gRPC service with protocol-specific configuration. - */ -const grpcEndpoint = ApiEndpointRegistration.create({ - id: 'grpc_get_customer', - path: '/grpc/CustomerService/GetCustomer', - summary: 'gRPC: Get Customer', - - // Protocol-specific configuration for gRPC - protocolConfig: { - subProtocol: 'grpc', - serviceName: 'CustomerService', - methodName: 'GetCustomer', - streaming: false, - packageName: 'objectstack.customer.v1', - }, - - responses: [], -}); - -/** - * Example: tRPC Procedure - * - * tRPC query with procedure-specific metadata. - */ -const trpcEndpoint = ApiEndpointRegistration.create({ - id: 'trpc_customer_get_by_id', - path: '/trpc/customer.getById', - summary: 'tRPC: Get Customer by ID', - - // tRPC-specific configuration - protocolConfig: { - subProtocol: 'trpc', - procedureType: 'query', - router: 'customer', - procedureName: 'getById', - }, - - responses: [], -}); - -/** - * Example: WebSocket Event - * - * Real-time event with WebSocket-specific metadata. - */ -const websocketEndpoint = ApiEndpointRegistration.create({ - id: 'ws_customer_updated', - path: '/ws/events/customer.updated', - summary: 'WebSocket: Customer Updated Event', - - // WebSocket-specific configuration - protocolConfig: { - subProtocol: 'websocket', - eventName: 'customer.updated', - direction: 'server-to-client', - requiresAuth: true, - room: 'customer_updates', - }, - - responses: [], -}); - -// ========================================== -// Example 4: Route Priority & Conflict Resolution -// ========================================== - -/** - * Example: High Priority Core Endpoint - * - * Core system endpoints should have high priority (900-1000) - * to ensure they're registered before plugin endpoints. - */ -const coreEndpoint = ApiEndpointRegistration.create({ - id: 'core_data_operation', - method: 'GET', - path: '/api/v1/data/:object/:id', - summary: 'Core data operation', - - // High priority for core system endpoint - priority: 950, - - responses: [], -}); - -/** - * Example: Medium Priority Plugin Endpoint - * - * Plugin endpoints should have medium priority (100-500). - */ -const pluginEndpoint = ApiEndpointRegistration.create({ - id: 'plugin_custom_action', - method: 'POST', - path: '/api/v1/custom/action', - summary: 'Plugin custom action', - - // Medium priority for plugin endpoint - priority: 300, - - protocolConfig: { - pluginId: 'custom_actions_plugin', - }, - - responses: [], -}); - -/** - * Example: Low Priority Fallback Endpoint - * - * Fallback or catch-all endpoints should have low priority (0-100). - */ -const fallbackEndpoint = ApiEndpointRegistration.create({ - id: 'fallback_handler', - method: 'GET', - path: '/api/*', - summary: 'Fallback handler', - - // Low priority for fallback endpoint - priority: 50, - - responses: [ - { - statusCode: 404, - description: 'Not found', - }, - ], -}); - -// ========================================== -// Example 5: Complete Registry with Conflict Resolution -// ========================================== - -/** - * Example: Complete Registry with Priority-based Conflict Resolution - * - * When multiple endpoints have overlapping routes, the priority field - * determines which endpoint wins. - */ -const completeRegistry = ApiRegistry.create({ - version: '1.0.0', - - // Use priority-based conflict resolution - conflictResolution: 'priority' as ConflictResolutionStrategy, - - apis: [ - // Core REST API (high priority endpoints) - ApiRegistryEntry.create({ - id: 'core_rest_api', - name: 'Core REST API', - type: 'rest', - version: 'v1', - basePath: '/api/v1', - description: 'Core system REST API', - endpoints: [ - coreEndpoint, - ], - metadata: { - owner: 'platform_team', - status: 'active', - }, - }), - - // Plugin API (medium priority endpoints) - ApiRegistryEntry.create({ - id: 'plugin_api', - name: 'Custom Actions Plugin API', - type: 'plugin', - version: '1.0.0', - basePath: '/api/v1/custom', - description: 'Custom actions provided by plugin', - endpoints: [ - pluginEndpoint, - ], - metadata: { - owner: 'plugin_team', - status: 'active', - pluginSource: 'custom_actions_plugin', - }, - }), - - // gRPC API - ApiRegistryEntry.create({ - id: 'grpc_api', - name: 'gRPC API', - type: 'plugin', - version: '1.0.0', - basePath: '/grpc', - description: 'gRPC services', - endpoints: [ - grpcEndpoint, - ], - config: { - grpcVersion: '1.0.0', - reflection: true, - }, - metadata: { - status: 'beta', - }, - }), - ], - - totalApis: 3, - totalEndpoints: 3, -}); - -// ========================================== -// Example 6: Complete Endpoint with All Features -// ========================================== - -/** - * Example: Production-ready Endpoint with All Enhancements - * - * This example combines all four enhancements: - * - RBAC permissions - * - Dynamic schema linking - * - Protocol configuration - * - Route priority - */ -const productionEndpoint = ApiEndpointRegistration.create({ - id: 'get_customer_full_featured', - method: 'GET', - path: '/api/v1/customers/:id', - summary: 'Get customer by ID (full-featured)', - description: 'Production-ready endpoint with all enhancements', - operationId: 'getCustomerById', - tags: ['customer', 'crm', 'public'], - - // 1. RBAC Integration - requiredPermissions: ['customer.read'], - - // 2. Route Priority - priority: 500, - - // 3. Protocol Configuration - protocolConfig: { - cacheEnabled: true, - cacheTtl: 300, // 5 minutes - rateLimitPerMinute: 100, - }, - - // Standard OpenAPI security (in addition to RBAC) - security: [ - { - bearerAuth: [] - }, - ], - - parameters: [ - { - name: 'id', - in: 'path', - description: 'Customer ID', - required: true, - schema: { - type: 'string', - format: 'uuid', - }, - example: '123e4567-e89b-12d3-a456-426614174000', - }, - { - name: 'include', - in: 'query', - description: 'Related objects to include', - required: false, - schema: { - type: 'array', - items: { type: 'string' }, - enum: ['orders', 'contacts', 'activities'], - }, - }, - ], - - // 4. Dynamic Schema Linking - responses: [ - { - statusCode: 200, - description: 'Customer found', - schema: { - $ref: { - objectId: 'customer', - excludeFields: ['password_hash', 'internal_notes'], - includeRelated: ['account'], - }, - }, - example: { - id: '123e4567-e89b-12d3-a456-426614174000', - name: 'Acme Corporation', - email: 'contact@acme.com', - phone: '+1-555-0100', - account: { - id: 'acc-001', - name: 'Acme Account', - }, - }, - }, - { - statusCode: 404, - description: 'Customer not found', - }, - { - statusCode: 403, - description: 'Permission denied', - }, - ], - - externalDocs: { - description: 'Customer API Documentation', - url: 'https://docs.objectstack.ai/api/customers', - }, -}); - -// Export examples for documentation -export { - endpointWithRBAC, - adminEndpoint, - endpointWithDynamicSchema, - createEndpointWithDynamicSchema, - orderWithRelations, - grpcEndpoint, - trpcEndpoint, - websocketEndpoint, - coreEndpoint, - pluginEndpoint, - fallbackEndpoint, - completeRegistry, - productionEndpoint, -}; diff --git a/packages/spec/src/api/registry.test.ts b/packages/spec/src/api/registry.test.ts deleted file mode 100644 index 6bfac8a60f..0000000000 --- a/packages/spec/src/api/registry.test.ts +++ /dev/null @@ -1,988 +0,0 @@ -import { describe, it, expect } from 'vitest'; -import { - ApiProtocolType, - ApiParameterSchema, - ApiResponseSchema, - ApiEndpointRegistrationSchema, - ApiMetadataSchema, - ApiRegistryEntrySchema, - ApiRegistrySchema, - ApiDiscoveryQuerySchema, - ApiDiscoveryResponseSchema, - ApiEndpointRegistration, - ApiRegistryEntry, - ApiRegistry, - ObjectQLReferenceSchema, - SchemaDefinition, - ConflictResolutionStrategy, -} from './registry.zod'; - -describe('API Registry Protocol', () => { - describe('ApiProtocolType', () => { - it('should accept valid API protocol types', () => { - expect(ApiProtocolType.parse('rest')).toBe('rest'); - expect(ApiProtocolType.parse('odata')).toBe('odata'); - expect(ApiProtocolType.parse('websocket')).toBe('websocket'); - expect(ApiProtocolType.parse('file')).toBe('file'); - expect(ApiProtocolType.parse('auth')).toBe('auth'); - expect(ApiProtocolType.parse('metadata')).toBe('metadata'); - expect(ApiProtocolType.parse('plugin')).toBe('plugin'); - expect(ApiProtocolType.parse('webhook')).toBe('webhook'); - expect(ApiProtocolType.parse('rpc')).toBe('rpc'); - }); - - it('should reject invalid API protocol types', () => { - expect(() => ApiProtocolType.parse('invalid')).toThrow(); - }); - }); - - describe('ApiParameterSchema', () => { - it('should validate valid parameter', () => { - const param = { - name: 'id', - in: 'path' as const, - description: 'Customer ID', - required: true, - schema: { - type: 'string' as const, - format: 'uuid', - }, - example: '123e4567-e89b-12d3-a456-426614174000', - }; - - const result = ApiParameterSchema.parse(param); - expect(result.name).toBe('id'); - expect(result.in).toBe('path'); - expect(result.required).toBe(true); - }); - - it('should apply defaults for optional fields', () => { - const param = { - name: 'filter', - in: 'query' as const, - schema: { type: 'string' as const }, - }; - - const result = ApiParameterSchema.parse(param); - expect(result.required).toBe(false); - }); - - it('should validate parameter in different locations', () => { - expect(() => ApiParameterSchema.parse({ - name: 'auth', - in: 'header', - schema: { type: 'string' }, - })).not.toThrow(); - - expect(() => ApiParameterSchema.parse({ - name: 'page', - in: 'query', - schema: { type: 'number' }, - })).not.toThrow(); - - expect(() => ApiParameterSchema.parse({ - name: 'id', - in: 'path', - schema: { type: 'string' }, - })).not.toThrow(); - - expect(() => ApiParameterSchema.parse({ - name: 'data', - in: 'body', - schema: { type: 'object' }, - })).not.toThrow(); - }); - }); - - describe('ApiResponseSchema', () => { - it('should validate valid response', () => { - const response = { - statusCode: 200, - description: 'Successful response', - contentType: 'application/json', - schema: { type: 'object' }, - example: { id: '123', name: 'Test' }, - }; - - const result = ApiResponseSchema.parse(response); - expect(result.statusCode).toBe(200); - expect(result.contentType).toBe('application/json'); - }); - - it('should apply default content type', () => { - const response = { - statusCode: 200, - description: 'Success', - }; - - const result = ApiResponseSchema.parse(response); - expect(result.contentType).toBe('application/json'); - }); - - it('should accept status code patterns', () => { - expect(() => ApiResponseSchema.parse({ - statusCode: '2xx', - description: 'Success range', - })).not.toThrow(); - - expect(() => ApiResponseSchema.parse({ - statusCode: 404, - description: 'Not found', - })).not.toThrow(); - }); - }); - - describe('ApiEndpointRegistrationSchema', () => { - it('should validate complete endpoint registration', () => { - const endpoint = { - id: 'get_customer', - method: 'GET', - path: '/api/v1/customers/:id', - summary: 'Get customer by ID', - description: 'Retrieves a single customer record', - operationId: 'getCustomerById', - tags: ['customer', 'data'], - parameters: [ - { - name: 'id', - in: 'path' as const, - required: true, - schema: { type: 'string' as const }, - }, - ], - responses: [ - { - statusCode: 200, - description: 'Customer found', - schema: { type: 'object' as const }, - }, - { - statusCode: 404, - description: 'Customer not found', - }, - ], - deprecated: false, - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.id).toBe('get_customer'); - expect(result.method).toBe('GET'); - expect(result.tags).toHaveLength(2); - expect(result.parameters).toHaveLength(1); - expect(result.responses).toHaveLength(2); - }); - - it('should apply defaults for optional fields', () => { - const endpoint = { - id: 'simple_endpoint', - path: '/api/test', - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.tags).toEqual([]); - expect(result.parameters).toEqual([]); - expect(result.responses).toEqual([]); - expect(result.deprecated).toBe(false); - }); - - it('should support request body', () => { - const endpoint = { - id: 'create_customer', - method: 'POST', - path: '/api/v1/customers', - requestBody: { - description: 'Customer data', - required: true, - contentType: 'application/json', - schema: { type: 'object' }, - example: { name: 'John Doe', email: 'john@example.com' }, - }, - responses: [ - { - statusCode: 201, - description: 'Customer created', - }, - ], - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.requestBody).toBeDefined(); - expect(result.requestBody?.required).toBe(true); - }); - - it('should support security requirements', () => { - const endpoint = { - id: 'protected_endpoint', - path: '/api/v1/protected', - security: [ - { - 'bearerAuth': [], - }, - { - 'apiKey': [], - }, - ], - responses: [], - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.security).toHaveLength(2); - expect(result.security?.[0]).toHaveProperty('bearerAuth'); - }); - - it('should use helper create function', () => { - const endpoint = ApiEndpointRegistration.create({ - id: 'test_endpoint', - path: '/test', - summary: 'Test endpoint', - }); - - expect(endpoint.id).toBe('test_endpoint'); - expect(endpoint.path).toBe('/test'); - }); - }); - - describe('ApiMetadataSchema', () => { - it('should validate API metadata', () => { - const metadata = { - owner: 'api_team', - status: 'active' as const, - tags: ['customer', 'public'], - custom: { - rateLimit: 1000, - cacheable: true, - }, - }; - - const result = ApiMetadataSchema.parse(metadata); - expect(result.owner).toBe('api_team'); - expect(result.status).toBe('active'); - expect(result.tags).toHaveLength(2); - }); - - it('should apply defaults', () => { - const metadata = {}; - - const result = ApiMetadataSchema.parse(metadata); - expect(result.status).toBe('active'); - expect(result.tags).toEqual([]); - }); - - it('should validate status values', () => { - expect(() => ApiMetadataSchema.parse({ status: 'active' })).not.toThrow(); - expect(() => ApiMetadataSchema.parse({ status: 'deprecated' })).not.toThrow(); - expect(() => ApiMetadataSchema.parse({ status: 'experimental' })).not.toThrow(); - expect(() => ApiMetadataSchema.parse({ status: 'beta' })).not.toThrow(); - expect(() => ApiMetadataSchema.parse({ status: 'invalid' })).toThrow(); - }); - - it('should support plugin source', () => { - const metadata = { - pluginSource: 'payment_gateway_plugin', - status: 'active' as const, - }; - - const result = ApiMetadataSchema.parse(metadata); - expect(result.pluginSource).toBe('payment_gateway_plugin'); - }); - }); - - describe('ApiRegistryEntrySchema', () => { - it('should validate complete registry entry', () => { - const entry = { - id: 'customer_api', - name: 'Customer Management API', - type: 'rest', - version: 'v1', - basePath: '/api/v1/customers', - description: 'CRUD operations for customer records', - endpoints: [ - { - id: 'list_customers', - method: 'GET', - path: '/api/v1/customers', - summary: 'List customers', - responses: [], - }, - { - id: 'get_customer', - method: 'GET', - path: '/api/v1/customers/:id', - summary: 'Get customer', - responses: [], - }, - ], - metadata: { - owner: 'sales_team', - status: 'active' as const, - tags: ['customer', 'crm'], - }, - contact: { - name: 'API Team', - email: 'api@example.com', - }, - license: { - name: 'Apache 2.0', - url: 'https://www.apache.org/licenses/LICENSE-2.0', - }, - }; - - const result = ApiRegistryEntrySchema.parse(entry); - expect(result.id).toBe('customer_api'); - expect(result.type).toBe('rest'); - expect(result.endpoints).toHaveLength(2); - }); - - it('should enforce snake_case for id', () => { - expect(() => ApiRegistryEntrySchema.parse({ - id: 'customer_api', - name: 'Customer API', - type: 'rest', - version: 'v1', - basePath: '/api/customers', - endpoints: [], - })).not.toThrow(); - - expect(() => ApiRegistryEntrySchema.parse({ - id: 'CustomerAPI', - name: 'Customer API', - type: 'rest', - version: 'v1', - basePath: '/api/customers', - endpoints: [], - })).toThrow(); - }); - - it('should support plugin-registered APIs', () => { - const entry = { - id: 'payment_webhook', - name: 'Payment Webhook API', - type: 'plugin', - version: '1.0.0', - basePath: '/plugins/payment/webhook', - endpoints: [ - { - id: 'receive_payment_notification', - method: 'POST', - path: '/plugins/payment/webhook', - responses: [], - }, - ], - metadata: { - pluginSource: 'payment_gateway_plugin', - status: 'active' as const, - }, - }; - - const result = ApiRegistryEntrySchema.parse(entry); - expect(result.type).toBe('plugin'); - expect(result.metadata?.pluginSource).toBe('payment_gateway_plugin'); - }); - - it('should use helper create function', () => { - const entry = ApiRegistryEntry.create({ - id: 'test_api', - name: 'Test API', - type: 'rest', - version: 'v1', - basePath: '/api/test', - endpoints: [], - }); - - expect(entry.id).toBe('test_api'); - expect(entry.type).toBe('rest'); - }); - }); - - describe('ApiRegistrySchema', () => { - it('should validate complete registry', () => { - const registry = { - version: '1.0.0', - apis: [ - { - id: 'customer_api', - name: 'Customer API', - type: 'rest', - version: 'v1', - basePath: '/api/v1/customers', - endpoints: [ - { - id: 'list_customers', - path: '/api/v1/customers', - responses: [], - }, - ], - }, - { - id: 'odata_api', - name: 'OData API', - type: 'odata', - version: 'v1', - basePath: '/odata', - endpoints: [ - { - id: 'odata_query', - path: '/odata', - responses: [], - }, - ], - }, - ], - totalApis: 2, - totalEndpoints: 2, - updatedAt: new Date().toISOString(), - }; - - const result = ApiRegistrySchema.parse(registry); - expect(result.totalApis).toBe(2); - expect(result.apis).toHaveLength(2); - }); - - it('should support grouping by type', () => { - const registry = { - version: '1.0.0', - apis: [ - { - id: 'rest_api_1', - name: 'REST API 1', - type: 'rest' as const, - version: 'v1', - basePath: '/api/v1', - endpoints: [], - }, - { - id: 'rest_api_2', - name: 'REST API 2', - type: 'rest' as const, - version: 'v1', - basePath: '/api/v2', - endpoints: [], - }, - { - id: 'odata_api', - name: 'OData API', - type: 'odata' as const, - version: 'v1', - basePath: '/odata', - endpoints: [], - }, - ], - totalApis: 3, - totalEndpoints: 0, - }; - - const result = ApiRegistrySchema.parse(registry); - expect(result.totalApis).toBe(3); - expect(result.apis).toHaveLength(3); - }); - - it('should use helper create function', () => { - const registry = ApiRegistry.create({ - version: '1.0.0', - apis: [], - totalApis: 0, - totalEndpoints: 0, - }); - - expect(registry.version).toBe('1.0.0'); - expect(registry.totalApis).toBe(0); - }); - }); - - describe('ApiDiscoveryQuerySchema', () => { - it('should validate discovery query', () => { - const query = { - type: 'rest', - tags: ['customer', 'public'], - status: 'active' as const, - search: 'customer', - }; - - const result = ApiDiscoveryQuerySchema.parse(query); - expect(result.type).toBe('rest'); - expect(result.tags).toHaveLength(2); - expect(result.status).toBe('active'); - }); - - it('should allow empty query', () => { - const query = {}; - const result = ApiDiscoveryQuerySchema.parse(query); - expect(result).toEqual({}); - }); - - it('should filter by plugin source', () => { - const query = { - pluginSource: 'payment_gateway', - }; - - const result = ApiDiscoveryQuerySchema.parse(query); - expect(result.pluginSource).toBe('payment_gateway'); - }); - }); - - describe('ApiDiscoveryResponseSchema', () => { - it('should validate discovery response', () => { - const response = { - apis: [ - { - id: 'customer_api', - name: 'Customer API', - type: 'rest', - version: 'v1', - basePath: '/api/customers', - endpoints: [], - }, - ], - total: 1, - filters: { - type: 'rest', - status: 'active' as const, - }, - }; - - const result = ApiDiscoveryResponseSchema.parse(response); - expect(result.total).toBe(1); - expect(result.apis).toHaveLength(1); - }); - }); - - // ========================================== - // NEW TESTS: Enhancement Features - // ========================================== - - describe('ObjectQL Reference Schema', () => { - it('should validate ObjectQL reference', () => { - const ref = { - objectId: 'customer', - }; - - const result = ObjectQLReferenceSchema.parse(ref); - expect(result.objectId).toBe('customer'); - }); - - it('should support field inclusion/exclusion', () => { - const ref = { - objectId: 'customer', - includeFields: ['id', 'name', 'email'], - excludeFields: ['password_hash'], - }; - - const result = ObjectQLReferenceSchema.parse(ref); - expect(result.includeFields).toHaveLength(3); - expect(result.excludeFields).toHaveLength(1); - }); - - it('should support related object inclusion', () => { - const ref = { - objectId: 'order', - includeRelated: ['customer', 'items'], - }; - - const result = ObjectQLReferenceSchema.parse(ref); - expect(result.includeRelated).toHaveLength(2); - }); - - it('should enforce snake_case for objectId', () => { - expect(() => ObjectQLReferenceSchema.parse({ - objectId: 'customer_account', - })).not.toThrow(); - - expect(() => ObjectQLReferenceSchema.parse({ - objectId: 'CustomerAccount', - })).toThrow(); - }); - }); - - describe('Dynamic Schema Linking', () => { - it('should support ObjectQL reference in parameter schema', () => { - const param = { - name: 'customer', - in: 'body' as const, - schema: { - $ref: { - objectId: 'customer', - excludeFields: ['internal_notes'], - }, - }, - }; - - const result = ApiParameterSchema.parse(param); - expect(result.schema).toHaveProperty('$ref'); - if ('$ref' in result.schema) { - expect(result.schema.$ref.objectId).toBe('customer'); - } - }); - - it('should support static JSON schema in parameter', () => { - const param = { - name: 'id', - in: 'path' as const, - schema: { - type: 'string' as const, - format: 'uuid', - }, - }; - - const result = ApiParameterSchema.parse(param); - if ('type' in result.schema) { - expect(result.schema.type).toBe('string'); - } - }); - - it('should support ObjectQL reference in response schema', () => { - const response = { - statusCode: 200, - description: 'Customer retrieved', - schema: { - $ref: { - objectId: 'customer', - excludeFields: ['password_hash'], - }, - }, - }; - - const result = ApiResponseSchema.parse(response); - expect(result.schema).toHaveProperty('$ref'); - if (result.schema && typeof result.schema === 'object' && '$ref' in result.schema) { - expect(result.schema.$ref.objectId).toBe('customer'); - } - }); - - it('should support static schema in response', () => { - const response = { - statusCode: 200, - description: 'Success', - schema: { - type: 'object', - properties: { - id: { type: 'string' }, - name: { type: 'string' }, - }, - }, - }; - - const result = ApiResponseSchema.parse(response); - expect(result.schema).toBeDefined(); - }); - }); - - describe('RBAC Integration', () => { - it('should support required permissions', () => { - const endpoint = { - id: 'get_customer', - path: '/api/v1/customers/:id', - requiredPermissions: ['customer.read'], - responses: [], - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.requiredPermissions).toHaveLength(1); - expect(result.requiredPermissions).toContain('customer.read'); - }); - - it('should support multiple permissions', () => { - const endpoint = { - id: 'complex_operation', - path: '/api/v1/complex', - requiredPermissions: ['customer.read', 'account.read', 'order.viewAll'], - responses: [], - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.requiredPermissions).toHaveLength(3); - }); - - it('should support system permissions', () => { - const endpoint = { - id: 'manage_users', - path: '/api/v1/admin/users', - requiredPermissions: ['manage_users', 'view_setup'], - responses: [], - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.requiredPermissions).toContain('manage_users'); - }); - - it('should default to empty array when no permissions specified', () => { - const endpoint = { - id: 'public_endpoint', - path: '/api/v1/public', - responses: [], - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.requiredPermissions).toEqual([]); - }); - }); - - describe('Route Priority', () => { - it('should support priority field', () => { - const endpoint = { - id: 'high_priority', - path: '/api/v1/data/:object', - priority: 950, - responses: [], - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.priority).toBe(950); - }); - - it('should default priority to 100', () => { - const endpoint = { - id: 'default_priority', - path: '/api/v1/test', - responses: [], - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.priority).toBe(100); - }); - - it('should validate priority range', () => { - expect(() => ApiEndpointRegistrationSchema.parse({ - id: 'test', - path: '/test', - priority: -1, - responses: [], - })).toThrow(); - - expect(() => ApiEndpointRegistrationSchema.parse({ - id: 'test', - path: '/test', - priority: 1001, - responses: [], - })).toThrow(); - - expect(() => ApiEndpointRegistrationSchema.parse({ - id: 'test', - path: '/test', - priority: 0, - responses: [], - })).not.toThrow(); - - expect(() => ApiEndpointRegistrationSchema.parse({ - id: 'test', - path: '/test', - priority: 1000, - responses: [], - })).not.toThrow(); - }); - }); - - describe('Protocol Configuration', () => { - it('should support gRPC protocol config', () => { - const endpoint = { - id: 'grpc_method', - path: '/grpc/CustomerService/GetCustomer', - protocolConfig: { - subProtocol: 'grpc', - serviceName: 'CustomerService', - methodName: 'GetCustomer', - streaming: false, - }, - responses: [], - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.protocolConfig).toBeDefined(); - expect(result.protocolConfig?.subProtocol).toBe('grpc'); - expect(result.protocolConfig?.serviceName).toBe('CustomerService'); - }); - - it('should support tRPC protocol config', () => { - const endpoint = { - id: 'trpc_query', - path: '/trpc/customer.getById', - protocolConfig: { - subProtocol: 'trpc', - procedureType: 'query', - router: 'customer', - }, - responses: [], - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.protocolConfig?.subProtocol).toBe('trpc'); - expect(result.protocolConfig?.procedureType).toBe('query'); - }); - - it('should support WebSocket protocol config', () => { - const endpoint = { - id: 'ws_event', - path: '/ws/customer.updated', - protocolConfig: { - subProtocol: 'websocket', - eventName: 'customer.updated', - direction: 'server-to-client', - }, - responses: [], - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.protocolConfig?.eventName).toBe('customer.updated'); - }); - - it('should allow custom protocol configurations', () => { - const endpoint = { - id: 'custom_protocol', - path: '/custom/endpoint', - protocolConfig: { - customField1: 'value1', - customField2: 123, - customField3: true, - }, - responses: [], - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.protocolConfig).toBeDefined(); - expect(Object.keys(result.protocolConfig || {})).toHaveLength(3); - }); - }); - - describe('Conflict Resolution Strategy', () => { - it('should validate conflict resolution strategies', () => { - expect(ConflictResolutionStrategy.parse('error')).toBe('error'); - expect(ConflictResolutionStrategy.parse('priority')).toBe('priority'); - expect(ConflictResolutionStrategy.parse('first-wins')).toBe('first-wins'); - expect(ConflictResolutionStrategy.parse('last-wins')).toBe('last-wins'); - }); - - it('should reject invalid strategies', () => { - expect(() => ConflictResolutionStrategy.parse('invalid')).toThrow(); - }); - - it('should support conflict resolution in registry', () => { - const registry = { - version: '1.0.0', - conflictResolution: 'priority' as const, - apis: [], - totalApis: 0, - totalEndpoints: 0, - }; - - const result = ApiRegistrySchema.parse(registry); - expect(result.conflictResolution).toBe('priority'); - }); - - it('should default conflict resolution to error', () => { - const registry = { - version: '1.0.0', - apis: [], - totalApis: 0, - totalEndpoints: 0, - }; - - const result = ApiRegistrySchema.parse(registry); - expect(result.conflictResolution).toBe('error'); - }); - }); - - describe('Complete Integration Test', () => { - it('should validate endpoint with all enhancements', () => { - const endpoint = { - id: 'get_customer_full', - method: 'GET', - path: '/api/v1/customers/:id', - summary: 'Get customer by ID', - description: 'Retrieves a customer with all enhancements', - tags: ['customer', 'crm'], - - // RBAC Integration - requiredPermissions: ['customer.read'], - - // Route Priority - priority: 500, - - // Protocol Config - protocolConfig: { - cacheEnabled: true, - cacheTtl: 300, - }, - - // Parameters with ObjectQL reference - parameters: [ - { - name: 'id', - in: 'path' as const, - required: true, - schema: { - type: 'string' as const, - format: 'uuid', - }, - }, - ], - - // Responses with ObjectQL reference - responses: [ - { - statusCode: 200, - description: 'Customer found', - schema: { - $ref: { - objectId: 'customer', - excludeFields: ['password_hash', 'internal_notes'], - }, - }, - }, - { - statusCode: 404, - description: 'Customer not found', - }, - ], - }; - - const result = ApiEndpointRegistrationSchema.parse(endpoint); - expect(result.id).toBe('get_customer_full'); - expect(result.requiredPermissions).toContain('customer.read'); - expect(result.priority).toBe(500); - expect(result.protocolConfig?.cacheEnabled).toBe(true); - expect(result.responses).toHaveLength(2); - }); - - it('should validate complete registry with all enhancements', () => { - const registry = { - version: '1.0.0', - conflictResolution: 'priority' as const, - apis: [ - { - id: 'customer_api', - name: 'Customer API', - type: 'rest' as const, - version: 'v1', - basePath: '/api/v1/customers', - endpoints: [ - { - id: 'list_customers', - method: 'GET', - path: '/api/v1/customers', - requiredPermissions: ['customer.read'], - priority: 500, - responses: [ - { - statusCode: 200, - description: 'Success', - schema: { - $ref: { - objectId: 'customer', - }, - }, - }, - ], - }, - ], - }, - ], - totalApis: 1, - totalEndpoints: 1, - }; - - const result = ApiRegistrySchema.parse(registry); - expect(result.conflictResolution).toBe('priority'); - expect(result.apis).toHaveLength(1); - expect(result.apis[0].endpoints[0].requiredPermissions).toContain('customer.read'); - }); - }); -}); diff --git a/packages/spec/src/api/registry.zod.ts b/packages/spec/src/api/registry.zod.ts deleted file mode 100644 index bfe7f09a0c..0000000000 --- a/packages/spec/src/api/registry.zod.ts +++ /dev/null @@ -1,863 +0,0 @@ -// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. - -import { z } from 'zod'; -import { HttpMethod, RateLimitConfigSchema } from '../shared/http.zod'; -import { SnakeCaseIdentifierSchema } from '../shared/identifiers.zod'; - -/** - * Unified API Registry Protocol - * - * Provides a centralized registry for managing all API endpoints across different - * API types (REST, OData, WebSocket, Auth, File, Plugin-registered). - * - * This enables: - * - Unified API discovery and documentation (similar to Swagger/OpenAPI) - * - API testing interfaces - * - API governance and monitoring - * - Plugin API registration - * - Multi-protocol support - * - * Architecture Alignment: - * - Kubernetes: Service Discovery & API Server - * - AWS API Gateway: Unified API Management - * - Kong Gateway: Plugin-based API Management - * - * @example API Registry Entry - * ```typescript - * const apiEntry: ApiRegistryEntry = { - * id: 'customer_crud', - * name: 'Customer CRUD API', - * type: 'rest', - * version: 'v1', - * basePath: '/api/v1/data/customer', - * endpoints: [...], - * metadata: { - * owner: 'sales_team', - * tags: ['customer', 'crm'] - * } - * } - * ``` - */ - -// ========================================== -// API Type Enumeration -// ========================================== - -/** - * API Protocol Type - * - * Defines the different types of APIs supported by ObjectStack. - */ -import { lazySchema } from '../shared/lazy-schema'; -export const ApiProtocolType = z.enum([ - 'rest', // RESTful API (CRUD operations) - 'odata', // OData v4 API (enterprise integration) - 'websocket', // WebSocket API (real-time) - 'file', // File/Storage API (uploads/downloads) - 'auth', // Authentication/Authorization API - 'metadata', // Metadata/Schema API - 'plugin', // Plugin-registered custom API - 'webhook', // Webhook endpoints - 'rpc', // JSON-RPC or similar -]); - -export type ApiProtocolType = z.infer; - -// ========================================== -// API Endpoint Registration -// ========================================== - -/** - * HTTP Status Code - */ -export const HttpStatusCode = z.union([ - z.number().int().min(100).max(599), - z.enum(['2xx', '3xx', '4xx', '5xx']), // Pattern matching -]); - -export type HttpStatusCode = z.infer; - -// ========================================== -// Schema Reference Types -// ========================================== - -/** - * ObjectQL Reference Schema - * - * Allows referencing ObjectStack data objects instead of static JSON schemas. - * When an API parameter or response references an ObjectQL object, the schema - * is dynamically derived from the object definition, enabling automatic updates - * when the object schema changes. - * - * **IMPORTANT - Schema Resolution Responsibility:** - * The API Registry STORES these references as metadata but does NOT resolve them. - * Schema resolution (expanding references into actual JSON Schema) is performed by: - * - **API Gateway**: For runtime request/response validation - * - **OpenAPI Generator**: For Swagger/OpenAPI documentation - * - **Documentation Tools**: For developer documentation - * - * This separation allows the Registry to remain lightweight and focused on - * registration/discovery, while specialized tools handle schema transformation. - * - * **Benefits:** - * - Auto-updating API documentation when object schemas change - * - Consistent type definitions across API and database - * - Reduced duplication and maintenance - * - Registry remains protocol-agnostic and lightweight - * - * @example Reference Customer object - * ```json - * { - * "objectId": "customer", - * "includeFields": ["id", "name", "email"], - * "excludeFields": ["internal_notes"] - * } - * ``` - */ -export const ObjectQLReferenceSchema = lazySchema(() => z.object({ - /** Referenced object name (snake_case) */ - objectId: SnakeCaseIdentifierSchema.describe('Object name to reference'), - - /** Include only specific fields (optional) */ - includeFields: z.array(z.string()).optional() - .describe('Include only these fields in the schema'), - - /** Exclude specific fields (optional) */ - excludeFields: z.array(z.string()).optional() - .describe('Exclude these fields from the schema'), - - /** Include related objects via lookup fields */ - includeRelated: z.array(z.string()).optional() - .describe('Include related objects via lookup fields'), -})); - -export type ObjectQLReference = z.infer; - -/** - * Schema Definition - * - * Unified schema definition that supports both: - * 1. Static JSON Schema (traditional approach) - * 2. Dynamic ObjectQL reference (linked to object definitions) - * - * When using ObjectQL references, the API documentation and validation - * automatically update when object schemas change, eliminating the need - * to manually sync API schemas with data models. - */ -export const SchemaDefinition = z.union([ - z.unknown().describe('Static JSON Schema definition'), - z.object({ - $ref: ObjectQLReferenceSchema.describe('Dynamic reference to ObjectQL object'), - }).describe('Dynamic ObjectQL reference'), -]); - -export type SchemaDefinition = z.infer; - -// ========================================== -// API Parameter & Response Schemas -// ========================================== - -/** - * API Parameter Schema - * - * Defines a single API parameter (path, query, header, or body). - * - * **Enhancement: Dynamic Schema Linking** - * - Supports both static JSON Schema and dynamic ObjectQL references - * - When using ObjectQL references, parameter validation automatically updates - * when the referenced object schema changes - * - * @example Static schema - * ```json - * { - * "name": "customer_id", - * "in": "path", - * "schema": { - * "type": "string", - * "format": "uuid" - * } - * } - * ``` - * - * @example Dynamic ObjectQL reference - * ```json - * { - * "name": "customer", - * "in": "body", - * "schema": { - * "$ref": { - * "objectId": "customer", - * "excludeFields": ["internal_notes"] - * } - * } - * } - * ``` - */ -export const ApiParameterSchema = lazySchema(() => z.object({ - /** Parameter name */ - name: z.string().describe('Parameter name'), - - /** Parameter location */ - in: z.enum(['path', 'query', 'header', 'body', 'cookie']).describe('Parameter location'), - - /** Parameter description */ - description: z.string().optional().describe('Parameter description'), - - /** Required flag */ - required: z.boolean().default(false).describe('Whether parameter is required'), - - /** Parameter type/schema - supports static or dynamic (ObjectQL) schemas */ - schema: z.union([ - z.object({ - type: z.enum(['string', 'number', 'integer', 'boolean', 'array', 'object']).describe('Parameter type'), - format: z.string().optional().describe('Format (e.g., date-time, email, uuid)'), - enum: z.array(z.unknown()).optional().describe('Allowed values'), - default: z.unknown().optional().describe('Default value'), - items: z.unknown().optional().describe('Array item schema'), - properties: z.record(z.string(), z.unknown()).optional().describe('Object properties'), - }).describe('Static JSON Schema'), - z.object({ - $ref: ObjectQLReferenceSchema, - }).describe('Dynamic ObjectQL reference'), - ]).describe('Parameter schema definition'), - - /** Example value */ - example: z.unknown().optional().describe('Example value'), -})); - -export type ApiParameter = z.infer; - -/** - * API Response Schema - * - * Defines an API response for a specific status code. - * - * **Enhancement: Dynamic Schema Linking** - * - Response schema can reference ObjectQL objects - * - When object definitions change, response documentation auto-updates - * - * @example Response with ObjectQL reference - * ```json - * { - * "statusCode": 200, - * "description": "Customer retrieved successfully", - * "schema": { - * "$ref": { - * "objectId": "customer", - * "excludeFields": ["password_hash"] - * } - * } - * } - * ``` - */ -export const ApiResponseSchema = lazySchema(() => z.object({ - /** HTTP status code */ - statusCode: HttpStatusCode.describe('HTTP status code'), - - /** Response description */ - description: z.string().describe('Response description'), - - /** Response content type */ - contentType: z.string().default('application/json').describe('Response content type'), - - /** Response schema - supports static or dynamic (ObjectQL) schemas */ - schema: z.union([ - z.unknown().describe('Static JSON Schema'), - z.object({ - $ref: ObjectQLReferenceSchema, - }).describe('Dynamic ObjectQL reference'), - ]).optional().describe('Response body schema'), - - /** Response headers */ - headers: z.record(z.string(), z.object({ - description: z.string().optional(), - schema: z.unknown(), - })).optional().describe('Response headers'), - - /** Example response */ - example: z.unknown().optional().describe('Example response'), -})); - -export type ApiResponse = z.infer; -export type ApiResponseInput = z.input; - -/** - * API Endpoint Registration Schema - * - * Represents a single API endpoint registration with complete metadata. - * - * **Enhancements:** - * 1. **RBAC Integration**: `requiredPermissions` field for automatic permission checking - * 2. **Dynamic Schema Linking**: Parameters and responses can reference ObjectQL objects - * 3. **Route Priority**: `priority` field for conflict resolution - * 4. **Protocol Config**: `protocolConfig` for protocol-specific extensions - * - * @example REST Endpoint with RBAC - * ```json - * { - * "id": "get_customer_by_id", - * "method": "GET", - * "path": "/api/v1/data/customer/:id", - * "summary": "Get customer by ID", - * "requiredPermissions": ["customer.read"], - * "parameters": [ - * { - * "name": "id", - * "in": "path", - * "required": true, - * "schema": { "type": "string" } - * } - * ], - * "responses": [ - * { - * "statusCode": 200, - * "description": "Customer found", - * "schema": { - * "$ref": { - * "objectId": "customer" - * } - * } - * } - * ], - * "priority": 100 - * } - * ``` - * - * @example Plugin Endpoint with Protocol Config - * ```json - * { - * "id": "grpc_service_method", - * "path": "/grpc/ServiceName/MethodName", - * "summary": "gRPC service method", - * "protocolConfig": { - * "subProtocol": "grpc", - * "serviceName": "CustomerService", - * "methodName": "GetCustomer" - * }, - * "priority": 50 - * } - * ``` - */ -export const ApiEndpointRegistrationSchema = lazySchema(() => z.object({ - /** Unique endpoint identifier */ - id: z.string().describe('Unique endpoint identifier'), - - /** HTTP method (for HTTP-based APIs) */ - method: HttpMethod.optional().describe('HTTP method'), - - /** URL path pattern */ - path: z.string().describe('URL path pattern'), - - /** Short summary */ - summary: z.string().optional().describe('Short endpoint summary'), - - /** Detailed description */ - description: z.string().optional().describe('Detailed endpoint description'), - - /** Operation ID (OpenAPI) */ - operationId: z.string().optional().describe('Unique operation identifier'), - - /** Tags for grouping */ - tags: z.array(z.string()).optional().default([]).describe('Tags for categorization'), - - /** Parameters */ - parameters: z.array(ApiParameterSchema).optional().default([]).describe('Endpoint parameters'), - - /** Request body schema */ - requestBody: z.object({ - description: z.string().optional(), - required: z.boolean().default(false), - contentType: z.string().default('application/json'), - schema: z.unknown().optional(), - example: z.unknown().optional(), - }).optional().describe('Request body specification'), - - /** Response definitions */ - responses: z.array(ApiResponseSchema).optional().default([]).describe('Possible responses'), - - /** Rate Limiting */ - rateLimit: RateLimitConfigSchema.optional().describe('Endpoint specific rate limiting'), - - /** Security Requirements */ - security: z.array(z.record(z.string(), z.array(z.string()))).optional().describe('Security requirements (e.g. [{"bearerAuth": []}])'), - - /** - * Required Permissions (RBAC Integration) - * - * Array of permission names required to access this endpoint. - * The gateway layer automatically validates these permissions before - * allowing the request to proceed, eliminating the need for permission - * checks in individual API handlers. - * - * **Format:** `.` or system permission name - * - * **Object Permissions:** - * - `customer.read` - Read customer records - * - `customer.create` - Create customer records - * - `customer.edit` - Update customer records - * - `customer.delete` - Delete customer records - * - `customer.viewAll` - View all customer records (bypass sharing) - * - `customer.modifyAll` - Modify all customer records (bypass sharing) - * - * **System Permissions:** - * - `manage_users` - User management - * - `view_setup` - Access to system setup - * - `customize_application` - Modify metadata - * - `api_enabled` - API access - * - * @example Object-level permissions - * ```json - * { - * "requiredPermissions": ["customer.read"] - * } - * ``` - * - * @example Multiple permissions (ALL required) - * ```json - * { - * "requiredPermissions": ["customer.read", "account.read"] - * } - * ``` - * - * @example System permission - * ```json - * { - * "requiredPermissions": ["manage_users"] - * } - * ``` - * - * @see {@link file://../../permission/permission.zod.ts} for permission definitions - */ - requiredPermissions: z.array(z.string()).optional().default([]) - .describe('Required RBAC permissions (e.g., "customer.read", "manage_users")'), - - /** - * Route Priority - * - * Priority level for route conflict resolution. Higher priority routes - * are registered first and take precedence when multiple routes match - * the same path pattern. - * - * **Default:** 100 (medium priority) - * **Range:** 0-1000 (higher = more important) - * - * **Use Cases:** - * - Core system APIs: 900-1000 - * - Plugin APIs: 100-500 - * - Custom/override APIs: 500-900 - * - Fallback routes: 0-100 - * - * @example High priority core endpoint - * ```json - * { - * "path": "/api/v1/data/:object/:id", - * "priority": 950 - * } - * ``` - * - * @example Medium priority plugin endpoint - * ```json - * { - * "path": "/api/v1/custom/action", - * "priority": 300 - * } - * ``` - */ - priority: z.number().int().min(0).max(1000).optional().default(100) - .describe('Route priority for conflict resolution (0-1000, higher = more important)'), - - /** - * Protocol-Specific Configuration - * - * Allows plugins and custom APIs to define protocol-specific metadata - * that can be used for specialized handling or documentation generation. - * - * **Examples:** - * - gRPC: Service and method names - * - tRPC: Procedure type (query/mutation) - * - WebSocket: Event names and handlers - * - Custom protocols: Any metadata needed - * - * @example gRPC configuration - * ```json - * { - * "protocolConfig": { - * "subProtocol": "grpc", - * "serviceName": "CustomerService", - * "methodName": "GetCustomer", - * "streaming": false - * } - * } - * ``` - * - * @example tRPC configuration - * ```json - * { - * "protocolConfig": { - * "subProtocol": "trpc", - * "procedureType": "query", - * "router": "customer" - * } - * } - * ``` - * - * @example WebSocket configuration - * ```json - * { - * "protocolConfig": { - * "subProtocol": "websocket", - * "eventName": "customer.updated", - * "direction": "server-to-client" - * } - * } - * ``` - */ - protocolConfig: z.record(z.string(), z.unknown()).optional() - .describe('Protocol-specific configuration for custom protocols (gRPC, tRPC, etc.)'), - - /** Deprecation flag */ - deprecated: z.boolean().default(false).describe('Whether endpoint is deprecated'), - - /** External documentation */ - externalDocs: z.object({ - description: z.string().optional(), - url: z.string().url(), - }).optional().describe('External documentation link'), -})); - -export type ApiEndpointRegistration = z.infer; -export type ApiEndpointRegistrationInput = z.input; - -// ========================================== -// API Registry Entry -// ========================================== - -/** - * API Metadata Schema - * - * Additional metadata for an API registration. - */ -export const ApiMetadataSchema = lazySchema(() => z.object({ - /** API owner/team */ - owner: z.string().optional().describe('Owner team or person'), - - /** API status */ - status: z.enum(['active', 'deprecated', 'experimental', 'beta']).default('active') - .describe('API lifecycle status'), - - /** Categorization tags */ - tags: z.array(z.string()).optional().default([]).describe('Classification tags'), - - /** Plugin source (if plugin-registered) */ - pluginSource: z.string().optional().describe('Source plugin name'), - - /** Custom metadata */ - custom: z.record(z.string(), z.unknown()).optional().describe('Custom metadata fields'), -})); - -export type ApiMetadata = z.infer; -export type ApiMetadataInput = z.input; - -/** - * API Registry Entry Schema - * - * Complete registration entry for an API in the unified registry. - * - * @example REST API Entry - * ```json - * { - * "id": "customer_api", - * "name": "Customer Management API", - * "type": "rest", - * "version": "v1", - * "basePath": "/api/v1/data/customer", - * "description": "CRUD operations for customer records", - * "endpoints": [...], - * "metadata": { - * "owner": "sales_team", - * "status": "active", - * "tags": ["customer", "crm"] - * } - * } - * ``` - * - * @example Plugin API Entry - * ```json - * { - * "id": "payment_webhook", - * "name": "Payment Webhook API", - * "type": "plugin", - * "version": "1.0.0", - * "basePath": "/plugins/payment/webhook", - * "endpoints": [...], - * "metadata": { - * "pluginSource": "payment_gateway_plugin", - * "status": "active" - * } - * } - * ``` - */ -export const ApiRegistryEntrySchema = lazySchema(() => z.object({ - /** Unique API identifier */ - id: z.string().regex(/^[a-z_][a-z0-9_]*$/).describe('Unique API identifier (snake_case)'), - - /** Human-readable name */ - name: z.string().describe('API display name'), - - /** API protocol type */ - type: ApiProtocolType.describe('API protocol type'), - - /** API version */ - version: z.string().describe('API version (e.g., v1, 2024-01)'), - - /** Base URL path */ - basePath: z.string().describe('Base URL path for this API'), - - /** API description */ - description: z.string().optional().describe('API description'), - - /** Endpoints in this API */ - endpoints: z.array(ApiEndpointRegistrationSchema).describe('Registered endpoints'), - - /** OpenAPI/OData specific configuration */ - config: z.record(z.string(), z.unknown()).optional().describe('Protocol-specific configuration'), - - /** API metadata */ - metadata: ApiMetadataSchema.optional().describe('Additional metadata'), - - /** Terms of service URL */ - termsOfService: z.string().url().optional().describe('Terms of service URL'), - - /** Contact information */ - contact: z.object({ - name: z.string().optional(), - url: z.string().url().optional(), - email: z.string().email().optional(), - }).optional().describe('Contact information'), - - /** License information */ - license: z.object({ - name: z.string(), - url: z.string().url().optional(), - }).optional().describe('License information'), -})); - -export type ApiRegistryEntry = z.infer; -export type ApiRegistryEntryInput = z.input; - -// ========================================== -// API Registry -// ========================================== - -/** - * Route Conflict Resolution Strategy - * - * Defines how to handle conflicts when multiple endpoints register - * the same or overlapping URL patterns. - */ -export const ConflictResolutionStrategy = z.enum([ - 'error', // Throw error on conflict (safest, default) - 'priority', // Use priority field to resolve (highest priority wins) - 'first-wins', // First registered endpoint wins - 'last-wins', // Last registered endpoint wins (override mode) -]); - -export type ConflictResolutionStrategy = z.infer; - -/** - * API Registry Schema - * - * Central registry containing all registered APIs. - * - * **Enhancement: Route Conflict Detection** - * - `conflictResolution`: Strategy for handling route conflicts - * - Prevents silent overwrites and unexpected routing behavior - * - * @example - * ```json - * { - * "version": "1.0.0", - * "conflictResolution": "priority", - * "apis": [ - * { "id": "customer_api", "type": "rest", ... }, - * { "id": "odata_api", "type": "odata", ... }, - * { "id": "file_upload_api", "type": "file", ... } - * ], - * "totalApis": 3, - * "totalEndpoints": 47 - * } - * ``` - * - * @example Priority-based conflict resolution - * ```json - * { - * "conflictResolution": "priority", - * "apis": [ - * { - * "id": "core_api", - * "endpoints": [ - * { - * "path": "/api/v1/data/:object", - * "priority": 950 - * } - * ] - * }, - * { - * "id": "plugin_api", - * "endpoints": [ - * { - * "path": "/api/v1/data/custom", - * "priority": 300 - * } - * ] - * } - * ] - * } - * ``` - */ -export const ApiRegistrySchema = lazySchema(() => z.object({ - /** Registry version */ - version: z.string().describe('Registry version'), - - /** - * Conflict Resolution Strategy - * - * Defines how to handle route conflicts when multiple endpoints - * register the same or overlapping URL patterns. - * - * **Strategies:** - * - `error`: Throw error on conflict (safest, prevents silent overwrites) - * - `priority`: Use endpoint priority field (highest priority wins) - * - `first-wins`: First registered endpoint wins (stable, predictable) - * - `last-wins`: Last registered endpoint wins (allows overrides) - * - * **Default:** `error` - * - * **Best Practices:** - * - Use `error` in production to catch configuration issues - * - Use `priority` when mixing core and plugin APIs - * - Use `last-wins` for development/testing overrides - * - * @example Prevent accidental conflicts - * ```json - * { - * "conflictResolution": "error" - * } - * ``` - * - * @example Allow plugin overrides with priority - * ```json - * { - * "conflictResolution": "priority" - * } - * ``` - */ - conflictResolution: ConflictResolutionStrategy.optional().default('error') - .describe('Strategy for handling route conflicts'), - - /** Registered APIs */ - apis: z.array(ApiRegistryEntrySchema).describe('All registered APIs'), - - /** Total API count */ - totalApis: z.number().int().describe('Total number of registered APIs'), - - /** Total endpoint count across all APIs */ - totalEndpoints: z.number().int().describe('Total number of endpoints'), - - /** APIs grouped by type */ - byType: z.record(ApiProtocolType, z.array(ApiRegistryEntrySchema)).optional() - .describe('APIs grouped by protocol type'), - - /** APIs grouped by status */ - byStatus: z.record(z.string(), z.array(ApiRegistryEntrySchema)).optional() - .describe('APIs grouped by status'), - - /** Last updated timestamp */ - updatedAt: z.string().datetime().optional().describe('Last registry update time'), -})); - -export type ApiRegistry = z.infer; - -// ========================================== -// API Discovery & Query -// ========================================== - -/** - * API Discovery Query Schema - * - * Query parameters for discovering/filtering APIs in the registry. - * - * @example - * ```json - * { - * "type": "rest", - * "tags": ["customer"], - * "status": "active" - * } - * ``` - */ -export const ApiDiscoveryQuerySchema = lazySchema(() => z.object({ - /** Filter by API type */ - type: ApiProtocolType.optional().describe('Filter by API protocol type'), - - /** Filter by tags */ - tags: z.array(z.string()).optional().describe('Filter by tags (ANY match)'), - - /** Filter by status */ - status: z.enum(['active', 'deprecated', 'experimental', 'beta']).optional() - .describe('Filter by lifecycle status'), - - /** Filter by plugin source */ - pluginSource: z.string().optional().describe('Filter by plugin name'), - - /** Search in name/description */ - search: z.string().optional().describe('Full-text search in name/description'), - - /** Filter by version */ - version: z.string().optional().describe('Filter by specific version'), -})); - -export type ApiDiscoveryQuery = z.infer; - -/** - * API Discovery Response Schema - * - * Response for API discovery queries. - */ -export const ApiDiscoveryResponseSchema = lazySchema(() => z.object({ - /** Matching APIs */ - apis: z.array(ApiRegistryEntrySchema).describe('Matching API entries'), - - /** Total matches */ - total: z.number().int().describe('Total matching APIs'), - - /** Applied filters */ - filters: ApiDiscoveryQuerySchema.optional().describe('Applied query filters'), -})); - -export type ApiDiscoveryResponse = z.infer; - -// ========================================== -// Helper Functions -// ========================================== - -/** - * Helper to create API endpoint registration - */ -export const ApiEndpointRegistration = Object.assign(ApiEndpointRegistrationSchema, { - create: >(config: T) => config, -}); - -/** - * Helper to create API registry entry - */ -export const ApiRegistryEntry = Object.assign(ApiRegistryEntrySchema, { - create: >(config: T) => config, -}); - -/** - * Helper to create API registry - */ -export const ApiRegistry = Object.assign(ApiRegistrySchema, { - create: >(config: T) => config, -}); diff --git a/packages/spec/src/api/router.zod.ts b/packages/spec/src/api/router.zod.ts index 37dcff7db0..712dff406d 100644 --- a/packages/spec/src/api/router.zod.ts +++ b/packages/spec/src/api/router.zod.ts @@ -22,6 +22,36 @@ export const RouteCategory = z.enum([ export type RouteCategory = z.infer; +/** + * Route Conflict Resolution Strategy + * + * Defines how to handle conflicts when multiple endpoints register the same or + * overlapping URL patterns. + * + * MOVED HERE in #4939 from the retired `api/registry.zod.ts`. The `ApiRegistry` + * family that declared it was removed whole — it was assembled only in + * `packages/core/examples/`, never in a real composition, so every key on + * `ApiEndpointRegistrationSchema` was zero-execution (including + * `requiredPermissions`, whose TSDoc promised in the present tense that "the + * gateway layer automatically validates these permissions" while no gateway + * read it). This enum survives that removal deliberately and is NOT a + * re-introduction of the registry: it is pinned as a `@objectstack/spec/api` + * export by two independent ratchets — `spec/src/automation/sync-retirement.test.ts` + * (#4738: it is the FOURTH relative of the `ConflictResolution` family and must + * never collapse into the `ui` declaration) and, cross-repo, objectui's + * `offline-nav-performance-spec-parity.test.ts`, whose `useOffline` hook renamed + * its own symbol precisely because this name was taken. Route conflicts are a + * router concern, so the router module is where it belongs now. + */ +export const ConflictResolutionStrategy = z.enum([ + 'error', // Throw error on conflict (safest, default) + 'priority', // Use priority field to resolve (highest priority wins) + 'first-wins', // First registered endpoint wins + 'last-wins', // Last registered endpoint wins (override mode) +]); + +export type ConflictResolutionStrategy = z.infer; + /** * Route Definition Schema * Describes a single routable endpoint in the Kernel. diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index a275829747..a4bf1bea51 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -134,12 +134,37 @@ export type DatasourceMappingRule = z.infer; * 3. Runtime Bootstrapping (In-memory loading) * 4. Platform Reflection (API & Capabilities Discovery) */ +/** + * The prescription raised when a stack declares a non-empty `apis:` (#4936). + * + * 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. + * + * Deliberately module-local: a rejection that a later release deletes should + * not first become a public export other packages can start depending on. + */ +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 — your endpoint definitions stay valid.'; + /** * 1. DEFINITION PROTOCOL (Static) * ---------------------------------------------------------------------- * Describes the "Blueprint" or "Source Code" of an ObjectStack Plugin/Project. * This represents the complete declarative state of the application. - * + * * Usage: * - Developers write this in files locally. * - AI Agents generate this to create apps. @@ -263,8 +288,26 @@ export const ObjectStackDefinitionSchema = lazySchema(() => z.object({ /** * ObjectAPI: API Layer + * + * ⚠️ **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. + * + * 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. */ - apis: z.array(ApiEndpointSchema).optional().describe('API Endpoints'), + 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)'), webhooks: z.array(WebhookSchema).optional().describe('Outbound Webhooks'), /** diff --git a/packages/spec/src/system/stack-server.zod.ts b/packages/spec/src/system/stack-server.zod.ts index 73942dc166..7c5252b6fa 100644 --- a/packages/spec/src/system/stack-server.zod.ts +++ b/packages/spec/src/system/stack-server.zod.ts @@ -39,8 +39,10 @@ * baked into the artifact. * * Related: #4910 (this seam), #4937 (the limiter that documented an execution - * chain it never had), #4936 (`apis:` endpoint-level `rateLimit`, still - * unwired), ADR-0069 D2 (shared counters), ADR-0049 (enforce or remove). + * chain it never had), #4936 (the declarative `apis:` surface: vocabulary kept, + * a non-empty array rejected until an executor exists) and #5040 (that + * executor, which wires endpoint-level `rateLimit` — still unwired today), + * ADR-0069 D2 (shared counters), ADR-0049 (enforce or remove). */ import { z } from 'zod'; diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 23e3518c70..fb536ebea2 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -1172,8 +1172,12 @@ export const AppSchema = lazySchema(() => z.object({ ), apis: retiredKey( '`App.apis` was removed in @objectstack/spec 17.0.0 (2026-06 liveness audit — ' + - 'never read). Declarative endpoints belong to the stack (`defineStack({ apis })`), ' + - 'not the app shell. Delete the key.', + 'never read). Delete the key. Note the stack-level `defineStack({ apis })` this ' + + 'prescription used to redirect to is ALSO not executable in v17 (#4936): the ' + + 'vocabulary is kept but a non-empty array is rejected there too, until the endpoint ' + + 'executor ships (tracked by ' + + 'https://github.com/objectstack-ai/objectstack/issues/5040). Serve the route in code ' + + 'meanwhile — a plugin manifest `contributes.routes` entry or an `http.server` route.', ), /** From 819ea67a7dc54c42811f7e35250285bd781097f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 01:54:01 +0000 Subject: [PATCH 2/5] =?UTF-8?q?chore(changeset):=20runtime=20bump=20is=20m?= =?UTF-8?q?ajor=20=E2=80=94=20`handleApiEndpoint`=20was=20a=20public=20met?= =?UTF-8?q?hod?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `HttpDispatcher.handleApiEndpoint()` carried no `private` modifier, so deleting it removes a symbol from `@objectstack/runtime`'s public surface even though the method returned `{ handled: false }` on every call it ever received. Record it as breaking with that nuance stated, rather than letting a `minor` imply the symbol survived. Also drops the `@objectstack/client` entry: that change is a comment in a `.test.ts`, which never ships. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- .../apis-loud-reject-and-apiregistry-retirement.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/.changeset/apis-loud-reject-and-apiregistry-retirement.md b/.changeset/apis-loud-reject-and-apiregistry-retirement.md index 7d4f7867c4..7e920925d2 100644 --- a/.changeset/apis-loud-reject-and-apiregistry-retirement.md +++ b/.changeset/apis-loud-reject-and-apiregistry-retirement.md @@ -2,9 +2,8 @@ "@objectstack/spec": major "@objectstack/core": major "@objectstack/plugin-hono-server": major -"@objectstack/runtime": minor +"@objectstack/runtime": major "@objectstack/metadata-protocol": patch -"@objectstack/client": patch --- feat(spec,core,runtime)!: declarative `apis:` refuses loudly instead of parsing into silence; the `ApiRegistry` family retires (#4936, #4939) @@ -88,10 +87,13 @@ independent ratchets and is not part of the retired surface. ## Also in this change -- `handleApiEndpoint` and its private `callData` delegate are deleted from - `http-dispatcher.ts`, and `/__api-endpoint` leaves `LEGACY_CHAIN_PREFIXES` and the route - ledger. Absence is now loud (ADR-0076): the surface is refused at authoring rather than - 404ing at runtime with dead code behind it. +- **BREAKING (`@objectstack/runtime`):** `HttpDispatcher.handleApiEndpoint()` is deleted, + along with its now-orphaned private `callData` delegate, and `/__api-endpoint` leaves + `LEGACY_CHAIN_PREFIXES` and the route ledger. The method was public, so this is an API + removal — but it returned `{ handled: false }` for every call it ever received, so no + caller can observe a behaviour change beyond the missing symbol. Delete the call. + Absence is now loud (ADR-0076): the surface is refused at authoring rather than 404ing + at runtime with dead code behind it. - `examples/app-showcase` no longer declares endpoints, and its coverage manifest no longer claims the capability is `demonstrated` — that entry read "executed by the runtime dispatcher (handleApiEndpoint)", which was exactly the advertise-what-you-don't-deliver From c557a283513cf3a8e235e97d10a94e3cfe4ede87 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 01:58:39 +0000 Subject: [PATCH 3/5] style(spec): keep the schema's own doc comments adjacent to the schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The APIS_NO_EXECUTOR_GUIDANCE const landed between the two doc-comment blocks that both belong to ObjectStackDefinitionSchema, orphaning the first. Move the const above them — no behaviour change. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- packages/spec/src/stack.zod.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index a4bf1bea51..15e828af64 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -124,16 +124,6 @@ export const DatasourceMappingRuleSchema = lazySchema(() => z.object({ export type DatasourceMappingRule = z.infer; -/** - * ObjectStack Ecosystem Definition - * - * This schema represents the "Full Stack" definition of a project or environment. - * It is used for: - * 1. Project Export/Import (YAML/JSON dumps) - * 2. IDE Validation (IntelliSense) - * 3. Runtime Bootstrapping (In-memory loading) - * 4. Platform Reflection (API & Capabilities Discovery) - */ /** * The prescription raised when a stack declares a non-empty `apis:` (#4936). * @@ -159,6 +149,16 @@ const APIS_NO_EXECUTOR_GUIDANCE = + 'is tracked by https://github.com/objectstack-ai/objectstack/issues/5040, and this ' + 'rejection is replaced by real execution there — your endpoint definitions stay valid.'; +/** + * ObjectStack Ecosystem Definition + * + * This schema represents the "Full Stack" definition of a project or environment. + * It is used for: + * 1. Project Export/Import (YAML/JSON dumps) + * 2. IDE Validation (IntelliSense) + * 3. Runtime Bootstrapping (In-memory loading) + * 4. Platform Reflection (API & Capabilities Discovery) + */ /** * 1. DEFINITION PROTOCOL (Static) * ---------------------------------------------------------------------- From b3b02c5650466475f7e4f1669da44319cf463443 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 02:00:26 +0000 Subject: [PATCH 4/5] test(spec): make the tracking-pointer pin assert what its name promises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test claimed the prescription does not name #4936 as the tracker but only asserted #5040 was present — true even with a stale issues/4936 link beside it. Assert the set of issue URLs in the message is exactly {5040}. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- packages/spec/src/api/apis-no-executor.test.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/api/apis-no-executor.test.ts b/packages/spec/src/api/apis-no-executor.test.ts index 18dae01076..f6d1238314 100644 --- a/packages/spec/src/api/apis-no-executor.test.ts +++ b/packages/spec/src/api/apis-no-executor.test.ts @@ -89,12 +89,18 @@ describe('[#4936] non-empty `apis:` is rejected at publish/validate', () => { expect(message).toMatch(/vocabulary is deliberately KEPT/); }); - it('does NOT name #4936 as the tracking pointer (it closes with this change)', () => { + 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); - // #4936 may appear as the DECISION's provenance; what must not happen is - // it standing in as the live tracker with no #5040 alongside it. - expect(message).toMatch(/issues\/5040/); + 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', () => { From e050719e47a2158368ff11bb5c9e79c75cf3e674 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 4 Aug 2026 02:21:18 +0000 Subject: [PATCH 5/5] =?UTF-8?q?docs(spec,showcase):=20the=20prescription?= =?UTF-8?q?=20must=20not=20over-promise=20=E2=80=94=20ADR-0121=20D1=20rena?= =?UTF-8?q?mes=20paths=20on=20restore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ADR-0121 (accepted 2026-08-04, after this branch opened) namespaces endpoint paths as `/apps//`. The rejection message said definitions "stay valid"; that is true of every key except `path`, so it is now stated precisely, with the FROM -> TO. The showcase's commented endpoints carry the same note — they would be rejected under D1 if uncommented verbatim. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01EYGdmvWP1ieZSLqvAW6uyd --- examples/app-showcase/src/system/apis/index.ts | 12 ++++++++++-- packages/spec/src/stack.zod.ts | 6 +++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/examples/app-showcase/src/system/apis/index.ts b/examples/app-showcase/src/system/apis/index.ts index 68090014a7..5306b0540b 100644 --- a/examples/app-showcase/src/system/apis/index.ts +++ b/examples/app-showcase/src/system/apis/index.ts @@ -35,8 +35,16 @@ import type { ApiEndpoint } from '@objectstack/spec/api'; * retiring it, because endpoint shapes are an industry-stable form that would * only be re-introduced identically later. When the executor ships (#5040 — * mounting + endpoint matching + per-key wiring), the rejection is replaced by - * real execution and the two definitions below can be uncommented as-is. They - * are kept verbatim for exactly that reason. + * real execution and these two come back. They are kept verbatim for that. + * + * ⚠️ ONE edit is required when restoring them, and it is not cosmetic: + * ADR-0121 D1 (accepted 2026-08-04, after these were commented out) namespaces + * endpoint paths as `/apps//`, so that an + * app can only claim its own namespace and can never collide with a built-in + * domain or another installed package. The `path` values below predate that + * rule and would be rejected under it. Per that ADR they return as + * `/api/v1/apps/showcase/tasks` and `/api/v1/apps/showcase/inquiries/purge` + * (its §D1 names them explicitly, restored by #5040 E8). */ // /** Read-only data projection: GET a filtered task list through a stable URL. */ diff --git a/packages/spec/src/stack.zod.ts b/packages/spec/src/stack.zod.ts index e8efc387c1..fddd0040b5 100644 --- a/packages/spec/src/stack.zod.ts +++ b/packages/spec/src/stack.zod.ts @@ -147,7 +147,11 @@ const APIS_NO_EXECUTOR_GUIDANCE = + '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 — your endpoint definitions stay valid.'; + + '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.'; /** * ObjectStack Ecosystem Definition