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

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