mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45:24 +00:00
feat: serve plugin http and websockets from one mount instead of ports
This commit is contained in:
1 parent
c496a093ca
commit
8431570ff7
102 files changed
+2894
-2308
No files matched your search
@@ -150,6 +150,130 @@ export interface PluginSecrets {
|
||||
) => Promise<string | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Express types, structurally. The SDK does not depend on express: a plugin
|
||||
* brings its own copy, and a nominal type from core's would not match it.
|
||||
*/
|
||||
export interface PluginRequestLike {
|
||||
method: string;
|
||||
url: string;
|
||||
path: string;
|
||||
headers: Record<string, unknown>;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface PluginResponseLike {
|
||||
status: (code: number) => PluginResponseLike;
|
||||
json: (body: unknown) => unknown;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type PluginNextFunction = (error?: unknown) => void;
|
||||
|
||||
export type PluginMiddleware = (
|
||||
req: PluginRequestLike,
|
||||
res: PluginResponseLike,
|
||||
next: PluginNextFunction,
|
||||
) => void;
|
||||
|
||||
export interface PluginRouterOptions {
|
||||
/**
|
||||
* Paths served without authentication, relative to the plugin's mount point
|
||||
* and matched exactly ("/callback", not "/plugin-api/<id>/callback").
|
||||
*
|
||||
* For the handful of routes an unauthenticated third party has to reach: an
|
||||
* OIDC callback, an inbound webhook. Every entry is audited when the router
|
||||
* is registered and every request that uses one is logged, because "this
|
||||
* plugin opened a hole in auth" is exactly the thing an operator should be
|
||||
* able to find later.
|
||||
*/
|
||||
public?: readonly string[];
|
||||
/** Body size limit for JSON and urlencoded bodies. Defaults to "1mb". */
|
||||
bodyLimit?: string;
|
||||
/**
|
||||
* Skips the built-in body parsers entirely, for a router that parses its own
|
||||
* (multer, a raw body for signature checking, a proxied stream).
|
||||
*/
|
||||
rawBody?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* HTTP routes, served at /plugin-api/<id>/ by the main backend server.
|
||||
*
|
||||
* No plugin gets its own port or nginx block. Core runs auth, the actor, the
|
||||
* enabled check, body limits and an error wrapper in front of every route, so
|
||||
* a plugin's router only has to handle its own paths.
|
||||
*/
|
||||
export interface PluginHttp {
|
||||
/**
|
||||
* Returns an Express Router mounted at /plugin-api/<id>/. Call it once in
|
||||
* activate and register routes on the result; it is unmounted automatically
|
||||
* on deactivate.
|
||||
*/
|
||||
router: <T = unknown>(options?: PluginRouterOptions) => T;
|
||||
}
|
||||
|
||||
/** RBAC middleware for a plugin's own routes. */
|
||||
export interface PluginRbac {
|
||||
/**
|
||||
* Middleware that rejects a request whose user lacks `permission`.
|
||||
*
|
||||
* A plugin may only require a permission it declares in
|
||||
* contributes.permissionGroup, so a route cannot gate on admin.users.manage
|
||||
* and borrow someone else's authority.
|
||||
*/
|
||||
require: (permission: string) => PluginMiddleware;
|
||||
}
|
||||
|
||||
export interface PluginWebSocketConnection {
|
||||
/** The authenticated user for this socket. */
|
||||
readonly userId: string;
|
||||
/** The upgrade request, for query parameters and headers. */
|
||||
readonly request: unknown;
|
||||
/** The ws WebSocket. Binary frames and backpressure work as usual. */
|
||||
readonly socket: unknown;
|
||||
}
|
||||
|
||||
export type PluginWebSocketHandler = (
|
||||
connection: PluginWebSocketConnection,
|
||||
) => void | Promise<void>;
|
||||
|
||||
export interface PluginWebSocketOptions {
|
||||
/**
|
||||
* Serves the upgrade without core authentication. The handler gets an empty
|
||||
* userId and must authenticate the socket itself, which is what a share-link
|
||||
* guest or a token-in-query protocol needs.
|
||||
*/
|
||||
public?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* WebSocket routes, served at /plugin-ws/<id>/<path> through the main server's
|
||||
* upgrade event. Every socket a plugin opened is closed on deactivate.
|
||||
*/
|
||||
export interface PluginWebSockets {
|
||||
route: (
|
||||
path: string,
|
||||
handler: PluginWebSocketHandler,
|
||||
options?: PluginWebSocketOptions,
|
||||
) => void;
|
||||
/**
|
||||
* Hands the raw upgrade to the plugin, for a library that insists on owning
|
||||
* its own WebSocket server (guacamole-lite attaches this way). Auth and the
|
||||
* actor still run first.
|
||||
*/
|
||||
upgrade: (
|
||||
path: string,
|
||||
handler: (
|
||||
request: unknown,
|
||||
socket: unknown,
|
||||
head: unknown,
|
||||
userId: string,
|
||||
) => void,
|
||||
options?: PluginWebSocketOptions,
|
||||
) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Anything the runtime should undo when the plugin deactivates. Everything
|
||||
* registered through ctx is tracked automatically; this is for resources a
|
||||
@@ -170,6 +294,9 @@ export interface PluginContext {
|
||||
readonly registry: PluginRegistry;
|
||||
readonly services: PluginServices;
|
||||
readonly secrets: PluginSecrets;
|
||||
readonly http: PluginHttp;
|
||||
readonly ws: PluginWebSockets;
|
||||
readonly rbac: PluginRbac;
|
||||
readonly disposables: PluginDisposables;
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,7 @@ import type {
|
||||
import type { TermixApp } from "./frontend.js";
|
||||
import type { PluginManifest } from "./manifest.js";
|
||||
import type { PluginTableDefinition } from "./db.js";
|
||||
import type { PluginMiddleware, PluginRouterOptions } from "./backend.js";
|
||||
import type { SyncEntityRegistration } from "./backend.js";
|
||||
|
||||
export interface FakeContextOptions {
|
||||
@@ -38,6 +39,10 @@ export interface FakePluginContext {
|
||||
tables: PluginTableDefinition[];
|
||||
/** Sync entities registered through ctx.sync.registerEntity, in order. */
|
||||
syncEntities: SyncEntityRegistration[];
|
||||
/** WebSocket routes registered through ctx.ws, in order. */
|
||||
wsRoutes: Array<{ path: string; raw: boolean }>;
|
||||
/** Router options passed to ctx.http.router, in order. */
|
||||
httpRouters: Array<PluginRouterOptions | undefined>;
|
||||
}
|
||||
|
||||
function noopLogger(): PluginLogger {
|
||||
@@ -63,6 +68,8 @@ export function createFakeContext(
|
||||
const kv = new Map<string, unknown>();
|
||||
const tables: PluginTableDefinition[] = [];
|
||||
const syncEntities: SyncEntityRegistration[] = [];
|
||||
const wsRoutes: Array<{ path: string; raw: boolean }> = [];
|
||||
const httpRouters: Array<PluginRouterOptions | undefined> = [];
|
||||
const listeners = new Map<string, Set<(payload: unknown) => void>>();
|
||||
let actor = options.actor;
|
||||
|
||||
@@ -153,6 +160,30 @@ export function createFakeContext(
|
||||
getShared: async () => null,
|
||||
},
|
||||
|
||||
http: {
|
||||
// No express here: a test that needs a real router should mount the
|
||||
// plugin's own route module against its own express app.
|
||||
router: (routerOptions) => {
|
||||
httpRouters.push(routerOptions);
|
||||
return undefined as never;
|
||||
},
|
||||
},
|
||||
|
||||
ws: {
|
||||
route: (path) => {
|
||||
wsRoutes.push({ path, raw: false });
|
||||
},
|
||||
upgrade: (path) => {
|
||||
wsRoutes.push({ path, raw: true });
|
||||
},
|
||||
},
|
||||
|
||||
rbac: {
|
||||
// Passes everything through: a test asserting a permission gate should
|
||||
// drive PermissionManager, not this double.
|
||||
require: () => ((_req, _res, next) => next()) as PluginMiddleware,
|
||||
},
|
||||
|
||||
disposables,
|
||||
|
||||
asUser: async (userId, fn) => {
|
||||
@@ -168,7 +199,16 @@ export function createFakeContext(
|
||||
currentActor: () => actor,
|
||||
};
|
||||
|
||||
return { ctx, disposals, emitted, kv, tables, syncEntities };
|
||||
return {
|
||||
ctx,
|
||||
disposals,
|
||||
emitted,
|
||||
kv,
|
||||
tables,
|
||||
syncEntities,
|
||||
wsRoutes,
|
||||
httpRouters,
|
||||
};
|
||||
}
|
||||
|
||||
export interface MockContextOptions {
|
||||
@@ -285,6 +325,24 @@ export function createMockCtx(
|
||||
},
|
||||
on: (topic, listener) => ctx.events.on(topic, listener),
|
||||
},
|
||||
|
||||
http: {
|
||||
router: (routerOptions) => {
|
||||
require("network:serve");
|
||||
return ctx.http.router(routerOptions);
|
||||
},
|
||||
},
|
||||
|
||||
ws: {
|
||||
route: (path, handler, wsOptions) => {
|
||||
require("network:serve");
|
||||
ctx.ws.route(path, handler, wsOptions);
|
||||
},
|
||||
upgrade: (path, handler, wsOptions) => {
|
||||
require("network:serve");
|
||||
ctx.ws.upgrade(path, handler, wsOptions);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return { ...base, ctx: gatedCtx, settings, checked };
|
||||
|
||||
Reference in new issue
Block a user