feat: plugins v1
This commit is contained in:
@@ -10,6 +10,10 @@ export interface LinkPreviewConfig {
|
||||
maxCacheSizeMb: number;
|
||||
}
|
||||
|
||||
export interface OpenApiDocsConfig {
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface ServerVariablesConfig {
|
||||
klipyApiKey: string;
|
||||
rawgApiKey: string;
|
||||
@@ -18,6 +22,7 @@ export interface ServerVariablesConfig {
|
||||
serverProtocol: ServerHttpProtocol;
|
||||
serverHost: string;
|
||||
linkPreview: LinkPreviewConfig;
|
||||
openApiDocs: OpenApiDocsConfig;
|
||||
}
|
||||
|
||||
const DATA_DIR = resolveRuntimePath('data');
|
||||
@@ -102,6 +107,14 @@ function normalizeLinkPreviewConfig(value: unknown): LinkPreviewConfig {
|
||||
return { enabled, cacheTtlMinutes: cacheTtl, maxCacheSizeMb: maxSize };
|
||||
}
|
||||
|
||||
function normalizeOpenApiDocsConfig(value: unknown): OpenApiDocsConfig {
|
||||
const raw = (value && typeof value === 'object' && !Array.isArray(value))
|
||||
? value as Record<string, unknown>
|
||||
: {};
|
||||
|
||||
return { enabled: raw.enabled === true };
|
||||
}
|
||||
|
||||
function hasEnvironmentOverride(value: string | undefined): value is string {
|
||||
return typeof value === 'string' && value.trim().length > 0;
|
||||
}
|
||||
@@ -149,7 +162,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
serverPort: normalizeServerPort(remainingParsed.serverPort),
|
||||
serverProtocol: normalizeServerProtocol(remainingParsed.serverProtocol),
|
||||
serverHost: normalizeServerHost(remainingParsed.serverHost ?? legacyServerIpAddress),
|
||||
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview)
|
||||
linkPreview: normalizeLinkPreviewConfig(remainingParsed.linkPreview),
|
||||
openApiDocs: normalizeOpenApiDocsConfig(remainingParsed.openApiDocs)
|
||||
};
|
||||
const nextContents = JSON.stringify(normalized, null, 2) + '\n';
|
||||
|
||||
@@ -164,7 +178,8 @@ export function ensureVariablesConfig(): ServerVariablesConfig {
|
||||
serverPort: normalized.serverPort,
|
||||
serverProtocol: normalized.serverProtocol,
|
||||
serverHost: normalized.serverHost,
|
||||
linkPreview: normalized.linkPreview
|
||||
linkPreview: normalized.linkPreview,
|
||||
openApiDocs: normalized.openApiDocs
|
||||
};
|
||||
}
|
||||
|
||||
@@ -218,6 +233,31 @@ export function isHttpsServerEnabled(): boolean {
|
||||
return getServerProtocol() === 'https';
|
||||
}
|
||||
|
||||
export function areOpenApiDocsEnabled(): boolean {
|
||||
if (hasEnvironmentOverride(process.env.OPENAPI_DOCS_ENABLED)) {
|
||||
return process.env.OPENAPI_DOCS_ENABLED.trim().toLowerCase() === 'true';
|
||||
}
|
||||
|
||||
return getVariablesConfig().openApiDocs.enabled;
|
||||
}
|
||||
|
||||
export function setOpenApiDocsEnabled(enabled: boolean): OpenApiDocsConfig {
|
||||
if (!fs.existsSync(DATA_DIR)) {
|
||||
fs.mkdirSync(DATA_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
const { parsed } = readRawVariables();
|
||||
const next = {
|
||||
...parsed,
|
||||
openApiDocs: { enabled }
|
||||
};
|
||||
|
||||
fs.writeFileSync(VARIABLES_FILE, JSON.stringify(next, null, 2) + '\n', 'utf8');
|
||||
ensureVariablesConfig();
|
||||
|
||||
return { enabled };
|
||||
}
|
||||
|
||||
export function getLinkPreviewConfig(): LinkPreviewConfig {
|
||||
return getVariablesConfig().linkPreview;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,12 @@ import {
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity,
|
||||
GameMatchMissEntity
|
||||
GameMatchMissEntity,
|
||||
ServerPluginRequirementEntity,
|
||||
ServerPluginEventDefinitionEntity,
|
||||
PluginDataEntity,
|
||||
ServerPluginSettingsEntity,
|
||||
PluginUserMetadataEntity
|
||||
} from '../entities';
|
||||
import { serverMigrations } from '../migrations';
|
||||
import {
|
||||
@@ -49,8 +54,18 @@ const DB_BACKUP = DB_FILE + '.bak';
|
||||
const DATA_DIR = path.dirname(DB_FILE);
|
||||
// SQLite files start with this 16-byte header string.
|
||||
const SQLITE_MAGIC = 'SQLite format 3\0';
|
||||
const SAVE_RETRY_DELAYS_MS = [25, 75, 150, 300, 600];
|
||||
const RETRYABLE_SAVE_ERROR_CODES = new Set(['EPERM', 'EACCES', 'EBUSY']);
|
||||
const SAVE_RETRY_DELAYS_MS = [
|
||||
25,
|
||||
75,
|
||||
150,
|
||||
300,
|
||||
600
|
||||
];
|
||||
const RETRYABLE_SAVE_ERROR_CODES = new Set([
|
||||
'EPERM',
|
||||
'EACCES',
|
||||
'EBUSY'
|
||||
]);
|
||||
|
||||
let applicationDataSource: DataSource | undefined;
|
||||
let saveQueue: Promise<void> = Promise.resolve();
|
||||
@@ -250,7 +265,12 @@ export async function initDatabase(): Promise<void> {
|
||||
ServerMembershipEntity,
|
||||
ServerInviteEntity,
|
||||
ServerBanEntity,
|
||||
GameMatchMissEntity
|
||||
GameMatchMissEntity,
|
||||
ServerPluginRequirementEntity,
|
||||
ServerPluginEventDefinitionEntity,
|
||||
PluginDataEntity,
|
||||
ServerPluginSettingsEntity,
|
||||
PluginUserMetadataEntity
|
||||
],
|
||||
migrations: serverMigrations,
|
||||
synchronize: process.env.DB_SYNCHRONIZE === 'true',
|
||||
|
||||
35
server/src/entities/PluginDataEntity.ts
Normal file
35
server/src/entities/PluginDataEntity.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('plugin_data')
|
||||
export class PluginDataEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
pluginId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
scope!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
ownerId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
key!: string;
|
||||
|
||||
@Column('text')
|
||||
valueJson!: string;
|
||||
|
||||
@Column('integer', { default: 1 })
|
||||
schemaVersion!: number;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
updatedBy!: string | null;
|
||||
|
||||
@Column('integer')
|
||||
updatedAt!: number;
|
||||
}
|
||||
38
server/src/entities/PluginUserMetadataEntity.ts
Normal file
38
server/src/entities/PluginUserMetadataEntity.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('plugin_user_metadata')
|
||||
export class PluginUserMetadataEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
pluginId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
pluginUserId!: string;
|
||||
|
||||
@Column('text')
|
||||
displayName!: string;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
avatarHash!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
avatarMime!: string | null;
|
||||
|
||||
@Column('integer', { nullable: true })
|
||||
avatarUpdatedAt!: number | null;
|
||||
|
||||
@Column('text')
|
||||
roleIdsJson!: string;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
|
||||
@Column('integer')
|
||||
updatedAt!: number;
|
||||
}
|
||||
41
server/src/entities/ServerPluginEventDefinitionEntity.ts
Normal file
41
server/src/entities/ServerPluginEventDefinitionEntity.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
export type ServerPluginEventDirection = 'clientToServer' | 'serverRelay' | 'p2pHint';
|
||||
export type ServerPluginEventScope = 'server' | 'channel' | 'user' | 'plugin';
|
||||
|
||||
@Entity('server_plugin_event_definitions')
|
||||
export class ServerPluginEventDefinitionEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
pluginId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
eventName!: string;
|
||||
|
||||
@Column('text')
|
||||
direction!: ServerPluginEventDirection;
|
||||
|
||||
@Column('text')
|
||||
scope!: ServerPluginEventScope;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
schemaJson!: string | null;
|
||||
|
||||
@Column('integer')
|
||||
maxPayloadBytes!: number;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
rateLimitJson!: string | null;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
|
||||
@Column('integer')
|
||||
updatedAt!: number;
|
||||
}
|
||||
36
server/src/entities/ServerPluginRequirementEntity.ts
Normal file
36
server/src/entities/ServerPluginRequirementEntity.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
Index,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
export type ServerPluginRequirementStatus = 'required' | 'optional' | 'recommended' | 'blocked' | 'incompatible';
|
||||
|
||||
@Entity('server_plugin_requirements')
|
||||
export class ServerPluginRequirementEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
pluginId!: string;
|
||||
|
||||
@Index()
|
||||
@Column('text')
|
||||
status!: ServerPluginRequirementStatus;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
versionRange!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
reason!: string | null;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
configuredBy!: string | null;
|
||||
|
||||
@Column('integer')
|
||||
createdAt!: number;
|
||||
|
||||
@Column('integer')
|
||||
updatedAt!: number;
|
||||
}
|
||||
26
server/src/entities/ServerPluginSettingsEntity.ts
Normal file
26
server/src/entities/ServerPluginSettingsEntity.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
PrimaryColumn
|
||||
} from 'typeorm';
|
||||
|
||||
@Entity('server_plugin_settings')
|
||||
export class ServerPluginSettingsEntity {
|
||||
@PrimaryColumn('text')
|
||||
serverId!: string;
|
||||
|
||||
@PrimaryColumn('text')
|
||||
pluginId!: string;
|
||||
|
||||
@Column('text')
|
||||
settingsJson!: string;
|
||||
|
||||
@Column('integer', { default: 1 })
|
||||
schemaVersion!: number;
|
||||
|
||||
@Column('text', { nullable: true })
|
||||
updatedBy!: string | null;
|
||||
|
||||
@Column('integer')
|
||||
updatedAt!: number;
|
||||
}
|
||||
@@ -10,3 +10,10 @@ export { ServerMembershipEntity } from './ServerMembershipEntity';
|
||||
export { ServerInviteEntity } from './ServerInviteEntity';
|
||||
export { ServerBanEntity } from './ServerBanEntity';
|
||||
export { GameMatchMissEntity } from './GameMatchMissEntity';
|
||||
export { ServerPluginRequirementEntity } from './ServerPluginRequirementEntity';
|
||||
export type { ServerPluginRequirementStatus } from './ServerPluginRequirementEntity';
|
||||
export { ServerPluginEventDefinitionEntity } from './ServerPluginEventDefinitionEntity';
|
||||
export type { ServerPluginEventDirection, ServerPluginEventScope } from './ServerPluginEventDefinitionEntity';
|
||||
export { PluginDataEntity } from './PluginDataEntity';
|
||||
export { ServerPluginSettingsEntity } from './ServerPluginSettingsEntity';
|
||||
export { PluginUserMetadataEntity } from './PluginUserMetadataEntity';
|
||||
|
||||
92
server/src/migrations/1000000000007-PluginSupport.ts
Normal file
92
server/src/migrations/1000000000007-PluginSupport.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
export class PluginSupport1000000000007 implements MigrationInterface {
|
||||
name = 'PluginSupport1000000000007';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_plugin_requirements" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"pluginId" TEXT NOT NULL,
|
||||
"status" TEXT NOT NULL,
|
||||
"versionRange" TEXT,
|
||||
"reason" TEXT,
|
||||
"configuredBy" TEXT,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"updatedAt" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("serverId", "pluginId")
|
||||
)
|
||||
`);
|
||||
await queryRunner.query(`
|
||||
CREATE INDEX IF NOT EXISTS "idx_server_plugin_requirements_status"
|
||||
ON "server_plugin_requirements" ("status")
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_plugin_event_definitions" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"pluginId" TEXT NOT NULL,
|
||||
"eventName" TEXT NOT NULL,
|
||||
"direction" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL,
|
||||
"schemaJson" TEXT,
|
||||
"maxPayloadBytes" INTEGER NOT NULL,
|
||||
"rateLimitJson" TEXT,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"updatedAt" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("serverId", "pluginId", "eventName")
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "plugin_data" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"pluginId" TEXT NOT NULL,
|
||||
"scope" TEXT NOT NULL,
|
||||
"ownerId" TEXT NOT NULL,
|
||||
"key" TEXT NOT NULL,
|
||||
"valueJson" TEXT NOT NULL,
|
||||
"schemaVersion" INTEGER NOT NULL DEFAULT 1,
|
||||
"updatedBy" TEXT,
|
||||
"updatedAt" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("serverId", "pluginId", "scope", "ownerId", "key")
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "server_plugin_settings" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"pluginId" TEXT NOT NULL,
|
||||
"settingsJson" TEXT NOT NULL,
|
||||
"schemaVersion" INTEGER NOT NULL DEFAULT 1,
|
||||
"updatedBy" TEXT,
|
||||
"updatedAt" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("serverId", "pluginId")
|
||||
)
|
||||
`);
|
||||
|
||||
await queryRunner.query(`
|
||||
CREATE TABLE IF NOT EXISTS "plugin_user_metadata" (
|
||||
"serverId" TEXT NOT NULL,
|
||||
"pluginId" TEXT NOT NULL,
|
||||
"pluginUserId" TEXT NOT NULL,
|
||||
"displayName" TEXT NOT NULL,
|
||||
"avatarHash" TEXT,
|
||||
"avatarMime" TEXT,
|
||||
"avatarUpdatedAt" INTEGER,
|
||||
"roleIdsJson" TEXT NOT NULL,
|
||||
"createdAt" INTEGER NOT NULL,
|
||||
"updatedAt" INTEGER NOT NULL,
|
||||
PRIMARY KEY ("serverId", "pluginId", "pluginUserId")
|
||||
)
|
||||
`);
|
||||
}
|
||||
|
||||
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "plugin_user_metadata"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_plugin_settings"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "plugin_data"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_plugin_event_definitions"`);
|
||||
await queryRunner.query(`DROP TABLE IF EXISTS "server_plugin_requirements"`);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { RepairLegacyVoiceChannels1000000000003 } from './1000000000003-RepairLe
|
||||
import { NormalizeServerArrays1000000000004 } from './1000000000004-NormalizeServerArrays';
|
||||
import { ServerRoleAccessControl1000000000005 } from './1000000000005-ServerRoleAccessControl';
|
||||
import { GameMatchMisses1000000000006 } from './1000000000006-GameMatchMisses';
|
||||
import { PluginSupport1000000000007 } from './1000000000007-PluginSupport';
|
||||
|
||||
export const serverMigrations = [
|
||||
InitialSchema1000000000000,
|
||||
@@ -13,5 +14,6 @@ export const serverMigrations = [
|
||||
RepairLegacyVoiceChannels1000000000003,
|
||||
NormalizeServerArrays1000000000004,
|
||||
ServerRoleAccessControl1000000000005,
|
||||
GameMatchMisses1000000000006
|
||||
GameMatchMisses1000000000006,
|
||||
PluginSupport1000000000007
|
||||
];
|
||||
|
||||
@@ -6,6 +6,8 @@ import gamesRouter from './games';
|
||||
import proxyRouter from './proxy';
|
||||
import usersRouter from './users';
|
||||
import serversRouter from './servers';
|
||||
import pluginSupportRouter from './plugin-support';
|
||||
import openApiDocsRouter from './openapi-docs';
|
||||
import joinRequestsRouter from './join-requests';
|
||||
import { invitesApiRouter, invitePageRouter } from './invites';
|
||||
|
||||
@@ -16,6 +18,8 @@ export function registerRoutes(app: Express): void {
|
||||
app.use('/api/games', gamesRouter);
|
||||
app.use('/api', proxyRouter);
|
||||
app.use('/api/users', usersRouter);
|
||||
app.use('/api', openApiDocsRouter);
|
||||
app.use('/api/servers', pluginSupportRouter);
|
||||
app.use('/api/servers', serversRouter);
|
||||
app.use('/api/invites', invitesApiRouter);
|
||||
app.use('/api/requests', joinRequestsRouter);
|
||||
|
||||
106
server/src/routes/openapi-docs.ts
Normal file
106
server/src/routes/openapi-docs.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { Router } from 'express';
|
||||
import { areOpenApiDocsEnabled, setOpenApiDocsEnabled } from '../config/variables';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function createOpenApiDocument(baseUrl: string) {
|
||||
return {
|
||||
openapi: '3.1.0',
|
||||
info: {
|
||||
title: 'MetoYou Plugin Support API',
|
||||
version: '1.0.0',
|
||||
description: 'Official HTTP endpoints for plugin metadata, event definitions, and plugin data. '
|
||||
+ 'Plugin code is never executed by the signal server.'
|
||||
},
|
||||
servers: [{ url: `${baseUrl}/api` }],
|
||||
paths: {
|
||||
'/servers/{serverId}/plugins': {
|
||||
get: {
|
||||
summary: 'Read plugin requirement snapshot',
|
||||
parameters: [{ name: 'serverId', in: 'path', required: true, schema: { type: 'string' } }],
|
||||
responses: { '200': { description: 'Plugin requirements and event definitions' } }
|
||||
}
|
||||
},
|
||||
'/servers/{serverId}/plugins/{pluginId}/requirement': {
|
||||
put: {
|
||||
summary: 'Create or update a server plugin requirement',
|
||||
responses: { '200': { description: 'Requirement saved' }, '403': { description: 'Not authorized' } }
|
||||
},
|
||||
delete: {
|
||||
summary: 'Delete a server plugin requirement',
|
||||
responses: { '200': { description: 'Requirement deleted' }, '403': { description: 'Not authorized' } }
|
||||
}
|
||||
},
|
||||
'/servers/{serverId}/plugins/{pluginId}/events/{eventName}': {
|
||||
put: {
|
||||
summary: 'Create or update a plugin event definition',
|
||||
responses: { '200': { description: 'Event definition saved' }, '403': { description: 'Not authorized' } }
|
||||
},
|
||||
delete: {
|
||||
summary: 'Delete a plugin event definition',
|
||||
responses: { '200': { description: 'Event definition deleted' }, '403': { description: 'Not authorized' } }
|
||||
}
|
||||
},
|
||||
'/servers/{serverId}/plugins/{pluginId}/data': {
|
||||
get: {
|
||||
summary: 'List plugin data records',
|
||||
responses: { '200': { description: 'Plugin data records' }, '403': { description: 'Not a server member' } }
|
||||
}
|
||||
},
|
||||
'/servers/{serverId}/plugins/{pluginId}/data/{key}': {
|
||||
put: {
|
||||
summary: 'Write plugin data',
|
||||
responses: { '200': { description: 'Plugin data saved' }, '403': { description: 'Not a server member' } }
|
||||
},
|
||||
delete: {
|
||||
summary: 'Delete plugin data',
|
||||
responses: { '200': { description: 'Plugin data deleted' }, '403': { description: 'Not a server member' } }
|
||||
}
|
||||
},
|
||||
'/openapi/settings': {
|
||||
get: { summary: 'Read OpenAPI docs setting', responses: { '200': { description: 'Setting value' } } },
|
||||
put: { summary: 'Toggle OpenAPI docs exposure', responses: { '200': { description: 'Setting value' } } }
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function docsDisabledResponse() {
|
||||
return { error: 'OpenAPI docs are disabled', errorCode: 'OPENAPI_DOCS_DISABLED' };
|
||||
}
|
||||
|
||||
router.get('/openapi/settings', (_req, res) => {
|
||||
res.json({ enabled: areOpenApiDocsEnabled() });
|
||||
});
|
||||
|
||||
router.put('/openapi/settings', (req, res) => {
|
||||
res.json(setOpenApiDocsEnabled(req.body?.enabled === true));
|
||||
});
|
||||
|
||||
router.get('/openapi.json', (req, res) => {
|
||||
if (!areOpenApiDocsEnabled()) {
|
||||
res.status(404).json(docsDisabledResponse());
|
||||
return;
|
||||
}
|
||||
|
||||
res.json(createOpenApiDocument(`${req.protocol}://${req.get('host') ?? 'localhost'}`));
|
||||
});
|
||||
|
||||
router.get('/docs', (_req, res) => {
|
||||
if (!areOpenApiDocsEnabled()) {
|
||||
res.status(404).json(docsDisabledResponse());
|
||||
return;
|
||||
}
|
||||
|
||||
res.type('html').send(`<!doctype html>
|
||||
<html lang="en">
|
||||
<head><meta charset="utf-8"><title>MetoYou Plugin API Docs</title></head>
|
||||
<body style="font-family:system-ui;margin:2rem;line-height:1.5">
|
||||
<h1>MetoYou Plugin Support API</h1>
|
||||
<p>Plugin support endpoints are available at <a href="/api/openapi.json">/api/openapi.json</a>.</p>
|
||||
<p>The signal server stores metadata, data, and event definitions only. It never executes plugin code.</p>
|
||||
</body>
|
||||
</html>`);
|
||||
});
|
||||
|
||||
export default router;
|
||||
208
server/src/routes/plugin-support.ts
Normal file
208
server/src/routes/plugin-support.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { Response, Router } from 'express';
|
||||
import {
|
||||
deletePluginData,
|
||||
deletePluginEventDefinition,
|
||||
deletePluginRequirement,
|
||||
getPluginRequirementsSnapshot,
|
||||
listPluginData,
|
||||
PluginSupportError,
|
||||
upsertPluginData,
|
||||
upsertPluginEventDefinition,
|
||||
upsertPluginRequirement
|
||||
} from '../services/plugin-support.service';
|
||||
import { broadcastToServer } from '../websocket/broadcast';
|
||||
|
||||
const router = Router();
|
||||
|
||||
function sendPluginSupportError(error: unknown, res: Response): void {
|
||||
if (error instanceof PluginSupportError) {
|
||||
res.status(error.status).json({ error: error.message, errorCode: error.code });
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Unhandled plugin support error:', error);
|
||||
res.status(500).json({ error: 'Internal server error', errorCode: 'INTERNAL_ERROR' });
|
||||
}
|
||||
|
||||
function readActorUserId(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
}
|
||||
|
||||
async function broadcastRequirementsSnapshot(serverId: string): Promise<void> {
|
||||
const snapshot = await getPluginRequirementsSnapshot(serverId);
|
||||
|
||||
broadcastToServer(serverId, {
|
||||
type: 'plugin_requirements_changed',
|
||||
serverId,
|
||||
snapshot
|
||||
});
|
||||
}
|
||||
|
||||
router.get('/:serverId/plugins', async (req, res) => {
|
||||
try {
|
||||
res.json(await getPluginRequirementsSnapshot(req.params.serverId));
|
||||
} catch (error) {
|
||||
sendPluginSupportError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
|
||||
const { serverId, pluginId } = req.params;
|
||||
|
||||
try {
|
||||
const requirement = await upsertPluginRequirement({
|
||||
actorUserId: readActorUserId(req.body.actorUserId),
|
||||
pluginId,
|
||||
reason: req.body.reason,
|
||||
serverId,
|
||||
status: req.body.status,
|
||||
versionRange: req.body.versionRange
|
||||
});
|
||||
|
||||
await broadcastRequirementsSnapshot(serverId);
|
||||
res.json({ requirement });
|
||||
} catch (error) {
|
||||
sendPluginSupportError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:serverId/plugins/:pluginId/requirement', async (req, res) => {
|
||||
const { serverId, pluginId } = req.params;
|
||||
|
||||
try {
|
||||
await deletePluginRequirement({
|
||||
actorUserId: readActorUserId(req.body.actorUserId),
|
||||
pluginId,
|
||||
serverId
|
||||
});
|
||||
|
||||
await broadcastRequirementsSnapshot(serverId);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
sendPluginSupportError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => {
|
||||
const { serverId, pluginId, eventName } = req.params;
|
||||
|
||||
try {
|
||||
const eventDefinition = await upsertPluginEventDefinition({
|
||||
actorUserId: readActorUserId(req.body.actorUserId),
|
||||
direction: req.body.direction,
|
||||
eventName,
|
||||
maxPayloadBytes: req.body.maxPayloadBytes,
|
||||
pluginId,
|
||||
rateLimitJson: req.body.rateLimitJson,
|
||||
schemaJson: req.body.schemaJson,
|
||||
scope: req.body.scope,
|
||||
serverId
|
||||
});
|
||||
|
||||
await broadcastRequirementsSnapshot(serverId);
|
||||
res.json({ eventDefinition });
|
||||
} catch (error) {
|
||||
sendPluginSupportError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:serverId/plugins/:pluginId/events/:eventName', async (req, res) => {
|
||||
const { serverId, pluginId, eventName } = req.params;
|
||||
|
||||
try {
|
||||
await deletePluginEventDefinition({
|
||||
actorUserId: readActorUserId(req.body.actorUserId),
|
||||
eventName,
|
||||
pluginId,
|
||||
serverId
|
||||
});
|
||||
|
||||
await broadcastRequirementsSnapshot(serverId);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
sendPluginSupportError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:serverId/plugins/:pluginId/data', async (req, res) => {
|
||||
const { serverId, pluginId } = req.params;
|
||||
|
||||
try {
|
||||
const records = await listPluginData({
|
||||
actorUserId: readActorUserId(req.query.userId),
|
||||
key: req.query.key,
|
||||
ownerId: req.query.ownerId,
|
||||
pluginId,
|
||||
scope: req.query.scope,
|
||||
serverId
|
||||
});
|
||||
|
||||
res.json({ records });
|
||||
} catch (error) {
|
||||
sendPluginSupportError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:serverId/plugins/:pluginId/data/:key', async (req, res) => {
|
||||
const { serverId, pluginId, key } = req.params;
|
||||
|
||||
try {
|
||||
const record = await upsertPluginData({
|
||||
actorUserId: readActorUserId(req.body.actorUserId),
|
||||
key,
|
||||
ownerId: req.body.ownerId,
|
||||
pluginId,
|
||||
schemaVersion: req.body.schemaVersion,
|
||||
scope: req.body.scope,
|
||||
serverId,
|
||||
value: req.body.value
|
||||
});
|
||||
|
||||
broadcastToServer(serverId, {
|
||||
type: 'plugin_data_changed',
|
||||
serverId,
|
||||
pluginId: record.pluginId,
|
||||
scope: record.scope,
|
||||
ownerId: record.ownerId,
|
||||
key: record.key,
|
||||
updatedAt: record.updatedAt
|
||||
});
|
||||
|
||||
res.json({ record });
|
||||
} catch (error) {
|
||||
sendPluginSupportError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:serverId/plugins/:pluginId/data/:key', async (req, res) => {
|
||||
const { serverId, pluginId, key } = req.params;
|
||||
const scope = req.body.scope ?? req.query.scope;
|
||||
const ownerId = req.body.ownerId ?? req.query.ownerId;
|
||||
|
||||
try {
|
||||
await deletePluginData({
|
||||
actorUserId: readActorUserId(req.body.actorUserId),
|
||||
key,
|
||||
ownerId,
|
||||
pluginId,
|
||||
scope,
|
||||
serverId
|
||||
});
|
||||
|
||||
broadcastToServer(serverId, {
|
||||
type: 'plugin_data_changed',
|
||||
serverId,
|
||||
pluginId,
|
||||
scope: typeof scope === 'string' ? scope : 'server',
|
||||
ownerId: typeof ownerId === 'string' && ownerId.trim() ? ownerId.trim() : undefined,
|
||||
key,
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
sendPluginSupportError(error, res);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
523
server/src/services/plugin-support.service.ts
Normal file
523
server/src/services/plugin-support.service.ts
Normal file
@@ -0,0 +1,523 @@
|
||||
import { getServerById } from '../cqrs';
|
||||
import { getDataSource } from '../db/database';
|
||||
import {
|
||||
PluginDataEntity,
|
||||
ServerPluginEventDefinitionEntity,
|
||||
ServerPluginEventDirection,
|
||||
ServerPluginEventScope,
|
||||
ServerPluginRequirementEntity,
|
||||
ServerPluginRequirementStatus
|
||||
} from '../entities';
|
||||
import { findServerMembership } from './server-access.service';
|
||||
import { resolveServerPermission } from './server-permissions.service';
|
||||
|
||||
export const DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES = 64 * 1024;
|
||||
|
||||
const VALID_REQUIREMENT_STATUSES = new Set<ServerPluginRequirementStatus>([
|
||||
'required',
|
||||
'optional',
|
||||
'recommended',
|
||||
'blocked',
|
||||
'incompatible'
|
||||
]);
|
||||
const VALID_EVENT_DIRECTIONS = new Set<ServerPluginEventDirection>([
|
||||
'clientToServer',
|
||||
'serverRelay',
|
||||
'p2pHint'
|
||||
]);
|
||||
const VALID_EVENT_SCOPES = new Set<ServerPluginEventScope>([
|
||||
'server',
|
||||
'channel',
|
||||
'user',
|
||||
'plugin'
|
||||
]);
|
||||
const PLUGIN_ID_PATTERN = /^[a-z0-9][a-z0-9.-]{1,126}[a-z0-9]$/;
|
||||
const EVENT_NAME_PATTERN = /^[a-z][a-z0-9.:-]{1,126}[a-z0-9]$/;
|
||||
const DATA_KEY_PATTERN = /^[a-zA-Z0-9][a-zA-Z0-9._:-]{0,127}$/;
|
||||
const DATA_SCOPE_PATTERN = /^[a-zA-Z][a-zA-Z0-9._:-]{0,63}$/;
|
||||
|
||||
export interface PluginRequirementSummary {
|
||||
pluginId: string;
|
||||
reason?: string;
|
||||
status: ServerPluginRequirementStatus;
|
||||
updatedAt: number;
|
||||
versionRange?: string;
|
||||
}
|
||||
|
||||
export interface PluginEventDefinitionSummary {
|
||||
direction: ServerPluginEventDirection;
|
||||
eventName: string;
|
||||
maxPayloadBytes: number;
|
||||
pluginId: string;
|
||||
scope: ServerPluginEventScope;
|
||||
schemaJson?: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface PluginRequirementsSnapshot {
|
||||
eventDefinitions: PluginEventDefinitionSummary[];
|
||||
requirements: PluginRequirementSummary[];
|
||||
serverId: string;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export interface PluginDataRecord {
|
||||
key: string;
|
||||
ownerId?: string;
|
||||
pluginId: string;
|
||||
schemaVersion: number;
|
||||
scope: string;
|
||||
serverId: string;
|
||||
updatedAt: number;
|
||||
updatedBy?: string;
|
||||
value: unknown;
|
||||
}
|
||||
|
||||
export interface PluginEventEnvelope {
|
||||
eventId?: string;
|
||||
eventName: string;
|
||||
payload: unknown;
|
||||
pluginId: string;
|
||||
serverId: string;
|
||||
sourcePluginUserId?: string;
|
||||
type: 'plugin_event';
|
||||
}
|
||||
|
||||
export class PluginSupportError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PluginSupportError';
|
||||
}
|
||||
}
|
||||
|
||||
function requirementRepository() {
|
||||
return getDataSource().getRepository(ServerPluginRequirementEntity);
|
||||
}
|
||||
|
||||
function eventDefinitionRepository() {
|
||||
return getDataSource().getRepository(ServerPluginEventDefinitionEntity);
|
||||
}
|
||||
|
||||
function pluginDataRepository() {
|
||||
return getDataSource().getRepository(PluginDataEntity);
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown, maxLength: number): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = value.trim();
|
||||
|
||||
return normalized ? normalized.slice(0, maxLength) : null;
|
||||
}
|
||||
|
||||
function assertPattern(value: string, pattern: RegExp, code: string, label: string): void {
|
||||
if (!pattern.test(value)) {
|
||||
throw new PluginSupportError(400, code, `Invalid ${label}`);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizePluginId(pluginId: unknown): string {
|
||||
const normalized = normalizeOptionalString(pluginId, 128);
|
||||
|
||||
if (!normalized) {
|
||||
throw new PluginSupportError(400, 'MISSING_PLUGIN_ID', 'Missing plugin id');
|
||||
}
|
||||
|
||||
assertPattern(normalized, PLUGIN_ID_PATTERN, 'INVALID_PLUGIN_ID', 'plugin id');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeEventName(eventName: unknown): string {
|
||||
const normalized = normalizeOptionalString(eventName, 128);
|
||||
|
||||
if (!normalized) {
|
||||
throw new PluginSupportError(400, 'MISSING_EVENT_NAME', 'Missing event name');
|
||||
}
|
||||
|
||||
assertPattern(normalized, EVENT_NAME_PATTERN, 'INVALID_EVENT_NAME', 'event name');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeDataKey(key: unknown): string {
|
||||
const normalized = normalizeOptionalString(key, 128);
|
||||
|
||||
if (!normalized) {
|
||||
throw new PluginSupportError(400, 'MISSING_DATA_KEY', 'Missing data key');
|
||||
}
|
||||
|
||||
assertPattern(normalized, DATA_KEY_PATTERN, 'INVALID_DATA_KEY', 'data key');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeDataScope(scope: unknown): string {
|
||||
const normalized = normalizeOptionalString(scope, 64) ?? 'server';
|
||||
|
||||
assertPattern(normalized, DATA_SCOPE_PATTERN, 'INVALID_DATA_SCOPE', 'data scope');
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeOwnerId(ownerId: unknown): string {
|
||||
return normalizeOptionalString(ownerId, 128) ?? '';
|
||||
}
|
||||
|
||||
function parseJsonValue(valueJson: string): unknown {
|
||||
try {
|
||||
return JSON.parse(valueJson) as unknown;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function serializeJsonValue(value: unknown, code: string): string {
|
||||
try {
|
||||
return JSON.stringify(value ?? null);
|
||||
} catch {
|
||||
throw new PluginSupportError(400, code, 'Value must be JSON serializable');
|
||||
}
|
||||
}
|
||||
|
||||
function toRequirementSummary(entity: ServerPluginRequirementEntity): PluginRequirementSummary {
|
||||
return {
|
||||
pluginId: entity.pluginId,
|
||||
reason: entity.reason ?? undefined,
|
||||
status: entity.status,
|
||||
updatedAt: entity.updatedAt,
|
||||
versionRange: entity.versionRange ?? undefined
|
||||
};
|
||||
}
|
||||
|
||||
function toEventDefinitionSummary(entity: ServerPluginEventDefinitionEntity): PluginEventDefinitionSummary {
|
||||
return {
|
||||
direction: entity.direction,
|
||||
eventName: entity.eventName,
|
||||
maxPayloadBytes: entity.maxPayloadBytes,
|
||||
pluginId: entity.pluginId,
|
||||
scope: entity.scope,
|
||||
schemaJson: entity.schemaJson ?? undefined,
|
||||
updatedAt: entity.updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
function toPluginDataRecord(entity: PluginDataEntity): PluginDataRecord {
|
||||
return {
|
||||
key: entity.key,
|
||||
ownerId: entity.ownerId || undefined,
|
||||
pluginId: entity.pluginId,
|
||||
schemaVersion: entity.schemaVersion,
|
||||
scope: entity.scope,
|
||||
serverId: entity.serverId,
|
||||
updatedAt: entity.updatedAt,
|
||||
updatedBy: entity.updatedBy ?? undefined,
|
||||
value: parseJsonValue(entity.valueJson)
|
||||
};
|
||||
}
|
||||
|
||||
async function assertServerExists(serverId: string) {
|
||||
const server = await getServerById(serverId);
|
||||
|
||||
if (!server) {
|
||||
throw new PluginSupportError(404, 'SERVER_NOT_FOUND', 'Server not found');
|
||||
}
|
||||
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function assertCanManagePluginSupport(serverId: string, actorUserId: string): Promise<void> {
|
||||
const server = await assertServerExists(serverId);
|
||||
|
||||
if (!actorUserId || !resolveServerPermission(server, actorUserId, 'manageServer')) {
|
||||
throw new PluginSupportError(403, 'NOT_AUTHORIZED', 'Not authorized');
|
||||
}
|
||||
}
|
||||
|
||||
export async function assertCanUsePluginData(serverId: string, actorUserId: string): Promise<void> {
|
||||
const server = await assertServerExists(serverId);
|
||||
|
||||
if (!actorUserId) {
|
||||
throw new PluginSupportError(400, 'MISSING_USER', 'Missing user id');
|
||||
}
|
||||
|
||||
if (server.ownerId === actorUserId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const membership = await findServerMembership(serverId, actorUserId);
|
||||
|
||||
if (!membership) {
|
||||
throw new PluginSupportError(403, 'NOT_MEMBER', 'Only joined users can access plugin data');
|
||||
}
|
||||
}
|
||||
|
||||
export async function getPluginRequirementsSnapshot(serverId: string): Promise<PluginRequirementsSnapshot> {
|
||||
await assertServerExists(serverId);
|
||||
|
||||
const requirementQuery = requirementRepository().find({ where: { serverId } });
|
||||
const eventDefinitionQuery = eventDefinitionRepository().find({ where: { serverId } });
|
||||
const [requirements, eventDefinitions] = await Promise.all([requirementQuery, eventDefinitionQuery]);
|
||||
const requirementSummaries = requirements
|
||||
.map(toRequirementSummary)
|
||||
.sort((first, second) => first.pluginId.localeCompare(second.pluginId));
|
||||
const eventDefinitionSummaries = eventDefinitions
|
||||
.map(toEventDefinitionSummary)
|
||||
.sort((first, second) => `${first.pluginId}:${first.eventName}`.localeCompare(`${second.pluginId}:${second.eventName}`));
|
||||
const updatedAt = Math.max(
|
||||
0,
|
||||
...requirementSummaries.map((requirement) => requirement.updatedAt),
|
||||
...eventDefinitionSummaries.map((definition) => definition.updatedAt)
|
||||
);
|
||||
|
||||
return {
|
||||
eventDefinitions: eventDefinitionSummaries,
|
||||
requirements: requirementSummaries,
|
||||
serverId,
|
||||
updatedAt
|
||||
};
|
||||
}
|
||||
|
||||
export async function upsertPluginRequirement(options: {
|
||||
actorUserId: string;
|
||||
pluginId: string;
|
||||
reason?: unknown;
|
||||
serverId: string;
|
||||
status: unknown;
|
||||
versionRange?: unknown;
|
||||
}): Promise<PluginRequirementSummary> {
|
||||
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
||||
|
||||
const pluginId = normalizePluginId(options.pluginId);
|
||||
const status = options.status;
|
||||
|
||||
if (!VALID_REQUIREMENT_STATUSES.has(status as ServerPluginRequirementStatus)) {
|
||||
throw new PluginSupportError(400, 'INVALID_REQUIREMENT_STATUS', 'Invalid plugin requirement status');
|
||||
}
|
||||
|
||||
const repo = requirementRepository();
|
||||
const now = Date.now();
|
||||
const existing = await repo.findOne({ where: { serverId: options.serverId, pluginId } });
|
||||
const entity = repo.create({
|
||||
serverId: options.serverId,
|
||||
pluginId,
|
||||
status: status as ServerPluginRequirementStatus,
|
||||
versionRange: normalizeOptionalString(options.versionRange, 128),
|
||||
reason: normalizeOptionalString(options.reason, 512),
|
||||
configuredBy: options.actorUserId,
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
return toRequirementSummary(entity);
|
||||
}
|
||||
|
||||
export async function deletePluginRequirement(options: {
|
||||
actorUserId: string;
|
||||
pluginId: string;
|
||||
serverId: string;
|
||||
}): Promise<void> {
|
||||
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
||||
await requirementRepository().delete({ serverId: options.serverId, pluginId: normalizePluginId(options.pluginId) });
|
||||
}
|
||||
|
||||
export async function upsertPluginEventDefinition(options: {
|
||||
actorUserId: string;
|
||||
direction: unknown;
|
||||
eventName: string;
|
||||
maxPayloadBytes?: unknown;
|
||||
pluginId: string;
|
||||
rateLimitJson?: unknown;
|
||||
schemaJson?: unknown;
|
||||
scope: unknown;
|
||||
serverId: string;
|
||||
}): Promise<PluginEventDefinitionSummary> {
|
||||
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
||||
|
||||
const pluginId = normalizePluginId(options.pluginId);
|
||||
const eventName = normalizeEventName(options.eventName);
|
||||
const { direction, scope } = options;
|
||||
|
||||
if (!VALID_EVENT_DIRECTIONS.has(direction as ServerPluginEventDirection)) {
|
||||
throw new PluginSupportError(400, 'INVALID_EVENT_DIRECTION', 'Invalid plugin event direction');
|
||||
}
|
||||
|
||||
if (!VALID_EVENT_SCOPES.has(scope as ServerPluginEventScope)) {
|
||||
throw new PluginSupportError(400, 'INVALID_EVENT_SCOPE', 'Invalid plugin event scope');
|
||||
}
|
||||
|
||||
const maxPayloadBytes = typeof options.maxPayloadBytes === 'number' && Number.isFinite(options.maxPayloadBytes)
|
||||
? Math.max(1, Math.min(Math.floor(options.maxPayloadBytes), DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES))
|
||||
: DEFAULT_PLUGIN_EVENT_MAX_PAYLOAD_BYTES;
|
||||
const repo = eventDefinitionRepository();
|
||||
const now = Date.now();
|
||||
const existing = await repo.findOne({ where: { serverId: options.serverId, pluginId, eventName } });
|
||||
const entity = repo.create({
|
||||
serverId: options.serverId,
|
||||
pluginId,
|
||||
eventName,
|
||||
direction: direction as ServerPluginEventDirection,
|
||||
scope: scope as ServerPluginEventScope,
|
||||
schemaJson: normalizeOptionalString(options.schemaJson, 10_000),
|
||||
maxPayloadBytes,
|
||||
rateLimitJson: normalizeOptionalString(options.rateLimitJson, 2_000),
|
||||
createdAt: existing?.createdAt ?? now,
|
||||
updatedAt: now
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
return toEventDefinitionSummary(entity);
|
||||
}
|
||||
|
||||
export async function deletePluginEventDefinition(options: {
|
||||
actorUserId: string;
|
||||
eventName: string;
|
||||
pluginId: string;
|
||||
serverId: string;
|
||||
}): Promise<void> {
|
||||
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
||||
await eventDefinitionRepository().delete({
|
||||
serverId: options.serverId,
|
||||
pluginId: normalizePluginId(options.pluginId),
|
||||
eventName: normalizeEventName(options.eventName)
|
||||
});
|
||||
}
|
||||
|
||||
export async function listPluginData(options: {
|
||||
actorUserId: string;
|
||||
key?: unknown;
|
||||
ownerId?: unknown;
|
||||
pluginId: string;
|
||||
scope?: unknown;
|
||||
serverId: string;
|
||||
}): Promise<PluginDataRecord[]> {
|
||||
await assertCanUsePluginData(options.serverId, options.actorUserId);
|
||||
|
||||
const pluginId = normalizePluginId(options.pluginId);
|
||||
const scope = options.scope === undefined ? undefined : normalizeDataScope(options.scope);
|
||||
const ownerId = options.ownerId === undefined ? undefined : normalizeOwnerId(options.ownerId);
|
||||
const key = options.key === undefined ? undefined : normalizeDataKey(options.key);
|
||||
const query = pluginDataRepository()
|
||||
.createQueryBuilder('data')
|
||||
.where('data.serverId = :serverId', { serverId: options.serverId })
|
||||
.andWhere('data.pluginId = :pluginId', { pluginId });
|
||||
|
||||
if (scope !== undefined) {
|
||||
query.andWhere('data.scope = :scope', { scope });
|
||||
}
|
||||
|
||||
if (ownerId !== undefined) {
|
||||
query.andWhere('data.ownerId = :ownerId', { ownerId });
|
||||
}
|
||||
|
||||
if (key !== undefined) {
|
||||
query.andWhere('data.key = :key', { key });
|
||||
}
|
||||
|
||||
const records = await query
|
||||
.orderBy('data.scope', 'ASC')
|
||||
.addOrderBy('data.ownerId', 'ASC')
|
||||
.addOrderBy('data.key', 'ASC')
|
||||
.getMany();
|
||||
|
||||
return records.map(toPluginDataRecord);
|
||||
}
|
||||
|
||||
export async function upsertPluginData(options: {
|
||||
actorUserId: string;
|
||||
key: string;
|
||||
ownerId?: unknown;
|
||||
pluginId: string;
|
||||
schemaVersion?: unknown;
|
||||
scope?: unknown;
|
||||
serverId: string;
|
||||
value: unknown;
|
||||
}): Promise<PluginDataRecord> {
|
||||
await assertCanUsePluginData(options.serverId, options.actorUserId);
|
||||
|
||||
const pluginId = normalizePluginId(options.pluginId);
|
||||
const scope = normalizeDataScope(options.scope);
|
||||
const ownerId = scope === 'user' ? normalizeOwnerId(options.ownerId ?? options.actorUserId) : normalizeOwnerId(options.ownerId);
|
||||
|
||||
if (scope === 'user' && ownerId !== options.actorUserId) {
|
||||
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
||||
}
|
||||
|
||||
const key = normalizeDataKey(options.key);
|
||||
const schemaVersion = typeof options.schemaVersion === 'number' && Number.isFinite(options.schemaVersion)
|
||||
? Math.max(1, Math.floor(options.schemaVersion))
|
||||
: 1;
|
||||
const repo = pluginDataRepository();
|
||||
const entity = repo.create({
|
||||
serverId: options.serverId,
|
||||
pluginId,
|
||||
scope,
|
||||
ownerId,
|
||||
key,
|
||||
valueJson: serializeJsonValue(options.value, 'INVALID_PLUGIN_DATA'),
|
||||
schemaVersion,
|
||||
updatedBy: options.actorUserId,
|
||||
updatedAt: Date.now()
|
||||
});
|
||||
|
||||
await repo.save(entity);
|
||||
return toPluginDataRecord(entity);
|
||||
}
|
||||
|
||||
export async function deletePluginData(options: {
|
||||
actorUserId: string;
|
||||
key: string;
|
||||
ownerId?: unknown;
|
||||
pluginId: string;
|
||||
scope?: unknown;
|
||||
serverId: string;
|
||||
}): Promise<void> {
|
||||
await assertCanUsePluginData(options.serverId, options.actorUserId);
|
||||
|
||||
const pluginId = normalizePluginId(options.pluginId);
|
||||
const scope = normalizeDataScope(options.scope);
|
||||
const ownerId = scope === 'user' ? normalizeOwnerId(options.ownerId ?? options.actorUserId) : normalizeOwnerId(options.ownerId);
|
||||
|
||||
if (scope === 'user' && ownerId !== options.actorUserId) {
|
||||
await assertCanManagePluginSupport(options.serverId, options.actorUserId);
|
||||
}
|
||||
|
||||
await pluginDataRepository().delete({
|
||||
serverId: options.serverId,
|
||||
pluginId,
|
||||
scope,
|
||||
ownerId,
|
||||
key: normalizeDataKey(options.key)
|
||||
});
|
||||
}
|
||||
|
||||
export async function validatePluginEventEnvelope(envelope: PluginEventEnvelope): Promise<ServerPluginEventDefinitionEntity> {
|
||||
const pluginId = normalizePluginId(envelope.pluginId);
|
||||
const eventName = normalizeEventName(envelope.eventName);
|
||||
const definition = await eventDefinitionRepository().findOne({
|
||||
where: {
|
||||
serverId: envelope.serverId,
|
||||
pluginId,
|
||||
eventName
|
||||
}
|
||||
});
|
||||
|
||||
if (!definition) {
|
||||
throw new PluginSupportError(404, 'PLUGIN_EVENT_NOT_REGISTERED', 'Plugin event is not registered for this server');
|
||||
}
|
||||
|
||||
if (definition.direction === 'p2pHint') {
|
||||
throw new PluginSupportError(400, 'PLUGIN_EVENT_NOT_RELAYABLE', 'P2P plugin events must not be relayed by the signal server');
|
||||
}
|
||||
|
||||
const payloadBytes = Buffer.byteLength(serializeJsonValue(envelope.payload, 'INVALID_PLUGIN_EVENT_PAYLOAD'), 'utf8');
|
||||
|
||||
if (payloadBytes > definition.maxPayloadBytes) {
|
||||
throw new PluginSupportError(413, 'PLUGIN_EVENT_TOO_LARGE', 'Plugin event payload is too large');
|
||||
}
|
||||
|
||||
return definition;
|
||||
}
|
||||
221
server/src/websocket/handler-plugin.spec.ts
Normal file
221
server/src/websocket/handler-plugin.spec.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
import {
|
||||
beforeEach,
|
||||
describe,
|
||||
expect,
|
||||
it,
|
||||
vi
|
||||
} from 'vitest';
|
||||
import { WebSocket } from 'ws';
|
||||
import { ConnectedUser } from './types';
|
||||
import { connectedUsers } from './state';
|
||||
|
||||
const pluginSupportMocks = vi.hoisted(() => {
|
||||
class MockPluginSupportError extends Error {
|
||||
constructor(
|
||||
readonly status: number,
|
||||
readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'PluginSupportError';
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getPluginRequirementsSnapshot: vi.fn(),
|
||||
PluginSupportError: MockPluginSupportError,
|
||||
validatePluginEventEnvelope: vi.fn()
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../services/server-access.service', () => ({
|
||||
authorizeWebSocketJoin: vi.fn(async () => ({ allowed: true as const }))
|
||||
}));
|
||||
|
||||
vi.mock('../services/plugin-support.service', () => pluginSupportMocks);
|
||||
|
||||
import { handleWebSocketMessage } from './handler';
|
||||
|
||||
interface SentMessageStore {
|
||||
sentMessages: string[];
|
||||
}
|
||||
|
||||
function createMockWs(): WebSocket & SentMessageStore {
|
||||
const sentMessages: string[] = [];
|
||||
const socket = {
|
||||
readyState: WebSocket.OPEN,
|
||||
send: (data: string) => {
|
||||
sentMessages.push(data);
|
||||
},
|
||||
close: () => {},
|
||||
sentMessages
|
||||
} as unknown as WebSocket & SentMessageStore;
|
||||
|
||||
return socket;
|
||||
}
|
||||
|
||||
function createConnectedUser(
|
||||
connectionId: string,
|
||||
oderId: string,
|
||||
overrides: Partial<ConnectedUser> = {}
|
||||
): ConnectedUser {
|
||||
const user: ConnectedUser = {
|
||||
displayName: `User ${oderId}`,
|
||||
lastPong: Date.now(),
|
||||
oderId,
|
||||
serverIds: new Set(),
|
||||
ws: createMockWs(),
|
||||
...overrides
|
||||
};
|
||||
|
||||
connectedUsers.set(connectionId, user);
|
||||
return user;
|
||||
}
|
||||
|
||||
function readSentMessages(user: ConnectedUser): Record<string, unknown>[] {
|
||||
return (user.ws as unknown as SentMessageStore).sentMessages.map((messageText) => JSON.parse(messageText) as Record<string, unknown>);
|
||||
}
|
||||
|
||||
describe('server websocket handler - plugin support', () => {
|
||||
beforeEach(() => {
|
||||
connectedUsers.clear();
|
||||
pluginSupportMocks.getPluginRequirementsSnapshot.mockReset();
|
||||
pluginSupportMocks.validatePluginEventEnvelope.mockReset();
|
||||
pluginSupportMocks.getPluginRequirementsSnapshot.mockResolvedValue({
|
||||
eventDefinitions: [],
|
||||
requirements: [],
|
||||
serverId: 'server-1',
|
||||
updatedAt: 0
|
||||
});
|
||||
|
||||
pluginSupportMocks.validatePluginEventEnvelope.mockResolvedValue({ direction: 'serverRelay' });
|
||||
});
|
||||
|
||||
it('sends plugin requirement snapshots after joining a server', async () => {
|
||||
const alice = createConnectedUser('conn-1', 'alice');
|
||||
|
||||
pluginSupportMocks.getPluginRequirementsSnapshot.mockResolvedValue({
|
||||
eventDefinitions: [
|
||||
{
|
||||
direction: 'serverRelay',
|
||||
eventName: 'e2e:relay',
|
||||
maxPayloadBytes: 2048,
|
||||
pluginId: 'e2e.plugin-api',
|
||||
scope: 'server',
|
||||
updatedAt: 2
|
||||
}
|
||||
],
|
||||
requirements: [
|
||||
{
|
||||
pluginId: 'e2e.plugin-api',
|
||||
status: 'required',
|
||||
updatedAt: 1
|
||||
}
|
||||
],
|
||||
serverId: 'server-1',
|
||||
updatedAt: 2
|
||||
});
|
||||
|
||||
await handleWebSocketMessage('conn-1', { type: 'join_server', serverId: 'server-1' });
|
||||
|
||||
const messages = readSentMessages(alice);
|
||||
const pluginRequirements = messages.find((message) => message['type'] === 'plugin_requirements');
|
||||
|
||||
expect(pluginRequirements?.['serverId']).toBe('server-1');
|
||||
expect(pluginRequirements?.['snapshot']).toEqual(expect.objectContaining({ updatedAt: 2 }));
|
||||
});
|
||||
|
||||
it('validates and relays plugin events to other joined users', async () => {
|
||||
const alice = createConnectedUser('conn-1', 'alice', { viewedServerId: 'server-1' });
|
||||
const bob = createConnectedUser('conn-2', 'bob', { viewedServerId: 'server-1' });
|
||||
|
||||
alice.serverIds.add('server-1');
|
||||
bob.serverIds.add('server-1');
|
||||
|
||||
await handleWebSocketMessage('conn-1', {
|
||||
type: 'plugin_event',
|
||||
eventId: 'event-1',
|
||||
eventName: 'e2e:relay',
|
||||
payload: { ok: true },
|
||||
pluginId: 'e2e.plugin-api',
|
||||
serverId: 'server-1',
|
||||
sourcePluginUserId: 'fixture-user'
|
||||
});
|
||||
|
||||
expect(pluginSupportMocks.validatePluginEventEnvelope).toHaveBeenCalledWith({
|
||||
type: 'plugin_event',
|
||||
eventId: 'event-1',
|
||||
eventName: 'e2e:relay',
|
||||
payload: { ok: true },
|
||||
pluginId: 'e2e.plugin-api',
|
||||
serverId: 'server-1',
|
||||
sourcePluginUserId: 'fixture-user'
|
||||
});
|
||||
|
||||
const bobMessages = readSentMessages(bob);
|
||||
const relayedEvent = bobMessages.find((message) => message['type'] === 'plugin_event');
|
||||
|
||||
expect(relayedEvent).toEqual(expect.objectContaining({
|
||||
eventId: 'event-1',
|
||||
eventName: 'e2e:relay',
|
||||
pluginId: 'e2e.plugin-api',
|
||||
serverId: 'server-1',
|
||||
sourcePluginUserId: 'fixture-user',
|
||||
sourceUserId: 'alice'
|
||||
}));
|
||||
|
||||
expect(typeof relayedEvent?.['emittedAt']).toBe('number');
|
||||
});
|
||||
|
||||
it('returns plugin errors for invalid plugin event messages', async () => {
|
||||
const alice = createConnectedUser('conn-1', 'alice');
|
||||
|
||||
await handleWebSocketMessage('conn-1', {
|
||||
type: 'plugin_event',
|
||||
eventName: 'e2e:relay',
|
||||
pluginId: 'e2e.plugin-api',
|
||||
serverId: 'server-1'
|
||||
});
|
||||
|
||||
const pluginError = readSentMessages(alice).find((message) => message['type'] === 'plugin_error');
|
||||
|
||||
expect(pluginError).toEqual(expect.objectContaining({
|
||||
code: 'INVALID_PLUGIN_EVENT',
|
||||
eventName: 'e2e:relay',
|
||||
pluginId: 'e2e.plugin-api',
|
||||
serverId: 'server-1'
|
||||
}));
|
||||
|
||||
expect(pluginSupportMocks.validatePluginEventEnvelope).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards plugin support validation errors to the sending user', async () => {
|
||||
const alice = createConnectedUser('conn-1', 'alice', { viewedServerId: 'server-1' });
|
||||
|
||||
alice.serverIds.add('server-1');
|
||||
pluginSupportMocks.validatePluginEventEnvelope.mockRejectedValue(new pluginSupportMocks.PluginSupportError(
|
||||
400,
|
||||
'PLUGIN_EVENT_NOT_RELAYABLE',
|
||||
'P2P plugin events must not be relayed by the signal server'
|
||||
));
|
||||
|
||||
await handleWebSocketMessage('conn-1', {
|
||||
type: 'plugin_event',
|
||||
eventId: 'event-p2p',
|
||||
eventName: 'e2e:p2p',
|
||||
payload: { hint: true },
|
||||
pluginId: 'e2e.plugin-api',
|
||||
serverId: 'server-1'
|
||||
});
|
||||
|
||||
const pluginError = readSentMessages(alice).find((message) => message['type'] === 'plugin_error');
|
||||
|
||||
expect(pluginError).toEqual(expect.objectContaining({
|
||||
code: 'PLUGIN_EVENT_NOT_RELAYABLE',
|
||||
eventId: 'event-p2p',
|
||||
eventName: 'e2e:p2p',
|
||||
pluginId: 'e2e.plugin-api',
|
||||
serverId: 'server-1'
|
||||
}));
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,11 @@ import {
|
||||
isOderIdConnectedToServer
|
||||
} from './broadcast';
|
||||
import { authorizeWebSocketJoin } from '../services/server-access.service';
|
||||
import {
|
||||
getPluginRequirementsSnapshot,
|
||||
PluginSupportError,
|
||||
validatePluginEventEnvelope
|
||||
} from '../services/plugin-support.service';
|
||||
|
||||
interface WsMessage {
|
||||
[key: string]: unknown;
|
||||
@@ -50,6 +55,29 @@ function readMessageId(value: unknown): string | undefined {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function sendPluginError(user: ConnectedUser, error: unknown, message: WsMessage): void {
|
||||
if (error instanceof PluginSupportError) {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'plugin_error',
|
||||
serverId: typeof message['serverId'] === 'string' ? message['serverId'] : undefined,
|
||||
pluginId: typeof message['pluginId'] === 'string' ? message['pluginId'] : undefined,
|
||||
eventName: typeof message['eventName'] === 'string' ? message['eventName'] : undefined,
|
||||
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
||||
code: error.code,
|
||||
message: error.message
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
console.error('Unhandled plugin websocket error:', error);
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'plugin_error',
|
||||
code: 'INTERNAL_ERROR',
|
||||
message: 'Internal server error'
|
||||
}));
|
||||
}
|
||||
|
||||
/** Sends the current user list for a given server to a single connected user. */
|
||||
function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
const users = getUniqueUsersInServer(serverId, user.oderId)
|
||||
@@ -64,6 +92,20 @@ function sendServerUsers(user: ConnectedUser, serverId: string): void {
|
||||
user.ws.send(JSON.stringify({ type: 'server_users', serverId, users }));
|
||||
}
|
||||
|
||||
async function sendPluginRequirements(user: ConnectedUser, serverId: string): Promise<void> {
|
||||
try {
|
||||
const snapshot = await getPluginRequirementsSnapshot(serverId);
|
||||
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'plugin_requirements',
|
||||
serverId,
|
||||
snapshot
|
||||
}));
|
||||
} catch (error) {
|
||||
sendPluginError(user, error, { type: 'plugin_requirements', serverId });
|
||||
}
|
||||
}
|
||||
|
||||
function handleIdentify(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
const newOderId = readMessageId(message['oderId']) ?? connectionId;
|
||||
const newScope = typeof message['connectionScope'] === 'string' ? message['connectionScope'] : undefined;
|
||||
@@ -137,6 +179,7 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
);
|
||||
|
||||
sendServerUsers(user, sid);
|
||||
await sendPluginRequirements(user, sid);
|
||||
|
||||
if (isNewIdentityMembership) {
|
||||
broadcastToServer(sid, {
|
||||
@@ -151,17 +194,22 @@ async function handleJoinServer(user: ConnectedUser, message: WsMessage, connect
|
||||
}
|
||||
}
|
||||
|
||||
function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
async function handleViewServer(user: ConnectedUser, message: WsMessage, connectionId: string): Promise<void> {
|
||||
const viewSid = readMessageId(message['serverId']);
|
||||
|
||||
if (!viewSid)
|
||||
return;
|
||||
|
||||
if (!user.serverIds.has(viewSid)) {
|
||||
return;
|
||||
}
|
||||
|
||||
user.viewedServerId = viewSid;
|
||||
connectedUsers.set(connectionId, user);
|
||||
console.log(`User ${normalizeDisplayName(user.displayName)} (${user.oderId}) viewing server ${viewSid}`);
|
||||
|
||||
sendServerUsers(user, viewSid);
|
||||
await sendPluginRequirements(user, viewSid);
|
||||
}
|
||||
|
||||
function handleLeaveServer(user: ConnectedUser, message: WsMessage, connectionId: string): void {
|
||||
@@ -268,6 +316,52 @@ function handleStatusUpdate(user: ConnectedUser, message: WsMessage, connectionI
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePluginEvent(user: ConnectedUser, message: WsMessage): Promise<void> {
|
||||
const serverId = readMessageId(message['serverId']) ?? user.viewedServerId;
|
||||
const pluginId = readMessageId(message['pluginId']);
|
||||
const eventName = readMessageId(message['eventName']);
|
||||
|
||||
if (!serverId || !pluginId || !eventName || !user.serverIds.has(serverId)) {
|
||||
user.ws.send(JSON.stringify({
|
||||
type: 'plugin_error',
|
||||
serverId,
|
||||
pluginId,
|
||||
eventName,
|
||||
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
||||
code: 'INVALID_PLUGIN_EVENT',
|
||||
message: 'Plugin event is missing required fields or server membership'
|
||||
}));
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await validatePluginEventEnvelope({
|
||||
type: 'plugin_event',
|
||||
serverId,
|
||||
pluginId,
|
||||
eventName,
|
||||
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
||||
payload: message['payload'],
|
||||
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined
|
||||
});
|
||||
|
||||
broadcastToServer(serverId, {
|
||||
type: 'plugin_event',
|
||||
serverId,
|
||||
pluginId,
|
||||
eventName,
|
||||
eventId: typeof message['eventId'] === 'string' ? message['eventId'] : undefined,
|
||||
payload: message['payload'],
|
||||
sourcePluginUserId: typeof message['sourcePluginUserId'] === 'string' ? message['sourcePluginUserId'] : undefined,
|
||||
sourceUserId: user.oderId,
|
||||
emittedAt: Date.now()
|
||||
}, user.oderId);
|
||||
} catch (error) {
|
||||
sendPluginError(user, error, message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function handleWebSocketMessage(connectionId: string, message: WsMessage): Promise<void> {
|
||||
const user = connectedUsers.get(connectionId);
|
||||
|
||||
@@ -290,7 +384,7 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
break;
|
||||
|
||||
case 'view_server':
|
||||
handleViewServer(user, message, connectionId);
|
||||
await handleViewServer(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'leave_server':
|
||||
@@ -315,6 +409,10 @@ export async function handleWebSocketMessage(connectionId: string, message: WsMe
|
||||
handleStatusUpdate(user, message, connectionId);
|
||||
break;
|
||||
|
||||
case 'plugin_event':
|
||||
await handlePluginEvent(user, message);
|
||||
break;
|
||||
|
||||
default:
|
||||
console.log('Unknown message type:', message.type);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user