feat: plugins v1

This commit is contained in:
2026-04-29 01:14:14 +02:00
parent ec3802ade6
commit 6920f93b41
86 changed files with 9036 additions and 14 deletions

View File

@@ -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;
}

View File

@@ -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',

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -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';

View 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"`);
}
}

View File

@@ -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
];

View File

@@ -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);

View 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;

View 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;

View 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;
}

View 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'
}));
});
});

View File

@@ -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);
}