Status snapshot:
| Slice | Status |
|---|---|
RoutePlugin interface + registry scaffold |
✅ shipped |
Migrate every /api/v1/* route group onto the registry (incl. MCP) |
✅ shipped |
External plugin loading from workbench.yaml |
🔭 future |
Today, adding a new resource under /api/v1/* is an N+1 edit across
the runtime: the route module is new, but it has to be wired into
app.ts by hand, every other
green box has to mirror the change, and the web client has to grow a
matching hook + page. The first two costs land on the cross-runtime
contract; this note addresses only the in-runtime half — making
the TypeScript runtime's app.ts a host that mounts a list of route
plugins instead of importing each route file by name.
The failure mode this fixes: app.ts is the single chokepoint for
every new route. Touching it for every feature blurs git blame,
inflates code review for unrelated changes, and pressures contributors
to skip security-relevant middleware ("just one more app.use") to
keep diffs small.
- A new resource module never edits
app.ts. It exports aRoutePluginvalue that the registry mounts. - Existing middleware (auth, rate limiting, body limits, audit, error envelope) continues to apply uniformly. Plugins do not get to opt out.
- Registration is statically composed at startup — not dynamic. We want type-safe wiring and reproducible builds, not a mutable runtime registry.
- The conformance harness keeps working unchanged. Plugins serve the
same
/api/v1/*paths; the cross-runtime contract is unaffected.
- External plugin loading from
workbench.yaml— out of scope for this slice. The interface should not preclude it, but the initial registry only accepts in-tree plugins so we don't ship a third-party code-execution surface by accident. - Cross-runtime plugin model. This is a TypeScript-runtime refactor. Python and Java green boxes choose their own composition story; the only contract that crosses runtimes is the HTTP one.
- Per-tenant plugin sets. Every plugin runs for every workspace that hits the runtime. Tenant-specific feature flags live in workspace records, not in the registry.
// runtimes/typescript/src/plugins/types.ts
export interface RoutePluginContext {
readonly store: ControlPlaneStore;
readonly drivers: VectorStoreDriverRegistry;
readonly embedders: EmbedderFactory;
readonly secrets: SecretResolver;
readonly jobs: JobStore;
readonly chatService: ChatService | null;
readonly chatConfig: ChatConfig | null;
readonly replicaId: string;
}
export interface RoutePlugin {
/** Stable id, snake_case. Used in logs and duplicate-detection. */
readonly id: string;
/** Mount path under the app root. */
readonly mountPath: string;
/** Build a sub-app exposing the plugin's routes. */
build(ctx: RoutePluginContext): OpenAPIHono<AppEnv>;
}A plugin is data — not a class hierarchy. The build function gets a
narrowed view of the runtime's dependencies and returns a Hono
sub-app, which app.ts mounts at mountPath.
// runtimes/typescript/src/plugins/registry.ts
export class RoutePluginRegistry {
register(plugin: RoutePlugin): this;
list(): readonly RoutePlugin[];
}Rules:
registerthrows on duplicateid. Fail fast at startup, never at request time.listreturns plugins in the order they were registered. The registration order is also the mount order, which matters for Hono's route precedence.- The registry is built once during startup in
root.tsand passed tocreateApp. Tests build their own registry with the subset of plugins they need.
createApp keeps wiring the cross-cutting concerns it owns today
(request-id, security headers, rate limiting, body limits, auth
middleware, error handler). After those, it walks the registry and
calls app.route(plugin.mountPath, plugin.build(ctx)) for each
plugin. The OpenAPI generation step is unchanged; the sub-apps
contribute their routes to the same OpenAPIHono instance.
- This PR (scaffold). Land
plugins/types.ts,plugins/registry.ts, tests for the registry. No existing routes move yet;app.tsis untouched. New code can opt in. - First migration (api-keys). Rewrite
apiKeyRoutes(...)to export aRoutePluginand register it fromroot.ts. Replace the matchingapp.route(...)line inapp.tswith the registry walk for just this plugin. Conformance + tests must stay green. - Bulk migration. Move every other
/api/v1/*route group onto the registry. After this,app.tsno longer mentions individual route modules — only the host loop. - Future: external plugins. Add a YAML-loaded plugin list with an explicit allowlist of trusted paths. Out of scope here.
- Sub-app middleware. Some routes today inject their own
middleware (
authMiddlewareover/api/v1/workspaces/*). Should those middleware be expressed as separate "middleware plugins" registered in the same registry, or stay hand-wired inapp.ts? Initial answer: keep them hand-wired. The middleware set is small and security-critical; the plugin system targets the long tail of resource routes, not the security perimeter. - OpenAPI tag conventions. Plugins should default their OpenAPI
tag to their
id. We don't enforce this in the interface; route modules continue to set tags per route. Worth revisiting once we have more than one resource family per plugin. - Hono context typing. All plugins share the runtime's
AppEnvcontext. If a future plugin needs to set its own context variables, we'll need a typed extension mechanism — not a problem yet.