feat: Security

This commit is contained in:
2026-06-05 18:34:01 +02:00
parent ee293d7daf
commit 45675192a5
134 changed files with 4128 additions and 446 deletions

View File

@@ -1,5 +1,5 @@
import { Injectable, inject } from '@angular/core';
import { Store } from '@ngrx/store';
import { Action, Store } from '@ngrx/store';
import { Subscription } from 'rxjs';
import { RealtimeSessionFacade } from '../../../../core/realtime';
import { DatabaseService } from '../../../../infrastructure/persistence';
@@ -11,6 +11,7 @@ import type {
Channel,
ChatEvent,
Message,
MessageRevision,
PluginCapabilityId,
PluginEventEnvelope,
TojuPluginManifest,
@@ -49,6 +50,8 @@ import { PluginLoggerService } from './plugin-logger.service';
import { PluginMessageBusService } from './plugin-message-bus.service';
import { PluginStorageService } from './plugin-storage.service';
import { PluginUiRegistryService } from './plugin-ui-registry.service';
import { MessageRevisionService } from '../../../chat/application/services/message-revision.service';
import { materializeMessageFromRevision } from '../../../chat/domain/rules/message-revision.builder.rules';
@Injectable({ providedIn: 'root' })
export class PluginClientApiService {
@@ -63,6 +66,7 @@ export class PluginClientApiService {
private readonly storage = inject(PluginStorageService);
private readonly uiRegistry = inject(PluginUiRegistryService);
private readonly voice = inject(VoiceConnectionFacade);
private readonly messageRevisions = inject(MessageRevisionService);
private readonly currentMessages = this.store.selectSignal(selectCurrentRoomMessages);
private readonly currentRoom = this.store.selectSignal(selectCurrentRoom);
@@ -380,7 +384,6 @@ export class PluginClientApiService {
this.serverDirectory.updateServer(room.id, {
actingRole: isOwner ? 'host' : undefined,
currentOwnerId: currentUser.id,
icon,
iconUpdatedAt
}, {
@@ -601,7 +604,8 @@ export class PluginClientApiService {
private receivePluginUserMessage(pluginId: string, request: PluginApiMessageAsPluginUserRequest): void {
const roomId = this.requireRoomId();
const message: Message = {
const timestamp = Date.now();
const draftMessage: Message = {
channelId: request.channelId ?? this.activeChannelId() ?? undefined,
content: request.content,
id: createId(),
@@ -610,53 +614,74 @@ export class PluginClientApiService {
roomId,
senderId: request.pluginUserId,
senderName: request.pluginUserId,
timestamp: Date.now()
timestamp,
revision: 0
};
this.logger.info(pluginId, 'Plugin user message emitted', { messageId: message.id });
this.persistPluginMessage(pluginId, message);
this.store.dispatch(MessagesActions.receiveMessage({ message }));
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
void this.emitPluginMessageRevision(pluginId, {
draftMessage,
type: 'create',
actorId: request.pluginUserId,
editedAt: timestamp,
pluginId,
sign: false,
dispatch: (message) => MessagesActions.receiveMessage({ message })
});
}
private deletePluginMessage(pluginId: string, messageId: string): void {
this.persistPluginMessageUpdate(pluginId, messageId, {
content: '[Message deleted]',
editedAt: Date.now(),
isDeleted: true
void this.emitPluginMessageMutation(pluginId, messageId, {
type: 'plugin-delete',
apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({
message: existing,
type: 'plugin-delete',
actorId: this.currentUser()?.id ?? pluginId,
editedAt,
isDeleted: true,
pluginId,
sign: false
}),
legacyBroadcast: (editedAt) => ({
deletedAt: editedAt,
messageId,
type: 'message-deleted'
}),
dispatch: () => MessagesActions.deleteMessageSuccess({ messageId })
});
this.store.dispatch(MessagesActions.deleteMessageSuccess({ messageId }));
this.voice.broadcastMessage({
deletedAt: Date.now(),
messageId,
type: 'message-deleted'
} as unknown as ChatEvent);
}
private editPluginMessage(pluginId: string, messageId: string, content: string): void {
const editedAt = Date.now();
this.persistPluginMessageUpdate(pluginId, messageId, { content, editedAt });
this.store.dispatch(MessagesActions.editMessageSuccess({
void this.emitPluginMessageMutation(pluginId, messageId, {
type: 'plugin-edit',
content,
editedAt,
messageId
}));
this.voice.broadcastMessage({
content,
editedAt,
messageId,
type: 'message-edited'
} as unknown as ChatEvent);
apply: async (existing, editedAt) => this.messageRevisions.createSignedRevision({
message: existing,
type: 'plugin-edit',
actorId: this.currentUser()?.id ?? pluginId,
content,
editedAt,
pluginId,
sign: false
}),
legacyBroadcast: (editedAt) => ({
content,
editedAt,
messageId,
type: 'message-edited'
}),
dispatch: (message) => MessagesActions.editMessageSuccess({
content: message.content,
editedAt: message.editedAt ?? Date.now(),
messageId
})
});
}
private sendPluginMessage(pluginId: string, content: string, channelId?: string): Message {
const currentUser = this.currentUser();
const roomId = this.requireRoomId();
const message: Message = {
const timestamp = Date.now();
const draftMessage: Message = {
channelId: channelId ?? this.activeChannelId() ?? 'general',
content,
id: createId(),
@@ -665,14 +690,89 @@ export class PluginClientApiService {
roomId,
senderId: currentUser?.id ?? 'plugin',
senderName: currentUser?.displayName || currentUser?.username || 'Plugin',
timestamp: Date.now()
timestamp,
revision: 0
};
this.persistPluginMessage(pluginId, message);
this.store.dispatch(MessagesActions.sendMessageSuccess({ message }));
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
void this.emitPluginMessageRevision(pluginId, {
draftMessage,
type: 'create',
actorId: currentUser?.id ?? pluginId,
editedAt: timestamp,
pluginId,
sign: !!currentUser,
dispatch: (message) => MessagesActions.sendMessageSuccess({ message })
});
return message;
return draftMessage;
}
private async emitPluginMessageRevision(
pluginId: string,
input: {
draftMessage: Message;
type: 'create';
actorId: string;
editedAt: number;
pluginId: string;
sign: boolean;
dispatch: (message: Message) => Action;
}
): Promise<void> {
try {
const revision = await this.messageRevisions.createSignedRevision({
message: input.draftMessage,
type: input.type,
actorId: input.actorId,
editedAt: input.editedAt,
pluginId: input.pluginId,
sign: input.sign
});
const message = materializeMessageFromRevision(null, revision);
this.logger.info(pluginId, 'Plugin message emitted', { messageId: message.id });
await this.db.saveMessage(message);
await this.messageRevisions.persistRevision(revision);
this.store.dispatch(input.dispatch(message));
this.voice.broadcastMessage({ type: 'chat-message', message } as unknown as ChatEvent);
this.messageRevisions.broadcastRevision(revision);
} catch (error: unknown) {
this.logger.warn(pluginId, 'Failed to emit plugin message revision', error);
}
}
private async emitPluginMessageMutation(
pluginId: string,
messageId: string,
input: {
type: 'plugin-edit' | 'plugin-delete';
content?: string;
apply: (existing: Message, editedAt: number) => Promise<MessageRevision>;
legacyBroadcast: (editedAt: number) => ChatEvent;
dispatch: (message: Message) => Action;
}
): Promise<void> {
try {
const existing = await this.db.getMessageById(messageId);
if (!existing) {
this.logger.warn(pluginId, 'Plugin message mutation target not found', { messageId });
return;
}
const editedAt = Date.now();
const revision = await input.apply(existing, editedAt);
const message = materializeMessageFromRevision(existing, revision);
await this.db.saveMessage(message);
await this.messageRevisions.persistRevision(revision);
this.store.dispatch(input.dispatch(message));
this.voice.broadcastMessage(input.legacyBroadcast(editedAt) as unknown as ChatEvent);
this.messageRevisions.broadcastRevision(revision);
} catch (error: unknown) {
this.logger.warn(pluginId, 'Failed to emit plugin message mutation revision', error);
}
}
private setTyping(pluginId: string, isTyping: boolean, channelId?: string): void {
@@ -831,8 +931,7 @@ export class PluginClientApiService {
this.serverDirectory.updateServer(room.id, {
actingRole: isOwner ? 'host' : undefined,
channels,
currentOwnerId: currentUser.id
channels
}, {
sourceId: room.sourceId,
sourceUrl: room.sourceUrl