diff --git a/packages/backend/src/di/container.ts b/packages/backend/src/di/container.ts index fe0673b0..455f3546 100644 --- a/packages/backend/src/di/container.ts +++ b/packages/backend/src/di/container.ts @@ -75,6 +75,7 @@ import { UserDataExportService } from "../services/UserDataExportService.js"; import { BlockedUsernameService } from "../services/BlockedUsernameService.js"; import { SystemAccountService } from "../services/SystemAccountService.js"; import { logger } from "../lib/logger.js"; +import { EventBus } from "../lib/events.js"; export interface AppContainer { userRepository: IUserRepository; @@ -119,6 +120,7 @@ export interface AppContainer { userDataExportService: UserDataExportService; blockedUsernameService: BlockedUsernameService; systemAccountService: SystemAccountService; + eventBus: EventBus; } /** @@ -235,6 +237,9 @@ export function createContainer(): AppContainer { repositories.userRepository, ); + // Event Bus for plugin system + const eventBus = new EventBus(); + return { ...repositories, fileStorage, @@ -253,6 +258,7 @@ export function createContainer(): AppContainer { userDataExportService, blockedUsernameService, systemAccountService, + eventBus, }; } diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index 185f0350..f134ac43 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -171,6 +171,7 @@ const noteService = new NoteService( container.cacheService, container.notificationService, container.listRepository, + container.eventBus, ); const scheduledNoteService = new ScheduledNoteService( container.scheduledNoteRepository, diff --git a/packages/backend/src/lib/events.ts b/packages/backend/src/lib/events.ts new file mode 100644 index 00000000..67e2db95 --- /dev/null +++ b/packages/backend/src/lib/events.ts @@ -0,0 +1,305 @@ +/** + * Event Bus for Plugin System + * + * Provides a typed event emitter for plugin hooks. Supports both + * synchronous and asynchronous event handlers with proper error handling. + * + * @module lib/events + */ + +import type { PluginEvents, PluginEventPayload } from "../plugins/types.js"; +import { logger } from "./logger.js"; + +/** + * Event handler function type + */ +export type EventHandler = (payload: T) => void | Promise; + +/** + * Before event handler that can cancel or modify the operation + */ +export type BeforeEventHandler = ( + payload: T +) => BeforeEventResult | Promise>; + +/** + * Result from a before event handler + */ +export interface BeforeEventResult { + /** Whether to cancel the operation */ + cancelled?: boolean; + /** Reason for cancellation (for logging) */ + cancelReason?: string; + /** Modified payload to use instead */ + modifiedPayload?: T; +} + +/** + * Internal handler storage + */ +interface HandlerEntry { + handler: EventHandler | BeforeEventHandler; + pluginId: string; + priority: number; +} + +/** + * EventBus - Central event management for the plugin system + * + * Features: + * - Type-safe event emission and subscription + * - Support for "before" events that can cancel/modify operations + * - Priority-based handler execution + * - Error isolation (one handler failure doesn't affect others) + * - Plugin-scoped handler management + * + * @example + * ```typescript + * const eventBus = new EventBus(); + * + * // Subscribe to an event + * eventBus.on('note:afterCreate', (payload) => { + * console.log('Note created:', payload.note.id); + * }, 'my-plugin'); + * + * // Emit an event + * await eventBus.emit('note:afterCreate', { note, author }); + * + * // Before events can cancel operations + * eventBus.onBefore('note:beforeCreate', async (payload) => { + * if (containsSpam(payload.text)) { + * return { cancelled: true, cancelReason: 'Spam detected' }; + * } + * return {}; + * }, 'spam-filter-plugin'); + * ``` + */ +export class EventBus { + private handlers: Map[]>; + private beforeHandlers: Map[]>; + + constructor() { + this.handlers = new Map(); + this.beforeHandlers = new Map(); + } + + /** + * Subscribe to an event + * + * @param event - Event name to subscribe to + * @param handler - Handler function to call when event is emitted + * @param pluginId - ID of the plugin registering this handler + * @param priority - Handler priority (higher = runs first, default: 0) + */ + on( + event: K, + handler: EventHandler>, + pluginId: string, + priority = 0 + ): void { + const handlers = this.handlers.get(event) || []; + handlers.push({ + handler: handler as EventHandler, + pluginId, + priority, + }); + // Sort by priority (descending) + handlers.sort((a, b) => b.priority - a.priority); + this.handlers.set(event, handlers); + + logger.debug(`[EventBus] Handler registered for ${event} by ${pluginId}`); + } + + /** + * Subscribe to a "before" event that can cancel or modify operations + * + * @param event - Before event name (e.g., 'note:beforeCreate') + * @param handler - Handler that returns cancellation/modification result + * @param pluginId - ID of the plugin registering this handler + * @param priority - Handler priority (higher = runs first, default: 0) + */ + onBefore( + event: K, + handler: BeforeEventHandler>, + pluginId: string, + priority = 0 + ): void { + const handlers = this.beforeHandlers.get(event) || []; + handlers.push({ + handler: handler as BeforeEventHandler, + pluginId, + priority, + }); + handlers.sort((a, b) => b.priority - a.priority); + this.beforeHandlers.set(event, handlers); + + logger.debug( + `[EventBus] Before handler registered for ${event} by ${pluginId}` + ); + } + + /** + * Unsubscribe all handlers for a specific plugin + * + * @param pluginId - Plugin ID to remove handlers for + */ + offPlugin(pluginId: string): void { + for (const [event, handlers] of this.handlers.entries()) { + const filtered = handlers.filter((h) => h.pluginId !== pluginId); + if (filtered.length === 0) { + this.handlers.delete(event); + } else { + this.handlers.set(event, filtered); + } + } + + for (const [event, handlers] of this.beforeHandlers.entries()) { + const filtered = handlers.filter((h) => h.pluginId !== pluginId); + if (filtered.length === 0) { + this.beforeHandlers.delete(event); + } else { + this.beforeHandlers.set(event, filtered); + } + } + + logger.debug(`[EventBus] All handlers removed for plugin ${pluginId}`); + } + + /** + * Emit an event to all subscribed handlers + * + * Handlers are executed in priority order. Errors in one handler + * don't prevent other handlers from running. + * + * @param event - Event name to emit + * @param payload - Event payload + */ + async emit( + event: K, + payload: PluginEventPayload + ): Promise { + const handlers = this.handlers.get(event); + if (!handlers || handlers.length === 0) { + return; + } + + for (const entry of handlers) { + try { + await entry.handler(payload); + } catch (error) { + logger.error( + { err: error, event, pluginId: entry.pluginId }, + `[EventBus] Error in handler for ${event} (plugin: ${entry.pluginId})` + ); + } + } + } + + /** + * Emit a "before" event and collect cancellation/modification results + * + * Handlers are executed in priority order. If any handler cancels, + * the operation should be aborted. + * + * @param event - Before event name + * @param payload - Event payload + * @returns Combined result from all handlers. When handlers exist: + * - If cancelled: `{ cancelled: true, cancelReason?: string }` + * - If not cancelled: `{ modifiedPayload: T }` (always includes payload, even if unchanged) + * - If no handlers: `{}` (empty object) + * + * @remarks + * If a handler throws an error, it is logged but processing continues. + * This means security-critical plugins should handle their own errors + * and return `{ cancelled: true }` explicitly rather than throwing. + */ + async emitBefore( + event: K, + payload: PluginEventPayload + ): Promise>> { + const handlers = this.beforeHandlers.get(event); + if (!handlers || handlers.length === 0) { + return {}; + } + + let currentPayload = payload; + + for (const entry of handlers) { + try { + const handler = entry.handler as BeforeEventHandler< + PluginEventPayload + >; + const result = await handler(currentPayload); + + if (result.cancelled) { + logger.info( + `[EventBus] Event ${event} cancelled by ${entry.pluginId}: ${result.cancelReason || "No reason provided"}` + ); + return { + cancelled: true, + cancelReason: result.cancelReason, + }; + } + + if (result.modifiedPayload) { + currentPayload = result.modifiedPayload; + } + } catch (error) { + logger.error( + { err: error, event, pluginId: entry.pluginId }, + `[EventBus] Error in before handler for ${event} (plugin: ${entry.pluginId})` + ); + } + } + + return { modifiedPayload: currentPayload }; + } + + /** + * Check if any handlers are registered for an event + */ + hasHandlers(event: keyof PluginEvents): boolean { + const handlers = this.handlers.get(event); + const beforeHandlers = this.beforeHandlers.get(event); + return ( + (handlers !== undefined && handlers.length > 0) || + (beforeHandlers !== undefined && beforeHandlers.length > 0) + ); + } + + /** + * Get count of registered handlers for an event + */ + getHandlerCount(event: keyof PluginEvents): number { + const handlers = this.handlers.get(event) || []; + const beforeHandlers = this.beforeHandlers.get(event) || []; + return handlers.length + beforeHandlers.length; + } + + /** + * Get all registered events + */ + getRegisteredEvents(): string[] { + const events = new Set(); + for (const event of this.handlers.keys()) { + events.add(event); + } + for (const event of this.beforeHandlers.keys()) { + events.add(event); + } + return Array.from(events); + } + + /** + * Clear all handlers (mainly for testing) + */ + clear(): void { + this.handlers.clear(); + this.beforeHandlers.clear(); + } +} + +/** + * Global EventBus instance + */ +export const eventBus = new EventBus(); diff --git a/packages/backend/src/middleware/di.ts b/packages/backend/src/middleware/di.ts index ac9f7994..7693e19b 100644 --- a/packages/backend/src/middleware/di.ts +++ b/packages/backend/src/middleware/di.ts @@ -51,6 +51,7 @@ export function diMiddleware() { c.set("systemAccountService", container.systemAccountService); c.set("listRepository", container.listRepository); c.set("deckProfileRepository", container.deckProfileRepository); + c.set("eventBus", container.eventBus); // Also set the container itself for routes that need multiple services c.set("container", container); diff --git a/packages/backend/src/plugins/index.ts b/packages/backend/src/plugins/index.ts new file mode 100644 index 00000000..765e0a98 --- /dev/null +++ b/packages/backend/src/plugins/index.ts @@ -0,0 +1,7 @@ +/** + * Plugin System Exports + * + * @module plugins + */ + +export * from "./types.js"; diff --git a/packages/backend/src/plugins/types.ts b/packages/backend/src/plugins/types.ts new file mode 100644 index 00000000..f403ae62 --- /dev/null +++ b/packages/backend/src/plugins/types.ts @@ -0,0 +1,483 @@ +/** + * Plugin System Type Definitions + * + * Defines the plugin interface, event types, and context for the Rox plugin system. + * + * @module plugins/types + */ + +import type { Hono, MiddlewareHandler } from "hono"; +import type { EventBus } from "../lib/events.js"; +import type { Visibility } from "shared"; + +/** + * Minimal user information for event payloads + * Excludes sensitive fields like passwordHash, privateKey + */ +export interface PluginUser { + id: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + host: string | null; + isAdmin: boolean; + isSuspended: boolean; + isSystemUser: boolean; + uri: string | null; + createdAt: Date; +} + +/** + * Minimal note information for event payloads + */ +export interface PluginNote { + id: string; + userId: string; + text: string | null; + cw: string | null; + visibility: Visibility; + localOnly: boolean; + replyId: string | null; + renoteId: string | null; + fileIds: string[]; + mentions: string[]; + uri: string | null; + createdAt: Date; +} + +/** + * Follow relationship for event payloads + */ +export interface PluginFollow { + id: string; + followerId: string; + followeeId: string; + createdAt: Date; +} + +/** + * ActivityPub activity for event payloads + */ +export interface PluginActivity { + id?: string; + type: string; + actor: string; + object?: unknown; + target?: unknown; + [key: string]: unknown; +} + +// ============================================================================= +// Event Payloads +// ============================================================================= + +/** + * Note creation event payloads + */ +export interface NoteBeforeCreatePayload { + /** Note text content */ + text: string | null; + /** Content warning */ + cw: string | null; + /** Visibility level */ + visibility: Visibility; + /** Local only flag */ + localOnly: boolean; + /** Reply target note ID */ + replyId: string | null; + /** Renote target note ID */ + renoteId: string | null; + /** Attached file IDs */ + fileIds: string[]; + /** Author user */ + author: PluginUser; +} + +export interface NoteAfterCreatePayload { + /** Created note */ + note: PluginNote; + /** Author user */ + author: PluginUser; +} + +export interface NoteBeforeDeletePayload { + /** Note to be deleted */ + note: PluginNote; + /** User performing deletion */ + deletedBy: PluginUser; + /** Reason for deletion (if provided) */ + reason: string | null; +} + +export interface NoteAfterDeletePayload { + /** Deleted note ID */ + noteId: string; + /** User who deleted */ + deletedBy: PluginUser; + /** Reason for deletion */ + reason: string | null; +} + +/** + * User event payloads + */ +export interface UserBeforeRegisterPayload { + /** Username */ + username: string; + /** + * Display name (optional) + * + * Note: Email is intentionally excluded from this payload + * as it is PII and should not be exposed to plugins. + */ + displayName: string | null; +} + +export interface UserAfterRegisterPayload { + /** Registered user */ + user: PluginUser; +} + +export interface UserBeforeLoginPayload { + /** + * Username used for login + * + * Note: ipAddress and userAgent are intentionally excluded from this payload + * as they are PII. Plugins can only see the username to decide whether to + * cancel the login attempt. + */ + username: string; +} + +export interface UserAfterLoginPayload { + /** Logged in user */ + user: PluginUser; + /** IP address of request */ + ipAddress: string | null; + /** User agent string */ + userAgent: string | null; +} + +export interface UserAfterLogoutPayload { + /** User who logged out */ + user: PluginUser; +} + +/** + * Follow event payloads + */ +export interface FollowAfterCreatePayload { + /** Follow relationship */ + follow: PluginFollow; + /** Follower user */ + follower: PluginUser; + /** Followee user */ + followee: PluginUser; +} + +export interface FollowAfterDeletePayload { + /** + * Follow relationship ID + * + * For delete events, this is a composite format: `${followerId}:${followeeId}` + * since the original follow record has been deleted and its ID is no longer available. + */ + followId: string; + /** Follower user */ + follower: PluginUser; + /** Followee user */ + followee: PluginUser; +} + +/** + * ActivityPub event payloads + */ +export interface ApBeforeInboxPayload { + /** Incoming activity */ + activity: PluginActivity; + /** Actor URI */ + actorUri: string; +} + +export interface ApAfterInboxPayload { + /** Processed activity */ + activity: PluginActivity; + /** Actor URI */ + actorUri: string; + /** Processing result */ + result: "accepted" | "rejected" | "ignored"; +} + +export interface ApBeforeDeliveryPayload { + /** Activity to deliver */ + activity: PluginActivity; + /** Target inbox URLs */ + inboxUrls: string[]; +} + +export interface ApAfterDeliveryPayload { + /** Delivered activity */ + activity: PluginActivity; + /** Delivery results per inbox */ + results: Array<{ + inboxUrl: string; + success: boolean; + error?: string; + }>; +} + +/** + * Moderation event payloads + */ +export interface ModUserSuspendedPayload { + /** Suspended user */ + user: PluginUser; + /** Admin who performed suspension */ + suspendedBy: PluginUser; + /** Reason for suspension */ + reason: string | null; +} + +export interface ModNoteDeletedPayload { + /** Deleted note ID */ + noteId: string; + /** Original author */ + author: PluginUser; + /** Moderator who deleted */ + deletedBy: PluginUser; + /** Reason for deletion */ + reason: string | null; +} + +// ============================================================================= +// Event Type Map +// ============================================================================= + +/** + * Map of all plugin events and their payload types + */ +export interface PluginEvents { + // Note lifecycle events + "note:beforeCreate": NoteBeforeCreatePayload; + "note:afterCreate": NoteAfterCreatePayload; + "note:beforeDelete": NoteBeforeDeletePayload; + "note:afterDelete": NoteAfterDeletePayload; + + // User lifecycle events + "user:beforeRegister": UserBeforeRegisterPayload; + "user:afterRegister": UserAfterRegisterPayload; + "user:beforeLogin": UserBeforeLoginPayload; + "user:afterLogin": UserAfterLoginPayload; + "user:afterLogout": UserAfterLogoutPayload; + + // Follow lifecycle events + "follow:afterCreate": FollowAfterCreatePayload; + "follow:afterDelete": FollowAfterDeletePayload; + + // ActivityPub events + "ap:beforeInbox": ApBeforeInboxPayload; + "ap:afterInbox": ApAfterInboxPayload; + "ap:beforeDelivery": ApBeforeDeliveryPayload; + "ap:afterDelivery": ApAfterDeliveryPayload; + + // Moderation events + "mod:userSuspended": ModUserSuspendedPayload; + "mod:noteDeleted": ModNoteDeletedPayload; +} + +/** + * Helper type to get payload type for an event + */ +export type PluginEventPayload = PluginEvents[K]; + +/** + * All available event names + */ +export type PluginEventName = keyof PluginEvents; + +// ============================================================================= +// Plugin Interface +// ============================================================================= + +/** + * Plugin configuration schema (for admin UI) + */ +export interface PluginConfigSchema { + type: "object"; + properties: Record< + string, + { + type: "string" | "number" | "boolean" | "array"; + title: string; + description?: string; + default?: unknown; + enum?: unknown[]; + } + >; + required?: string[]; +} + +/** + * Plugin context provided to plugins during initialization + */ +export interface PluginContext { + /** Event bus for subscribing to events */ + events: EventBus; + /** Logger scoped to the plugin */ + logger: { + debug: (message: string, ...args: unknown[]) => void; + info: (message: string, ...args: unknown[]) => void; + warn: (message: string, ...args: unknown[]) => void; + error: (message: string, ...args: unknown[]) => void; + }; + /** Plugin configuration storage */ + config: { + get: (key: string, defaultValue?: T) => Promise; + set: (key: string, value: T) => Promise; + delete: (key: string) => Promise; + }; + /** Instance base URL */ + baseUrl: string; + /** Instance name */ + instanceName: string; +} + +/** + * Plugin manifest (package.json or plugin.json) + */ +export interface PluginManifest { + /** Unique plugin identifier */ + id: string; + /** Plugin display name */ + name: string; + /** Plugin version (semver) */ + version: string; + /** Plugin description */ + description?: string; + /** Plugin author */ + author?: string; + /** Plugin homepage/repository URL */ + homepage?: string; + /** Minimum Rox version required */ + minRoxVersion?: string; + /** Plugin dependencies (other plugin IDs) */ + dependencies?: string[]; + /** Plugin permissions required */ + permissions?: PluginPermission[]; +} + +/** + * Plugin permissions + */ +export type PluginPermission = + | "notes:read" + | "notes:write" + | "users:read" + | "users:write" + | "follows:read" + | "follows:write" + | "admin:read" + | "admin:write" + | "activitypub:read" + | "activitypub:write" + | "storage:read" + | "storage:write" + | "config:read" + | "config:write"; + +/** + * Rox Plugin Interface + * + * Plugins must implement this interface to be loaded by Rox. + * + * @example + * ```typescript + * const myPlugin: RoxPlugin = { + * id: 'my-plugin', + * name: 'My Plugin', + * version: '1.0.0', + * + * async onLoad(context) { + * context.events.on('note:afterCreate', (payload) => { + * context.logger.info(`Note created: ${payload.note.id}`); + * }, this.id); + * }, + * + * async onUnload() { + * // Cleanup + * } + * }; + * ``` + */ +export interface RoxPlugin { + /** Unique plugin identifier */ + id: string; + /** Plugin display name */ + name: string; + /** Plugin version (semver) */ + version: string; + /** Plugin description */ + description?: string; + /** Minimum Rox version required */ + minRoxVersion?: string; + /** Plugin dependencies (other plugin IDs) */ + dependencies?: string[]; + + /** + * Called when the plugin is loaded + * Register event handlers and initialize resources here + */ + onLoad?(context: PluginContext): Promise | void; + + /** + * Called when the plugin is unloaded + * Clean up resources and unregister handlers here + */ + onUnload?(): Promise | void; + + /** + * Custom API routes to register + * Routes are mounted at /api/x/{pluginId}/ + */ + routes?: (app: Hono) => void; + + /** + * Custom middleware to add to the request pipeline + */ + middleware?: MiddlewareHandler[]; + + /** + * Custom ActivityPub activity handlers + * Key is the activity type (e.g., 'Like', 'Announce') + */ + activityHandlers?: Record< + string, + (activity: PluginActivity, context: PluginContext) => Promise + >; + + /** + * Admin UI configuration + */ + adminUI?: { + /** Configuration schema for settings UI */ + configSchema?: PluginConfigSchema; + /** Icon for admin sidebar (Lucide icon name) */ + icon?: string; + }; +} + +/** + * Loaded plugin instance with runtime state + */ +export interface LoadedPlugin { + /** Plugin definition */ + plugin: RoxPlugin; + /** Plugin manifest */ + manifest: PluginManifest; + /** Whether the plugin is currently enabled */ + enabled: boolean; + /** Load timestamp */ + loadedAt: Date; + /** Error if plugin failed to load */ + error?: string; +} diff --git a/packages/backend/src/plugins/utils.ts b/packages/backend/src/plugins/utils.ts new file mode 100644 index 00000000..72b94ac8 --- /dev/null +++ b/packages/backend/src/plugins/utils.ts @@ -0,0 +1,76 @@ +/** + * Plugin Utility Functions + * + * Shared conversion utilities for transforming database entities + * into plugin-safe types that exclude sensitive fields. + * + * @module plugins/utils + */ + +import type { User } from "../../../shared/src/types/user.js"; +import type { Note } from "../../../shared/src/types/note.js"; +import type { PluginUser, PluginNote } from "./types.js"; + +/** + * Convert User to PluginUser (excluding sensitive fields) + * + * Creates a safe representation of a user for plugin consumption, + * excluding fields like password hashes, email addresses, and other + * sensitive data that plugins should not have access to. + * + * @param user - The full User object from the database + * @returns A PluginUser with only safe fields exposed + * + * @example + * ```typescript + * const pluginUser = toPluginUser(dbUser); + * eventBus.emit('user:afterLogin', { user: pluginUser }); + * ``` + */ +export function toPluginUser(user: User): PluginUser { + return { + id: user.id, + username: user.username, + displayName: user.displayName, + avatarUrl: user.avatarUrl, + host: user.host, + isAdmin: user.isAdmin, + isSuspended: user.isSuspended, + isSystemUser: user.isSystemUser, + uri: user.uri, + createdAt: user.createdAt, + }; +} + +/** + * Convert Note to PluginNote + * + * Creates a representation of a note for plugin consumption. + * Note objects don't contain sensitive data, but this provides + * a consistent interface for plugins. + * + * @param note - The full Note object from the database + * @returns A PluginNote with the standard note fields + * + * @example + * ```typescript + * const pluginNote = toPluginNote(dbNote); + * eventBus.emit('note:afterCreate', { note: pluginNote, author: pluginUser }); + * ``` + */ +export function toPluginNote(note: Note): PluginNote { + return { + id: note.id, + userId: note.userId, + text: note.text, + cw: note.cw, + visibility: note.visibility, + localOnly: note.localOnly, + replyId: note.replyId, + renoteId: note.renoteId, + fileIds: note.fileIds, + mentions: note.mentions, + uri: note.uri, + createdAt: note.createdAt, + }; +} diff --git a/packages/backend/src/routes/auth.ts b/packages/backend/src/routes/auth.ts index 37ebc58b..b0a941f3 100644 --- a/packages/backend/src/routes/auth.ts +++ b/packages/backend/src/routes/auth.ts @@ -167,6 +167,7 @@ app.post("/register", rateLimit(RateLimitPresets.register), async (c) => { c.get("userRepository"), c.get("sessionRepository"), c.get("blockedUsernameService"), + c.get("eventBus"), ); const { user, session } = await authService.register({ username: body.username, @@ -241,11 +242,22 @@ app.post("/session", rateLimit(RateLimitPresets.login), async (c) => { } try { - const authService = new AuthService(c.get("userRepository"), c.get("sessionRepository")); - const { user, session } = await authService.login({ - username: body.username, - password: body.password, - }); + const authService = new AuthService( + c.get("userRepository"), + c.get("sessionRepository"), + undefined, // blockedUsernameService not needed for login + c.get("eventBus"), + ); + const { user, session } = await authService.login( + { + username: body.username, + password: body.password, + }, + { + ipAddress: c.req.header("x-forwarded-for") || c.req.header("x-real-ip") || undefined, + userAgent: c.req.header("user-agent") || undefined, + }, + ); // パスワードハッシュとメールアドレスを除外してレスポンス const { passwordHash: _passwordHash, email: _email, ...publicUser } = user; diff --git a/packages/backend/src/routes/following.ts b/packages/backend/src/routes/following.ts index 0ad2165b..209377d2 100644 --- a/packages/backend/src/routes/following.ts +++ b/packages/backend/src/routes/following.ts @@ -33,12 +33,14 @@ following.post( const userRepository = c.get("userRepository"); const deliveryService = c.get("activityPubDeliveryService"); const notificationService = c.get("notificationService"); + const eventBus = c.get("eventBus"); const followService = new FollowService( followRepository, userRepository, deliveryService, notificationService, + eventBus, ); const body = await c.req.json(); @@ -75,8 +77,15 @@ following.post( const followRepository = c.get("followRepository"); const userRepository = c.get("userRepository"); const deliveryService = c.get("activityPubDeliveryService"); + const eventBus = c.get("eventBus"); - const followService = new FollowService(followRepository, userRepository, deliveryService); + const followService = new FollowService( + followRepository, + userRepository, + deliveryService, + undefined, // notificationService not needed for unfollow + eventBus, + ); const body = await c.req.json(); diff --git a/packages/backend/src/routes/lists.ts b/packages/backend/src/routes/lists.ts index 51f1be96..4f4f4fe7 100644 --- a/packages/backend/src/routes/lists.ts +++ b/packages/backend/src/routes/lists.ts @@ -28,6 +28,7 @@ function getListService(c: Context): ListService { const deliveryService = c.get("activityPubDeliveryService"); const cacheService = c.get("cacheService"); const systemAccountService = c.get("systemAccountService"); + const eventBus = c.get("eventBus"); // Create NoteService for hydrating notes in list timeline const noteService = new NoteService( @@ -37,6 +38,9 @@ function getListService(c: Context): ListService { userRepository, deliveryService, cacheService, + undefined, // notificationService + undefined, // listRepository + eventBus, ); return new ListService(listRepository, userRepository, noteRepository, noteService, systemAccountService); diff --git a/packages/backend/src/routes/notes.ts b/packages/backend/src/routes/notes.ts index fbe4b9d2..343ad46c 100644 --- a/packages/backend/src/routes/notes.ts +++ b/packages/backend/src/routes/notes.ts @@ -32,6 +32,7 @@ function createNoteService(c: Context, includeNotification = false): NoteService const userRepository = c.get("userRepository"); const deliveryService = c.get("activityPubDeliveryService"); const cacheService = c.get("cacheService"); + const eventBus = c.get("eventBus"); if (includeNotification) { const notificationService = c.get("notificationService"); @@ -45,6 +46,7 @@ function createNoteService(c: Context, includeNotification = false): NoteService cacheService, notificationService, listRepository, + eventBus, ); } @@ -55,6 +57,9 @@ function createNoteService(c: Context, includeNotification = false): NoteService userRepository, deliveryService, cacheService, + undefined, // notificationService + undefined, // listRepository + eventBus, ); } diff --git a/packages/backend/src/routes/onboarding.ts b/packages/backend/src/routes/onboarding.ts index d43cc29c..0a57364b 100644 --- a/packages/backend/src/routes/onboarding.ts +++ b/packages/backend/src/routes/onboarding.ts @@ -112,7 +112,8 @@ app.post("/complete", requireOnboardingMode, async (c) => { const sessionRepository = c.get("sessionRepository"); const instanceSettingsService = c.get("instanceSettingsService"); const blockedUsernameService = c.get("blockedUsernameService"); - const authService = new AuthService(userRepository, sessionRepository, blockedUsernameService); + const eventBus = c.get("eventBus"); + const authService = new AuthService(userRepository, sessionRepository, blockedUsernameService, eventBus); // Check if username or email already exists const existingUsername = await userRepository.findByUsername(admin.username); diff --git a/packages/backend/src/services/AuthService.ts b/packages/backend/src/services/AuthService.ts index d92d26cc..b62a9ed2 100644 --- a/packages/backend/src/services/AuthService.ts +++ b/packages/backend/src/services/AuthService.ts @@ -15,6 +15,9 @@ import { hashPassword, verifyPassword } from "../utils/password.js"; import { generateSessionToken, calculateSessionExpiry } from "../utils/session.js"; import { generateId } from "shared"; import { generateKeyPair } from "../utils/crypto.js"; +import type { EventBus } from "../lib/events.js"; +import { toPluginUser } from "../plugins/utils.js"; +import { logger } from "../lib/logger.js"; /** * User Registration Input Data @@ -51,11 +54,14 @@ export class AuthService { * * @param userRepository - User repository * @param sessionRepository - Session repository + * @param blockedUsernameService - Optional blocked username service + * @param eventBus - Optional event bus for plugin events */ constructor( private userRepository: IUserRepository, private sessionRepository: ISessionRepository, private blockedUsernameService?: BlockedUsernameService, + private eventBus?: EventBus, ) {} /** @@ -99,6 +105,27 @@ export class AuthService { throw new Error("Email already exists"); } + // Emit user:beforeRegister event (plugins can cancel or modify) + // Note: email is intentionally excluded as it is PII + // Note: username cannot be modified as it affects duplicate checks and ActivityPub URIs + let displayName = input.name || null; + + if (this.eventBus) { + const beforeResult = await this.eventBus.emitBefore("user:beforeRegister", { + username: input.username, + displayName, + }); + + if (beforeResult.cancelled) { + throw new Error(beforeResult.cancelReason || "Registration cancelled by plugin"); + } + + // Apply modifications from plugins (only displayName can be modified) + if (beforeResult.modifiedPayload) { + displayName = beforeResult.modifiedPayload.displayName ?? displayName; + } + } + // パスワードをハッシュ化 const passwordHash = await hashPassword(input.password); @@ -116,7 +143,7 @@ export class AuthService { username: input.username, email: input.email, passwordHash, - displayName: input.name || input.username, + displayName: displayName || input.username, host: null, // ローカルユーザー avatarUrl: null, bannerUrl: null, @@ -158,6 +185,17 @@ export class AuthService { // セッション作成 const session = await this.createSession(user.id); + // Emit user:afterRegister event (async, non-blocking) + if (this.eventBus) { + this.eventBus + .emit("user:afterRegister", { + user: toPluginUser(user), + }) + .catch((error) => { + logger.error({ err: error, userId: user.id }, "Failed to emit user:afterRegister event"); + }); + } + return { user, session }; } @@ -179,7 +217,19 @@ export class AuthService { * }); * ``` */ - async login(input: LoginInput): Promise<{ user: User; session: Session }> { + async login(input: LoginInput, context?: { ipAddress?: string; userAgent?: string }): Promise<{ user: User; session: Session }> { + // Emit user:beforeLogin event (plugins can cancel) + // Note: ipAddress and userAgent are excluded as PII + if (this.eventBus) { + const beforeResult = await this.eventBus.emitBefore("user:beforeLogin", { + username: input.username, + }); + + if (beforeResult.cancelled) { + throw new Error(beforeResult.cancelReason || "Login cancelled by plugin"); + } + } + // ユーザー検索 const user = await this.userRepository.findByUsername(input.username); if (!user) { @@ -205,13 +255,26 @@ export class AuthService { // セッション作成 const session = await this.createSession(user.id); + // Emit user:afterLogin event (async, non-blocking) + if (this.eventBus) { + this.eventBus + .emit("user:afterLogin", { + user: toPluginUser(user), + ipAddress: context?.ipAddress || null, + userAgent: context?.userAgent || null, + }) + .catch((error) => { + logger.error({ err: error, userId: user.id }, "Failed to emit user:afterLogin event"); + }); + } + return { user, session }; } /** * User Logout * - * Deletes the session for the specified token. + * Deletes the session for the specified token and emits a logout event. * * @param token - Token of the session to delete * @@ -221,7 +284,27 @@ export class AuthService { * ``` */ async logout(token: string): Promise { + // Get session and user info before deletion for event emission + let user: User | null = null; + if (this.eventBus) { + const session = await this.sessionRepository.findByToken(token); + if (session) { + user = await this.userRepository.findById(session.userId); + } + } + await this.sessionRepository.deleteByToken(token); + + // Emit user:afterLogout event (async, non-blocking) + if (this.eventBus && user) { + this.eventBus + .emit("user:afterLogout", { + user: toPluginUser(user), + }) + .catch((error) => { + logger.error({ err: error, userId: user!.id }, "Failed to emit user:afterLogout event"); + }); + } } /** diff --git a/packages/backend/src/services/FollowService.ts b/packages/backend/src/services/FollowService.ts index 49a3e083..2709b4d3 100644 --- a/packages/backend/src/services/FollowService.ts +++ b/packages/backend/src/services/FollowService.ts @@ -14,6 +14,9 @@ import { generateId } from "../../../shared/src/utils/id.js"; import type { ActivityPubDeliveryService } from "./ap/ActivityPubDeliveryService.js"; import type { NotificationService } from "./NotificationService.js"; import { logger } from "../lib/logger.js"; +import type { EventBus } from "../lib/events.js"; +import type { PluginFollow } from "../plugins/types.js"; +import { toPluginUser } from "../plugins/utils.js"; /** * Follow Service @@ -38,14 +41,28 @@ export class FollowService { * @param userRepository - User repository * @param deliveryService - ActivityPub delivery service (optional, for federation) * @param notificationService - Notification service (optional, for notifications) + * @param eventBus - Event bus for plugin events (optional) */ constructor( private readonly followRepository: IFollowRepository, private readonly userRepository: IUserRepository, private readonly deliveryService?: ActivityPubDeliveryService, private readonly notificationService?: NotificationService, + private readonly eventBus?: EventBus, ) {} + /** + * Convert Follow to PluginFollow + */ + private toPluginFollow(follow: Follow): PluginFollow { + return { + id: follow.id, + followerId: follow.followerId, + followeeId: follow.followeeId, + createdAt: follow.createdAt, + }; + } + /** * Create a follow relationship * @@ -116,6 +133,19 @@ export class FollowService { }); } + // Emit follow:afterCreate event (async, non-blocking) + if (this.eventBus) { + this.eventBus + .emit("follow:afterCreate", { + follow: this.toPluginFollow(follow), + follower: toPluginUser(follower), + followee: toPluginUser(followee), + }) + .catch((error) => { + logger.error({ err: error, followId: follow.id }, "Failed to emit follow:afterCreate event"); + }); + } + return follow; } @@ -164,6 +194,20 @@ export class FollowService { logger.error({ err: error, followerId, followeeId }, "Failed to deliver Undo Follow activity"); }); } + + // Emit follow:afterDelete event (async, non-blocking) + // Only emit if the relationship existed and we have valid user info + if (this.eventBus && exists && follower && followee) { + this.eventBus + .emit("follow:afterDelete", { + followId: `${followerId}:${followeeId}`, // Composite ID since we don't have the original + follower: toPluginUser(follower), + followee: toPluginUser(followee), + }) + .catch((error) => { + logger.error({ err: error, followerId, followeeId }, "Failed to emit follow:afterDelete event"); + }); + } } /** diff --git a/packages/backend/src/services/NoteService.ts b/packages/backend/src/services/NoteService.ts index b8156954..e9a99221 100644 --- a/packages/backend/src/services/NoteService.ts +++ b/packages/backend/src/services/NoteService.ts @@ -22,6 +22,8 @@ import { CacheTTL, CachePrefix } from "../adapters/cache/DragonflyCacheAdapter.j import type { NotificationService } from "./NotificationService.js"; import { getTimelineStreamService } from "./TimelineStreamService.js"; import { logger } from "../lib/logger.js"; +import type { EventBus } from "../lib/events.js"; +import { toPluginUser, toPluginNote } from "../plugins/utils.js"; /** * Note creation input data @@ -103,6 +105,7 @@ export class NoteService { cacheService?: ICacheService, private readonly notificationService?: NotificationService, private readonly listRepository?: IListRepository, + private readonly eventBus?: EventBus, ) { this.cacheService = cacheService ?? null; } @@ -136,16 +139,18 @@ export class NoteService { async create(input: NoteCreateInput): Promise { const { userId, - text = null, - cw = null, - visibility = "public", - localOnly = false, replyId = null, renoteId = null, fileIds = [], visibleUserIds = [], } = input; + // Modifiable fields (may be changed by plugins via beforeCreate event) + let text = input.text ?? null; + let cw = input.cw ?? null; + let visibility = input.visibility ?? "public"; + let localOnly = input.localOnly ?? false; + // バリデーション: テキストまたはファイルが必須(Renoteの場合は除く) if (!renoteId && !text && fileIds.length === 0) { throw new Error("Note must have text or files"); @@ -183,6 +188,39 @@ export class NoteService { } } + // Get author for event emission and delivery + const author = await this.userRepository.findById(userId); + if (!author) { + throw new Error("Author not found"); + } + + // Emit note:beforeCreate event (plugins can cancel or modify) + if (this.eventBus) { + const beforeResult = await this.eventBus.emitBefore("note:beforeCreate", { + text, + cw, + visibility, + localOnly, + replyId, + renoteId, + fileIds, + author: toPluginUser(author), + }); + + if (beforeResult.cancelled) { + throw new Error(beforeResult.cancelReason || "Note creation cancelled by plugin"); + } + + // Apply modifications from plugins + if (beforeResult.modifiedPayload) { + text = beforeResult.modifiedPayload.text ?? text; + cw = beforeResult.modifiedPayload.cw ?? cw; + visibility = beforeResult.modifiedPayload.visibility ?? visibility; + localOnly = beforeResult.modifiedPayload.localOnly ?? localOnly; + // Note: replyId, renoteId, fileIds, author cannot be modified for security reasons + } + } + // メンション抽出(簡易実装、Phase 1.1で拡張予定) // Extract usernames from text and resolve to user IDs for consistent storage const extractedMentionUsernames = this.extractMentions(text || ""); @@ -260,9 +298,6 @@ export class NoteService { // Pure renotes get Announce activity, quote renotes get Create activity const isPureRenote = renoteTarget && !text && !cw && !replyId && fileIds.length === 0; - // Deliver ActivityPub activity (async, non-blocking) - const author = await this.userRepository.findById(userId); - // Log DM delivery decision for debugging (only counts for privacy) if (visibility === "specified") { logger.debug( @@ -334,6 +369,18 @@ export class NoteService { logger.error({ err: error, noteId }, "Failed to push note to timeline streams"); }); + // Emit note:afterCreate event (async, non-blocking) + if (this.eventBus) { + this.eventBus + .emit("note:afterCreate", { + note: toPluginNote(note), + author: toPluginUser(author), + }) + .catch((error) => { + logger.error({ err: error, noteId }, "Failed to emit note:afterCreate event"); + }); + } + return note; } @@ -508,6 +555,22 @@ export class NoteService { // Get author info before deletion for ActivityPub delivery const author = await this.userRepository.findById(userId); + if (!author) { + throw new Error("User not found"); + } + + // Emit note:beforeDelete event (plugins can cancel) + if (this.eventBus) { + const beforeResult = await this.eventBus.emitBefore("note:beforeDelete", { + note: toPluginNote(note), + deletedBy: toPluginUser(author), + reason: null, + }); + + if (beforeResult.cancelled) { + throw new Error(beforeResult.cancelReason || "Note deletion cancelled by plugin"); + } + } // Decrement reply count on parent note (async, non-blocking) if (note.replyId) { @@ -527,12 +590,25 @@ export class NoteService { await this.noteRepository.delete(noteId); // Deliver Delete activity to remote followers (async, non-blocking) - if (author && !author.host) { + if (!author.host) { // Only deliver if author is a local user this.deliveryService.deliverDelete(note, author).catch((error) => { logger.error({ err: error, noteId }, "Failed to deliver Delete activity"); }); } + + // Emit note:afterDelete event (async, non-blocking) + if (this.eventBus) { + this.eventBus + .emit("note:afterDelete", { + noteId, + deletedBy: toPluginUser(author), + reason: null, + }) + .catch((error) => { + logger.error({ err: error, noteId }, "Failed to emit note:afterDelete event"); + }); + } } /** diff --git a/packages/backend/src/tests/helpers/pluginTestHelpers.ts b/packages/backend/src/tests/helpers/pluginTestHelpers.ts new file mode 100644 index 00000000..139d23a5 --- /dev/null +++ b/packages/backend/src/tests/helpers/pluginTestHelpers.ts @@ -0,0 +1,107 @@ +/** + * Plugin Test Helpers + * + * Factory functions for creating test data for plugin-related tests. + * These helpers reduce repetition and ensure consistent test data. + * + * @module tests/helpers/pluginTestHelpers + */ + +import type { PluginUser, PluginNote } from "../../plugins/types.js"; +import type { NoteBeforeCreatePayload, NoteAfterCreatePayload } from "../../plugins/types.js"; + +/** + * Create a test PluginUser with default values + * + * @param overrides - Partial user data to override defaults + * @returns A complete PluginUser object + * + * @example + * ```typescript + * const user = createTestPluginUser({ username: "alice" }); + * // user.id = "user-test-1", user.username = "alice", ... + * ``` + */ +export function createTestPluginUser(overrides: Partial = {}): PluginUser { + return { + id: "user-test-1", + username: "testuser", + displayName: null, + avatarUrl: null, + host: null, + isAdmin: false, + isSuspended: false, + isSystemUser: false, + uri: null, + createdAt: new Date(), + ...overrides, + }; +} + +/** + * Create a test PluginNote with default values + * + * @param overrides - Partial note data to override defaults + * @returns A complete PluginNote object + * + * @example + * ```typescript + * const note = createTestPluginNote({ text: "Hello world" }); + * // note.id = "note-test-1", note.text = "Hello world", ... + * ``` + */ +export function createTestPluginNote(overrides: Partial = {}): PluginNote { + return { + id: "note-test-1", + userId: "user-test-1", + text: "Test note content", + cw: null, + visibility: "public", + localOnly: false, + replyId: null, + renoteId: null, + fileIds: [], + mentions: [], + uri: null, + createdAt: new Date(), + ...overrides, + }; +} + +/** + * Create a note:beforeCreate event payload with default values + * + * @param overrides - Partial payload data to override defaults + * @returns A complete NoteBeforeCreatePayload object + */ +export function createNoteBeforeCreatePayload( + overrides: Partial = {} +): NoteBeforeCreatePayload { + return { + text: "Test note content", + cw: null, + visibility: "public", + localOnly: false, + replyId: null, + renoteId: null, + fileIds: [], + author: createTestPluginUser(), + ...overrides, + }; +} + +/** + * Create a note:afterCreate event payload with default values + * + * @param overrides - Partial payload data to override defaults + * @returns A complete NoteAfterCreatePayload object + */ +export function createNoteAfterCreatePayload( + overrides: Partial = {} +): NoteAfterCreatePayload { + return { + note: createTestPluginNote(), + author: createTestPluginUser(), + ...overrides, + }; +} diff --git a/packages/backend/src/tests/unit/EventBus.test.ts b/packages/backend/src/tests/unit/EventBus.test.ts new file mode 100644 index 00000000..ab3dd5ed --- /dev/null +++ b/packages/backend/src/tests/unit/EventBus.test.ts @@ -0,0 +1,206 @@ +/** + * EventBus Unit Tests + * + * Tests the EventBus class for plugin event management. + * + * @module tests/unit/EventBus.test + */ + +import { describe, it, expect, beforeEach, mock } from "bun:test"; +import { EventBus } from "../../lib/events.js"; +import { + createTestPluginUser, + createTestPluginNote, + createNoteBeforeCreatePayload, + createNoteAfterCreatePayload, +} from "../helpers/pluginTestHelpers.js"; + +describe("EventBus", () => { + let eventBus: EventBus; + + beforeEach(() => { + eventBus = new EventBus(); + }); + + describe("on() and emit()", () => { + it("should call handler when event is emitted", async () => { + const handler = mock(() => {}); + + eventBus.on("note:afterCreate", handler, "test-plugin"); + + await eventBus.emit("note:afterCreate", createNoteAfterCreatePayload({ + note: createTestPluginNote({ id: "note1", text: "Hello" }), + author: createTestPluginUser({ id: "user1", username: "alice", displayName: "Alice" }), + })); + + expect(handler).toHaveBeenCalledTimes(1); + }); + + it("should call handlers in priority order", async () => { + const callOrder: number[] = []; + + eventBus.on( + "note:afterCreate", + () => { + callOrder.push(1); + }, + "plugin1", + 1 + ); + eventBus.on( + "note:afterCreate", + () => { + callOrder.push(3); + }, + "plugin3", + 3 + ); + eventBus.on( + "note:afterCreate", + () => { + callOrder.push(2); + }, + "plugin2", + 2 + ); + + await eventBus.emit("note:afterCreate", createNoteAfterCreatePayload()); + + expect(callOrder).toEqual([3, 2, 1]); // Higher priority first + }); + + it("should continue calling handlers even if one throws", async () => { + const handler1 = mock(() => { + throw new Error("Test error"); + }); + const handler2 = mock(() => {}); + + eventBus.on("note:afterCreate", handler1, "plugin1", 2); + eventBus.on("note:afterCreate", handler2, "plugin2", 1); + + await eventBus.emit("note:afterCreate", createNoteAfterCreatePayload()); + + expect(handler1).toHaveBeenCalledTimes(1); + expect(handler2).toHaveBeenCalledTimes(1); + }); + }); + + describe("onBefore() and emitBefore()", () => { + it("should allow cancellation of operations", async () => { + eventBus.onBefore( + "note:beforeCreate", + async () => ({ + cancelled: true, + cancelReason: "Spam detected", + }), + "spam-filter" + ); + + const result = await eventBus.emitBefore("note:beforeCreate", createNoteBeforeCreatePayload({ + text: "Buy now! Click here!", + author: createTestPluginUser({ username: "spammer" }), + })); + + expect(result.cancelled).toBe(true); + expect(result.cancelReason).toBe("Spam detected"); + }); + + it("should allow modification of payload", async () => { + eventBus.onBefore( + "note:beforeCreate", + async (payload) => ({ + modifiedPayload: { + ...payload, + text: payload.text?.toUpperCase() || null, + }, + }), + "modifier-plugin" + ); + + const result = await eventBus.emitBefore("note:beforeCreate", createNoteBeforeCreatePayload({ + text: "hello world", + })); + + expect(result.cancelled).toBeUndefined(); + expect(result.modifiedPayload?.text).toBe("HELLO WORLD"); + }); + + it("should stop processing after cancellation", async () => { + const handler2 = mock(async () => ({})); + + eventBus.onBefore( + "note:beforeCreate", + async () => ({ + cancelled: true, + cancelReason: "Blocked", + }), + "blocker", + 2 + ); + eventBus.onBefore("note:beforeCreate", handler2, "modifier", 1); + + const result = await eventBus.emitBefore("note:beforeCreate", createNoteBeforeCreatePayload({ + text: "test", + })); + + expect(result.cancelled).toBe(true); + expect(handler2).not.toHaveBeenCalled(); + }); + }); + + describe("offPlugin()", () => { + it("should remove all handlers for a plugin", async () => { + const handler1 = mock(() => {}); + const handler2 = mock(() => {}); + + eventBus.on("note:afterCreate", handler1, "plugin1"); + eventBus.on("note:afterCreate", handler2, "plugin2"); + + eventBus.offPlugin("plugin1"); + + await eventBus.emit("note:afterCreate", createNoteAfterCreatePayload()); + + expect(handler1).not.toHaveBeenCalled(); + expect(handler2).toHaveBeenCalledTimes(1); + }); + }); + + describe("utility methods", () => { + it("should report hasHandlers correctly", () => { + expect(eventBus.hasHandlers("note:afterCreate")).toBe(false); + + eventBus.on("note:afterCreate", () => {}, "plugin1"); + + expect(eventBus.hasHandlers("note:afterCreate")).toBe(true); + }); + + it("should report handler count correctly", () => { + expect(eventBus.getHandlerCount("note:afterCreate")).toBe(0); + + eventBus.on("note:afterCreate", () => {}, "plugin1"); + eventBus.onBefore("note:beforeCreate", async () => ({}), "plugin2"); + + expect(eventBus.getHandlerCount("note:afterCreate")).toBe(1); + expect(eventBus.getHandlerCount("note:beforeCreate")).toBe(1); + }); + + it("should list registered events", () => { + eventBus.on("note:afterCreate", () => {}, "plugin1"); + eventBus.onBefore("user:beforeRegister", async () => ({}), "plugin2"); + + const events = eventBus.getRegisteredEvents(); + + expect(events).toContain("note:afterCreate"); + expect(events).toContain("user:beforeRegister"); + }); + + it("should clear all handlers", () => { + eventBus.on("note:afterCreate", () => {}, "plugin1"); + eventBus.onBefore("note:beforeCreate", async () => ({}), "plugin2"); + + eventBus.clear(); + + expect(eventBus.getRegisteredEvents()).toHaveLength(0); + }); + }); +});