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..7e920925d2
--- /dev/null
+++ b/.changeset/apis-loud-reject-and-apiregistry-retirement.md
@@ -0,0 +1,102 @@
+---
+"@objectstack/spec": major
+"@objectstack/core": major
+"@objectstack/plugin-hono-server": major
+"@objectstack/runtime": major
+"@objectstack/metadata-protocol": 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
+
+- **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
+ 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..5306b0540b 100644
--- a/examples/app-showcase/src/system/apis/index.ts
+++ b/examples/app-showcase/src/system/apis/index.ts
@@ -3,41 +3,82 @@
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 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. */
-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..f6d1238314
--- /dev/null
+++ b/packages/spec/src/api/apis-no-executor.test.ts
@@ -0,0 +1,175 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * [#4936] A non-empty `apis:` is REJECTED; the `ApiEndpoint` vocabulary is KEPT.
+ *
+ * ## What this pins, and why it is a rejection rather than a retirement
+ *
+ * The declarative `apis:` surface was zero-execution end to end. Metadata
+ * loading worked perfectly — `GET /api/v1/meta/api` returned the showcase's two
+ * endpoints with every key intact — while the execution side never fired once:
+ * no route was mounted for a declared `path` (so the request died at Hono's
+ * `notFound`, not even reaching the dispatcher), and the dispatcher branch that
+ * would have run it called a `matchEndpoint` method that NO implementation in
+ * the repo provided. Every key on `ApiEndpointSchema` was therefore
+ * declared ≠ enforced, `authRequired: true` included — a security semantic that
+ * parsed green and gated nothing.
+ *
+ * The maintainer verdict (2026-08-04) chose the third route: keep the
+ * vocabulary, refuse the authoring. Endpoint shapes are an industry-stable
+ * form, so retiring `ApiEndpointSchema` would only mean re-introducing the same
+ * schema later; refusing loudly kills the lie just as dead while preserving the
+ * vocabulary and the metadata investment. The executor (#5040) replaces this
+ * rejection with real execution, and every definition stays valid across that
+ * change.
+ *
+ * ## Why the assertions below live on the SCHEMA, not on `defineStack`
+ *
+ * `ObjectStackDefinitionSchema` is the single choke point every publish and
+ * validate path runs through — `defineStack` (spec), the metadata plugin's
+ * artifact ingestion (`ObjectStackDefinitionSchema.parse`), `os validate`, the
+ * lint scorer, and `EnvironmentArtifactSchema.metadata`. Putting the refusal
+ * there rather than in one caller is what makes it impossible to reach the
+ * runtime through a path that forgot to check (Prime Directive #12: reject at
+ * authoring/publish, never tolerate in a consumer).
+ */
+
+import { describe, it, expect } from 'vitest';
+
+import { ObjectStackDefinitionSchema, defineStack } from '../stack.zod';
+import { ApiEndpointSchema } from './endpoint.zod';
+
+const manifest = {
+ id: 'com.example.apis',
+ name: 'apis-test',
+ version: '1.0.0',
+ type: 'app' as const,
+};
+
+/** The endpoint the showcase used to ship — a realistic, fully valid one. */
+const validEndpoint = {
+ name: 'showcase_task_feed',
+ path: '/api/v1/showcase/tasks',
+ method: 'GET' as const,
+ summary: 'Task feed',
+ type: 'object_operation' as const,
+ target: 'showcase_task',
+ objectParams: { object: 'showcase_task', operation: 'find' as const },
+ authRequired: true,
+ cacheTtl: 30,
+};
+
+describe('[#4936] non-empty `apis:` is rejected at publish/validate', () => {
+ it('rejects a non-empty `apis:` through the schema — the shared publish/validate seam', () => {
+ const result = ObjectStackDefinitionSchema.safeParse({ manifest, apis: [validEndpoint] });
+ expect(result.success, 'a declared endpoint must NOT parse clean').toBe(false);
+ });
+
+ it('rejects it through `defineStack` too — the build path an author actually calls', () => {
+ expect(() => defineStack({ manifest, apis: [validEndpoint] })).toThrow(/apis:/);
+ });
+
+ it('the rejection carries the prescription, not a bare "too big"', () => {
+ const result = ObjectStackDefinitionSchema.safeParse({ manifest, apis: [validEndpoint] });
+ expect(result.success).toBe(false);
+ const message = result.success ? '' : JSON.stringify(result.error.issues);
+
+ // The fact: declared, but nothing executes it.
+ expect(message).toMatch(/DECLARED BUT NOT EXECUTABLE/);
+ // The fix the author must apply.
+ expect(message).toMatch(/delete the `apis:` entries/i);
+ // The honest alternative that works today.
+ expect(message).toMatch(/contributes\.routes|http\.server/);
+ // The LIVE tracking pointer. #4936 closes with this change, so the
+ // prescription must name #5040 — the executor card that is still open —
+ // or it sends an upgrading author to a closed issue.
+ expect(message).toMatch(/issues\/5040/);
+ // And it must promise the vocabulary survives, so nobody "migrates away"
+ // from endpoint definitions that are about to start working.
+ expect(message).toMatch(/vocabulary is deliberately KEPT/);
+ });
+
+ it('points at exactly ONE issue URL, and it is the OPEN one', () => {
+ // #4936 closes with this change, so it may appear only as the decision's
+ // provenance ("(#4936)"), never as a link an upgrading author is invited to
+ // follow for status. #5040 — the executor card — is the live tracker, and
+ // it must be the only URL in the string. This is the assertion the test
+ // above cannot make: "contains 5040" stays true even if a stale
+ // `issues/4936` link were sitting next to it.
+ const result = ObjectStackDefinitionSchema.safeParse({ manifest, apis: [validEndpoint] });
+ const message = result.success ? '' : JSON.stringify(result.error.issues);
+ const urls = [...message.matchAll(/issues\/(\d+)/g)].map((m) => m[1]);
+ expect(urls.length, 'the prescription must carry a tracking link').toBeGreaterThan(0);
+ expect([...new Set(urls)]).toEqual(['5040']);
+ });
+
+ it('accepts an EMPTY `apis:` — the key stays declared and parseable', () => {
+ expect(ObjectStackDefinitionSchema.safeParse({ manifest, apis: [] }).success).toBe(true);
+ expect(() => defineStack({ manifest, apis: [] })).not.toThrow();
+ });
+
+ it('accepts an ABSENT `apis:`', () => {
+ expect(ObjectStackDefinitionSchema.safeParse({ manifest }).success).toBe(true);
+ expect(() => defineStack({ manifest })).not.toThrow();
+ });
+
+ it('rejects on COUNT, not on content — a second valid endpoint is refused the same way', () => {
+ const second = {
+ name: 'showcase_inquiry_purge_api',
+ path: '/api/v1/showcase/inquiries/purge',
+ method: 'POST' as const,
+ type: 'flow' as const,
+ target: 'showcase_inquiry_purge',
+ authRequired: true,
+ };
+ expect(ObjectStackDefinitionSchema.safeParse({ manifest, apis: [second] }).success).toBe(false);
+ expect(
+ ObjectStackDefinitionSchema.safeParse({ manifest, apis: [validEndpoint, second] }).success,
+ ).toBe(false);
+ });
+});
+
+describe('[#4936] the `ApiEndpoint` vocabulary itself is untouched', () => {
+ // Anti-vacuity for the whole file: if the schema had been retired instead of
+ // the authoring refused, every assertion above would still pass while the
+ // verdict ("词表零折腾" — zero churn to the vocabulary) had been violated.
+ it('still parses a full endpoint on its own, every key intact', () => {
+ const parsed = ApiEndpointSchema.parse(validEndpoint);
+ expect(parsed.name).toBe('showcase_task_feed');
+ expect(parsed.path).toBe('/api/v1/showcase/tasks');
+ expect(parsed.method).toBe('GET');
+ expect(parsed.type).toBe('object_operation');
+ expect(parsed.objectParams).toEqual({ object: 'showcase_task', operation: 'find' });
+ expect(parsed.authRequired).toBe(true);
+ expect(parsed.cacheTtl).toBe(30);
+ });
+
+ it('keeps `authRequired` defaulting to true — the key #4936 called out by name', () => {
+ const parsed = ApiEndpointSchema.parse({
+ name: 'x_endpoint',
+ path: '/api/v1/x',
+ method: 'GET',
+ type: 'flow',
+ target: 'x_flow',
+ });
+ expect(parsed.authRequired).toBe(true);
+ });
+
+ it('keeps endpoint-level `rateLimit` in the vocabulary (#4910-Q2 routed it here)', () => {
+ const parsed = ApiEndpointSchema.parse({
+ name: 'x_endpoint',
+ path: '/api/v1/x',
+ method: 'GET',
+ type: 'flow',
+ target: 'x_flow',
+ rateLimit: { requests: 10, window: 60 },
+ });
+ expect(parsed.rateLimit).toBeDefined();
+ });
+
+ it('still rejects a malformed endpoint on its own terms', () => {
+ // Guards the guard: the vocabulary must still be a real schema, not an
+ // `any` that would make the assertions above meaningless.
+ expect(() => ApiEndpointSchema.parse({ name: 'Bad Name', path: 'no-slash' })).toThrow();
+ });
+});
diff --git a/packages/spec/src/api/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 47ae961cf6..fddd0040b5 100644
--- a/packages/spec/src/stack.zod.ts
+++ b/packages/spec/src/stack.zod.ts
@@ -124,6 +124,35 @@ export const DatasourceMappingRuleSchema = lazySchema(() => z.object({
export type DatasourceMappingRule = z.infer;
+/**
+ * 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 — 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
*
@@ -139,7 +168,7 @@ export type DatasourceMappingRule = z.infer;
* ----------------------------------------------------------------------
* 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 +292,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.',
),
/**