@checkstack/backend 0.9.1 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,119 @@
1
1
  # @checkstack/backend
2
2
 
3
+ ## 0.10.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 1909a61: Address open CodeQL code-scanning findings:
8
+
9
+ - **`@checkstack/ui` (`LinksEditor`)**: validate URL scheme on render and on
10
+ add; only `http:` / `https:` URLs are accepted, defeating stored XSS via
11
+ `javascript:` / `data:` schemes in user-supplied hotlinks
12
+ (`js/xss-through-dom`).
13
+ - **`@checkstack/backend-api` (`markdownToPlainText`)**: decode HTML entities
14
+ before stripping tags, then strip tags in a loop until the output
15
+ stabilizes. Decoding `&` last avoids reintroducing tag delimiters
16
+ via `<` round-trips (`js/double-escaping`,
17
+ `js/incomplete-multi-character-sanitization`).
18
+ - **`@checkstack/backend` (`createScopedWsRegistry`)**: drop the
19
+ identity-replacement on the path suffix; the leading-slash invariant
20
+ is documented on `WebSocketRouteRegistry` (`js/identity-replacement`).
21
+
22
+ - b33fb4d: Refresh `bun.lock` to clear MEDIUM-severity Trivy advisories on transitive
23
+ runtime dependencies. No public API change — bumping every workspace
24
+ package that lists `@orpc/server` as a direct dep so consumers re-resolve
25
+ the optional `ws` peer to the patched release on their next install.
26
+
27
+ - `ws` `8.20.0` → `8.20.1` (CVE-2026-45736). Pulled into the install tree
28
+ as `@orpc/server`'s optional WebSocket peer; Bun auto-installs it into
29
+ every backend package that depends on `@orpc/server`, so a stale 8.20.0
30
+ ships in the consumer's `node_modules` until the parent package
31
+ re-resolves.
32
+ - `brace-expansion` `5.0.5` → `5.0.6` (CVE-2026-45149). Pulled in only
33
+ through dev tooling (`minimatch@10` via `@typescript-eslint` and
34
+ `storybook`'s `glob@13`), so it does not ship to consumers and no
35
+ workspace `package.json` lists it; the lockfile bump alone clears the
36
+ finding for the Docker image and the local dev tree. No version bump
37
+ is attributed to this advisory.
38
+
39
+ The fix lives entirely in `bun.lock` — no `package.json`, `overrides`, or
40
+ `resolutions` change is needed because both parent ranges (`minimatch@10
41
+ → brace-expansion@^5.0.5`, `@orpc/server / storybook / happy-dom →
42
+ ws@>=8.18.x`) already accept the patched releases, and `bun install`
43
+ keeps the resolved versions sticky after the initial `bun update`.
44
+
45
+ - Updated dependencies [1909a61]
46
+ - Updated dependencies [b33fb4d]
47
+ - @checkstack/backend-api@0.15.3
48
+ - @checkstack/cache-api@0.3.2
49
+ - @checkstack/queue-api@0.3.2
50
+ - @checkstack/signal-backend@0.2.6
51
+
52
+ ## 0.10.0
53
+
54
+ ### Minor Changes
55
+
56
+ - 9016526: Add a `/rest/:pluginId/*` HTTP mount that serves every plugin's oRPC contract
57
+ through the REST/OpenAPI shape described by `/api/openapi.json`. Queries are
58
+ `GET` with query parameters, mutations are `POST` with the input as the raw
59
+ JSON body. The existing `/api/:pluginId/*` mount continues to serve oRPC's
60
+ native wire protocol unchanged, so existing clients are not affected.
61
+
62
+ The OpenAPI spec at `/api/openapi.json` now reflects the real mount: every
63
+ `paths` entry is prefixed with `/rest` instead of `/api`.
64
+
65
+ Also fixes a SPA-fallback bug: the backend's `/api-docs` route previously
66
+ returned 404 on production deployments because the static-file middleware
67
+ skipped any path starting with `/api`, capturing `/api-docs` along with real
68
+ API routes. The skip now requires a trailing slash (`/api/`, `/rest/`).
69
+
70
+ Required access rules are now visible in the API Docs UI. The OpenAPI spec
71
+ generator was reading a non-existent `accessRules` field on procedure
72
+ metadata; the real field is `access: AccessRule[]`. Each procedure's access
73
+ rules are now flattened to fully-qualified IDs (e.g. `catalog.system.read`)
74
+ and emitted under `x-orpc-meta.accessRules`, which the existing
75
+ `Required Access Rules` section in the docs UI already knew how to render.
76
+
77
+ The API Docs schema renderer now handles record types (zod `z.record`),
78
+ `$ref`s into `components.schemas`, `oneOf`/`anyOf`/`allOf`, nullable union
79
+ types (`type: ["string", "null"]`), and `format` qualifiers. Previously
80
+ record outputs like `{ statuses: object }` masked the actual value type;
81
+ they now render as `{ [key]: <ResolvedType> { ... } }` with the inner
82
+ schema expanded, capped at 12 levels with cycle detection.
83
+
84
+ **REST method conventions.** `proc()` now defaults to `GET` for queries and
85
+ `POST` for mutations on the `/rest` mount, using bracket-notation query
86
+ params (`?filter[status]=active&ids[0]=a`) for GET inputs. Existing
87
+ procedures were updated to follow REST semantics:
88
+
89
+ - `update*` mutations → `PATCH`
90
+ - `delete*` / `remove*` mutations → `DELETE`
91
+ - `getBulk*` queries and any query taking a large array input → `POST`
92
+ (because `@orpc/openapi@1.13.x` has no GET→POST URL-length fallback)
93
+
94
+ GET endpoints require an `object` input — bare scalars like
95
+ `.input(z.string())` are not valid on GET. `getSystemConfigurations` was
96
+ refactored from `.input(z.string())` to `.input(z.object({ systemId: ... }))`
97
+ to fit the GET shape; the only call-site update was the in-process router
98
+ unpacking `input.systemId` instead of passing `input` directly.
99
+
100
+ The API Docs UI now renders query parameters (path/query/header/cookie) in a
101
+ dedicated table for GET endpoints, and the fetch example shows them in the
102
+ URL with `<required>` / `<optional>` placeholders.
103
+
104
+ ### Patch Changes
105
+
106
+ - Updated dependencies [9016526]
107
+ - @checkstack/common@0.10.0
108
+ - @checkstack/auth-common@0.7.0
109
+ - @checkstack/api-docs-common@0.1.13
110
+ - @checkstack/backend-api@0.15.2
111
+ - @checkstack/pluginmanager-common@0.2.2
112
+ - @checkstack/signal-backend@0.2.5
113
+ - @checkstack/signal-common@0.2.3
114
+ - @checkstack/cache-api@0.3.1
115
+ - @checkstack/queue-api@0.3.1
116
+
3
117
  ## 0.9.1
4
118
 
5
119
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend",
3
- "version": "0.9.1",
3
+ "version": "0.10.1",
4
4
  "license": "Elastic-2.0",
5
5
  "checkstack": {
6
6
  "type": "backend"
@@ -14,16 +14,16 @@
14
14
  "lint:code": "eslint . --max-warnings 0"
15
15
  },
16
16
  "dependencies": {
17
- "@checkstack/api-docs-common": "0.1.11",
18
- "@checkstack/auth-common": "0.6.5",
19
- "@checkstack/backend-api": "0.15.0",
20
- "@checkstack/common": "0.8.0",
17
+ "@checkstack/api-docs-common": "0.1.13",
18
+ "@checkstack/auth-common": "0.7.0",
19
+ "@checkstack/backend-api": "0.15.2",
20
+ "@checkstack/common": "0.10.0",
21
21
  "@checkstack/drizzle-helper": "0.0.5",
22
- "@checkstack/cache-api": "0.2.4",
23
- "@checkstack/queue-api": "0.2.18",
24
- "@checkstack/signal-backend": "0.2.3",
25
- "@checkstack/signal-common": "0.2.1",
26
- "@checkstack/pluginmanager-common": "0.2.0",
22
+ "@checkstack/cache-api": "0.3.1",
23
+ "@checkstack/queue-api": "0.3.1",
24
+ "@checkstack/signal-backend": "0.2.5",
25
+ "@checkstack/signal-common": "0.2.3",
26
+ "@checkstack/pluginmanager-common": "0.2.2",
27
27
  "@hono/zod-validator": "^0.7.6",
28
28
  "@orpc/client": "^1.13.14",
29
29
  "@orpc/contract": "^1.13.14",
@@ -45,8 +45,8 @@
45
45
  "@types/bun": "latest",
46
46
  "@types/semver": "^7.5.0",
47
47
  "@checkstack/tsconfig": "0.0.7",
48
- "@checkstack/scripts": "0.3.0",
49
- "@checkstack/test-utils-backend": "0.1.24",
48
+ "@checkstack/scripts": "0.3.2",
49
+ "@checkstack/test-utils-backend": "0.1.26",
50
50
  "drizzle-kit": "^0.31.10"
51
51
  }
52
52
  }
package/src/index.ts CHANGED
@@ -335,8 +335,13 @@ if (frontendDistPath && fs.existsSync(frontendDistPath)) {
335
335
  // Serve root-level static files (e.g., /favicon.svg) from the dist directory
336
336
  // before the SPA fallback, so they don't get caught by the index.html handler
337
337
  app.get("*", async (c, next) => {
338
- // Skip API and WebSocket routes - let them pass through to actual handlers
339
- if (c.req.path.startsWith("/api")) {
338
+ // Skip API and WebSocket routes - let them pass through to actual handlers.
339
+ // The trailing slash matters: `/api-docs` is a frontend route and must hit
340
+ // the SPA fallback below, while `/api/...` and `/rest/...` go to backend
341
+ // handlers (oRPC RPC + OpenAPI REST mounts).
342
+ const apiPath =
343
+ c.req.path.startsWith("/api/") || c.req.path.startsWith("/rest/");
344
+ if (apiPath) {
340
345
  return next();
341
346
  }
342
347
 
@@ -10,6 +10,7 @@ import { ZodToJsonSchemaConverter } from "@orpc/zod/zod4";
10
10
  import type { AnyContractRouter } from "@orpc/contract";
11
11
  import type { PluginManager } from "./plugin-manager";
12
12
  import type { AuthService } from "@checkstack/backend-api";
13
+ import type { AccessRule } from "@checkstack/common";
13
14
 
14
15
  /**
15
16
  * Check if a user has a specific access rule.
@@ -25,40 +26,57 @@ function hasAccess(
25
26
  );
26
27
  }
27
28
 
29
+ /**
30
+ * Shape of the metadata we surface in the OpenAPI spec as `x-orpc-meta`.
31
+ * `accessRules` are the fully-qualified access rule IDs (e.g.
32
+ * `"catalog.system.read"`), flattened from each procedure's `access`
33
+ * AccessRule[] so doc consumers don't need to know the AccessRule shape.
34
+ */
35
+ interface ExposedProcedureMeta {
36
+ userType?: string;
37
+ accessRules?: string[];
38
+ }
39
+
28
40
  /**
29
41
  * Extract procedure metadata from a contract using oRPC internal structure.
42
+ * Returns the raw `meta` block stored on the contract procedure builder.
30
43
  */
31
- function extractProcedureMetadata(
44
+ function extractRawProcedureMeta(
32
45
  contract: unknown
33
- ): { userType?: string; accessRules?: string[] } | undefined {
46
+ ): { userType?: string; access?: AccessRule[] } | undefined {
34
47
  const orpcData = (contract as Record<string, unknown>)?.["~orpc"] as
35
- | { meta?: { userType?: string; accessRules?: string[] } }
48
+ | { meta?: { userType?: string; access?: AccessRule[] } }
36
49
  | undefined;
37
50
  return orpcData?.meta;
38
51
  }
39
52
 
40
53
  /**
41
- * Build a lookup map of operationId -> metadata from all contracts.
42
- * operationId format: "pluginId.procedureName"
54
+ * Build a lookup map of operationId -> exposed metadata from all contracts.
55
+ * operationId format: "pluginId.procedureName".
56
+ *
57
+ * The procedure metadata stores `access` as AccessRule[]; we flatten it to
58
+ * `accessRules: string[]` of qualified IDs (`{pluginId}.{ruleId}`) so the
59
+ * OpenAPI consumer (API docs UI, third-party clients) gets a plain list.
43
60
  */
44
61
  function buildMetadataLookup(
45
62
  contracts: Map<string, AnyContractRouter>
46
- ): Map<string, { userType?: string; accessRules?: string[] }> {
47
- const lookup = new Map<
48
- string,
49
- { userType?: string; accessRules?: string[] }
50
- >();
63
+ ): Map<string, ExposedProcedureMeta> {
64
+ const lookup = new Map<string, ExposedProcedureMeta>();
51
65
 
52
66
  for (const [pluginId, contract] of contracts) {
53
- // Contract is an object with procedure names as keys
54
67
  for (const [procedureName, procedure] of Object.entries(
55
68
  contract as Record<string, unknown>
56
69
  )) {
57
- const meta = extractProcedureMetadata(procedure);
58
- if (meta) {
59
- const operationId = `${pluginId}.${procedureName}`;
60
- lookup.set(operationId, meta);
61
- }
70
+ const raw = extractRawProcedureMeta(procedure);
71
+ if (!raw) continue;
72
+
73
+ const accessRules = raw.access?.map((rule) => `${pluginId}.${rule.id}`);
74
+
75
+ const operationId = `${pluginId}.${procedureName}`;
76
+ lookup.set(operationId, {
77
+ userType: raw.userType,
78
+ ...(accessRules && accessRules.length > 0 ? { accessRules } : {}),
79
+ });
62
80
  }
63
81
  }
64
82
 
@@ -107,13 +125,14 @@ export async function generateOpenApiSpec({
107
125
  >;
108
126
  };
109
127
 
110
- // Post-process: Add x-orpc-meta to each operation and prefix paths with /api
128
+ // Post-process: Add x-orpc-meta to each operation and prefix paths with /rest.
129
+ // The REST handler is mounted at /rest/:pluginId/* (see api-router.ts);
130
+ // /api/:pluginId/* serves oRPC's native wire protocol, not REST.
111
131
  if (spec.paths) {
112
132
  const prefixedPaths: typeof spec.paths = {};
113
133
 
114
134
  for (const [path, methods] of Object.entries(spec.paths)) {
115
- // Prefix path with /api
116
- const prefixedPath = `/api${path.startsWith("/") ? path : `/${path}`}`;
135
+ const prefixedPath = `/rest${path.startsWith("/") ? path : `/${path}`}`;
117
136
  prefixedPaths[prefixedPath] = methods;
118
137
 
119
138
  // Add metadata to each operation
@@ -1,6 +1,7 @@
1
1
  import type { Hono, Context } from "hono";
2
2
  import type { SafeDatabase } from "@checkstack/backend-api";
3
3
  import { RPCHandler } from "@orpc/server/fetch";
4
+ import { OpenAPIHandler } from "@orpc/openapi/fetch";
4
5
  import {
5
6
  coreServices,
6
7
  AuthService,
@@ -23,9 +24,182 @@ import type { PluginMetadata } from "@checkstack/common";
23
24
  import { rootLogger } from "../logger";
24
25
  import { extractErrorMessage } from "@checkstack/common";
25
26
 
27
+ interface RouteHandlerDeps {
28
+ registry: ServiceRegistry;
29
+ pluginRpcRouters: Map<string, unknown>;
30
+ pluginMetadataRegistry: Map<string, PluginMetadata>;
31
+ }
32
+
33
+ interface ResolvedRequestContext {
34
+ context: RpcContext;
35
+ logger: Logger;
36
+ }
37
+
38
+ type ContextResult =
39
+ | { ok: true; resolved: ResolvedRequestContext }
40
+ | { ok: false; response: Response };
41
+
42
+ function createServiceGetter(registry: ServiceRegistry) {
43
+ return async function getService<T>(ref: {
44
+ id: string;
45
+ T: T;
46
+ }): Promise<T | undefined> {
47
+ try {
48
+ return await registry.get(ref, { pluginId: "core" });
49
+ } catch {
50
+ return undefined;
51
+ }
52
+ };
53
+ }
54
+
55
+ /**
56
+ * Resolve auth + core services and build the per-request RpcContext.
57
+ * Shared between the oRPC `/api/*` handler and the REST `/rest/*` handler.
58
+ */
59
+ async function resolveRequestContext({
60
+ c,
61
+ pluginId,
62
+ deps,
63
+ }: {
64
+ c: Context;
65
+ pluginId: string;
66
+ deps: RouteHandlerDeps;
67
+ }): Promise<ContextResult> {
68
+ const getService = createServiceGetter(deps.registry);
69
+ const pathname = new URL(c.req.raw.url).pathname;
70
+
71
+ const logger = await getService(coreServices.logger);
72
+ const auth = await getService(coreServices.auth);
73
+ const db = await getService(coreServices.database);
74
+ const fetch = await getService(coreServices.fetch);
75
+ const healthCheckRegistry = await getService(
76
+ coreServices.healthCheckRegistry,
77
+ );
78
+ const collectorRegistry = await getService(coreServices.collectorRegistry);
79
+ const queuePluginRegistry = await getService(
80
+ coreServices.queuePluginRegistry,
81
+ );
82
+ const queueManager = await getService(coreServices.queueManager);
83
+ const cachePluginRegistry = await getService(
84
+ coreServices.cachePluginRegistry,
85
+ );
86
+ const cacheManager = await getService(coreServices.cacheManager);
87
+ const eventBus = await getService(coreServices.eventBus);
88
+
89
+ if (
90
+ !auth ||
91
+ !logger ||
92
+ !db ||
93
+ !fetch ||
94
+ !healthCheckRegistry ||
95
+ !collectorRegistry ||
96
+ !queuePluginRegistry ||
97
+ !queueManager ||
98
+ !cachePluginRegistry ||
99
+ !cacheManager ||
100
+ !eventBus
101
+ ) {
102
+ const missing = [
103
+ !auth && "auth",
104
+ !logger && "logger",
105
+ !db && "db",
106
+ !fetch && "fetch",
107
+ !healthCheckRegistry && "healthCheckRegistry",
108
+ !collectorRegistry && "collectorRegistry",
109
+ !queuePluginRegistry && "queuePluginRegistry",
110
+ !queueManager && "queueManager",
111
+ !cachePluginRegistry && "cachePluginRegistry",
112
+ !cacheManager && "cacheManager",
113
+ !eventBus && "eventBus",
114
+ ]
115
+ .filter(Boolean)
116
+ .join(", ");
117
+ (logger ?? rootLogger).error(
118
+ `${pathname}: core services not initialized — missing: ${missing}`,
119
+ );
120
+ return {
121
+ ok: false,
122
+ response: c.json({ error: "Core services not initialized" }, 500),
123
+ };
124
+ }
125
+
126
+ const user = await (auth as AuthService).authenticate(c.req.raw);
127
+
128
+ const emitHook: EmitHookFn = async <T>(hook: Hook<T>, payload: T) => {
129
+ await (eventBus as EventBus).emit(hook, payload);
130
+ };
131
+
132
+ const pluginMetadata: PluginMetadata | undefined =
133
+ deps.pluginMetadataRegistry.get(pluginId);
134
+
135
+ if (!pluginMetadata) {
136
+ (logger as Logger).error(
137
+ `${pathname}: no plugin metadata registered for pluginId='${pluginId}'. ` +
138
+ `Regular plugins populate this during register(); core routers must call ` +
139
+ `pluginManager.registerCorePluginMetadata().`,
140
+ );
141
+ return {
142
+ ok: false,
143
+ response: c.json({ error: "Plugin metadata not found in registry" }, 500),
144
+ };
145
+ }
146
+
147
+ const context: RpcContext = {
148
+ pluginMetadata,
149
+ auth: auth as AuthService,
150
+ logger: logger as Logger,
151
+ db: db as SafeDatabase<Record<string, unknown>>,
152
+ fetch: fetch as Fetch,
153
+ healthCheckRegistry: healthCheckRegistry as HealthCheckRegistry,
154
+ collectorRegistry: collectorRegistry as CollectorRegistry,
155
+ queuePluginRegistry: queuePluginRegistry as QueuePluginRegistry,
156
+ queueManager: queueManager as QueueManager,
157
+ cachePluginRegistry: cachePluginRegistry as CachePluginRegistry,
158
+ cacheManager: cacheManager as CacheManager,
159
+ user,
160
+ emitHook,
161
+ };
162
+
163
+ return { ok: true, resolved: { context, logger: logger as Logger } };
164
+ }
165
+
166
+ function buildRpcRouter(
167
+ pluginRpcRouters: Map<string, unknown>,
168
+ ): Record<string, unknown> {
169
+ const rootRpcRouter: Record<string, unknown> = {};
170
+ for (const [pluginId, router] of pluginRpcRouters.entries()) {
171
+ rootRpcRouter[pluginId] = router;
172
+ }
173
+ return rootRpcRouter;
174
+ }
175
+
176
+ function logHandlerError({
177
+ error,
178
+ pathname,
179
+ logger,
180
+ protocolLabel,
181
+ }: {
182
+ error: unknown;
183
+ pathname: string;
184
+ logger: Logger | undefined;
185
+ protocolLabel: string;
186
+ }) {
187
+ const target = (logger ?? rootLogger) as Logger;
188
+ target.error(
189
+ `${protocolLabel} ${pathname} failed: ${extractErrorMessage(error)}`,
190
+ );
191
+ const stack =
192
+ error !== null && typeof error === "object" && "stack" in error
193
+ ? (error as { stack: string }).stack
194
+ : undefined;
195
+ if (stack) {
196
+ target.error(`Stack trace: ${stack}`);
197
+ }
198
+ }
199
+
26
200
  /**
27
201
  * Creates the API route handler for Hono.
28
- * Extracted from PluginManager for better organization.
202
+ * Serves oRPC's native RPC wire protocol at /api/:pluginId/*.
29
203
  */
30
204
  export function createApiRouteHandler({
31
205
  registry,
@@ -38,54 +212,36 @@ export function createApiRouteHandler({
38
212
  pluginHttpHandlers: Map<string, (req: Request) => Promise<Response>>;
39
213
  pluginMetadataRegistry: Map<string, PluginMetadata>;
40
214
  }) {
41
- // Helper to get service from registry
42
- async function getService<T>(ref: {
43
- id: string;
44
- T: T;
45
- }): Promise<T | undefined> {
46
- try {
47
- return await registry.get(ref, { pluginId: "core" });
48
- } catch {
49
- return undefined;
50
- }
51
- }
215
+ const deps: RouteHandlerDeps = {
216
+ registry,
217
+ pluginRpcRouters,
218
+ pluginMetadataRegistry,
219
+ };
52
220
 
53
221
  return async function handleApiRequest(c: Context) {
54
- // Extract pluginId from Hono path parameter (/api/:pluginId/*)
55
222
  const pluginId = c.req.param("pluginId") || "";
56
223
  const pathname = new URL(c.req.raw.url).pathname;
57
224
 
58
- // Build RPC handler lazily at request time
59
- // This ensures all plugins registered during init are included
60
- const rootRpcRouter: Record<string, unknown> = {};
61
- for (const [pluginId, router] of pluginRpcRouters.entries()) {
62
- rootRpcRouter[pluginId] = router;
63
- }
64
-
65
- // Resolve logger first for use in interceptor
66
- const logger = await getService(coreServices.logger);
225
+ const result = await resolveRequestContext({ c, pluginId, deps });
226
+ if (!result.ok) return result.response;
227
+ const { context, logger } = result.resolved;
67
228
 
68
229
  const rpcHandler = new RPCHandler(
69
- rootRpcRouter as ConstructorParameters<typeof RPCHandler>[0],
230
+ buildRpcRouter(pluginRpcRouters) as ConstructorParameters<
231
+ typeof RPCHandler
232
+ >[0],
70
233
  {
71
234
  interceptors: [
72
235
  async ({ next, ...rest }) => {
73
236
  try {
74
237
  return await next(rest);
75
238
  } catch (error) {
76
- const target = (logger ?? rootLogger) as Logger;
77
- target.error(
78
- `RPC ${pathname} failed: ${extractErrorMessage(error)}`,
79
- );
80
- const stack =
81
- error !== null &&
82
- typeof error === "object" &&
83
- "stack" in error
84
- ? (error as { stack: string }).stack
85
- : undefined;
86
- if (stack) {
87
- target.error(`Stack trace: ${stack}`);
88
- }
239
+ logHandlerError({
240
+ error,
241
+ pathname,
242
+ logger,
243
+ protocolLabel: "RPC",
244
+ });
89
245
  throw error;
90
246
  }
91
247
  },
@@ -93,93 +249,6 @@ export function createApiRouteHandler({
93
249
  },
94
250
  );
95
251
 
96
- // Resolve core services for RPC context
97
- const auth = await getService(coreServices.auth);
98
- const db = await getService(coreServices.database);
99
- const fetch = await getService(coreServices.fetch);
100
- const healthCheckRegistry = await getService(
101
- coreServices.healthCheckRegistry,
102
- );
103
- const collectorRegistry = await getService(coreServices.collectorRegistry);
104
- const queuePluginRegistry = await getService(
105
- coreServices.queuePluginRegistry,
106
- );
107
- const queueManager = await getService(coreServices.queueManager);
108
- const cachePluginRegistry = await getService(
109
- coreServices.cachePluginRegistry,
110
- );
111
- const cacheManager = await getService(coreServices.cacheManager);
112
- const eventBus = await getService(coreServices.eventBus);
113
-
114
- if (
115
- !auth ||
116
- !logger ||
117
- !db ||
118
- !fetch ||
119
- !healthCheckRegistry ||
120
- !collectorRegistry ||
121
- !queuePluginRegistry ||
122
- !queueManager ||
123
- !cachePluginRegistry ||
124
- !cacheManager ||
125
- !eventBus
126
- ) {
127
- const missing = [
128
- !auth && "auth",
129
- !logger && "logger",
130
- !db && "db",
131
- !fetch && "fetch",
132
- !healthCheckRegistry && "healthCheckRegistry",
133
- !collectorRegistry && "collectorRegistry",
134
- !queuePluginRegistry && "queuePluginRegistry",
135
- !queueManager && "queueManager",
136
- !cachePluginRegistry && "cachePluginRegistry",
137
- !cacheManager && "cacheManager",
138
- !eventBus && "eventBus",
139
- ].filter(Boolean).join(", ");
140
- (logger ?? rootLogger).error(
141
- `${pathname}: core services not initialized — missing: ${missing}`,
142
- );
143
- return c.json({ error: "Core services not initialized" }, 500);
144
- }
145
-
146
- const user = await (auth as AuthService).authenticate(c.req.raw);
147
-
148
- // Create emitHook function using eventBus
149
- const emitHook: EmitHookFn = async <T>(hook: Hook<T>, payload: T) => {
150
- await (eventBus as EventBus).emit(hook, payload);
151
- };
152
-
153
- // Lookup plugin metadata from registry
154
- const pluginMetadata: PluginMetadata | undefined =
155
- pluginMetadataRegistry.get(pluginId);
156
-
157
- if (!pluginMetadata) {
158
- (logger as Logger).error(
159
- `${pathname}: no plugin metadata registered for pluginId='${pluginId}'. ` +
160
- `Regular plugins populate this during register(); core routers must call ` +
161
- `pluginManager.registerCorePluginMetadata().`,
162
- );
163
- return c.json({ error: "Plugin metadata not found in registry" }, 500);
164
- }
165
-
166
- const context: RpcContext = {
167
- pluginMetadata,
168
- auth: auth as AuthService,
169
- logger: logger as Logger,
170
- db: db as SafeDatabase<Record<string, unknown>>,
171
- fetch: fetch as Fetch,
172
- healthCheckRegistry: healthCheckRegistry as HealthCheckRegistry,
173
- collectorRegistry: collectorRegistry as CollectorRegistry,
174
- queuePluginRegistry: queuePluginRegistry as QueuePluginRegistry,
175
- queueManager: queueManager as QueueManager,
176
- cachePluginRegistry: cachePluginRegistry as CachePluginRegistry,
177
- cacheManager: cacheManager as CacheManager,
178
- user,
179
- emitHook,
180
- };
181
-
182
- // 1. Try oRPC first
183
252
  const { matched, response } = await rpcHandler.handle(c.req.raw, {
184
253
  prefix: "/api",
185
254
  context,
@@ -191,8 +260,7 @@ export function createApiRouteHandler({
191
260
 
192
261
  logger.debug(`RPC mismatch for: ${c.req.method} ${pathname}`);
193
262
 
194
- // 2. Try native handlers
195
- // Sort by path length (descending) to ensure more specific paths are tried first
263
+ // Fall through to native plugin HTTP handlers (sorted longest-prefix first)
196
264
  const sortedHandlers = [...pluginHttpHandlers.entries()].toSorted(
197
265
  function (a, b) {
198
266
  return b[0].length - a[0].length;
@@ -209,6 +277,71 @@ export function createApiRouteHandler({
209
277
  };
210
278
  }
211
279
 
280
+ /**
281
+ * Creates the REST route handler for Hono.
282
+ * Serves the REST/OpenAPI shape of the same oRPC contract at /rest/:pluginId/*.
283
+ * Standard JSON bodies / query params work here — matches /api/openapi.json.
284
+ */
285
+ export function createRestRouteHandler({
286
+ registry,
287
+ pluginRpcRouters,
288
+ pluginMetadataRegistry,
289
+ }: {
290
+ registry: ServiceRegistry;
291
+ pluginRpcRouters: Map<string, unknown>;
292
+ pluginMetadataRegistry: Map<string, PluginMetadata>;
293
+ }) {
294
+ const deps: RouteHandlerDeps = {
295
+ registry,
296
+ pluginRpcRouters,
297
+ pluginMetadataRegistry,
298
+ };
299
+
300
+ return async function handleRestRequest(c: Context) {
301
+ const pluginId = c.req.param("pluginId") || "";
302
+ const pathname = new URL(c.req.raw.url).pathname;
303
+
304
+ const result = await resolveRequestContext({ c, pluginId, deps });
305
+ if (!result.ok) return result.response;
306
+ const { context, logger } = result.resolved;
307
+
308
+ const restHandler = new OpenAPIHandler(
309
+ buildRpcRouter(pluginRpcRouters) as ConstructorParameters<
310
+ typeof OpenAPIHandler
311
+ >[0],
312
+ {
313
+ interceptors: [
314
+ async ({ next, ...rest }) => {
315
+ try {
316
+ return await next(rest);
317
+ } catch (error) {
318
+ logHandlerError({
319
+ error,
320
+ pathname,
321
+ logger,
322
+ protocolLabel: "REST",
323
+ });
324
+ throw error;
325
+ }
326
+ },
327
+ ],
328
+ },
329
+ );
330
+
331
+ const { matched, response } = await restHandler.handle(c.req.raw, {
332
+ prefix: "/rest",
333
+ context,
334
+ });
335
+
336
+ if (matched) {
337
+ return c.newResponse(response.body, response);
338
+ }
339
+
340
+ logger.debug(`REST mismatch for: ${c.req.method} ${pathname}`);
341
+ return c.json({ error: "Not Found" }, 404);
342
+ };
343
+ }
344
+
212
345
  /**
213
346
  * Registers the /api/:pluginId/* route with Hono.
214
347
  */
@@ -218,3 +351,13 @@ export function registerApiRoute(
218
351
  ) {
219
352
  rootRouter.all("/api/:pluginId/*", handler);
220
353
  }
354
+
355
+ /**
356
+ * Registers the /rest/:pluginId/* route with Hono.
357
+ */
358
+ export function registerRestRoute(
359
+ rootRouter: Hono,
360
+ handler: ReturnType<typeof createRestRouteHandler>,
361
+ ) {
362
+ rootRouter.all("/rest/:pluginId/*", handler);
363
+ }
@@ -29,7 +29,12 @@ import {
29
29
  } from "../utils/plugin-discovery";
30
30
  import type { InitCallback, PendingInit } from "./types";
31
31
  import { sortPlugins } from "./dependency-sorter";
32
- import { createApiRouteHandler, registerApiRoute } from "./api-router";
32
+ import {
33
+ createApiRouteHandler,
34
+ createRestRouteHandler,
35
+ registerApiRoute,
36
+ registerRestRoute,
37
+ } from "./api-router";
33
38
  import type { ExtensionPointManager } from "./extension-points";
34
39
  import { Router } from "@orpc/server";
35
40
  import { AnyContractRouter } from "@orpc/contract";
@@ -302,7 +307,10 @@ export async function loadPlugins({
302
307
  const sortedIds = sortPlugins({ pendingInits, providedBy, logger });
303
308
  rootLogger.debug(`✅ Initialization Order: ${sortedIds.join(" -> ")}`);
304
309
 
305
- // Register /api/* route BEFORE plugin initialization
310
+ // Register /api/* (oRPC wire format) and /rest/* (REST/OpenAPI shape) routes
311
+ // BEFORE plugin initialization. Both routes share the same plugin RPC routers
312
+ // and per-request context; they differ only in how the handler decodes the
313
+ // request and matches it to a procedure.
306
314
  const apiHandler = createApiRouteHandler({
307
315
  registry: deps.registry,
308
316
  pluginRpcRouters: deps.pluginRpcRouters,
@@ -311,6 +319,13 @@ export async function loadPlugins({
311
319
  });
312
320
  registerApiRoute(rootRouter, apiHandler);
313
321
 
322
+ const restHandler = createRestRouteHandler({
323
+ registry: deps.registry,
324
+ pluginRpcRouters: deps.pluginRpcRouters,
325
+ pluginMetadataRegistry: deps.pluginMetadataRegistry,
326
+ });
327
+ registerRestRoute(rootRouter, restHandler);
328
+
314
329
  // Routes are now registered on the root router. Signal readiness so the
315
330
  // server can stop blocking incoming requests in `waitForInit()`. We open
316
331
  // the gate here (BEFORE Phase 2 / Phase 3) so that:
@@ -37,8 +37,9 @@ export function createScopedWsRegistry(
37
37
  ): WebSocketRouteRegistry {
38
38
  return {
39
39
  register(path: string, handler: WebSocketRouteHandler): void {
40
- // Normalize: "/" maps to just the pluginId, "/foo" maps to "pluginId/foo"
41
- const suffix = path === "/" ? "" : path.replace(/^\//, "/");
40
+ // Normalize: "/" maps to just the pluginId, "/foo" maps to "pluginId/foo".
41
+ // Paths are documented to start with `/` (see WebSocketRouteRegistry).
42
+ const suffix = path === "/" ? "" : path;
42
43
  const fullPath = `${pluginId}${suffix}`;
43
44
  store.registerHandler(fullPath, handler);
44
45
  },