feat: plugins v1
This commit is contained in:
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;
|
||||
}
|
||||
Reference in New Issue
Block a user